diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 801bfe3..0000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "permissions": { - "allow": [ - "mcp__acp__Bash", - "mcp__acp__Write", - "mcp__acp__Edit" - ] - } -} diff --git a/.gitignore b/.gitignore index 4d574c5..ae9c3b7 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,7 @@ zig-out/ zig-cache/ .zig-cache/ .pugz-cache/ +.claude node_modules # compiled template file diff --git a/CLAUDE.md b/CLAUDE.md index 4f40325..b5b0a85 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co ## Project Purpose -Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It implements Pug 3 syntax for indentation-based HTML templating with a focus on server-side rendering. +Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It compiles Pug templates directly to HTML (unlike JS pug which compiles to JavaScript functions). It implements Pug 3 syntax for indentation-based HTML templating with a focus on server-side rendering. ## Rules - Do not auto commit, user will do it. @@ -16,119 +16,142 @@ Pugz is a Pug-like HTML template engine written in Zig 0.15.2. It implements Pug - `zig build` - Build the project (output in `zig-out/`) - `zig build test` - Run all tests -- `zig build bench-compiled` - Run compiled templates benchmark (compare with Pug.js) -- `zig build bench-interpreted` - Inpterpret trmplates +- `zig build bench-v1` - Run v1 template benchmark +- `zig build bench-interpreted` - Run interpreted templates benchmark ## Architecture Overview -The template engine supports two rendering modes: +### Compilation Pipeline -### 1. Runtime Rendering (Interpreted) ``` -Source → Lexer → Tokens → Parser → AST → Runtime → HTML +Source → Lexer → Tokens → StripComments → Parser → AST → Linker → Codegen → HTML ``` -### 2. Build-Time Compilation (Compiled) -``` -Source → Lexer → Tokens → Parser → AST → build_templates.zig → generated.zig → Native Zig Code -``` +### Two Rendering Modes -The compiled mode is **~3x faster** than Pug.js. +1. **Static compilation** (`pug.compile`): Outputs HTML directly +2. **Data binding** (`template.renderWithData`): Supports `#{field}` interpolation with Zig structs ### Core Modules -| Module | Purpose | -|--------|---------| -| **src/lexer.zig** | Tokenizes Pug source into tokens. Handles indentation tracking, raw text blocks, interpolation. | -| **src/parser.zig** | Converts token stream into AST. Handles nesting via indent/dedent tokens. | -| **src/ast.zig** | AST node definitions (Element, Text, Conditional, Each, Mixin, etc.) | -| **src/runtime.zig** | Evaluates AST with data context, produces final HTML. Handles variable interpolation, conditionals, loops, mixins. | -| **src/build_templates.zig** | Build-time template compiler. Generates optimized Zig code from `.pug` templates. | -| **src/view_engine.zig** | High-level ViewEngine for web servers. Manages views directory, auto-loads mixins. | -| **src/root.zig** | Public library API - exports `ViewEngine`, `renderTemplate()`, `build_templates` and core types. | +| Module | File | Purpose | +|--------|------|---------| +| **Lexer** | `src/lexer.zig` | Tokenizes Pug source into tokens | +| **Parser** | `src/parser.zig` | Builds AST from tokens | +| **Runtime** | `src/runtime.zig` | Shared utilities (HTML escaping, etc.) | +| **Error** | `src/error.zig` | Error formatting with source context | +| **Walk** | `src/walk.zig` | AST traversal with visitor pattern | +| **Strip Comments** | `src/strip_comments.zig` | Token filtering for comments | +| **Load** | `src/load.zig` | File loading for includes/extends | +| **Linker** | `src/linker.zig` | Template inheritance (extends/blocks) | +| **Codegen** | `src/codegen.zig` | AST to HTML generation | +| **Template** | `src/template.zig` | Data binding renderer | +| **Pug** | `src/pug.zig` | Main entry point | +| **ViewEngine** | `src/view_engine.zig` | High-level API for web servers | +| **Root** | `src/root.zig` | Public library API exports | ### Test Files -- **src/tests/general_test.zig** - Comprehensive integration tests for all features +- **src/tests/general_test.zig** - Comprehensive integration tests - **src/tests/doctype_test.zig** - Doctype-specific tests -- **src/tests/inheritance_test.zig** - Template inheritance tests +- **src/tests/check_list_test.zig** - Template output validation tests +- **src/lexer_test.zig** - Lexer unit tests +- **src/parser_test.zig** - Parser unit tests -## Build-Time Template Compilation +## API Usage -For maximum performance, templates can be compiled to native Zig code at build time. - -### Setup in build.zig +### Static Compilation (no data) ```zig const std = @import("std"); +const pug = @import("pugz").pug; -pub fn build(b: *std.Build) void { - const pugz_dep = b.dependency("pugz", .{}); - - // Compile templates at build time - const build_templates = @import("pugz").build_templates; - const compiled_templates = build_templates.compileTemplates(b, .{ - .source_dir = "views", // Directory containing .pug files - }); +pub fn main() !void { + const allocator = std.heap.page_allocator; - const exe = b.addExecutable(.{ - .name = "myapp", - .root_module = b.createModule(.{ - .root_source_file = b.path("src/main.zig"), - .imports = &.{ - .{ .name = "pugz", .module = pugz_dep.module("pugz") }, - .{ .name = "tpls", .module = compiled_templates }, - }, - }), - }); + var result = try pug.compile(allocator, "p Hello World", .{}); + defer result.deinit(allocator); + + std.debug.print("{s}\n", .{result.html}); //

Hello World

} ``` -### Usage in Code +### Dynamic Rendering with Data ```zig -const tpls = @import("tpls"); +const std = @import("std"); +const pugz = @import("pugz"); -pub fn handleRequest(allocator: std.mem.Allocator) ![]u8 { - // Zero-cost template rendering - just native Zig code - return try tpls.home(allocator, .{ +pub fn main() !void { + var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator); + defer arena.deinit(); + + const html = try pugz.renderTemplate(arena.allocator(), + \\h1 #{title} + \\p #{message} + , .{ .title = "Welcome", - .user = .{ .name = "Alice", .email = "alice@example.com" }, - .items = &[_][]const u8{ "One", "Two", "Three" }, + .message = "Hello, World!", + }); + + std.debug.print("{s}\n", .{html}); + // Output:

Welcome

Hello, World!

+} +``` + +### Data Binding Features + +- **Interpolation**: `#{fieldName}` in text content +- **Attribute binding**: `a(href=url)` binds `url` field to href +- **Buffered code**: `p= message` outputs the `message` field +- **Auto-escaping**: HTML is escaped by default (XSS protection) + +```zig +const html = try pugz.renderTemplate(allocator, + \\a(href=url, class=style) #{text} +, .{ + .url = "https://example.com", + .style = "btn", + .text = "Click me!", +}); +// Output: Click me! +``` + +### ViewEngine (for Web Servers) + +```zig +const std = @import("std"); +const pugz = @import("pugz"); + +const engine = pugz.ViewEngine.init(.{ + .views_dir = "src/views", + .extension = ".pug", +}); + +// In request handler +pub fn handleRequest(allocator: std.mem.Allocator) ![]const u8 { + var arena = std.heap.ArenaAllocator.init(allocator); + defer arena.deinit(); + + return try engine.render(arena.allocator(), "pages/home", .{ + .title = "Home", + .user = .{ .name = "Alice" }, }); } ``` -### Generated Code Features +### Compile Options -The compiler generates optimized Zig code with: -- **Static string merging** - Consecutive static content merged into single `appendSlice` calls -- **Zero allocation for static templates** - Returns string literal directly -- **Type-safe data access** - Uses `@field(d, "name")` for compile-time checked field access -- **Automatic type conversion** - `strVal()` helper converts integers to strings -- **Optional handling** - Nullable slices handled with `orelse &.{}` -- **HTML escaping** - Lookup table for fast character escaping - -### Benchmark Results (2000 iterations) - -| Template | Pug.js | Pugz | Speedup | -|----------|--------|------|---------| -| simple-0 | 0.8ms | 0.1ms | **8x** | -| simple-1 | 1.4ms | 0.6ms | **2.3x** | -| simple-2 | 1.8ms | 0.6ms | **3x** | -| if-expression | 0.6ms | 0.2ms | **3x** | -| projects-escaped | 4.4ms | 0.6ms | **7.3x** | -| search-results | 15.2ms | 5.6ms | **2.7x** | -| friends | 153.5ms | 54.0ms | **2.8x** | -| **TOTAL** | **177.6ms** | **61.6ms** | **~3x faster** | - -Run benchmarks: -```bash -# Pugz (Zig) -zig build bench-compiled - -# Pug.js (for comparison) -cd src/benchmarks/pugjs && npm install && npm run bench +```zig +pub const CompileOptions = struct { + filename: ?[]const u8 = null, // For error messages + basedir: ?[]const u8 = null, // For absolute includes + pretty: bool = false, // Pretty print output + strip_unbuffered_comments: bool = true, + strip_buffered_comments: bool = false, + debug: bool = false, + doctype: ?[]const u8 = null, +}; ``` ## Memory Management @@ -144,46 +167,30 @@ const html = try pugz.renderTemplate(arena.allocator(), template, data); This pattern is recommended because template rendering creates many small allocations that are all freed together after the response is sent. -## Key Implementation Details +## Key Implementation Notes -### Lexer State Machine +### Lexer (`lexer.zig`) +- `Lexer.init(allocator, source, options)` - Initialize +- `Lexer.getTokens()` - Returns token slice +- `Lexer.last_error` - Check for errors after failed `getTokens()` -The lexer tracks several states for handling complex syntax: -- `in_raw_block` / `raw_block_indent` / `raw_block_started` - For dot block text (e.g., `script.`) -- `indent_stack` - Stack-based indent/dedent token generation +### Parser (`parser.zig`) +- `Parser.init(allocator, tokens, filename, source)` - Initialize +- `Parser.parse()` - Returns AST root node +- `Parser.err` - Check for errors after failed `parse()` -**Important**: The lexer distinguishes between `#id` (ID selector), `#{expr}` (interpolation), and `#[tag]` (tag interpolation) by looking ahead at the next character. +### Codegen (`codegen.zig`) +- `Compiler.init(allocator, options)` - Initialize +- `Compiler.compile(ast)` - Returns HTML string -### Token Types +### Walk (`walk.zig`) +- Uses O(1) stack operations (append/pop) not O(n) insert/remove +- `getParent(index)` uses reverse indexing (0 = immediate parent) +- `initWithCapacity()` for pre-allocation optimization -Key tokens: `tag`, `class`, `id`, `lparen`, `rparen`, `attr_name`, `attr_value`, `text`, `interp_start`, `interp_end`, `indent`, `dedent`, `dot_block`, `pipe_text`, `literal_html`, `self_close`, `mixin_call`, etc. - -### AST Node Types - -- `element` - HTML elements with tag, classes, id, attributes, children -- `text` - Text with segments (literal, escaped interpolation, unescaped interpolation, tag interpolation) -- `conditional` - if/else if/else/unless branches -- `each` - Iteration with value, optional index, else branch -- `mixin_def` / `mixin_call` - Mixin definitions and invocations -- `block` - Named blocks for template inheritance -- `include` / `extends` - File inclusion and inheritance -- `raw_text` - Literal HTML or text blocks - -### Runtime Value System - -```zig -pub const Value = union(enum) { - null, - bool: bool, - int: i64, - float: f64, - string: []const u8, - array: []const Value, - object: std.StringHashMapUnmanaged(Value), -}; -``` - -The `toValue()` function converts Zig structs to runtime Values automatically. +### Runtime (`runtime.zig`) +- `escapeChar(c)` - Shared HTML escape function +- `appendEscaped(list, allocator, str)` - Append with escaping ## Supported Pug Features @@ -222,12 +229,9 @@ p. Multi-line text block

Literal HTML

// passed through as-is - -// Interpolation-only text works too -h1.header #{title} // renders

Title Value

``` -**Security Note**: By default, `#{}` and `=` escape HTML entities (`<`, `>`, `&`, `"`, `'`) to prevent XSS attacks. Only use `!{}` or `!=` for content you fully trust (e.g., pre-sanitized HTML from your own code). Never use unescaped output for user-provided data. +**Security Note**: By default, `#{}` and `=` escape HTML entities (`<`, `>`, `&`, `"`, `'`) to prevent XSS attacks. Only use `!{}` or `!=` for content you fully trust. ### Tag Interpolation ```pug @@ -240,11 +244,6 @@ p Click #[a(href="/") here] to continue a: img(src="logo.png") // colon for inline nesting ``` -### Explicit Self-Closing -```pug -foo/ // renders as -``` - ### Conditionals ```pug if condition @@ -256,10 +255,6 @@ else unless loggedIn p Please login - -// String comparison in conditions -if status == "active" - p Active ``` ### Iteration @@ -274,16 +269,6 @@ each item in items li= item else li No items - -// Works with objects too (key as index) -each val, key in object - p #{key}: #{val} - -// Nested iteration with field access -each friend in friends - li #{friend.name} - each tag in friend.tags - span= tag ``` ### Case/When @@ -304,29 +289,6 @@ mixin button(text, type="primary") +button("Click me") +button("Submit", "success") - -// With block content -mixin card(title) - .card - h3= title - block - -+card("My Card") - p Card content here - -// Rest arguments -mixin list(id, ...items) - ul(id=id) - each item in items - li= item - -+list("mylist", "a", "b", "c") - -// Attributes pass-through -mixin link(href, text) - a(href=href)&attributes(attributes)= text - -+link("/home", "Home")(class="nav-link" data-id="1") ``` ### Includes & Inheritance @@ -336,13 +298,6 @@ include header.pug extends layout.pug block content h1 Page Title - -// Block modes -block append scripts - script(src="extra.js") - -block prepend styles - link(rel="stylesheet" href="extra.css") ``` ### Comments @@ -351,136 +306,57 @@ block prepend styles //- This is a silent comment (not in output) ``` -## Server Usage +## Benchmark Results (2000 iterations) -### Compiled Templates (Recommended for Production) +| Template | Time | +|----------|------| +| simple-0 | 0.8ms | +| simple-1 | 11.6ms | +| simple-2 | 8.2ms | +| if-expression | 7.4ms | +| projects-escaped | 7.1ms | +| search-results | 13.4ms | +| friends | 22.9ms | +| **TOTAL** | **71.3ms** | -Use build-time compilation for best performance. See "Build-Time Template Compilation" section above. +## Limitations vs JS Pug -### ViewEngine (Runtime Rendering) - -The `ViewEngine` provides runtime template rendering with lazy-loading: - -```zig -const std = @import("std"); -const pugz = @import("pugz"); - -// Initialize once at server startup -var engine = try pugz.ViewEngine.init(allocator, .{ - .views_dir = "src/views", // Root views directory - .mixins_dir = "mixins", // Mixins dir for lazy-loading (optional, default: "mixins") - .extension = ".pug", // File extension (default: .pug) - .pretty = true, // Pretty-print output (default: true) -}); -defer engine.deinit(); - -// In request handler - use arena allocator per request -pub fn handleRequest(engine: *pugz.ViewEngine, allocator: std.mem.Allocator) ![]u8 { - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - - // Template path is relative to views_dir, extension added automatically - return try engine.render(arena.allocator(), "pages/home", .{ - .title = "Home", - .user = .{ .name = "Alice" }, - }); -} -``` - -### Mixin Resolution (Lazy Loading) - -Mixins are resolved in the following order: -1. **Same template** - Mixins defined in the current template file -2. **Mixins directory** - If not found, searches `views/mixins/*.pug` files (lazy-loaded on first use) - -This lazy-loading approach means: -- Mixins are only parsed when first called -- No upfront loading of all mixin files at server startup -- Templates can override mixins from the mixins directory by defining them locally - -### Directory Structure - -``` -src/views/ -├── mixins/ # Lazy-loaded mixins (searched when mixin not found in template) -│ ├── buttons.pug # mixin btn(text), mixin btn-link(href, text) -│ └── cards.pug # mixin card(title), mixin card-simple(title, body) -├── layouts/ -│ └── base.pug # Base layout with blocks -├── partials/ -│ ├── header.pug -│ └── footer.pug -└── pages/ - ├── home.pug # extends layouts/base - └── about.pug # extends layouts/base -``` - -Templates can use: -- `extends layouts/base` - Paths relative to views_dir -- `include partials/header` - Paths relative to views_dir -- `+btn("Click")` - Mixins from mixins/ dir loaded on-demand - -### Low-Level API - -For inline templates or custom use cases: - -```zig -const std = @import("std"); -const pugz = @import("pugz"); - -pub fn handleRequest(allocator: std.mem.Allocator) ![]u8 { - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - - return try pugz.renderTemplate(arena.allocator(), - \\html - \\ head - \\ title= title - \\ body - \\ h1 Hello, #{name}! - \\ if showList - \\ ul - \\ each item in items - \\ li= item - , .{ - .title = "My Page", - .name = "World", - .showList = true, - .items = &[_][]const u8{ "One", "Two", "Three" }, - }); -} -``` - -## Testing - -Run tests with `zig build test`. Tests cover: -- Basic element parsing and rendering -- Class and ID shorthand syntax -- Attribute parsing (quoted, unquoted, boolean, object literals) -- Text interpolation (escaped, unescaped, tag interpolation) -- Interpolation-only text (e.g., `h1.class #{var}`) -- Conditionals (if/else if/else/unless) -- Iteration (each with index, else branch, objects, nested loops) -- Case/when statements -- Mixin definitions and calls (with defaults, rest args, block content, attributes) -- Plain text (piped, dot blocks, literal HTML) -- Self-closing tags (void elements, explicit `/`) -- Block expansion with colon -- Comments (rendered and silent) -- String comparison in conditions +1. **No JavaScript expressions**: `- var x = 1` not supported +2. **No nested field access**: `#{user.name}` not supported, only `#{name}` +3. **No filters**: `:markdown`, `:coffee` etc. not implemented +4. **String fields only**: Data binding works best with `[]const u8` fields ## Error Handling -The lexer and parser return errors for invalid syntax: -- `ParserError.UnexpectedToken` -- `ParserError.MissingCondition` -- `ParserError.MissingMixinName` -- `RuntimeError.ParseError` (wrapped for convenience API) +Uses error unions with detailed `PugError` context including line, column, and source snippet: +- `LexerError` - Tokenization errors +- `ParserError` - Syntax errors +- `ViewEngineError` - Template not found, parse errors -## Future Improvements +## File Structure -Potential areas for enhancement: -- Filter support (`:markdown`, `:stylus`, etc.) -- More complete JavaScript expression evaluation -- Source maps for debugging -- Mixin support in compiled templates +``` +src/ +├── root.zig # Public library API +├── view_engine.zig # High-level ViewEngine +├── pug.zig # Main entry point (static compilation) +├── template.zig # Data binding renderer +├── lexer.zig # Tokenizer +├── lexer_test.zig # Lexer tests +├── parser.zig # AST parser +├── parser_test.zig # Parser tests +├── runtime.zig # Shared utilities +├── error.zig # Error formatting +├── walk.zig # AST traversal +├── strip_comments.zig # Comment filtering +├── load.zig # File loading +├── linker.zig # Template inheritance +├── codegen.zig # HTML generation +├── tests/ # Integration tests +│ ├── general_test.zig +│ ├── doctype_test.zig +│ └── check_list_test.zig +└── benchmarks/ # Performance benchmarks + ├── bench_v1.zig + └── bench_interpreted.zig +``` diff --git a/README.md b/README.md index dfa53b8..bcf5eb9 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,5 @@ -*Yet not ready to use in production, i tried to get it done using Cluade but its not quite there where i want it* - -*So i will try it by my self keeping PugJS version as a reference* +*! I am using ClaudeCode to build it* +*! Its Yet not ready for production use* # Pugz diff --git a/build.zig b/build.zig index 84a67e0..9d80855 100644 --- a/build.zig +++ b/build.zig @@ -1,8 +1,5 @@ const std = @import("std"); -// Re-export build_templates for use by dependent packages -pub const build_templates = @import("src/build_templates.zig"); - pub fn build(b: *std.Build) void { const target = b.standardTargetOptions(.{}); const optimize = b.standardOptimizeOption(.{}); @@ -46,19 +43,6 @@ pub fn build(b: *std.Build) void { }); const run_doctype_tests = b.addRunArtifact(doctype_tests); - // Integration tests - inheritance tests - const inheritance_tests = b.addTest(.{ - .root_module = b.createModule(.{ - .root_source_file = b.path("src/tests/inheritance_test.zig"), - .target = target, - .optimize = optimize, - .imports = &.{ - .{ .name = "pugz", .module = mod }, - }, - }), - }); - const run_inheritance_tests = b.addRunArtifact(inheritance_tests); - // Integration tests - check_list tests (pug files vs expected html output) const check_list_tests = b.addTest(.{ .root_module = b.createModule(.{ @@ -72,14 +56,11 @@ pub fn build(b: *std.Build) void { }); const run_check_list_tests = b.addRunArtifact(check_list_tests); - // A top level step for running all tests. dependOn can be called multiple - // times and since the two run steps do not depend on one another, this will - // make the two of them run in parallel. + // A top level step for running all tests. const test_step = b.step("test", "Run all tests"); test_step.dependOn(&run_mod_tests.step); test_step.dependOn(&run_general_tests.step); test_step.dependOn(&run_doctype_tests.step); - test_step.dependOn(&run_inheritance_tests.step); test_step.dependOn(&run_check_list_tests.step); // Individual test steps @@ -89,82 +70,28 @@ pub fn build(b: *std.Build) void { const test_doctype_step = b.step("test-doctype", "Run doctype tests"); test_doctype_step.dependOn(&run_doctype_tests.step); - const test_inheritance_step = b.step("test-inheritance", "Run inheritance tests"); - test_inheritance_step.dependOn(&run_inheritance_tests.step); - const test_unit_step = b.step("test-unit", "Run unit tests (lexer, parser, etc.)"); test_unit_step.dependOn(&run_mod_tests.step); const test_check_list_step = b.step("test-check-list", "Run check_list template tests"); test_check_list_step.dependOn(&run_check_list_tests.step); - // ───────────────────────────────────────────────────────────────────────── - // Compiled Templates Benchmark (compare with Pug.js bench.js) - // Uses auto-generated templates from src/benchmarks/templates/ - // ───────────────────────────────────────────────────────────────────────── - const mod_fast = b.addModule("pugz-fast", .{ - .root_source_file = b.path("src/root.zig"), - .target = target, - .optimize = .ReleaseFast, - }); - - const bench_templates = build_templates.compileTemplates(b, .{ - .source_dir = "src/benchmarks/templates", - }); - - const bench_compiled = b.addExecutable(.{ - .name = "bench-compiled", + // Benchmark executable + const bench_exe = b.addExecutable(.{ + .name = "bench", .root_module = b.createModule(.{ .root_source_file = b.path("src/benchmarks/bench.zig"), .target = target, .optimize = .ReleaseFast, .imports = &.{ - .{ .name = "pugz", .module = mod_fast }, - .{ .name = "tpls", .module = bench_templates }, + .{ .name = "pugz", .module = mod }, }, }), }); + b.installArtifact(bench_exe); - b.installArtifact(bench_compiled); - - const run_bench_compiled = b.addRunArtifact(bench_compiled); - run_bench_compiled.step.dependOn(b.getInstallStep()); - - const bench_compiled_step = b.step("bench-compiled", "Benchmark compiled templates (compare with Pug.js)"); - bench_compiled_step.dependOn(&run_bench_compiled.step); - - // ───────────────────────────────────────────────────────────────────────── - // Interpreted (Runtime) Benchmark - // ───────────────────────────────────────────────────────────────────────── - const bench_interpreted = b.addExecutable(.{ - .name = "bench-interpreted", - .root_module = b.createModule(.{ - .root_source_file = b.path("src/benchmarks/bench_interpreted.zig"), - .target = target, - .optimize = .ReleaseFast, - .imports = &.{ - .{ .name = "pugz", .module = mod_fast }, - }, - }), - }); - - b.installArtifact(bench_interpreted); - - const run_bench_interpreted = b.addRunArtifact(bench_interpreted); - run_bench_interpreted.step.dependOn(b.getInstallStep()); - - const bench_interpreted_step = b.step("bench-interpreted", "Benchmark interpreted (runtime) templates"); - bench_interpreted_step.dependOn(&run_bench_interpreted.step); - - // Just like flags, top level steps are also listed in the `--help` menu. - // - // The Zig build system is entirely implemented in userland, which means - // that it cannot hook into private compiler APIs. All compilation work - // orchestrated by the build system will result in other Zig compiler - // subcommands being invoked with the right flags defined. You can observe - // these invocations when one fails (or you pass a flag to increase - // verbosity) to validate assumptions and diagnose problems. - // - // Lastly, the Zig build system is relatively simple and self-contained, - // and reading its source code will allow you to master it. + const run_bench = b.addRunArtifact(bench_exe); + run_bench.setCwd(b.path(".")); + const bench_step = b.step("bench", "Run benchmark"); + bench_step.dependOn(&run_bench.step); } diff --git a/examples/demo/build.zig b/examples/demo/build.zig index 104b12c..e8bc6df 100644 --- a/examples/demo/build.zig +++ b/examples/demo/build.zig @@ -14,13 +14,6 @@ pub fn build(b: *std.Build) void { .optimize = optimize, }); - // Compile templates at build time using pugz's build_templates - // Generates views/generated.zig with all templates - const build_templates = @import("pugz").build_templates; - const compiled_templates = build_templates.compileTemplates(b, .{ - .source_dir = "views", - }); - // Main executable const exe = b.addExecutable(.{ .name = "demo", @@ -31,7 +24,6 @@ pub fn build(b: *std.Build) void { .imports = &.{ .{ .name = "pugz", .module = pugz_dep.module("pugz") }, .{ .name = "httpz", .module = httpz_dep.module("httpz") }, - .{ .name = "tpls", .module = compiled_templates }, }, }), }); diff --git a/examples/demo/src/main.zig b/examples/demo/src/main.zig index 253a176..d8d2ab8 100644 --- a/examples/demo/src/main.zig +++ b/examples/demo/src/main.zig @@ -1,22 +1,16 @@ -//! Pugz Demo - Interpreted vs Compiled Templates +//! Pugz Demo - ViewEngine Template Rendering //! -//! This demo shows two approaches: -//! 1. **Interpreted** (ViewEngine) - supports extends/blocks, parsed at runtime -//! 2. **Compiled** (build-time) - 3x faster, templates compiled to Zig code +//! This demo shows how to use ViewEngine for server-side rendering. //! //! Routes: -//! GET / - Compiled home page (fast) -//! GET /users - Compiled users list (fast) -//! GET /interpreted - Interpreted with inheritance (flexible) -//! GET /page-a - Interpreted page A +//! GET / - Home page +//! GET /users - Users list +//! GET /page-a - Page with data const std = @import("std"); const httpz = @import("httpz"); const pugz = @import("pugz"); -// Compiled templates - generated at build time from views/compiled/*.pug -const tpls = @import("tpls"); - const Allocator = std.mem.Allocator; /// Application state shared across all requests @@ -42,33 +36,28 @@ pub fn main() !void { var app = App.init(allocator); - const port = 8080; + const port = 8081; var server = try httpz.Server(*App).init(allocator, .{ .port = port }, &app); defer server.deinit(); var router = try server.router(.{}); - // Compiled template routes (fast - 3x faster than Pug.js) - router.get("/", indexCompiled, .{}); - router.get("/users", usersCompiled, .{}); - - // Interpreted template routes (flexible - supports extends/blocks) - router.get("/interpreted", indexInterpreted, .{}); + router.get("/", index, .{}); + router.get("/users", users, .{}); router.get("/page-a", pageA, .{}); + router.get("/mixin-test", mixinTest, .{}); std.debug.print( \\ - \\Pugz Demo - Interpreted vs Compiled Templates - \\============================================= + \\Pugz Demo - ViewEngine Template Rendering + \\========================================== \\Server running at http://localhost:{d} \\ - \\Compiled routes (3x faster than Pug.js): - \\ GET / - Home page (compiled) - \\ GET /users - Users list (compiled) - \\ - \\Interpreted routes (supports extends/blocks): - \\ GET /interpreted - Home with ViewEngine - \\ GET /page-a - Page with inheritance + \\Routes: + \\ GET / - Home page + \\ GET /users - Users list + \\ GET /page-a - Page with data + \\ GET /mixin-test - Mixin test page \\ \\Press Ctrl+C to stop. \\ @@ -77,57 +66,10 @@ pub fn main() !void { try server.listen(); } -// ───────────────────────────────────────────────────────────────────────────── -// Compiled template handlers (fast - no parsing at runtime) -// ───────────────────────────────────────────────────────────────────────────── - -/// GET / - Compiled home page -fn indexCompiled(_: *App, _: *httpz.Request, res: *httpz.Response) !void { - const html = tpls.home(res.arena, .{ - .title = "Welcome - Compiled", - .authenticated = true, - }) catch |err| { - res.status = 500; - res.body = @errorName(err); - return; - }; - - res.content_type = .HTML; - res.body = html; -} - -/// GET /users - Compiled users list -fn usersCompiled(_: *App, _: *httpz.Request, res: *httpz.Response) !void { - const User = struct { - name: []const u8, - email: []const u8, - }; - - const html = tpls.users(res.arena, .{ - .title = "Users - Compiled", - .users = &[_]User{ - .{ .name = "Alice", .email = "alice@example.com" }, - .{ .name = "Bob", .email = "bob@example.com" }, - .{ .name = "Charlie", .email = "charlie@example.com" }, - }, - }) catch |err| { - res.status = 500; - res.body = @errorName(err); - return; - }; - - res.content_type = .HTML; - res.body = html; -} - -// ───────────────────────────────────────────────────────────────────────────── -// Interpreted template handlers (flexible - supports inheritance) -// ───────────────────────────────────────────────────────────────────────────── - -/// GET /interpreted - Uses ViewEngine (parsed at runtime) -fn indexInterpreted(app: *App, _: *httpz.Request, res: *httpz.Response) !void { +/// GET / - Home page +fn index(app: *App, _: *httpz.Request, res: *httpz.Response) !void { const html = app.view.render(res.arena, "index", .{ - .title = "Home - Interpreted", + .title = "Welcome", .authenticated = true, }) catch |err| { res.status = 500; @@ -139,7 +81,21 @@ fn indexInterpreted(app: *App, _: *httpz.Request, res: *httpz.Response) !void { res.body = html; } -/// GET /page-a - Demonstrates extends and block override +/// GET /users - Users list +fn users(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const html = app.view.render(res.arena, "users", .{ + .title = "Users", + }) catch |err| { + res.status = 500; + res.body = @errorName(err); + return; + }; + + res.content_type = .HTML; + res.body = html; +} + +/// GET /page-a - Page with data fn pageA(app: *App, _: *httpz.Request, res: *httpz.Response) !void { const html = app.view.render(res.arena, "page-a", .{ .title = "Page A - Pets", @@ -154,3 +110,15 @@ fn pageA(app: *App, _: *httpz.Request, res: *httpz.Response) !void { res.content_type = .HTML; res.body = html; } + +/// GET /mixin-test - Mixin test page +fn mixinTest(app: *App, _: *httpz.Request, res: *httpz.Response) !void { + const html = app.view.render(res.arena, "mixin-test", .{}) catch |err| { + res.status = 500; + res.body = @errorName(err); + return; + }; + + res.content_type = .HTML; + res.body = html; +} diff --git a/examples/demo/views/generated.zig b/examples/demo/views/generated.zig index 7c38193..d534caa 100644 --- a/examples/demo/views/generated.zig +++ b/examples/demo/views/generated.zig @@ -214,6 +214,50 @@ pub fn page_a(a: Allocator, d: anytype) Allocator.Error![]u8 { return o.items; } +pub fn mixin_test(a: Allocator, d: anytype) Allocator.Error![]u8 { + var o: ArrayList = .empty; + try o.appendSlice(a, "Mixin Test

Mixin Test Page

Testing button mixin:

"); + { + const text = "Click Me"; + const @"type" = "primary"; + try o.appendSlice(a, ""); + try esc(&o, a, strVal(text)); + try o.appendSlice(a, ""); + } + { + const text = "Cancel"; + const @"type" = "btn btn-secondary"; + try o.appendSlice(a, ""); + try esc(&o, a, strVal(text)); + try o.appendSlice(a, ""); + } + try o.appendSlice(a, "

Testing link mixin:

"); + { + const href = "/home"; + const text = "Go Home"; + try o.appendSlice(a, ""); + try esc(&o, a, strVal(text)); + try o.appendSlice(a, ""); + } + try o.appendSlice(a, ""); + _ = d; + return o.items; +} + pub fn page_b(a: Allocator, d: anytype) Allocator.Error![]u8 { var o: ArrayList = .empty; try o.appendSlice(a, "My Site - "); @@ -281,6 +325,7 @@ pub const template_names = [_][]const u8{ "mixins_input_text", "home", "page_a", + "mixin_test", "page_b", "layout_2", "layout", diff --git a/examples/demo/views/mixin-test.pug b/examples/demo/views/mixin-test.pug new file mode 100644 index 0000000..2d0bd9b --- /dev/null +++ b/examples/demo/views/mixin-test.pug @@ -0,0 +1,15 @@ +include mixins/buttons.pug + +doctype html +html + head + title Mixin Test + body + h1 Mixin Test Page + + p Testing button mixin: + +btn("Click Me") + +btn("Cancel", "secondary") + + p Testing link mixin: + +btn-link("/home", "Go Home") diff --git a/src/ast.zig b/src/ast.zig deleted file mode 100644 index fffbd8f..0000000 --- a/src/ast.zig +++ /dev/null @@ -1,257 +0,0 @@ -//! AST (Abstract Syntax Tree) definitions for Pug templates. -//! -//! The AST represents the hierarchical structure of a Pug document. -//! Each node type corresponds to a Pug language construct. - -const std = @import("std"); - -/// An attribute on an element: name, value, and whether it's escaped. -pub const Attribute = struct { - name: []const u8, - value: ?[]const u8, // null for boolean attributes (e.g., `checked`) - escaped: bool, // true for `=`, false for `!=` -}; - -/// A segment of text content, which may be plain text or interpolation. -pub const TextSegment = union(enum) { - /// Plain text content. - literal: []const u8, - /// Escaped interpolation: #{expr} - HTML entities escaped. - interp_escaped: []const u8, - /// Unescaped interpolation: !{expr} - raw HTML output. - interp_unescaped: []const u8, - /// Tag interpolation: #[tag text] - inline HTML element. - interp_tag: InlineTag, -}; - -/// Inline tag from tag interpolation syntax: #[em text] or #[a(href='/') link] -pub const InlineTag = struct { - /// Tag name (e.g., "em", "a", "strong"). - tag: []const u8, - /// CSS classes from `.class` syntax. - classes: []const []const u8, - /// Element ID from `#id` syntax. - id: ?[]const u8, - /// Attributes from `(attr=value)` syntax. - attributes: []Attribute, - /// Text content (may contain nested interpolations). - text_segments: []TextSegment, -}; - -/// All AST node types. -pub const Node = union(enum) { - /// Root document node containing all top-level nodes. - document: Document, - /// Doctype declaration: `doctype html`. - doctype: Doctype, - /// HTML element with optional tag, classes, id, attributes, and children. - element: Element, - /// Text content (may contain interpolations). - text: Text, - /// Buffered code output: `= expr` (escaped) or `!= expr` (unescaped). - code: Code, - /// Comment: `//` (rendered) or `//-` (silent). - comment: Comment, - /// Conditional: if/else if/else/unless chains. - conditional: Conditional, - /// Each loop: `each item in collection` or `each item, index in collection`. - each: Each, - /// While loop: `while condition`. - @"while": While, - /// Case/switch statement. - case: Case, - /// Mixin definition: `mixin name(args)`. - mixin_def: MixinDef, - /// Mixin call: `+name(args)`. - mixin_call: MixinCall, - /// Mixin block placeholder: `block` inside a mixin. - mixin_block: void, - /// Include directive: `include path`. - include: Include, - /// Extends directive: `extends path`. - extends: Extends, - /// Named block: `block name`. - block: Block, - /// Raw text block (after `.` on element). - raw_text: RawText, -}; - -/// Root document containing all top-level nodes. -pub const Document = struct { - nodes: []Node, - /// Optional extends directive (must be first if present). - extends_path: ?[]const u8 = null, -}; - -/// Doctype declaration node. -pub const Doctype = struct { - /// The doctype value (e.g., "html", "xml", "strict", or custom string). - /// Empty string means default to "html". - value: []const u8, -}; - -/// HTML element node. -pub const Element = struct { - /// Tag name (defaults to "div" if only class/id specified). - tag: []const u8, - /// CSS classes from `.class` syntax. - classes: []const []const u8, - /// Element ID from `#id` syntax. - id: ?[]const u8, - /// Attributes from `(attr=value)` syntax. - attributes: []Attribute, - /// Spread attributes from `&attributes({...})` syntax. - spread_attributes: ?[]const u8 = null, - /// Child nodes (nested elements, text, etc.). - children: []Node, - /// Whether this is a self-closing tag. - self_closing: bool, - /// Inline text content (e.g., `p Hello`). - inline_text: ?[]TextSegment, - /// Buffered code content (e.g., `p= expr` or `p!= expr`). - buffered_code: ?Code = null, - /// Whether children should be rendered inline (block expansion with `:`). - is_inline: bool = false, -}; - -/// Text content node. -pub const Text = struct { - /// Segments of text (literals and interpolations). - segments: []TextSegment, -}; - -/// Code output node: `= expr` or `!= expr`. -pub const Code = struct { - /// The expression to evaluate. - expression: []const u8, - /// Whether output is HTML-escaped. - escaped: bool, -}; - -/// Comment node. -pub const Comment = struct { - /// Comment text content. - content: []const u8, - /// Whether comment is rendered in output (`//`) or silent (`//-`). - rendered: bool, - /// Nested content (for block comments). - children: []Node, -}; - -/// Conditional node for if/else if/else/unless chains. -pub const Conditional = struct { - /// The condition branches in order. - branches: []Branch, - - pub const Branch = struct { - /// Condition expression (null for `else`). - condition: ?[]const u8, - /// Whether this is `unless` (negated condition). - is_unless: bool, - /// Child nodes for this branch. - children: []Node, - }; -}; - -/// Each loop node. -pub const Each = struct { - /// Iterator variable name. - value_name: []const u8, - /// Optional index variable name. - index_name: ?[]const u8, - /// Collection expression to iterate. - collection: []const u8, - /// Loop body nodes. - children: []Node, - /// Optional else branch (when collection is empty). - else_children: []Node, -}; - -/// While loop node. -pub const While = struct { - /// Loop condition expression. - condition: []const u8, - /// Loop body nodes. - children: []Node, -}; - -/// Case/switch node. -pub const Case = struct { - /// Expression to match against. - expression: []const u8, - /// When branches (in order, for fall-through support). - whens: []When, - /// Default branch children (if any). - default_children: []Node, - - pub const When = struct { - /// Value to match. - value: []const u8, - /// Child nodes for this case. Empty means fall-through to next case. - children: []Node, - /// Explicit break (- break) means output nothing. - has_break: bool, - }; -}; - -/// Mixin definition node. -pub const MixinDef = struct { - /// Mixin name. - name: []const u8, - /// Parameter names. - params: []const []const u8, - /// Default values for parameters (null if no default). - defaults: []?[]const u8, - /// Whether last param is rest parameter (...args). - has_rest: bool, - /// Mixin body nodes. - children: []Node, -}; - -/// Mixin call node. -pub const MixinCall = struct { - /// Mixin name to call. - name: []const u8, - /// Argument expressions. - args: []const []const u8, - /// Attributes passed to mixin. - attributes: []Attribute, - /// Block content passed to mixin. - block_children: []Node, -}; - -/// Include directive node. -pub const Include = struct { - /// Path to include. - path: []const u8, - /// Optional filter (e.g., `:markdown`). - filter: ?[]const u8, -}; - -/// Extends directive node. -pub const Extends = struct { - /// Path to parent template. - path: []const u8, -}; - -/// Named block node for template inheritance. -pub const Block = struct { - /// Block name. - name: []const u8, - /// Block mode: replace, append, or prepend. - mode: Mode, - /// Block content nodes. - children: []Node, - - pub const Mode = enum { - replace, - append, - prepend, - }; -}; - -/// Raw text block (from `.` syntax). -pub const RawText = struct { - /// Raw text content lines. - content: []const u8, -}; diff --git a/src/benchmarks/bench.zig b/src/benchmarks/bench.zig index 930004e..b6fd688 100644 --- a/src/benchmarks/bench.zig +++ b/src/benchmarks/bench.zig @@ -1,22 +1,16 @@ -//! Pugz Benchmark - Compiled Templates vs Pug.js +//! Pugz Benchmark - Template Rendering //! -//! Both Pugz and Pug.js benchmarks read from the same files: -//! src/benchmarks/templates/*.pug (templates) -//! src/benchmarks/templates/*.json (data) +//! This benchmark uses template.zig renderWithData function. //! -//! Run Pugz: zig build bench-all-compiled -//! Run Pug.js: cd src/benchmarks/pugjs && npm install && npm run bench +//! Run: zig build bench-v1 const std = @import("std"); -const tpls = @import("tpls"); +const pugz = @import("pugz"); const iterations: usize = 2000; const templates_dir = "src/benchmarks/templates"; -// ═══════════════════════════════════════════════════════════════════════════ // Data structures matching JSON files -// ═══════════════════════════════════════════════════════════════════════════ - const SubFriend = struct { id: i64, name: []const u8, @@ -58,10 +52,6 @@ const SearchRecord = struct { sizes: ?[]const []const u8, }; -// ═══════════════════════════════════════════════════════════════════════════ -// Main -// ═══════════════════════════════════════════════════════════════════════════ - pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); @@ -69,20 +59,17 @@ pub fn main() !void { std.debug.print("\n", .{}); std.debug.print("╔═══════════════════════════════════════════════════════════════╗\n", .{}); - std.debug.print("║ Compiled Zig Templates Benchmark ({d} iterations) ║\n", .{iterations}); + std.debug.print("║ V1 Template Benchmark ({d} iterations) ║\n", .{iterations}); std.debug.print("║ Templates: {s}/*.pug ║\n", .{templates_dir}); std.debug.print("╚═══════════════════════════════════════════════════════════════╝\n", .{}); - // ───────────────────────────────────────────────────────────────────────── // Load JSON data - // ───────────────────────────────────────────────────────────────────────── std.debug.print("\nLoading JSON data...\n", .{}); var data_arena = std.heap.ArenaAllocator.init(allocator); defer data_arena.deinit(); const data_alloc = data_arena.allocator(); - // Load all JSON files const simple0 = try loadJson(struct { name: []const u8 }, data_alloc, "simple-0.json"); const simple1 = try loadJson(struct { name: []const u8, @@ -108,38 +95,27 @@ pub fn main() !void { const search = try loadJson(struct { searchRecords: []const SearchRecord }, data_alloc, "search-results.json"); const friends_data = try loadJson(struct { friends: []const Friend }, data_alloc, "friends.json"); + // Load template sources + const simple0_tpl = try loadTemplate(data_alloc, "simple-0.pug"); + const simple1_tpl = try loadTemplate(data_alloc, "simple-1.pug"); + const simple2_tpl = try loadTemplate(data_alloc, "simple-2.pug"); + const if_expr_tpl = try loadTemplate(data_alloc, "if-expression.pug"); + const projects_tpl = try loadTemplate(data_alloc, "projects-escaped.pug"); + const search_tpl = try loadTemplate(data_alloc, "search-results.pug"); + const friends_tpl = try loadTemplate(data_alloc, "friends.pug"); + std.debug.print("Loaded. Starting benchmark...\n\n", .{}); var total: f64 = 0; - // ───────────────────────────────────────────────────────────────────────── - // Benchmark each template - // ───────────────────────────────────────────────────────────────────────── + total += try bench("simple-0", allocator, simple0_tpl, simple0); + total += try bench("simple-1", allocator, simple1_tpl, simple1); + total += try bench("simple-2", allocator, simple2_tpl, simple2); + total += try bench("if-expression", allocator, if_expr_tpl, if_expr); + total += try bench("projects-escaped", allocator, projects_tpl, projects); + total += try bench("search-results", allocator, search_tpl, search); + total += try bench("friends", allocator, friends_tpl, friends_data); - // simple-0 - total += try bench("simple-0", allocator, tpls.simple_0, simple0); - - // simple-1 - total += try bench("simple-1", allocator, tpls.simple_1, simple1); - - // simple-2 - total += try bench("simple-2", allocator, tpls.simple_2, simple2); - - // if-expression - total += try bench("if-expression", allocator, tpls.if_expression, if_expr); - - // projects-escaped - total += try bench("projects-escaped", allocator, tpls.projects_escaped, projects); - - // search-results - total += try bench("search-results", allocator, tpls.search_results, search); - - // friends - total += try bench("friends", allocator, tpls.friends, friends_data); - - // ───────────────────────────────────────────────────────────────────────── - // Summary - // ───────────────────────────────────────────────────────────────────────── std.debug.print("\n", .{}); std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ "TOTAL", total }); std.debug.print("\n", .{}); @@ -152,10 +128,15 @@ fn loadJson(comptime T: type, alloc: std.mem.Allocator, comptime filename: []con return parsed.value; } +fn loadTemplate(alloc: std.mem.Allocator, comptime filename: []const u8) ![]const u8 { + const path = templates_dir ++ "/" ++ filename; + return try std.fs.cwd().readFileAlloc(alloc, path, 1 * 1024 * 1024); +} + fn bench( name: []const u8, allocator: std.mem.Allocator, - comptime render_fn: anytype, + template: []const u8, data: anytype, ) !f64 { var arena = std.heap.ArenaAllocator.init(allocator); @@ -164,7 +145,10 @@ fn bench( var timer = try std.time.Timer.start(); for (0..iterations) |_| { _ = arena.reset(.retain_capacity); - _ = try render_fn(arena.allocator(), data); + _ = pugz.template.renderWithData(arena.allocator(), template, data) catch |err| { + std.debug.print(" {s:<20} => ERROR: {}\n", .{ name, err }); + return 0; + }; } const ms = @as(f64, @floatFromInt(timer.read())) / 1_000_000.0; std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ name, ms }); diff --git a/src/benchmarks/bench_interpreted.zig b/src/benchmarks/bench_interpreted.zig deleted file mode 100644 index e241e2d..0000000 --- a/src/benchmarks/bench_interpreted.zig +++ /dev/null @@ -1,154 +0,0 @@ -//! Pugz Benchmark - Interpreted (Runtime) Mode -//! -//! This benchmark uses the ViewEngine to render templates at runtime, -//! reading from the same template/data files as the compiled benchmark. -//! -//! Run: zig build bench-interpreted - -const std = @import("std"); -const pugz = @import("pugz"); - -const iterations: usize = 2000; -const templates_dir = "src/benchmarks/templates"; - -// Data structures matching JSON files -const SubFriend = struct { - id: i64, - name: []const u8, -}; - -const Friend = struct { - name: []const u8, - balance: []const u8, - age: i64, - address: []const u8, - picture: []const u8, - company: []const u8, - email: []const u8, - emailHref: []const u8, - about: []const u8, - tags: []const []const u8, - friends: []const SubFriend, -}; - -const Account = struct { - balance: i64, - balanceFormatted: []const u8, - status: []const u8, - negative: bool, -}; - -const Project = struct { - name: []const u8, - url: []const u8, - description: []const u8, -}; - -const SearchRecord = struct { - imgUrl: []const u8, - viewItemUrl: []const u8, - title: []const u8, - description: []const u8, - featured: bool, - sizes: ?[]const []const u8, -}; - -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{}){}; - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); - - std.debug.print("\n", .{}); - std.debug.print("╔═══════════════════════════════════════════════════════════════╗\n", .{}); - std.debug.print("║ Interpreted (Runtime) Benchmark ({d} iterations) ║\n", .{iterations}); - std.debug.print("║ Templates: {s}/*.pug ║\n", .{templates_dir}); - std.debug.print("╚═══════════════════════════════════════════════════════════════╝\n", .{}); - - // Load JSON data - std.debug.print("\nLoading JSON data...\n", .{}); - - var data_arena = std.heap.ArenaAllocator.init(allocator); - defer data_arena.deinit(); - const data_alloc = data_arena.allocator(); - - const simple0 = try loadJson(struct { name: []const u8 }, data_alloc, "simple-0.json"); - const simple1 = try loadJson(struct { - name: []const u8, - messageCount: i64, - colors: []const []const u8, - primary: bool, - }, data_alloc, "simple-1.json"); - const simple2 = try loadJson(struct { - header: []const u8, - header2: []const u8, - header3: []const u8, - header4: []const u8, - header5: []const u8, - header6: []const u8, - list: []const []const u8, - }, data_alloc, "simple-2.json"); - const if_expr = try loadJson(struct { accounts: []const Account }, data_alloc, "if-expression.json"); - const projects = try loadJson(struct { - title: []const u8, - text: []const u8, - projects: []const Project, - }, data_alloc, "projects-escaped.json"); - const search = try loadJson(struct { searchRecords: []const SearchRecord }, data_alloc, "search-results.json"); - const friends_data = try loadJson(struct { friends: []const Friend }, data_alloc, "friends.json"); - - // Load template sources - const simple0_tpl = try loadTemplate(data_alloc, "simple-0.pug"); - const simple1_tpl = try loadTemplate(data_alloc, "simple-1.pug"); - const simple2_tpl = try loadTemplate(data_alloc, "simple-2.pug"); - const if_expr_tpl = try loadTemplate(data_alloc, "if-expression.pug"); - const projects_tpl = try loadTemplate(data_alloc, "projects-escaped.pug"); - const search_tpl = try loadTemplate(data_alloc, "search-results.pug"); - const friends_tpl = try loadTemplate(data_alloc, "friends.pug"); - - std.debug.print("Loaded. Starting benchmark...\n\n", .{}); - - var total: f64 = 0; - - total += try bench("simple-0", allocator, simple0_tpl, simple0); - total += try bench("simple-1", allocator, simple1_tpl, simple1); - total += try bench("simple-2", allocator, simple2_tpl, simple2); - total += try bench("if-expression", allocator, if_expr_tpl, if_expr); - total += try bench("projects-escaped", allocator, projects_tpl, projects); - total += try bench("search-results", allocator, search_tpl, search); - total += try bench("friends", allocator, friends_tpl, friends_data); - - std.debug.print("\n", .{}); - std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ "TOTAL", total }); - std.debug.print("\n", .{}); -} - -fn loadJson(comptime T: type, alloc: std.mem.Allocator, comptime filename: []const u8) !T { - const path = templates_dir ++ "/" ++ filename; - const content = try std.fs.cwd().readFileAlloc(alloc, path, 10 * 1024 * 1024); - const parsed = try std.json.parseFromSlice(T, alloc, content, .{}); - return parsed.value; -} - -fn loadTemplate(alloc: std.mem.Allocator, comptime filename: []const u8) ![]const u8 { - const path = templates_dir ++ "/" ++ filename; - return try std.fs.cwd().readFileAlloc(alloc, path, 1 * 1024 * 1024); -} - -fn bench( - name: []const u8, - allocator: std.mem.Allocator, - template: []const u8, - data: anytype, -) !f64 { - var arena = std.heap.ArenaAllocator.init(allocator); - defer arena.deinit(); - - var timer = try std.time.Timer.start(); - for (0..iterations) |_| { - _ = arena.reset(.retain_capacity); - _ = try pugz.renderTemplate(arena.allocator(), template, data); - } - const ms = @as(f64, @floatFromInt(timer.read())) / 1_000_000.0; - std.debug.print(" {s:<20} => {d:>7.1}ms\n", .{ name, ms }); - return ms; -} diff --git a/src/build_templates.zig b/src/build_templates.zig deleted file mode 100644 index 76f65e3..0000000 --- a/src/build_templates.zig +++ /dev/null @@ -1,2067 +0,0 @@ -//! Pugz Build Step - Compile .pug templates to Zig code at build time. -//! -//! This module transforms .pug template files into native Zig functions during the build process. -//! The generated code runs ~3x faster than interpreted templates by eliminating runtime parsing. -//! -//! ## Architecture -//! -//! The compilation pipeline: -//! 1. `compileTemplates()` - Entry point, creates a build step that produces a Zig module -//! 2. `CompileTemplatesStep` - Build step that orchestrates template discovery and compilation -//! 3. `findTemplates()` - Recursively walks source_dir to find all .pug files -//! 4. `generateSingleFile()` - Creates generated.zig with helper functions and all templates -//! 5. `Compiler` - Core compiler that transforms AST nodes into Zig code -//! -//! ## Generated Output -//! -//! The generated.zig file contains: -//! - Shared helpers: `esc()` (HTML escaping), `truthy()` (boolean coercion), `strVal()` (type conversion) -//! - One public function per template, named after the file path (e.g., pages/home.pug -> pages_home()) -//! - Static string merging for consecutive literals (reduces allocations) -//! - Zero-allocation rendering for fully static templates -//! -//! ## Usage in build.zig: -//! ```zig -//! const build_templates = @import("pugz").build_templates; -//! const templates = build_templates.compileTemplates(b, .{ -//! .source_dir = "views", -//! }); -//! exe.root_module.addImport("templates", templates); -//! ``` -//! -//! ## Usage in code: -//! ```zig -//! const tpls = @import("templates"); -//! const html = try tpls.home(allocator, .{ .title = "Welcome" }); -//! ``` - -const std = @import("std"); -const lexer_mod = @import("lexer.zig"); -const Lexer = lexer_mod.Lexer; -const Diagnostic = lexer_mod.Diagnostic; -const Parser = @import("parser.zig").Parser; -const ast = @import("ast.zig"); - -pub const Options = struct { - /// Root directory containing .pug template files (searched recursively) - source_dir: []const u8 = "views", - /// File extension for template files - extension: []const u8 = ".pug", -}; - -/// Creates a build module containing compiled templates. -/// Call this from build.zig to integrate template compilation into your build. -/// Returns a module that can be imported as "templates" (or any name you choose). -pub fn compileTemplates(b: *std.Build, options: Options) *std.Build.Module { - const step = CompileTemplatesStep.create(b, options); - return b.createModule(.{ - .root_source_file = step.getOutput(), - }); -} - -/// Build step that discovers and compiles all .pug templates in source_dir. -/// Outputs a single generated.zig file containing all template functions. -const CompileTemplatesStep = struct { - step: std.Build.Step, - options: Options, - generated_file: std.Build.GeneratedFile, - - fn create(b: *std.Build, options: Options) *CompileTemplatesStep { - const self = b.allocator.create(CompileTemplatesStep) catch @panic("pugz failed on CompileTemplatesStep"); - self.* = .{ - .step = std.Build.Step.init(.{ - .id = .custom, - .name = "pugz-compile-templates", - .owner = b, - .makeFn = make, - }), - .options = options, - .generated_file = .{ .step = &self.step }, - }; - return self; - } - - fn getOutput(self: *CompileTemplatesStep) std.Build.LazyPath { - return .{ .generated = .{ .file = &self.generated_file } }; - } - - fn make(step: *std.Build.Step, _: std.Build.Step.MakeOptions) !void { - const self: *CompileTemplatesStep = @fieldParentPtr("step", step); - const b = step.owner; - const allocator = b.allocator; - - var templates = std.ArrayList(TemplateInfo){}; - defer templates.deinit(allocator); - - try findTemplates(allocator, self.options.source_dir, "", self.options.extension, &templates); - - const out_path = try std.fs.path.join(allocator, &.{ self.options.source_dir, "generated.zig" }); - try generateSingleFile( - allocator, - self.options.source_dir, - self.options.extension, - out_path, - templates.items, - ); - - self.generated_file.path = out_path; - } -}; - -/// Metadata for a discovered template file -const TemplateInfo = struct { - /// Path relative to source_dir (e.g., "pages/home.pug") - rel_path: []const u8, - /// Valid Zig identifier derived from path (e.g., "pages_home") - zig_name: []const u8, -}; - -/// Recursively walks source_dir to discover all .pug template files. -/// Populates the templates list with path and generated function name for each file. -fn findTemplates( - allocator: std.mem.Allocator, - source_dir: []const u8, - out_path: []const u8, - extension: []const u8, - templates: *std.ArrayList(TemplateInfo), -) !void { - const full_path = if (out_path.len > 0) - try std.fs.path.join(allocator, &.{ source_dir, out_path }) - else - try allocator.dupe(u8, source_dir); - defer allocator.free(full_path); - - var dir = std.fs.cwd().openDir(full_path, .{ .iterate = true }) catch |err| { - std.log.warn("Cannot open directory {s}: {}", .{ full_path, err }); - return; - }; - defer dir.close(); - - var iter = dir.iterate(); - while (try iter.next()) |entry| { - const name = try allocator.dupe(u8, entry.name); - if (entry.kind == .directory) { - const new_sub = if (out_path.len > 0) - try std.fs.path.join(allocator, &.{ out_path, name }) - else - name; - try findTemplates(allocator, source_dir, new_sub, extension, templates); - } else if (entry.kind == .file and std.mem.endsWith(u8, name, extension)) { - const rel_path = if (out_path.len > 0) - try std.fs.path.join(allocator, &.{ out_path, name }) - else - name; - - const without_ext = rel_path[0 .. rel_path.len - extension.len]; - const zig_name = try pathToIdent(allocator, without_ext); - - try templates.append(allocator, .{ - .rel_path = rel_path, - .zig_name = zig_name, - }); - } - } -} - -/// Converts a file path to a valid Zig identifier. -/// Replaces path separators and special chars with underscores. -/// Prefixes with '_' if the path starts with a digit. -fn pathToIdent(allocator: std.mem.Allocator, path: []const u8) ![]u8 { - if (path.len == 0) return try allocator.alloc(u8, 0); - - const first_char = path[0]; - const needs_prefix = !std.ascii.isAlphabetic(first_char) and first_char != '_'; - - const result_len = if (needs_prefix) path.len + 1 else path.len; - var result = try allocator.alloc(u8, result_len); - - const offset: usize = if (needs_prefix) blk: { - result[0] = '_'; - break :blk 1; - } else 0; - - for (path, 0..) |c, i| { - result[i + offset] = switch (c) { - '/', '\\', '-', '.' => '_', - else => c, - }; - } - - return result; -} - -/// Block content from child template, used during inheritance resolution. -/// Stores the mode (replace/append/prepend) and child nodes. -const BlockDef = struct { - mode: ast.Block.Mode, - children: []const ast.Node, -}; - -/// Generates the complete generated.zig file containing all compiled templates. -/// Writes helper functions at the top, followed by each template as a public function. -fn generateSingleFile( - allocator: std.mem.Allocator, - source_dir: []const u8, - extension: []const u8, - out_path: []const u8, - templates: []const TemplateInfo, -) !void { - var out = std.ArrayList(u8){}; - defer out.deinit(allocator); - - const w = out.writer(allocator); - - // Header - try w.writeAll( - \\//! Auto-generated by pugz.compileTemplates() - \\//! Do not edit manually - regenerate by running: zig build - \\ - \\const std = @import("std"); - \\const Allocator = std.mem.Allocator; - \\const ArrayList = std.ArrayList(u8); - \\ - \\// ───────────────────────────────────────────────────────────────────────────── - \\// Helpers - \\// ───────────────────────────────────────────────────────────────────────────── - \\ - \\const esc_lut: [256]?[]const u8 = blk: { - \\ var t: [256]?[]const u8 = .{null} ** 256; - \\ t['&'] = "&"; - \\ t['<'] = "<"; - \\ t['>'] = ">"; - \\ t['"'] = """; - \\ t['\''] = "'"; - \\ break :blk t; - \\}; - \\ - \\fn esc(o: *ArrayList, a: Allocator, s: []const u8) Allocator.Error!void { - \\ var i: usize = 0; - \\ for (s, 0..) |c, j| { - \\ if (esc_lut[c]) |e| { - \\ if (j > i) try o.appendSlice(a, s[i..j]); - \\ try o.appendSlice(a, e); - \\ i = j + 1; - \\ } - \\ } - \\ if (i < s.len) try o.appendSlice(a, s[i..]); - \\} - \\ - \\fn truthy(v: anytype) bool { - \\ return switch (@typeInfo(@TypeOf(v))) { - \\ .bool => v, - \\ .optional => v != null, - \\ .pointer => |p| if (p.size == .slice) v.len > 0 else true, - \\ .int, .comptime_int => v != 0, - \\ else => true, - \\ }; - \\} - \\ - \\var int_buf: [32]u8 = undefined; - \\ - \\fn strVal(v: anytype) []const u8 { - \\ const T = @TypeOf(v); - \\ switch (@typeInfo(T)) { - \\ .pointer => |p| switch (p.size) { - \\ .slice => return v, - \\ .one => { - \\ // For pointer-to-array, slice it - \\ const child_info = @typeInfo(p.child); - \\ if (child_info == .array) { - \\ const arr_info = child_info.array; - \\ const ptr: [*]const arr_info.child = @ptrCast(v); - \\ return ptr[0..arr_info.len]; - \\ } - \\ return strVal(v.*); - \\ }, - \\ else => @compileError("unsupported pointer type"), - \\ }, - \\ .array => @compileError("arrays must be passed by pointer"), - \\ .int, .comptime_int => return std.fmt.bufPrint(&int_buf, "{d}", .{v}) catch "0", - \\ .optional => return if (v) |val| strVal(val) else "", - \\ else => @compileError("strVal: unsupported type " ++ @typeName(T)), - \\ } - \\} - \\ - \\// ───────────────────────────────────────────────────────────────────────────── - \\// Templates - \\// ───────────────────────────────────────────────────────────────────────────── - \\ - \\ - ); - - // Generate each template - for (templates) |tpl| { - const src_path = try std.fs.path.join(allocator, &.{ source_dir, tpl.rel_path }); - defer allocator.free(src_path); - - const source = std.fs.cwd().readFileAlloc(allocator, src_path, 5 * 1024 * 1024) catch |err| { - std.log.err("Failed to read {s}: {}", .{ src_path, err }); - return err; - }; - defer allocator.free(source); - - compileTemplate(allocator, w, source_dir, extension, tpl.zig_name, source) catch |err| { - std.log.err("Failed to compile template {s}: {}", .{ tpl.rel_path, err }); - return err; - }; - } - - // Template names list - try w.writeAll("pub const template_names = [_][]const u8{\n"); - for (templates) |tpl| { - try w.print(" \"{s}\",\n", .{tpl.zig_name}); - } - try w.writeAll("};\n"); - - const file = try std.fs.cwd().createFile(out_path, .{}); - defer file.close(); - try file.writeAll(out.items); -} - -/// Logs a diagnostic error with file location in compiler-style format. -fn logDiagnostic(file_path: []const u8, diag: Diagnostic) void { - std.log.err("{s}:{d}:{d}: {s}", .{ file_path, diag.line, diag.column, diag.message }); - if (diag.source_line) |src_line| { - std.log.err(" | {s}", .{src_line}); - } - if (diag.suggestion) |hint| { - std.log.err(" = hint: {s}", .{hint}); - } -} - -/// Compiles a single .pug template into a Zig function. -/// Handles three cases: -/// - Empty templates: return "" -/// - Static-only templates: return literal string (zero allocation) -/// - Dynamic templates: use ArrayList and return o.items -fn compileTemplate( - allocator: std.mem.Allocator, - w: std.ArrayList(u8).Writer, - source_dir: []const u8, - extension: []const u8, - name: []const u8, - source: []const u8, -) !void { - var lexer = Lexer.init(allocator, source); - defer lexer.deinit(); - const tokens = lexer.tokenize() catch |err| { - if (lexer.getDiagnostic()) |diag| { - logDiagnostic(name, diag); - } else { - std.log.err("Tokenize error in '{s}': {}", .{ name, err }); - } - return err; - }; - - var parser = Parser.initWithSource(allocator, tokens, source); - const doc = parser.parse() catch |err| { - if (parser.getDiagnostic()) |diag| { - logDiagnostic(name, diag); - } else { - std.log.err("Parse error in '{s}': {}", .{ name, err }); - } - return err; - }; - - var compiler = Compiler.init(allocator, w, source_dir, extension); - - // Resolve extends/block inheritance chain before emission - const resolved_nodes = try compiler.resolveInheritance(doc); - - // Determine template characteristics for optimal code generation - var has_content = false; - for (resolved_nodes) |node| { - if (nodeHasOutput(node)) { - has_content = true; - break; - } - } - - var has_dynamic = false; - for (resolved_nodes) |node| { - if (nodeHasDynamic(node)) { - has_dynamic = true; - break; - } - } - - // Generate function signature: pub fn name(a: Allocator, d: anytype) ![]u8 - try w.print("pub fn {s}(a: Allocator, d: anytype) Allocator.Error![]u8 {{\n", .{name}); - - if (!has_content) { - // Empty template (e.g., mixin-only files) - try w.writeAll(" _ = .{ a, d };\n"); - try w.writeAll(" return \"\";\n"); - } else if (!has_dynamic) { - // Static-only: return string literal directly, no heap allocation needed - try w.writeAll(" _ = .{ a, d };\n"); - try w.writeAll(" return "); - for (resolved_nodes) |node| { - try compiler.emitNode(node); - } - try compiler.flushAsReturn(); - } else { - // Dynamic: build output incrementally with ArrayList - try w.writeAll(" var o: ArrayList = .empty;\n"); - - for (resolved_nodes) |node| { - try compiler.emitNode(node); - } - try compiler.flush(); - - // Suppress unused parameter warning if data wasn't accessed - if (!compiler.uses_data) { - try w.writeAll(" _ = d;\n"); - } - - try w.writeAll(" return o.items;\n"); - } - - try w.writeAll("}\n\n"); -} - -/// Checks if a node produces any HTML output (used to detect empty templates) -fn nodeHasOutput(node: ast.Node) bool { - return switch (node) { - .doctype, .element, .text, .raw_text, .comment => true, - .conditional => |c| blk: { - for (c.branches) |br| { - for (br.children) |child| { - if (nodeHasOutput(child)) break :blk true; - } - } - break :blk false; - }, - .each => |e| blk: { - for (e.children) |child| { - if (nodeHasOutput(child)) break :blk true; - } - break :blk false; - }, - .case => |c| blk: { - for (c.whens) |when| { - for (when.children) |child| { - if (nodeHasOutput(child)) break :blk true; - } - } - for (c.default_children) |child| { - if (nodeHasOutput(child)) break :blk true; - } - break :blk false; - }, - .mixin_call => true, // Mixin calls may produce output - .block => |b| blk: { - for (b.children) |child| { - if (nodeHasOutput(child)) break :blk true; - } - break :blk false; - }, - .include => true, // Includes may produce output - .document => |d| blk: { - for (d.nodes) |child| { - if (nodeHasOutput(child)) break :blk true; - } - break :blk false; - }, - else => false, - }; -} - -/// Checks if a node contains dynamic content requiring runtime evaluation -/// (interpolation, conditionals, loops, mixin calls) -fn nodeHasDynamic(node: ast.Node) bool { - return switch (node) { - .element => |e| blk: { - if (e.buffered_code != null) break :blk true; - if (e.inline_text) |segs| { - for (segs) |seg| { - if (seg != .literal) break :blk true; - } - } - for (e.children) |child| { - if (nodeHasDynamic(child)) break :blk true; - } - break :blk false; - }, - .text => |t| blk: { - for (t.segments) |seg| { - if (seg != .literal) break :blk true; - } - break :blk false; - }, - .conditional, .each, .case => true, - .mixin_call => true, // Mixin calls are dynamic - .block => |b| blk: { - for (b.children) |child| { - if (nodeHasDynamic(child)) break :blk true; - } - break :blk false; - }, - .include => true, // Includes may have dynamic content - .document => |d| blk: { - for (d.nodes) |child| { - if (nodeHasDynamic(child)) break :blk true; - } - break :blk false; - }, - else => false, - }; -} - -/// Checks if a mixin body references `attributes` (for &attributes pass-through). -/// Used to avoid emitting unused mixin_attrs struct in generated code. -fn mixinUsesAttributes(nodes: []const ast.Node) bool { - for (nodes) |node| { - switch (node) { - .element => |e| { - // Check spread_attributes field - if (e.spread_attributes != null) return true; - - // Check attribute values for 'attributes' reference - for (e.attributes) |attr| { - if (attr.value) |val| { - if (exprReferencesAttributes(val)) return true; - } - } - - // Check inline text for interpolated attributes reference - if (e.inline_text) |segs| { - if (textSegmentsReferenceAttributes(segs)) return true; - } - - // Check buffered code - if (e.buffered_code) |bc| { - if (exprReferencesAttributes(bc.expression)) return true; - } - - // Recurse into children - if (mixinUsesAttributes(e.children)) return true; - }, - .text => |t| { - if (textSegmentsReferenceAttributes(t.segments)) return true; - }, - .conditional => |c| { - for (c.branches) |br| { - if (mixinUsesAttributes(br.children)) return true; - } - }, - .each => |e| { - if (mixinUsesAttributes(e.children)) return true; - if (mixinUsesAttributes(e.else_children)) return true; - }, - .case => |c| { - for (c.whens) |when| { - if (mixinUsesAttributes(when.children)) return true; - } - if (mixinUsesAttributes(c.default_children)) return true; - }, - .block => |b| { - if (mixinUsesAttributes(b.children)) return true; - }, - else => {}, - } - } - return false; -} - -/// Checks if an expression string references 'attributes' (e.g., "attributes.class"). -fn exprReferencesAttributes(expr: []const u8) bool { - // Check for 'attributes' as standalone or prefix (attributes.class, attributes.id, etc.) - if (std.mem.startsWith(u8, expr, "attributes")) { - // Must be exactly "attributes" or "attributes." followed by more - if (expr.len == 10) return true; // exactly "attributes" - if (expr.len > 10 and expr[10] == '.') return true; // "attributes.something" - } - return false; -} - -/// Checks if text segments contain interpolations referencing 'attributes'. -fn textSegmentsReferenceAttributes(segs: []const ast.TextSegment) bool { - for (segs) |seg| { - switch (seg) { - .interp_escaped, .interp_unescaped => |expr| { - if (exprReferencesAttributes(expr)) return true; - }, - else => {}, - } - } - return false; -} - -/// Zig reserved keywords - field names matching these must be escaped with @"..." -/// when used in generated code (e.g., @"type" instead of type) -const zig_keywords = std.StaticStringMap(void).initComptime(.{ - .{ "addrspace", {} }, - .{ "align", {} }, - .{ "allowzero", {} }, - .{ "and", {} }, - .{ "anyframe", {} }, - .{ "anytype", {} }, - .{ "asm", {} }, - .{ "async", {} }, - .{ "await", {} }, - .{ "break", {} }, - .{ "callconv", {} }, - .{ "catch", {} }, - .{ "comptime", {} }, - .{ "const", {} }, - .{ "continue", {} }, - .{ "defer", {} }, - .{ "else", {} }, - .{ "enum", {} }, - .{ "errdefer", {} }, - .{ "error", {} }, - .{ "export", {} }, - .{ "extern", {} }, - .{ "false", {} }, - .{ "fn", {} }, - .{ "for", {} }, - .{ "if", {} }, - .{ "inline", {} }, - .{ "linksection", {} }, - .{ "noalias", {} }, - .{ "noinline", {} }, - .{ "nosuspend", {} }, - .{ "null", {} }, - .{ "opaque", {} }, - .{ "or", {} }, - .{ "orelse", {} }, - .{ "packed", {} }, - .{ "pub", {} }, - .{ "resume", {} }, - .{ "return", {} }, - .{ "struct", {} }, - .{ "suspend", {} }, - .{ "switch", {} }, - .{ "test", {} }, - .{ "threadlocal", {} }, - .{ "true", {} }, - .{ "try", {} }, - .{ "type", {} }, - .{ "undefined", {} }, - .{ "union", {} }, - .{ "unreachable", {} }, - .{ "usingnamespace", {} }, - .{ "var", {} }, - .{ "volatile", {} }, - .{ "while", {} }, -}); - -/// Escapes identifier if it's a Zig keyword by wrapping in @"..." -fn escapeIdent(ident: []const u8, buf: []u8) []const u8 { - if (zig_keywords.has(ident)) { - return std.fmt.bufPrint(buf, "@\"{s}\"", .{ident}) catch ident; - } - return ident; -} - -/// Core compiler that transforms AST nodes into Zig source code. -/// Maintains state for: -/// - Static string buffering (merges consecutive literals into single appendSlice) -/// - Loop variable tracking (to distinguish loop vars from data fields) -/// - Mixin parameter tracking (for proper scoping) -/// - Template inheritance (blocks from child templates) -/// - Mixin definitions (collected during parsing for later calls) -const Compiler = struct { - allocator: std.mem.Allocator, - writer: std.ArrayList(u8).Writer, - source_dir: []const u8, - extension: []const u8, - buf: std.ArrayList(u8), // Accumulates static strings for batch output - depth: usize, // Current indentation level in generated code - loop_vars: std.ArrayList([]const u8), // Active loop variable names (for each loops) - mixin_params: std.ArrayList([]const u8), // Current mixin's parameter names - mixins: std.StringHashMap(ast.MixinDef), // All discovered mixin definitions - blocks: std.StringHashMap(BlockDef), // Child template block overrides - uses_data: bool, // True if template accesses the data parameter 'd' - mixin_depth: usize, // Nesting level for generating unique mixin variable names - current_attrs_var: ?[]const u8, // Variable name for current mixin's &attributes - - fn init( - allocator: std.mem.Allocator, - writer: std.ArrayList(u8).Writer, - source_dir: []const u8, - extension: []const u8, - ) Compiler { - return .{ - .allocator = allocator, - .writer = writer, - .source_dir = source_dir, - .extension = extension, - .buf = .{}, - .depth = 1, - .loop_vars = .{}, - .mixin_params = .{}, - .mixins = std.StringHashMap(ast.MixinDef).init(allocator), - .blocks = std.StringHashMap(BlockDef).init(allocator), - .uses_data = false, - .mixin_depth = 0, - .current_attrs_var = null, - }; - } - - /// Resolves template inheritance chain (extends keyword). - /// Walks up the inheritance chain collecting blocks, then returns the root template's nodes. - /// Block overrides are stored in self.blocks and applied during emitBlock(). - fn resolveInheritance(self: *Compiler, doc: ast.Document) ![]const ast.Node { - try self.collectMixins(doc.nodes); - - if (doc.extends_path) |extends_path| { - // Child template: collect its block overrides - try self.collectBlocks(doc.nodes); - - // Load parent and recursively resolve (parent may also extend) - const parent_doc = try self.loadTemplate(extends_path); - try self.collectMixins(parent_doc.nodes); - return try self.resolveInheritance(parent_doc); - } - - // Root template: return its nodes (blocks resolved during emission) - return doc.nodes; - } - - /// Recursively collects all mixin definitions from the AST. - /// Mixins can be defined anywhere in a template (top-level or nested). - fn collectMixins(self: *Compiler, nodes: []const ast.Node) !void { - for (nodes) |node| { - switch (node) { - .mixin_def => |def| try self.mixins.put(def.name, def), - .element => |e| try self.collectMixins(e.children), - .conditional => |c| { - for (c.branches) |br| try self.collectMixins(br.children); - }, - .each => |e| { - try self.collectMixins(e.children); - try self.collectMixins(e.else_children); - }, - .block => |b| try self.collectMixins(b.children), - else => {}, - } - } - } - - /// Collects block definitions from a child template for inheritance. - /// These override or extend the parent template's blocks. - fn collectBlocks(self: *Compiler, nodes: []const ast.Node) !void { - for (nodes) |node| { - switch (node) { - .block => |blk| { - try self.blocks.put(blk.name, .{ - .mode = blk.mode, - .children = blk.children, - }); - }, - .element => |e| { - try self.collectBlocks(e.children); - }, - .conditional => |c| { - for (c.branches) |br| { - try self.collectBlocks(br.children); - } - }, - .each => |e| { - try self.collectBlocks(e.children); - }, - else => {}, - } - } - } - - /// Loads and parses a template file by path (for extends/include). - /// Path can be with or without extension. - fn loadTemplate(self: *Compiler, path: []const u8) !ast.Document { - const full_path = blk: { - if (std.mem.endsWith(u8, path, self.extension)) { - break :blk try std.fs.path.join(self.allocator, &.{ self.source_dir, path }); - } else { - const with_ext = try std.fmt.allocPrint(self.allocator, "{s}{s}", .{ path, self.extension }); - defer self.allocator.free(with_ext); - break :blk try std.fs.path.join(self.allocator, &.{ self.source_dir, with_ext }); - } - }; - defer self.allocator.free(full_path); - - const source = std.fs.cwd().readFileAlloc(self.allocator, full_path, 5 * 1024 * 1024) catch |err| { - std.log.err("Failed to load template '{s}': {}", .{ full_path, err }); - return err; - }; - - var lexer = Lexer.init(self.allocator, source); - const tokens = lexer.tokenize() catch |err| { - if (lexer.getDiagnostic()) |diag| { - logDiagnostic(path, diag); - } else { - std.log.err("Tokenize error in included template '{s}': {}", .{ path, err }); - } - return err; - }; - - var parser = Parser.initWithSource(self.allocator, tokens, source); - return parser.parse() catch |err| { - if (parser.getDiagnostic()) |diag| { - logDiagnostic(path, diag); - } else { - std.log.err("Parse error in included template '{s}': {}", .{ path, err }); - } - return err; - }; - } - - /// Writes buffered static content as a single appendSlice call and clears the buffer. - fn flush(self: *Compiler) !void { - if (self.buf.items.len > 0) { - try self.writeIndent(); - try self.writer.writeAll("try o.appendSlice(a, \""); - try self.writer.writeAll(self.buf.items); - try self.writer.writeAll("\");\n"); - self.buf.items.len = 0; - } - } - - /// Writes buffered static content as a return statement (for static-only templates). - fn flushAsReturn(self: *Compiler) !void { - try self.writer.writeAll("\""); - try self.writer.writeAll(self.buf.items); - try self.writer.writeAll("\";\n"); - self.buf.items.len = 0; - } - - /// Appends static string content to the buffer, escaping for Zig string literals. - fn appendStatic(self: *Compiler, s: []const u8) !void { - for (s) |c| { - const escaped: []const u8 = switch (c) { - '\\' => "\\\\", - '"' => "\\\"", - '\n' => "\\n", - '\r' => "\\r", - '\t' => "\\t", - else => &[_]u8{c}, - }; - try self.buf.appendSlice(self.allocator, escaped); - } - } - - /// Appends string with whitespace normalization (for backtick template literals). - /// Collapses newlines/spaces into single spaces, escapes quotes as " for HTML. - fn appendNormalizedWhitespace(self: *Compiler, s: []const u8) !void { - var in_whitespace = true; // Start true to skip leading whitespace - for (s) |c| { - if (c == ' ' or c == '\t' or c == '\n' or c == '\r') { - if (!in_whitespace) { - try self.buf.appendSlice(self.allocator, " "); - in_whitespace = true; - } - } else { - const escaped: []const u8 = switch (c) { - '\\' => "\\\\", - // Escape double quotes as HTML entity for valid attribute values - '"' => """, - else => &[_]u8{c}, - }; - try self.buf.appendSlice(self.allocator, escaped); - in_whitespace = false; - } - } - // Remove trailing space if present - if (self.buf.items.len > 0 and self.buf.items[self.buf.items.len - 1] == ' ') { - self.buf.items.len -= 1; - } - } - - fn writeIndent(self: *Compiler) !void { - for (0..self.depth) |_| try self.writer.writeAll(" "); - } - - /// Main dispatch function - emits Zig code for any AST node type. - fn emitNode(self: *Compiler, node: ast.Node) anyerror!void { - switch (node) { - .doctype => |dt| { - if (std.mem.eql(u8, dt.value, "html")) { - try self.appendStatic("<!DOCTYPE html>"); - } else { - try self.appendStatic("<!DOCTYPE "); - try self.appendStatic(dt.value); - try self.appendStatic(">"); - } - }, - .element => |e| try self.emitElement(e), - .text => |t| try self.emitText(t.segments), - .raw_text => |r| try self.appendStatic(r.content), - .conditional => |c| try self.emitConditional(c), - .each => |e| try self.emitEach(e), - .case => |c| try self.emitCase(c), - .comment => |c| if (c.rendered) { - try self.appendStatic("<!-- "); - try self.appendStatic(c.content); - try self.appendStatic(" -->"); - }, - .block => |b| try self.emitBlock(b), - .include => |inc| try self.emitInclude(inc), - .mixin_call => |call| try self.emitMixinCall(call), - .mixin_def => {}, // Mixin definitions are collected, not emitted directly - .mixin_block => {}, // Handled within mixin call context - .extends => {}, // Handled at document level - .document => |dc| for (dc.nodes) |child| try self.emitNode(child), - else => {}, - } - } - - /// Emits an HTML element: opening tag, attributes, children, closing tag. - /// Handles void elements (self-closing), class merging, and buffered code. - fn emitElement(self: *Compiler, e: ast.Element) anyerror!void { - const is_void = isVoidElement(e.tag) or e.self_closing; - - try self.appendStatic("<"); - try self.appendStatic(e.tag); - - if (e.id) |id| { - try self.appendStatic(" id=\""); - try self.appendStatic(id); - try self.appendStatic("\""); - } - - // Check if there's a class attribute that needs to be merged with shorthand classes - var class_attr_value: ?[]const u8 = null; - var class_attr_escaped: bool = true; - for (e.attributes) |attr| { - if (std.mem.eql(u8, attr.name, "class")) { - class_attr_value = attr.value; - class_attr_escaped = attr.escaped; - break; - } - } - - // Emit merged class attribute (shorthand classes + class attribute value) - if (e.classes.len > 0 or class_attr_value != null) { - try self.emitMergedClassAttribute(e.classes, class_attr_value, class_attr_escaped); - } - - // Emit other attributes (skip class since we handled it above) - for (e.attributes) |attr| { - if (std.mem.eql(u8, attr.name, "class")) continue; // Already handled - if (attr.value) |v| { - try self.emitAttribute(attr.name, v, attr.escaped); - } else { - // Boolean attribute - try self.appendStatic(" "); - try self.appendStatic(attr.name); - try self.appendStatic("=\""); - try self.appendStatic(attr.name); - try self.appendStatic("\""); - } - } - - if (is_void) { - try self.appendStatic(" />"); - return; - } - - try self.appendStatic(">"); - - if (e.inline_text) |segs| { - try self.emitText(segs); - } - - if (e.buffered_code) |bc| { - try self.emitExpr(bc.expression, bc.escaped); - } - - for (e.children) |child| { - try self.emitNode(child); - } - - try self.appendStatic("</"); - try self.appendStatic(e.tag); - try self.appendStatic(">"); - } - - /// Emits a merged class attribute combining shorthand classes (.foo.bar) with - /// dynamic class attribute values. Handles static strings, arrays, and concatenation. - fn emitMergedClassAttribute(self: *Compiler, shorthand_classes: []const []const u8, attr_value: ?[]const u8, escaped: bool) !void { - _ = escaped; - - if (attr_value) |value| { - // Check for string concatenation first: "literal" + variable - if (findConcatOperator(value)) |concat_pos| { - // Has concatenation - need runtime handling - try self.flush(); - try self.writeIndent(); - try self.writer.writeAll("try o.appendSlice(a, \" class=\\\"\");\n"); - - // Add shorthand classes first - if (shorthand_classes.len > 0) { - try self.writeIndent(); - try self.writer.writeAll("try o.appendSlice(a, \""); - for (shorthand_classes, 0..) |cls, i| { - if (i > 0) try self.writer.writeAll(" "); - try self.writer.writeAll(cls); - } - try self.writer.writeAll(" \");\n"); // trailing space before concat value - } - - // Emit the concatenation expression - try self.emitConcatExpr(value, concat_pos); - - try self.writeIndent(); - try self.writer.writeAll("try o.appendSlice(a, \"\\\"\");\n"); - return; - } - - // Check if attribute value is static (string literal) or dynamic - const is_static = value.len >= 2 and (value[0] == '"' or value[0] == '\'' or value[0] == '`'); - const is_array = value.len >= 2 and value[0] == '[' and value[value.len - 1] == ']'; - - if (is_static or is_array) { - // Static value - can merge at compile time - try self.appendStatic(" class=\""); - // First add shorthand classes - for (shorthand_classes, 0..) |cls, i| { - if (i > 0) try self.appendStatic(" "); - try self.appendStatic(cls); - } - // Then add attribute value - if (shorthand_classes.len > 0) try self.appendStatic(" "); - if (is_array) { - try self.appendStatic(parseArrayToSpaceSeparated(value)); - } else if (value[0] == '`') { - try self.appendNormalizedWhitespace(value[1 .. value.len - 1]); - } else { - try self.appendStatic(value[1 .. value.len - 1]); - } - try self.appendStatic("\""); - } else { - // Dynamic value - need runtime concatenation - try self.flush(); - try self.writeIndent(); - try self.writer.writeAll("try o.appendSlice(a, \" class=\\\"\");\n"); - - // Add shorthand classes first - if (shorthand_classes.len > 0) { - try self.writeIndent(); - try self.writer.writeAll("try o.appendSlice(a, \""); - for (shorthand_classes, 0..) |cls, i| { - if (i > 0) try self.writer.writeAll(" "); - try self.writer.writeAll(cls); - } - try self.writer.writeAll(" \");\n"); // trailing space before dynamic value - } - - // Add dynamic value - var accessor_buf: [512]u8 = undefined; - const accessor = self.buildAccessor(value, &accessor_buf); - try self.writeIndent(); - try self.writer.print("try o.appendSlice(a, strVal({s}));\n", .{accessor}); - - try self.writeIndent(); - try self.writer.writeAll("try o.appendSlice(a, \"\\\"\");\n"); - } - } else { - // No attribute value, just shorthand classes - try self.appendStatic(" class=\""); - for (shorthand_classes, 0..) |cls, i| { - if (i > 0) try self.appendStatic(" "); - try self.appendStatic(cls); - } - try self.appendStatic("\""); - } - } - - fn emitText(self: *Compiler, segs: []const ast.TextSegment) anyerror!void { - for (segs) |seg| { - switch (seg) { - .literal => |lit| try self.appendStatic(lit), - .interp_escaped => |expr| try self.emitExpr(expr, true), - .interp_unescaped => |expr| try self.emitExpr(expr, false), - .interp_tag => |t| try self.emitInlineTag(t), - } - } - } - - fn emitInlineTag(self: *Compiler, t: ast.InlineTag) anyerror!void { - try self.appendStatic("<"); - try self.appendStatic(t.tag); - if (t.id) |id| { - try self.appendStatic(" id=\""); - try self.appendStatic(id); - try self.appendStatic("\""); - } - if (t.classes.len > 0) { - try self.appendStatic(" class=\""); - for (t.classes, 0..) |cls, i| { - if (i > 0) try self.appendStatic(" "); - try self.appendStatic(cls); - } - try self.appendStatic("\""); - } - for (t.attributes) |attr| { - if (attr.value) |v| { - if (v.len >= 2 and (v[0] == '"' or v[0] == '\'')) { - try self.appendStatic(" "); - try self.appendStatic(attr.name); - try self.appendStatic("=\""); - try self.appendStatic(v[1 .. v.len - 1]); - try self.appendStatic("\""); - } - } - } - try self.appendStatic(">"); - try self.emitText(t.text_segments); - try self.appendStatic("</"); - try self.appendStatic(t.tag); - try self.appendStatic(">"); - } - - /// Emits code for an interpolated expression (#{expr} or !{expr}). - /// Flushes static buffer first since this generates runtime code. - fn emitExpr(self: *Compiler, expr: []const u8, escaped: bool) !void { - try self.flush(); - try self.writeIndent(); - - var accessor_buf: [512]u8 = undefined; - const accessor = self.buildAccessor(expr, &accessor_buf); - - if (escaped) { - try self.writer.print("try esc(&o, a, strVal({s}));\n", .{accessor}); - } else { - try self.writer.print("try o.appendSlice(a, strVal({s}));\n", .{accessor}); - } - } - - /// Emits an HTML attribute. Handles various value types: - /// - String literals (single, double, backtick quoted) - /// - Object literals ({color: 'red'} -> style="color:red;") - /// - Array literals (['a', 'b'] -> class="a b") - /// - String concatenation ("btn-" + type) - /// - Dynamic variable references - fn emitAttribute(self: *Compiler, name: []const u8, value: []const u8, escaped: bool) !void { - _ = escaped; - - // Check for string concatenation: "literal" + variable or variable + "literal" - if (findConcatOperator(value)) |concat_pos| { - // Parse concatenation expression - try self.flush(); - try self.writeIndent(); - try self.writer.print("try o.appendSlice(a, \" {s}=\\\"\");\n", .{name}); - - try self.emitConcatExpr(value, concat_pos); - - try self.writeIndent(); - try self.writer.writeAll("try o.appendSlice(a, \"\\\"\");\n"); - } else if (value.len >= 2 and (value[0] == '"' or value[0] == '\'' or value[0] == '`')) { - // Simple string literal (single, double, or backtick quoted) - try self.appendStatic(" "); - try self.appendStatic(name); - try self.appendStatic("=\""); - // For backtick strings, normalize whitespace (collapse newlines and multiple spaces) - if (value[0] == '`') { - try self.appendNormalizedWhitespace(value[1 .. value.len - 1]); - } else { - try self.appendStatic(value[1 .. value.len - 1]); - } - try self.appendStatic("\""); - } else if (value.len >= 2 and value[0] == '{' and value[value.len - 1] == '}') { - // Object literal - convert to appropriate format - try self.appendStatic(" "); - try self.appendStatic(name); - try self.appendStatic("=\""); - if (std.mem.eql(u8, name, "style")) { - // For style attribute, convert object to CSS: {color: 'red'} -> color:red; - try self.appendStatic(parseObjectToCSS(value)); - } else { - // For other attributes (like class), join values with spaces - try self.appendStatic(parseObjectToSpaceSeparated(value)); - } - try self.appendStatic("\""); - } else if (value.len >= 2 and value[0] == '[' and value[value.len - 1] == ']') { - // Array literal - join with spaces for class attribute, otherwise as-is - try self.appendStatic(" "); - try self.appendStatic(name); - try self.appendStatic("=\""); - try self.appendStatic(parseArrayToSpaceSeparated(value)); - try self.appendStatic("\""); - } else { - // Dynamic value (variable reference) - try self.flush(); - try self.writeIndent(); - try self.writer.print("try o.appendSlice(a, \" {s}=\\\"\");\n", .{name}); - - var accessor_buf: [512]u8 = undefined; - const accessor = self.buildAccessor(value, &accessor_buf); - try self.writeIndent(); - try self.writer.print("try o.appendSlice(a, strVal({s}));\n", .{accessor}); - - try self.writeIndent(); - try self.writer.writeAll("try o.appendSlice(a, \"\\\"\");\n"); - } - } - - /// Finds the + operator for string concatenation, skipping + chars inside quotes. - /// Returns the position of the operator, or null if not found. - fn findConcatOperator(value: []const u8) ?usize { - var in_string = false; - var string_char: u8 = 0; - var i: usize = 0; - - while (i < value.len) : (i += 1) { - const c = value[i]; - - if (in_string) { - if (c == string_char) { - in_string = false; - } - } else { - if (c == '"' or c == '\'' or c == '`') { - in_string = true; - string_char = c; - } else if (c == '+') { - // Check it's surrounded by spaces (typical concat) - if (i > 0 and i + 1 < value.len) { - return i; - } - } - } - } - return null; - } - - /// Emits code for a string concatenation expression (e.g., "btn btn-" + type). - /// Recursively handles chained concatenations. - fn emitConcatExpr(self: *Compiler, value: []const u8, concat_pos: usize) !void { - const left = std.mem.trim(u8, value[0..concat_pos], " "); - const right = std.mem.trim(u8, value[concat_pos + 1 ..], " "); - - // Emit left part - if (left.len >= 2 and (left[0] == '"' or left[0] == '\'' or left[0] == '`')) { - // String literal (single, double, or backtick quoted) - try self.writeIndent(); - try self.writer.print("try o.appendSlice(a, {s});\n", .{left}); - } else { - // Variable - var accessor_buf: [512]u8 = undefined; - const accessor = self.buildAccessor(left, &accessor_buf); - try self.writeIndent(); - try self.writer.print("try o.appendSlice(a, strVal({s}));\n", .{accessor}); - } - - // Check if right part also has concatenation - if (findConcatOperator(right)) |next_concat| { - try self.emitConcatExpr(right, next_concat); - } else { - // Emit right part - if (right.len >= 2 and (right[0] == '"' or right[0] == '\'' or right[0] == '`')) { - // String literal (single, double, or backtick quoted) - try self.writeIndent(); - try self.writer.print("try o.appendSlice(a, {s});\n", .{right}); - } else { - // Variable - var accessor_buf: [512]u8 = undefined; - const accessor = self.buildAccessor(right, &accessor_buf); - try self.writeIndent(); - try self.writer.print("try o.appendSlice(a, strVal({s}));\n", .{accessor}); - } - } - } - - /// Emits an expression inline (used for dynamic attribute values). - fn emitExprInline(self: *Compiler, expr: []const u8, escaped: bool) !void { - try self.flush(); - try self.writeIndent(); - - var accessor_buf: [512]u8 = undefined; - const accessor = self.buildAccessor(expr, &accessor_buf); - - if (escaped) { - try self.writer.print("try esc(&o, a, strVal({s}));\n", .{accessor}); - } else { - try self.writer.print("try o.appendSlice(a, strVal({s}));\n", .{accessor}); - } - } - - fn isLoopVar(self: *Compiler, name: []const u8) bool { - for (self.loop_vars.items) |v| { - if (std.mem.eql(u8, v, name)) return true; - } - return false; - } - - fn isMixinParam(self: *Compiler, name: []const u8) bool { - for (self.mixin_params.items) |p| { - if (std.mem.eql(u8, p, name)) return true; - } - return false; - } - - /// Builds a Zig accessor expression for a template variable. - /// Handles: loop vars (item), mixin params (text), data fields (@field(d, "name")), - /// nested access (user.name), and mixin attributes (attributes.class). - fn buildAccessor(self: *Compiler, expr: []const u8, buf: []u8) []const u8 { - if (std.mem.indexOfScalar(u8, expr, '.')) |dot| { - const base = expr[0..dot]; - const rest = expr[dot + 1 ..]; - - // Special case: attributes.X should use current mixin's attributes variable - if (std.mem.eql(u8, base, "attributes")) { - if (self.current_attrs_var) |attrs_var| { - return std.fmt.bufPrint(buf, "{s}.{s}", .{ attrs_var, rest }) catch expr; - } - } - - // For loop variables or mixin params like friend.name, access directly - if (self.isLoopVar(base) or self.isMixinParam(base)) { - // Escape base if it's a keyword - use the output buffer - if (zig_keywords.has(base)) { - return std.fmt.bufPrint(buf, "@\"{s}\".{s}", .{ base, rest }) catch expr; - } - return std.fmt.bufPrint(buf, "{s}.{s}", .{ base, rest }) catch expr; - } - // For top-level data field access - mark that we use 'd' - self.uses_data = true; - return std.fmt.bufPrint(buf, "@field(d, \"{s}\").{s}", .{ base, rest }) catch expr; - } else { - // Special case: 'attributes' alone should use current mixin's attributes variable - if (std.mem.eql(u8, expr, "attributes")) { - if (self.current_attrs_var) |attrs_var| { - return attrs_var; - } - } - - // Check if it's a loop variable or mixin param - if (self.isLoopVar(expr) or self.isMixinParam(expr)) { - // Escape if it's a keyword - use the output buffer - if (zig_keywords.has(expr)) { - return std.fmt.bufPrint(buf, "@\"{s}\"", .{expr}) catch expr; - } - return expr; - } - // For top-level like "name", access from d - mark that we use 'd' - self.uses_data = true; - return std.fmt.bufPrint(buf, "@field(d, \"{s}\")", .{expr}) catch expr; - } - } - - fn emitConditional(self: *Compiler, c: ast.Conditional) anyerror!void { - try self.flush(); - for (c.branches, 0..) |br, i| { - try self.writeIndent(); - if (i == 0) { - if (br.is_unless) { - try self.writer.writeAll("if (!"); - } else { - try self.writer.writeAll("if ("); - } - try self.emitCondition(br.condition orelse "true"); - try self.writer.writeAll(") {\n"); - } else if (br.condition) |cond| { - try self.writer.writeAll("} else if ("); - try self.emitCondition(cond); - try self.writer.writeAll(") {\n"); - } else { - try self.writer.writeAll("} else {\n"); - } - self.depth += 1; - for (br.children) |child| try self.emitNode(child); - try self.flush(); - self.depth -= 1; - } - try self.writeIndent(); - try self.writer.writeAll("}\n"); - } - - /// Emits a condition expression for if/else if. - /// Handles string comparisons (== "value") and optional field access (@hasField). - fn emitCondition(self: *Compiler, cond: []const u8) !void { - // String equality: status == "closed" -> std.mem.eql(u8, strVal(status), "closed") - if (std.mem.indexOf(u8, cond, " == \"")) |eq_pos| { - const lhs = std.mem.trim(u8, cond[0..eq_pos], " "); - const rhs_start = eq_pos + 5; // skip ' == "' - if (std.mem.indexOfScalar(u8, cond[rhs_start..], '"')) |rhs_end| { - const rhs = cond[rhs_start .. rhs_start + rhs_end]; - var accessor_buf: [512]u8 = undefined; - const accessor = self.buildAccessor(lhs, &accessor_buf); - try self.writer.print("std.mem.eql(u8, strVal({s}), \"{s}\")", .{ accessor, rhs }); - return; - } - } - // Handle string inequality: status != "closed" - if (std.mem.indexOf(u8, cond, " != \"")) |eq_pos| { - const lhs = std.mem.trim(u8, cond[0..eq_pos], " "); - const rhs_start = eq_pos + 5; - if (std.mem.indexOfScalar(u8, cond[rhs_start..], '"')) |rhs_end| { - const rhs = cond[rhs_start .. rhs_start + rhs_end]; - var accessor_buf: [512]u8 = undefined; - const accessor = self.buildAccessor(lhs, &accessor_buf); - try self.writer.print("!std.mem.eql(u8, strVal({s}), \"{s}\")", .{ accessor, rhs }); - return; - } - } - // Regular field access - use buildAccessor for consistency - var accessor_buf: [512]u8 = undefined; - const accessor = self.buildAccessor(cond, &accessor_buf); - - // Check if this is a simple top-level field access (no dots, not a loop var or mixin param) - const is_simple_field = std.mem.indexOfScalar(u8, cond, '.') == null and - !self.isLoopVar(cond) and !self.isMixinParam(cond); - - if (is_simple_field) { - // Use @hasField to make the field optional at compile time - self.uses_data = true; - try self.writer.print("@hasField(@TypeOf(d), \"{s}\") and truthy({s})", .{ cond, accessor }); - } else { - try self.writer.print("truthy({s})", .{accessor}); - } - } - - /// Emits code for an each loop (iteration over arrays/slices). - /// Handles optional index variable and else branch for empty collections. - fn emitEach(self: *Compiler, e: ast.Each) anyerror!void { - try self.flush(); - try self.writeIndent(); - - try self.loop_vars.append(self.allocator, e.value_name); - - var accessor_buf: [512]u8 = undefined; - const collection_accessor = self.buildAccessor(e.collection, &accessor_buf); - - // Wrap in length check if there's an else branch - if (e.else_children.len > 0) { - try self.writer.print("if ({s}.len > 0) {{\n", .{collection_accessor}); - self.depth += 1; - try self.writeIndent(); - } - - // Handle optional collections (nested fields may be nullable) - if (std.mem.indexOfScalar(u8, e.collection, '.')) |_| { - try self.writer.print("for (if (@typeInfo(@TypeOf({s})) == .optional) ({s} orelse &.{{}}) else {s}) |{s}", .{ collection_accessor, collection_accessor, collection_accessor, e.value_name }); - } else { - try self.writer.print("for ({s}) |{s}", .{ collection_accessor, e.value_name }); - } - if (e.index_name) |idx| { - try self.writer.print(", {s}", .{idx}); - } - try self.writer.writeAll("| {\n"); - - self.depth += 1; - for (e.children) |child| { - try self.emitNode(child); - } - try self.flush(); - self.depth -= 1; - - try self.writeIndent(); - try self.writer.writeAll("}\n"); - - // Handle else branch - if (e.else_children.len > 0) { - self.depth -= 1; - try self.writeIndent(); - try self.writer.writeAll("} else {\n"); - self.depth += 1; - for (e.else_children) |child| { - try self.emitNode(child); - } - try self.flush(); - self.depth -= 1; - try self.writeIndent(); - try self.writer.writeAll("}\n"); - } - - // Pop loop variable - _ = self.loop_vars.pop(); - } - - /// Emits code for a case/when statement (switch-like construct). - /// Generates if/else if chain since Zig switch requires comptime values. - fn emitCase(self: *Compiler, c: ast.Case) anyerror!void { - try self.flush(); - - var accessor_buf: [512]u8 = undefined; - const expr_accessor = self.buildAccessor(c.expression, &accessor_buf); - - var first = true; - for (c.whens) |when| { - try self.writeIndent(); - - if (first) { - first = false; - } else { - try self.writer.writeAll("} else "); - } - - // Check if value is a string literal - if (when.value.len >= 2 and when.value[0] == '"') { - const str_val = when.value[1 .. when.value.len - 1]; - try self.writer.print("if (std.mem.eql(u8, strVal({s}), \"{s}\")) {{\n", .{ expr_accessor, str_val }); - } else { - // Numeric or other comparison - try self.writer.print("if ({s} == {s}) {{\n", .{ expr_accessor, when.value }); - } - - self.depth += 1; - - if (when.has_break) { - // Explicit break - do nothing - } else if (when.children.len == 0) { - // Fall-through - we'll handle this by continuing to next case - // For now, just skip (Zig doesn't have fall-through) - } else { - for (when.children) |child| { - try self.emitNode(child); - } - } - try self.flush(); - self.depth -= 1; - } - - // Default case - if (c.default_children.len > 0) { - try self.writeIndent(); - if (!first) { - try self.writer.writeAll("} else {\n"); - } else { - try self.writer.writeAll("{\n"); - } - self.depth += 1; - for (c.default_children) |child| { - try self.emitNode(child); - } - try self.flush(); - self.depth -= 1; - } - - if (!first or c.default_children.len > 0) { - try self.writeIndent(); - try self.writer.writeAll("}\n"); - } - } - - /// Emits a named block, applying any child template overrides. - /// Supports replace, append, and prepend modes for inheritance. - fn emitBlock(self: *Compiler, blk: ast.Block) anyerror!void { - if (self.blocks.get(blk.name)) |child_block| { - switch (child_block.mode) { - .replace => { - // Child completely replaces parent block - for (child_block.children) |child| { - try self.emitNode(child); - } - }, - .append => { - // Parent content first, then child - for (blk.children) |child| { - try self.emitNode(child); - } - for (child_block.children) |child| { - try self.emitNode(child); - } - }, - .prepend => { - // Child content first, then parent - for (child_block.children) |child| { - try self.emitNode(child); - } - for (blk.children) |child| { - try self.emitNode(child); - } - }, - } - } else { - // No override - render default block content - for (blk.children) |child| { - try self.emitNode(child); - } - } - } - - /// Emits an include directive by inlining the included template's content. - fn emitInclude(self: *Compiler, inc: ast.Include) anyerror!void { - const included_doc = self.loadTemplate(inc.path) catch |err| { - std.log.warn("Failed to load include '{s}': {}", .{ inc.path, err }); - return; - }; - - try self.collectMixins(included_doc.nodes); - - for (included_doc.nodes) |node| { - try self.emitNode(node); - } - } - - /// Emits a mixin call (+mixinName(args)). - /// Looks up the mixin definition, falling back to lazy-loading from mixins/ directory. - fn emitMixinCall(self: *Compiler, call: ast.MixinCall) anyerror!void { - const mixin_def = self.mixins.get(call.name) orelse { - // Lazy-load from mixins/ directory - if (self.loadMixinFromDir(call.name)) |def| { - try self.mixins.put(def.name, def); - try self.emitMixinCallWithDef(call, def); - return; - } - std.log.warn("Mixin '{s}' not found", .{call.name}); - return; - }; - - try self.emitMixinCallWithDef(call, mixin_def); - } - - /// Emits the actual mixin body with parameter bindings. - /// Creates a scope block with local variables for each mixin parameter. - /// Handles default values, rest parameters, block content, and &attributes. - fn emitMixinCallWithDef(self: *Compiler, call: ast.MixinCall, mixin_def: ast.MixinDef) anyerror!void { - // Save/restore mixin params to handle nested mixin calls - const prev_params_len = self.mixin_params.items.len; - defer self.mixin_params.items.len = prev_params_len; - - const regular_params = if (mixin_def.has_rest and mixin_def.params.len > 0) - mixin_def.params.len - 1 - else - mixin_def.params.len; - - try self.flush(); - - // Scope block prevents variable name collisions on repeated mixin calls - try self.writeIndent(); - try self.writer.writeAll("{\n"); - self.depth += 1; - - for (mixin_def.params[0..regular_params], 0..) |param, i| { - try self.mixin_params.append(self.allocator, param); - - // Escape param name if it's a Zig keyword - var ident_buf: [64]u8 = undefined; - const safe_param = escapeIdent(param, &ident_buf); - - if (i < call.args.len) { - // Argument provided - const arg = call.args[i]; - // Check if it's a string literal - if (arg.len >= 2 and (arg[0] == '"' or arg[0] == '\'')) { - try self.writeIndent(); - try self.writer.print("const {s} = {s};\n", .{ safe_param, arg }); - } else { - // It's a variable reference - var accessor_buf: [512]u8 = undefined; - const accessor = self.buildAccessor(arg, &accessor_buf); - // Skip declaration if accessor equals param name (already in scope) - if (!std.mem.eql(u8, accessor, safe_param)) { - try self.writeIndent(); - try self.writer.print("const {s} = {s};\n", .{ safe_param, accessor }); - } - } - } else if (i < mixin_def.defaults.len) { - // Use default value - try self.writeIndent(); - if (mixin_def.defaults[i]) |default| { - try self.writer.print("const {s} = {s};\n", .{ safe_param, default }); - } else { - try self.writer.print("const {s} = \"\";\n", .{safe_param}); - } - } else { - // No value - use empty string - try self.writeIndent(); - try self.writer.print("const {s} = \"\";\n", .{safe_param}); - } - } - - // Handle rest parameters - if (mixin_def.has_rest and mixin_def.params.len > 0) { - const rest_param = mixin_def.params[mixin_def.params.len - 1]; - try self.mixin_params.append(self.allocator, rest_param); - - // Rest args are remaining arguments as an array - try self.writeIndent(); - try self.writer.print("const {s} = &[_][]const u8{{", .{rest_param}); - - for (call.args[regular_params..], 0..) |arg, i| { - if (i > 0) try self.writer.writeAll(", "); - try self.writer.print("{s}", .{arg}); - } - try self.writer.writeAll("};\n"); - } - - // Check if mixin body actually uses &attributes before emitting the struct - const uses_attributes = mixinUsesAttributes(mixin_def.children); - - // Save previous attrs var and restore after mixin body - const prev_attrs_var = self.current_attrs_var; - defer self.current_attrs_var = prev_attrs_var; - - // Only emit attributes struct if the mixin actually uses it - if (uses_attributes) { - // Use unique name based on mixin depth to avoid shadowing in nested mixin calls - self.mixin_depth += 1; - const current_depth = self.mixin_depth; - - var attr_var_buf: [32]u8 = undefined; - const attr_var_name = std.fmt.bufPrint(&attr_var_buf, "mixin_attrs_{d}", .{current_depth}) catch "mixin_attrs"; - - self.current_attrs_var = attr_var_name; - try self.mixin_params.append(self.allocator, attr_var_name); - - try self.writeIndent(); - try self.writer.print("const {s}: struct {{\n", .{attr_var_name}); - self.depth += 1; - try self.writeIndent(); - try self.writer.writeAll("class: []const u8 = \"\",\n"); - try self.writeIndent(); - try self.writer.writeAll("id: []const u8 = \"\",\n"); - try self.writeIndent(); - try self.writer.writeAll("style: []const u8 = \"\",\n"); - self.depth -= 1; - try self.writeIndent(); - try self.writer.writeAll("} = .{\n"); - self.depth += 1; - - for (call.attributes) |attr| { - if (std.mem.eql(u8, attr.name, "class") or - std.mem.eql(u8, attr.name, "id") or - std.mem.eql(u8, attr.name, "style")) - { - try self.writeIndent(); - try self.writer.print(".{s} = ", .{attr.name}); - if (attr.value) |val| { - if (val.len >= 2 and (val[0] == '"' or val[0] == '\'')) { - try self.writer.print("{s},\n", .{val}); - } else { - var accessor_buf: [512]u8 = undefined; - const accessor = self.buildAccessor(val, &accessor_buf); - try self.writer.print("{s},\n", .{accessor}); - } - } else { - try self.writer.writeAll("\"\",\n"); - } - } - } - - self.depth -= 1; - try self.writeIndent(); - try self.writer.writeAll("};\n"); - } - - // Emit mixin body - for (mixin_def.children) |child| { - if (child == .mixin_block) { - for (call.block_children) |block_child| { - try self.emitNode(block_child); - } - } else { - try self.emitNode(child); - } - } - - // Close scope block - try self.flush(); - self.depth -= 1; - try self.writeIndent(); - try self.writer.writeAll("}\n"); - - if (uses_attributes) { - self.mixin_depth -= 1; - } - } - - /// Attempts to load a mixin from the mixins/ subdirectory. - /// First tries mixins/{name}.pug, then scans all files in mixins/ for the definition. - fn loadMixinFromDir(self: *Compiler, name: []const u8) ?ast.MixinDef { - const specific_path = std.fs.path.join(self.allocator, &.{ self.source_dir, "mixins", name }) catch return null; - defer self.allocator.free(specific_path); - - const with_ext = std.fmt.allocPrint(self.allocator, "{s}{s}", .{ specific_path, self.extension }) catch return null; - defer self.allocator.free(with_ext); - - if (std.fs.cwd().readFileAlloc(self.allocator, with_ext, 1024 * 1024)) |source| { - if (self.parseMixinFromSource(source, name)) |def| { - return def; - } - } else |_| {} - - // Try scanning all files in mixins directory - const mixins_dir_path = std.fs.path.join(self.allocator, &.{ self.source_dir, "mixins" }) catch return null; - defer self.allocator.free(mixins_dir_path); - - var dir = std.fs.cwd().openDir(mixins_dir_path, .{ .iterate = true }) catch return null; - defer dir.close(); - - var iter = dir.iterate(); - while (iter.next() catch return null) |entry| { - if (entry.kind == .file and std.mem.endsWith(u8, entry.name, self.extension)) { - const file_path = std.fs.path.join(self.allocator, &.{ mixins_dir_path, entry.name }) catch continue; - defer self.allocator.free(file_path); - - if (std.fs.cwd().readFileAlloc(self.allocator, file_path, 1024 * 1024)) |source| { - if (self.parseMixinFromSource(source, name)) |def| { - return def; - } - } else |_| {} - } - } - - return null; - } - - /// Parses template source to find and return a specific mixin definition by name. - fn parseMixinFromSource(self: *Compiler, source: []const u8, name: []const u8) ?ast.MixinDef { - var lexer = Lexer.init(self.allocator, source); - const tokens = lexer.tokenize() catch return null; - - var parser = Parser.init(self.allocator, tokens); - const doc = parser.parse() catch return null; - - // Find the mixin with matching name - for (doc.nodes) |node| { - if (node == .mixin_def) { - if (std.mem.eql(u8, node.mixin_def.name, name)) { - return node.mixin_def; - } - } - } - - return null; - } -}; - -/// Parses a JS-style object literal into CSS property string. -/// Example: {color: 'red', background: 'green'} -> "color:red;background:green;" -/// Note: Returns slice from static buffer - safe because result is immediately consumed. -fn parseObjectToCSS(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; - } - - const content = std.mem.trim(u8, trimmed[1 .. trimmed.len - 1], " \t\n\r"); - if (content.len == 0) return ""; - - // Use comptime buffer for simple cases - var result: [1024]u8 = undefined; - var result_len: usize = 0; - - 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 - const name_start = pos; - while (pos < content.len 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 (handle quoted strings) - 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 - read until comma or end - while (pos < content.len and content[pos] != ',' and content[pos] != '}') { - pos += 1; - } - value_end = pos; - // Trim trailing whitespace from value - while (value_end > value_start and (content[value_end - 1] == ' ' or content[value_end - 1] == '\t')) { - value_end -= 1; - } - } - const value = content[value_start..value_end]; - - // Append name:value; - if (result_len + name.len + 1 + value.len + 1 < result.len) { - @memcpy(result[result_len..][0..name.len], name); - result_len += name.len; - result[result_len] = ':'; - result_len += 1; - @memcpy(result[result_len..][0..value.len], value); - result_len += value.len; - result[result_len] = ';'; - result_len += 1; - } - - // 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 slice from static buffer - this works because we're building static strings - return result[0..result_len]; -} - -/// Parses a JS-style object literal and extracts values as space-separated string. -/// Example: {foo: 'bar', baz: 'qux'} -> "bar qux" -fn parseObjectToSpaceSeparated(input: []const u8) []const u8 { - const trimmed = std.mem.trim(u8, input, " \t\n\r"); - if (trimmed.len < 2 or trimmed[0] != '{' or trimmed[trimmed.len - 1] != '}') { - return input; - } - - const content = std.mem.trim(u8, trimmed[1 .. trimmed.len - 1], " \t\n\r"); - if (content.len == 0) return ""; - - var result: [1024]u8 = undefined; - var result_len: usize = 0; - var first = true; - - 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; - - // Skip property name until 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; - } else { - while (pos < content.len and content[pos] != ',' and content[pos] != '}') { - pos += 1; - } - value_end = pos; - while (value_end > value_start and (content[value_end - 1] == ' ' or content[value_end - 1] == '\t')) { - value_end -= 1; - } - } - const value = content[value_start..value_end]; - - // Append value with space separator - if (result_len + (if (first) @as(usize, 0) else @as(usize, 1)) + value.len < result.len) { - if (!first) { - result[result_len] = ' '; - result_len += 1; - } - @memcpy(result[result_len..][0..value.len], value); - result_len += value.len; - first = false; - } - - // 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[0..result_len]; -} - -/// Parses a JS-style array literal and joins values with spaces. -/// Example: ['foo', 'bar', 'baz'] -> "foo bar baz" -fn parseArrayToSpaceSeparated(input: []const u8) []const u8 { - const trimmed = std.mem.trim(u8, input, " \t\n\r"); - if (trimmed.len < 2 or trimmed[0] != '[' or trimmed[trimmed.len - 1] != ']') { - return input; - } - - const content = std.mem.trim(u8, trimmed[1 .. trimmed.len - 1], " \t\n\r"); - if (content.len == 0) return ""; - - var result: [1024]u8 = undefined; - var result_len: usize = 0; - var first = true; - - 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 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; - } else { - while (pos < content.len and content[pos] != ',' and content[pos] != ']') { - pos += 1; - } - value_end = pos; - while (value_end > value_start and (content[value_end - 1] == ' ' or content[value_end - 1] == '\t')) { - value_end -= 1; - } - } - const value = content[value_start..value_end]; - - // Append value with space separator - if (value.len > 0 and result_len + (if (first) @as(usize, 0) else @as(usize, 1)) + value.len < result.len) { - if (!first) { - result[result_len] = ' '; - result_len += 1; - } - @memcpy(result[result_len..][0..value.len], value); - result_len += value.len; - first = false; - } - - // 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[0..result_len]; -} - -/// Returns true if the tag is a void element (self-closing, no closing tag). -fn isVoidElement(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); -} diff --git a/src/codegen.zig b/src/codegen.zig index 45670ed..0f7c379 100644 --- a/src/codegen.zig +++ b/src/codegen.zig @@ -1,34 +1,44 @@ -//! Pugz Code Generator - Converts AST to HTML output. -//! -//! This module traverses the AST and generates HTML strings. It handles: -//! - Element rendering with tags, classes, IDs, and attributes -//! - Text content with interpolation placeholders -//! - Proper indentation for pretty-printed output -//! - Self-closing tags (void elements) -//! - Comment rendering +// codegen.zig - Zig port of pug-code-gen +// +// Compiles a Pug AST to HTML output. +// This is a direct HTML generator (unlike the JS version which generates JS code). const std = @import("std"); -const ast = @import("ast.zig"); +const Allocator = std.mem.Allocator; +const mem = std.mem; -/// Configuration options for code generation. -pub const Options = struct { - /// Enable pretty-printing with indentation and newlines. - pretty: bool = true, - /// Indentation string (spaces or tabs). - indent_str: []const u8 = " ", - /// Enable self-closing tag syntax for void elements. - self_closing: bool = true, -}; +// Import AST types from parser +const parser = @import("parser.zig"); +pub const Node = parser.Node; +pub const NodeType = parser.NodeType; +pub const Attribute = parser.Attribute; -/// Errors that can occur during code generation. -pub const CodeGenError = error{ - OutOfMemory, -}; +// Import runtime for attribute handling and HTML escaping +const runtime = @import("runtime.zig"); +pub const escapeChar = runtime.escapeChar; -/// 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(.{ +// Import error types +const pug_error = @import("error.zig"); +pub const PugError = pug_error.PugError; + +// ============================================================================ +// Doctypes +// ============================================================================ + +pub const doctypes = std.StaticStringMap([]const u8).initComptime(.{ + .{ "html", "<!DOCTYPE html>" }, + .{ "xml", "<?xml version=\"1.0\" encoding=\"utf-8\" ?>" }, + .{ "transitional", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">" }, + .{ "strict", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">" }, + .{ "frameset", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Frameset//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd\">" }, + .{ "1.1", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">" }, + .{ "basic", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML Basic 1.1//EN\" \"http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd\">" }, + .{ "mobile", "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.2//EN\" \"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd\">" }, + .{ "plist", "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">" }, +}); + +// Self-closing (void) elements in HTML5 +pub const void_elements = std.StaticStringMap(void).initComptime(.{ .{ "area", {} }, .{ "base", {} }, .{ "br", {} }, @@ -45,738 +55,861 @@ const void_elements = std.StaticStringMap(void).initComptime(.{ .{ "wbr", {} }, }); -/// Whitespace-sensitive elements where pretty-printing should be disabled. -const whitespace_sensitive = std.StaticStringMap(void).initComptime(.{ +// Whitespace-sensitive tags +pub const whitespace_sensitive_tags = std.StaticStringMap(void).initComptime(.{ .{ "pre", {} }, .{ "textarea", {} }, .{ "script", {} }, .{ "style", {} }, }); -/// Code generator that converts AST to HTML. -pub const CodeGen = struct { - allocator: std.mem.Allocator, - options: Options, - output: std.ArrayList(u8), - depth: usize, - /// Track if we're inside a whitespace-sensitive element. - preserve_whitespace: bool, +// ============================================================================ +// Compiler Options +// ============================================================================ - /// Creates a new code generator with the given options. - pub fn init(allocator: std.mem.Allocator, options: Options) CodeGen { - return .{ +pub const CompilerOptions = struct { + /// Pretty print output with indentation + pretty: bool = false, + /// Indentation string (default: 2 spaces) + indent_str: []const u8 = " ", + /// Use terse mode (HTML5 style: boolean attrs, > instead of />) + terse: bool = true, + /// Doctype to use + doctype: ?[]const u8 = null, + /// Include debug info + debug: bool = false, + /// Self-closing style (true = />, false = >) + self_closing: bool = false, +}; + +// ============================================================================ +// Compiler Errors +// ============================================================================ + +pub const CompilerError = error{ + OutOfMemory, + InvalidNode, + UnsupportedNodeType, + SelfClosingContent, + InvalidDoctype, +}; + +// ============================================================================ +// Compiler +// ============================================================================ + +pub const Compiler = struct { + allocator: Allocator, + options: CompilerOptions, + output: std.ArrayListUnmanaged(u8), + indent_level: usize = 0, + has_doctype: bool = false, + has_tag: bool = false, + escape_pretty: bool = false, + terse: bool = true, + doctype_str: ?[]const u8 = null, + + pub fn init(allocator: Allocator, options: CompilerOptions) Compiler { + var compiler = Compiler{ .allocator = allocator, .options = options, - .output = .empty, - .depth = 0, - .preserve_whitespace = false, + .output = .{}, + .terse = options.terse, }; + + // Set up doctype + if (options.doctype) |dt| { + compiler.setDoctype(dt); + } + + return compiler; } - /// Releases allocated memory. - pub fn deinit(self: *CodeGen) void { + pub fn deinit(self: *Compiler) void { self.output.deinit(self.allocator); } - /// Generates HTML from the given document AST. - /// Returns a slice of the generated HTML owned by the CodeGen. - pub fn generate(self: *CodeGen, doc: ast.Document) CodeGenError![]const u8 { - // Pre-allocate reasonable capacity - try self.output.ensureTotalCapacity(self.allocator, 1024); + /// Compile an AST node to HTML + pub fn compile(self: *Compiler, node: *Node) CompilerError![]const u8 { + try self.visit(node); + return self.output.toOwnedSlice(self.allocator); + } - for (doc.nodes) |node| { - try self.visitNode(node); + /// Set the doctype + pub fn setDoctype(self: *Compiler, name: []const u8) void { + const lower = name; // TODO: lowercase conversion + if (doctypes.get(lower)) |dt| { + self.doctype_str = dt; + } else { + // Custom doctype + self.doctype_str = null; } - return self.output.items; + // HTML5 uses terse mode + self.terse = mem.eql(u8, lower, "html"); } - /// Generates HTML and returns an owned copy. - /// Caller must free the returned slice. - pub fn generateOwned(self: *CodeGen, doc: ast.Document) CodeGenError![]u8 { - const result = try self.generate(doc); - return try self.allocator.dupe(u8, result); + // ======================================================================== + // Output Helpers + // ======================================================================== + + fn write(self: *Compiler, str: []const u8) CompilerError!void { + try self.output.appendSlice(self.allocator, str); } - /// Visits a single AST node and generates corresponding HTML. - fn visitNode(self: *CodeGen, node: ast.Node) CodeGenError!void { - switch (node) { - .doctype => |dt| try self.visitDoctype(dt), - .element => |elem| try self.visitElement(elem), - .text => |text| try self.visitText(text), - .comment => |comment| try self.visitComment(comment), - .conditional => |cond| try self.visitConditional(cond), - .each => |each| try self.visitEach(each), - .@"while" => |whl| try self.visitWhile(whl), - .case => |c| try self.visitCase(c), - .mixin_def => {}, // Mixin definitions don't produce direct output - .mixin_call => |call| try self.visitMixinCall(call), - .mixin_block => {}, // Mixin block placeholder - handled at mixin call site - .include => |inc| try self.visitInclude(inc), - .extends => {}, // Handled at document level - .block => |blk| try self.visitBlock(blk), - .raw_text => |raw| try self.visitRawText(raw), - .code => |code| try self.visitCode(code), - .document => |doc| { - for (doc.nodes) |child| { - try self.visitNode(child); + fn writeChar(self: *Compiler, c: u8) CompilerError!void { + try self.output.append(self.allocator, c); + } + + fn writeEscaped(self: *Compiler, str: []const u8) CompilerError!void { + // For attribute values - escapes < > & " + for (str) |c| { + if (escapeChar(c)) |escaped| { + try self.write(escaped); + } else { + try self.writeChar(c); + } + } + } + + fn writeTextEscaped(self: *Compiler, str: []const u8) CompilerError!void { + // For text content - escapes < > & (NOT quotes) + // Preserves existing HTML entities like ’ or & + var i: usize = 0; + while (i < str.len) { + const c = str[i]; + switch (c) { + '<' => try self.write("<"), + '>' => try self.write(">"), + '&' => { + // Check if this is already an HTML entity + if (isHtmlEntity(str[i..])) { + // Pass through the entity as-is + try self.writeChar(c); + } else { + try self.write("&"); + } + }, + else => try self.writeChar(c), + } + i += 1; + } + } + + fn isHtmlEntity(str: []const u8) bool { + // Check if str starts with a valid HTML entity: &name; or &#digits; or &#xhex; + if (str.len < 3 or str[0] != '&') return false; + + var i: usize = 1; + + // Numeric entity: &#digits; or &#xhex; + if (str[i] == '#') { + i += 1; + if (i >= str.len) return false; + + // Hex entity: &#x...; + if (str[i] == 'x' or str[i] == 'X') { + i += 1; + if (i >= str.len) return false; + // Need at least one hex digit + var has_hex = false; + while (i < str.len and i < 10) : (i += 1) { + const ch = str[i]; + if (ch == ';') return has_hex; + if ((ch >= '0' and ch <= '9') or + (ch >= 'a' and ch <= 'f') or + (ch >= 'A' and ch <= 'F')) + { + has_hex = true; + } else { + return false; + } } + return false; + } + + // Decimal entity: &#digits; + var has_digit = false; + while (i < str.len and i < 10) : (i += 1) { + const ch = str[i]; + if (ch == ';') return has_digit; + if (ch >= '0' and ch <= '9') { + has_digit = true; + } else { + return false; + } + } + return false; + } + + // Named entity: &name; + var has_alpha = false; + while (i < str.len and i < 32) : (i += 1) { + const ch = str[i]; + if (ch == ';') return has_alpha; + if ((ch >= 'a' and ch <= 'z') or (ch >= 'A' and ch <= 'Z') or (ch >= '0' and ch <= '9')) { + has_alpha = true; + } else { + return false; + } + } + return false; + } + + fn prettyIndent(self: *Compiler) CompilerError!void { + if (self.options.pretty and !self.escape_pretty) { + try self.writeChar('\n'); + for (0..self.indent_level) |_| { + try self.write(self.options.indent_str); + } + } + } + + // ======================================================================== + // Visitor Methods + // ======================================================================== + + fn visit(self: *Compiler, node: *Node) CompilerError!void { + switch (node.type) { + .Block, .NamedBlock => try self.visitBlock(node), + .Tag => try self.visitTag(node), + .InterpolatedTag => try self.visitTag(node), + .Text => try self.visitText(node), + .Code => try self.visitCode(node), + .Comment => try self.visitComment(node), + .BlockComment => try self.visitBlockComment(node), + .Doctype => try self.visitDoctype(node), + .Mixin => try self.visitMixin(node), + .MixinBlock => try self.visitMixinBlock(node), + .Case => try self.visitCase(node), + .When => try self.visitWhen(node), + .Conditional => try self.visitConditional(node), + .While => try self.visitWhile(node), + .Each => try self.visitEach(node), + .EachOf => try self.visitEachOf(node), + .YieldBlock => {}, // No-op + .Include, .Extends, .RawInclude, .Filter, .IncludeFilter, .FileReference, .AttributeBlock => { + // These should be processed by linker/loader before codegen + return error.UnsupportedNodeType; }, } } - /// Doctype shortcuts mapping - const doctype_shortcuts = std.StaticStringMap([]const u8).initComptime(.{ - .{ "html", "<!DOCTYPE html>" }, - .{ "xml", "<?xml version=\"1.0\" encoding=\"utf-8\" ?>" }, - .{ "transitional", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Transitional//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd\">" }, - .{ "strict", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Strict//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd\">" }, - .{ "frameset", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.0 Frameset//EN\" \"http://www.w3.org/TR/xhtml1/DTD/xhtml1-frameset.dtd\">" }, - .{ "1.1", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML 1.1//EN\" \"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd\">" }, - .{ "basic", "<!DOCTYPE html PUBLIC \"-//W3C//DTD XHTML Basic 1.1//EN\" \"http://www.w3.org/TR/xhtml-basic/xhtml-basic11.dtd\">" }, - .{ "mobile", "<!DOCTYPE html PUBLIC \"-//WAPFORUM//DTD XHTML Mobile 1.2//EN\" \"http://www.openmobilealliance.org/tech/DTD/xhtml-mobile12.dtd\">" }, - .{ "plist", "<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">" }, - }); - - /// Generates doctype declaration. - fn visitDoctype(self: *CodeGen, dt: ast.Doctype) CodeGenError!void { - if (doctype_shortcuts.get(dt.value)) |output| { - try self.write(output); - } else { - try self.write("<!DOCTYPE "); - try self.write(dt.value); - try self.write(">"); + fn visitBlock(self: *Compiler, block: *Node) CompilerError!void { + for (block.nodes.items) |child| { + try self.visit(child); } - try self.writeNewline(); } - /// Generates HTML for an element node. - fn visitElement(self: *CodeGen, elem: ast.Element) CodeGenError!void { - const is_void_element = void_elements.has(elem.tag) or elem.self_closing; - const was_preserving = self.preserve_whitespace; + fn visitTag(self: *Compiler, tag: *Node) CompilerError!void { + const name = tag.name orelse return error.InvalidNode; - // Check if entering whitespace-sensitive element - if (whitespace_sensitive.has(elem.tag)) { - self.preserve_whitespace = true; + // Check for whitespace-sensitive tags - use defer to ensure state restoration + const was_escape_pretty = self.escape_pretty; + defer self.escape_pretty = was_escape_pretty; + + if (whitespace_sensitive_tags.has(name)) { + self.escape_pretty = true; } + // Auto-doctype for html tag + if (!self.has_tag) { + if (!self.has_doctype and mem.eql(u8, name, "html")) { + try self.visitDoctype(null); + } + self.has_tag = true; + } + + // Pretty indent before tag + if (self.options.pretty and !tag.is_inline) { + try self.prettyIndent(); + } + + self.indent_level += 1; + defer self.indent_level -= 1; + + // Check if self-closing + const is_void = void_elements.has(name); + const is_self_closing = tag.self_closing or is_void; + // Opening tag - try self.writeIndent(); - try self.write("<"); - try self.write(elem.tag); + try self.writeChar('<'); + try self.write(name); - // ID attribute - if (elem.id) |id| { - try self.write(" id=\""); - try self.writeEscaped(id); - try self.write("\""); - } + // Attributes + try self.visitAttributes(tag); - // Class attribute - if (elem.classes.len > 0) { - try self.write(" class=\""); - for (elem.classes, 0..) |class, i| { - if (i > 0) try self.write(" "); - try self.writeEscaped(class); - } - try self.write("\""); - } - - // Other attributes - for (elem.attributes) |attr| { - try self.write(" "); - try self.write(attr.name); - if (attr.value) |value| { - try self.write("=\""); - if (attr.escaped) { - try self.writeEscaped(value); - } else { - try self.write(value); - } - try self.write("\""); + if (is_self_closing) { + if (self.terse and !tag.self_closing) { + try self.writeChar('>'); } else { - // Boolean attribute: checked -> checked="checked" - try self.write("=\""); + try self.write("/>"); + } + + // Check for content in self-closing tag + if (tag.nodes.items.len > 0) { + return error.SelfClosingContent; + } + } else { + try self.writeChar('>'); + + // Visit children + for (tag.nodes.items) |child| { + try self.visit(child); + } + + // Pretty indent before closing tag + if (self.options.pretty and !tag.is_inline and !whitespace_sensitive_tags.has(name)) { + try self.prettyIndent(); + } + + // Closing tag + try self.write("</"); + try self.write(name); + try self.writeChar('>'); + } + // escape_pretty restoration handled by defer above + } + + fn visitAttributes(self: *Compiler, tag: *Node) CompilerError!void { + for (tag.attrs.items) |attr| { + if (attr.val) |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; + } + } + + // Check for boolean attributes in terse mode + const is_bool = mem.eql(u8, val, "true") or mem.eql(u8, val, "false"); + if (self.terse and is_bool) { + if (mem.eql(u8, val, "true")) { + // Terse boolean: just the attribute name + try self.writeChar(' '); + try self.write(attr.name); + continue; + } else { + // false: don't output the attribute at all + continue; + } + } + + try self.writeChar(' '); try self.write(attr.name); - try self.write("\""); + try self.write("=\""); + if (attr.must_escape) { + try self.writeEscaped(val); + } else { + try self.write(val); + } + try self.writeChar('"'); + } else { + // No value - output attribute name only (boolean attribute) + try self.writeChar(' '); + try self.write(attr.name); + } + } + } + + fn visitText(self: *Compiler, text: *Node) CompilerError!void { + if (text.val) |val| { + if (text.is_html) { + try self.write(val); + } else { + // Text content: only escape < > & (not quotes) + try self.writeTextEscaped(val); + } + } + } + + fn visitCode(self: *Compiler, code: *Node) CompilerError!void { + // Code nodes contain runtime expressions + // In a real implementation, we would evaluate these + // For now, just output the value as-is if buffered + if (code.buffer) { + if (code.val) |val| { + if (code.must_escape) { + try self.writeEscaped(val); + } else { + try self.write(val); + } } } - // Close opening tag - if (is_void_element and self.options.self_closing) { - try self.write(" />"); - try self.writeNewline(); - self.preserve_whitespace = was_preserving; + // Visit block if present + for (code.nodes.items) |child| { + try self.visit(child); + } + } + + fn visitComment(self: *Compiler, comment: *Node) CompilerError!void { + if (!comment.buffer) return; + + try self.prettyIndent(); + try self.write("<!--"); + if (comment.val) |val| { + try self.write(val); + } + try self.write("-->"); + } + + fn visitBlockComment(self: *Compiler, comment: *Node) CompilerError!void { + if (!comment.buffer) return; + + try self.prettyIndent(); + try self.write("<!--"); + if (comment.val) |val| { + try self.write(val); + } + + // Visit block content + for (comment.nodes.items) |child| { + try self.visit(child); + } + + try self.prettyIndent(); + try self.write("-->"); + } + + fn visitDoctype(self: *Compiler, doctype: ?*Node) CompilerError!void { + if (doctype) |dt| { + if (dt.val) |val| { + self.setDoctype(val); + } + } + + if (self.doctype_str) |dt_str| { + try self.write(dt_str); + } else { + try self.write("<!DOCTYPE html>"); + } + self.has_doctype = true; + } + + fn visitMixin(self: *Compiler, mixin: *Node) CompilerError!void { + // Mixin calls would be expanded at link time + // For now, just visit the block if it's a definition + if (!mixin.call) { + // This is a definition - skip it return; } - try self.write(">"); - - // Inline text - const has_inline_text = elem.inline_text != null and elem.inline_text.?.len > 0; - const has_children = elem.children.len > 0; - - if (has_inline_text) { - try self.writeTextSegments(elem.inline_text.?); - } - - // Children - if (has_children) { - if (!self.preserve_whitespace) { - try self.writeNewline(); - } - self.depth += 1; - for (elem.children) |child| { - try self.visitNode(child); - } - self.depth -= 1; - if (!self.preserve_whitespace) { - try self.writeIndent(); - } - } - - // Closing tag (not for void elements) - if (!is_void_element) { - try self.write("</"); - try self.write(elem.tag); - try self.write(">"); - try self.writeNewline(); - } - - self.preserve_whitespace = was_preserving; - } - - /// Generates output for a text node. - fn visitText(self: *CodeGen, text: ast.Text) CodeGenError!void { - try self.writeIndent(); - try self.writeTextSegments(text.segments); - try self.writeNewline(); - } - - /// Generates HTML comment. - fn visitComment(self: *CodeGen, comment: ast.Comment) CodeGenError!void { - if (!comment.rendered) return; - - try self.writeIndent(); - try self.write("<!--"); - if (comment.content.len > 0) { - try self.write(" "); - try self.write(comment.content); - try self.write(" "); - } - try self.write("-->"); - try self.writeNewline(); - } - - /// Generates placeholder for conditional (runtime evaluation needed). - fn visitConditional(self: *CodeGen, cond: ast.Conditional) CodeGenError!void { - // Output each branch with placeholder comments - for (cond.branches, 0..) |branch, i| { - try self.writeIndent(); - if (i == 0) { - if (branch.is_unless) { - try self.write("<!-- unless "); - } else { - try self.write("<!-- if "); - } - if (branch.condition) |condition| { - try self.write(condition); - } - try self.write(" -->"); - } else if (branch.condition) |condition| { - try self.write("<!-- else if "); - try self.write(condition); - try self.write(" -->"); - } else { - try self.write("<!-- else -->"); - } - try self.writeNewline(); - - self.depth += 1; - for (branch.children) |child| { - try self.visitNode(child); - } - self.depth -= 1; - } - - try self.writeIndent(); - try self.write("<!-- endif -->"); - try self.writeNewline(); - } - - /// Generates placeholder for each loop (runtime evaluation needed). - fn visitEach(self: *CodeGen, each: ast.Each) CodeGenError!void { - try self.writeIndent(); - try self.write("<!-- each "); - try self.write(each.value_name); - if (each.index_name) |idx| { - try self.write(", "); - try self.write(idx); - } - try self.write(" in "); - try self.write(each.collection); - try self.write(" -->"); - try self.writeNewline(); - - self.depth += 1; - for (each.children) |child| { - try self.visitNode(child); - } - self.depth -= 1; - - if (each.else_children.len > 0) { - try self.writeIndent(); - try self.write("<!-- else -->"); - try self.writeNewline(); - self.depth += 1; - for (each.else_children) |child| { - try self.visitNode(child); - } - self.depth -= 1; - } - - try self.writeIndent(); - try self.write("<!-- endeach -->"); - try self.writeNewline(); - } - - /// Generates placeholder for while loop (runtime evaluation needed). - fn visitWhile(self: *CodeGen, whl: ast.While) CodeGenError!void { - try self.writeIndent(); - try self.write("<!-- while "); - try self.write(whl.condition); - try self.write(" -->"); - try self.writeNewline(); - - self.depth += 1; - for (whl.children) |child| { - try self.visitNode(child); - } - self.depth -= 1; - - try self.writeIndent(); - try self.write("<!-- endwhile -->"); - try self.writeNewline(); - } - - /// Generates placeholder for case statement (runtime evaluation needed). - fn visitCase(self: *CodeGen, c: ast.Case) CodeGenError!void { - try self.writeIndent(); - try self.write("<!-- case "); - try self.write(c.expression); - try self.write(" -->"); - try self.writeNewline(); - - for (c.whens) |when| { - try self.writeIndent(); - try self.write("<!-- when "); - try self.write(when.value); - try self.write(" -->"); - try self.writeNewline(); - - self.depth += 1; - for (when.children) |child| { - try self.visitNode(child); - } - self.depth -= 1; - } - - if (c.default_children.len > 0) { - try self.writeIndent(); - try self.write("<!-- default -->"); - try self.writeNewline(); - self.depth += 1; - for (c.default_children) |child| { - try self.visitNode(child); - } - self.depth -= 1; - } - - try self.writeIndent(); - try self.write("<!-- endcase -->"); - try self.writeNewline(); - } - - /// Generates placeholder for mixin call (runtime evaluation needed). - fn visitMixinCall(self: *CodeGen, call: ast.MixinCall) CodeGenError!void { - try self.writeIndent(); - try self.write("<!-- +"); - try self.write(call.name); - try self.write(" -->"); - try self.writeNewline(); - } - - /// Generates placeholder for include (file loading needed). - fn visitInclude(self: *CodeGen, inc: ast.Include) CodeGenError!void { - try self.writeIndent(); - try self.write("<!-- include "); - try self.write(inc.path); - try self.write(" -->"); - try self.writeNewline(); - } - - /// Generates content for a named block. - fn visitBlock(self: *CodeGen, blk: ast.Block) CodeGenError!void { - try self.writeIndent(); - try self.write("<!-- block "); - try self.write(blk.name); - try self.write(" -->"); - try self.writeNewline(); - - self.depth += 1; - for (blk.children) |child| { - try self.visitNode(child); - } - self.depth -= 1; - - try self.writeIndent(); - try self.write("<!-- endblock -->"); - try self.writeNewline(); - } - - /// Generates raw text content (for script/style blocks). - fn visitRawText(self: *CodeGen, raw: ast.RawText) CodeGenError!void { - try self.writeIndent(); - try self.write(raw.content); - try self.writeNewline(); - } - - /// Generates code output (escaped or unescaped). - fn visitCode(self: *CodeGen, code: ast.Code) CodeGenError!void { - try self.writeIndent(); - if (code.escaped) { - try self.write("{{ "); - } else { - try self.write("{{{ "); - } - try self.write(code.expression); - if (code.escaped) { - try self.write(" }}"); - } else { - try self.write(" }}}"); - } - try self.writeNewline(); - } - - // ───────────────────────────────────────────────────────────────────────── - // Output helpers - // ───────────────────────────────────────────────────────────────────────── - - /// Writes text segments, handling interpolation. - fn writeTextSegments(self: *CodeGen, segments: []const ast.TextSegment) CodeGenError!void { - for (segments) |seg| { - switch (seg) { - .literal => |lit| try self.writeEscaped(lit), - .interp_escaped => |expr| { - try self.write("{{ "); - try self.write(expr); - try self.write(" }}"); - }, - .interp_unescaped => |expr| { - try self.write("{{{ "); - try self.write(expr); - try self.write(" }}}"); - }, - .interp_tag => |inline_tag| { - try self.writeInlineTag(inline_tag); - }, - } + // Mixin call - visit block if present + for (mixin.nodes.items) |child| { + try self.visit(child); } } - /// Writes an inline tag from tag interpolation. - fn writeInlineTag(self: *CodeGen, tag: ast.InlineTag) CodeGenError!void { - try self.write("<"); - try self.write(tag.tag); - - // Write ID if present - if (tag.id) |id| { - try self.write(" id=\""); - try self.writeEscaped(id); - try self.write("\""); - } - - // Write classes if present - if (tag.classes.len > 0) { - try self.write(" class=\""); - for (tag.classes, 0..) |class, i| { - if (i > 0) try self.write(" "); - try self.writeEscaped(class); - } - try self.write("\""); - } - - // Write attributes - for (tag.attributes) |attr| { - if (attr.value) |value| { - try self.write(" "); - try self.write(attr.name); - try self.write("=\""); - if (attr.escaped) { - try self.writeEscaped(value); - } else { - try self.write(value); - } - try self.write("\""); - } else { - try self.write(" "); - try self.write(attr.name); - try self.write("=\""); - try self.write(attr.name); - try self.write("\""); - } - } - - try self.write(">"); - - // Write text content (may contain nested interpolations) - try self.writeTextSegments(tag.text_segments); - - try self.write("</"); - try self.write(tag.tag); - try self.write(">"); + fn visitMixinBlock(_: *Compiler, _: *Node) CompilerError!void { + // MixinBlock is a placeholder for mixin content + // Handled at mixin call site } - /// Writes indentation based on current depth. - fn writeIndent(self: *CodeGen) CodeGenError!void { - if (!self.options.pretty or self.preserve_whitespace) return; - - for (0..self.depth) |_| { - try self.write(self.options.indent_str); + fn visitCase(self: *Compiler, case_node: *Node) CompilerError!void { + // Case/switch - visit block + for (case_node.nodes.items) |child| { + try self.visit(child); } } - /// Writes a newline if pretty-printing is enabled. - fn writeNewline(self: *CodeGen) CodeGenError!void { - if (!self.options.pretty or self.preserve_whitespace) return; - try self.write("\n"); - } - - /// Writes a string directly to output. - fn write(self: *CodeGen, str: []const u8) CodeGenError!void { - try self.output.appendSlice(self.allocator, str); - } - - /// Writes a string with HTML entity escaping. - fn writeEscaped(self: *CodeGen, str: []const u8) CodeGenError!void { - for (str) |c| { - switch (c) { - '&' => try self.write("&"), - '<' => try self.write("<"), - '>' => try self.write(">"), - '"' => try self.write("""), - '\'' => try self.write("'"), - else => try self.output.append(self.allocator, c), - } + fn visitWhen(self: *Compiler, when_node: *Node) CompilerError!void { + // When - visit block if present + for (when_node.nodes.items) |child| { + try self.visit(child); } } + + fn visitConditional(self: *Compiler, cond: *Node) CompilerError!void { + // In static compilation, we can't evaluate conditions + // Visit consequent by default + if (cond.consequent) |cons| { + try self.visit(cons); + } + } + + fn visitWhile(_: *Compiler, _: *Node) CompilerError!void { + // While loops need runtime evaluation + // In static mode, skip + } + + fn visitEach(_: *Compiler, _: *Node) CompilerError!void { + // Each loops need runtime evaluation + // In static mode, skip + } + + fn visitEachOf(_: *Compiler, _: *Node) CompilerError!void { + // EachOf loops need runtime evaluation + // In static mode, skip + } }; -// ───────────────────────────────────────────────────────────────────────────── -// Convenience function -// ───────────────────────────────────────────────────────────────────────────── +// ============================================================================ +// Convenience Functions +// ============================================================================ -/// Generates HTML from an AST document with default options. -/// Returns an owned slice that the caller must free. -pub fn generate(allocator: std.mem.Allocator, doc: ast.Document) CodeGenError![]u8 { - var gen = CodeGen.init(allocator, .{}); - defer gen.deinit(); - return gen.generateOwned(doc); +/// Compile an AST to HTML with default options +pub fn compile(allocator: Allocator, ast: *Node) CompilerError![]const u8 { + var compiler = Compiler.init(allocator, .{}); + defer compiler.deinit(); + return compiler.compile(ast); } -/// Generates HTML with custom options. -/// Returns an owned slice that the caller must free. -pub fn generateWithOptions(allocator: std.mem.Allocator, doc: ast.Document, options: Options) CodeGenError![]u8 { - var gen = CodeGen.init(allocator, options); - defer gen.deinit(); - return gen.generateOwned(doc); +/// Compile an AST to HTML with custom options +pub fn compileWithOptions(allocator: Allocator, ast: *Node, options: CompilerOptions) CompilerError![]const u8 { + var compiler = Compiler.init(allocator, options); + defer compiler.deinit(); + return compiler.compile(ast); } -// ───────────────────────────────────────────────────────────────────────────── +/// Compile an AST to pretty-printed HTML +pub fn compilePretty(allocator: Allocator, ast: *Node) CompilerError![]const u8 { + return compileWithOptions(allocator, ast, .{ .pretty = true }); +} + +// ============================================================================ // Tests -// ───────────────────────────────────────────────────────────────────────────── +// ============================================================================ -test "generate simple element" { +test "compile - simple text" { const allocator = std.testing.allocator; - const doc = ast.Document{ - .nodes = @constCast(&[_]ast.Node{ - .{ .element = .{ - .tag = "div", - .id = null, - .classes = &.{}, - .attributes = &.{}, - .inline_text = null, - .children = &.{}, - .self_closing = false, - } }, - }), + const text = try allocator.create(Node); + text.* = Node{ + .type = .Text, + .val = "Hello, World!", + .line = 1, + .column = 1, }; - const html = try generate(allocator, doc); - defer allocator.free(html); + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, text); - try std.testing.expectEqualStrings("<div></div>\n", html); + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + const output = try compile(allocator, root); + defer allocator.free(output); + + try std.testing.expectEqualStrings("Hello, World!", output); } -test "generate element with id and class" { +test "compile - simple tag" { const allocator = std.testing.allocator; - const doc = ast.Document{ - .nodes = @constCast(&[_]ast.Node{ - .{ .element = .{ - .tag = "div", - .id = "main", - .classes = &.{ "container", "active" }, - .attributes = &.{}, - .inline_text = null, - .children = &.{}, - .self_closing = false, - } }, - }), + const tag = try allocator.create(Node); + tag.* = Node{ + .type = .Tag, + .name = "div", + .line = 1, + .column = 1, }; - const html = try generate(allocator, doc); - defer allocator.free(html); + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, tag); - try std.testing.expectEqualStrings("<div id=\"main\" class=\"container active\"></div>\n", html); + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + const output = try compile(allocator, root); + defer allocator.free(output); + + try std.testing.expectEqualStrings("<div></div>", output); } -test "generate void element" { +test "compile - tag with text" { const allocator = std.testing.allocator; - const doc = ast.Document{ - .nodes = @constCast(&[_]ast.Node{ - .{ .element = .{ - .tag = "br", - .id = null, - .classes = &.{}, - .attributes = &.{}, - .inline_text = null, - .children = &.{}, - .self_closing = false, - } }, - }), + const text = try allocator.create(Node); + text.* = Node{ + .type = .Text, + .val = "Hello", + .line = 1, + .column = 5, }; - const html = try generate(allocator, doc); - defer allocator.free(html); + const tag = try allocator.create(Node); + tag.* = Node{ + .type = .Tag, + .name = "p", + .line = 1, + .column = 1, + }; + try tag.nodes.append(allocator, text); - try std.testing.expectEqualStrings("<br />\n", html); + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, tag); + + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + const output = try compile(allocator, root); + defer allocator.free(output); + + try std.testing.expectEqualStrings("<p>Hello</p>", output); } -test "generate nested elements" { +test "compile - tag with attributes" { const allocator = std.testing.allocator; - var inner_text = [_]ast.TextSegment{.{ .literal = "Hello" }}; - var inner_node = [_]ast.Node{ - .{ .element = .{ - .tag = "p", - .id = null, - .classes = &.{}, - .attributes = &.{}, - .inline_text = &inner_text, - .children = &.{}, - .self_closing = false, - } }, + const tag = try allocator.create(Node); + tag.* = Node{ + .type = .Tag, + .name = "a", + .line = 1, + .column = 1, }; + try tag.attrs.append(allocator, .{ + .name = "href", + .val = "/home", + .line = 1, + .column = 3, + .filename = null, + .must_escape = true, + }); - const doc = ast.Document{ - .nodes = @constCast(&[_]ast.Node{ - .{ .element = .{ - .tag = "div", - .id = null, - .classes = &.{}, - .attributes = &.{}, - .inline_text = null, - .children = &inner_node, - .self_closing = false, - } }, - }), + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, }; + try root.nodes.append(allocator, tag); - const html = try generate(allocator, doc); - defer allocator.free(html); + defer { + root.deinit(allocator); + allocator.destroy(root); + } - const expected = - \\<div> - \\ <p>Hello</p> - \\</div> - \\ - ; + const output = try compile(allocator, root); + defer allocator.free(output); - try std.testing.expectEqualStrings(expected, html); + try std.testing.expectEqualStrings("<a href=\"/home\"></a>", output); } -test "generate with interpolation" { +test "compile - self-closing tag" { const allocator = std.testing.allocator; - var inline_text = [_]ast.TextSegment{ - .{ .literal = "Hello, " }, - .{ .interp_escaped = "name" }, - .{ .literal = "!" }, + const tag = try allocator.create(Node); + tag.* = Node{ + .type = .Tag, + .name = "br", + .line = 1, + .column = 1, }; - const doc = ast.Document{ - .nodes = @constCast(&[_]ast.Node{ - .{ .element = .{ - .tag = "p", - .id = null, - .classes = &.{}, - .attributes = &.{}, - .inline_text = &inline_text, - .children = &.{}, - .self_closing = false, - } }, - }), + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, }; + try root.nodes.append(allocator, tag); - const html = try generate(allocator, doc); - defer allocator.free(html); + defer { + root.deinit(allocator); + allocator.destroy(root); + } - try std.testing.expectEqualStrings("<p>Hello, {{ name }}!</p>\n", html); + const output = try compile(allocator, root); + defer allocator.free(output); + + try std.testing.expectEqualStrings("<br>", output); } -test "generate html comment" { +test "compile - nested tags" { const allocator = std.testing.allocator; - const doc = ast.Document{ - .nodes = @constCast(&[_]ast.Node{ - .{ .comment = .{ - .content = "This is a comment", - .rendered = true, - .children = &.{}, - } }, - }), + const span = try allocator.create(Node); + span.* = Node{ + .type = .Tag, + .name = "span", + .line = 2, + .column = 3, }; - const html = try generate(allocator, doc); - defer allocator.free(html); + const div = try allocator.create(Node); + div.* = Node{ + .type = .Tag, + .name = "div", + .line = 1, + .column = 1, + }; + try div.nodes.append(allocator, span); - try std.testing.expectEqualStrings("<!-- This is a comment -->\n", html); + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, div); + + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + const output = try compile(allocator, root); + defer allocator.free(output); + + try std.testing.expectEqualStrings("<div><span></span></div>", output); } -test "escape html entities" { +test "compile - doctype" { const allocator = std.testing.allocator; - var inline_text = [_]ast.TextSegment{.{ .literal = "<script>alert('xss')</script>" }}; - - const doc = ast.Document{ - .nodes = @constCast(&[_]ast.Node{ - .{ .element = .{ - .tag = "p", - .id = null, - .classes = &.{}, - .attributes = &.{}, - .inline_text = &inline_text, - .children = &.{}, - .self_closing = false, - } }, - }), + const doctype = try allocator.create(Node); + doctype.* = Node{ + .type = .Doctype, + .val = "html", + .line = 1, + .column = 1, }; - const html = try generate(allocator, doc); - defer allocator.free(html); + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, doctype); - try std.testing.expectEqualStrings("<p><script>alert('xss')</script></p>\n", html); + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + const output = try compile(allocator, root); + defer allocator.free(output); + + try std.testing.expectEqualStrings("<!DOCTYPE html>", output); +} + +test "compile - comment" { + const allocator = std.testing.allocator; + + const comment = try allocator.create(Node); + comment.* = Node{ + .type = .Comment, + .val = " this is a comment ", + .buffer = true, + .line = 1, + .column = 1, + }; + + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, comment); + + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + const output = try compile(allocator, root); + defer allocator.free(output); + + try std.testing.expectEqualStrings("<!-- this is a comment -->", output); +} + +test "compile - text escaping" { + const allocator = std.testing.allocator; + + const text = try allocator.create(Node); + text.* = Node{ + .type = .Text, + .val = "<script>alert('xss')</script>", + .line = 1, + .column = 1, + }; + + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, text); + + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + const output = try compile(allocator, root); + defer allocator.free(output); + + try std.testing.expectEqualStrings("<script>alert('xss')</script>", output); +} + +test "compile - pretty print" { + const allocator = std.testing.allocator; + + const inner = try allocator.create(Node); + inner.* = Node{ + .type = .Tag, + .name = "span", + .line = 2, + .column = 3, + }; + + const outer = try allocator.create(Node); + outer.* = Node{ + .type = .Tag, + .name = "div", + .line = 1, + .column = 1, + }; + try outer.nodes.append(allocator, inner); + + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, outer); + + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + const output = try compilePretty(allocator, root); + defer allocator.free(output); + + // Pretty output has newlines and indentation + try std.testing.expect(mem.indexOf(u8, output, "\n") != null); } diff --git a/src/error.zig b/src/error.zig new file mode 100644 index 0000000..3138c6f --- /dev/null +++ b/src/error.zig @@ -0,0 +1,403 @@ +const std = @import("std"); +const mem = std.mem; +const Allocator = std.mem.Allocator; +const ArrayListUnmanaged = std.ArrayListUnmanaged; + +// ============================================================================ +// Pug Error - Error formatting with source context +// Based on pug-error package +// ============================================================================ + +/// Pug error with source context and formatting +pub const PugError = struct { + /// Error code (e.g., "PUG:SYNTAX_ERROR") + code: []const u8, + /// Short error message + msg: []const u8, + /// Line number (1-indexed) + line: usize, + /// Column number (1-indexed, 0 if unknown) + column: usize, + /// Source filename (optional) + filename: ?[]const u8, + /// Source code (optional, for context display) + src: ?[]const u8, + /// Full formatted message with context + full_message: ?[]const u8, + + allocator: Allocator, + /// Track if full_message was allocated + owns_full_message: bool, + + pub fn deinit(self: *PugError) void { + if (self.owns_full_message) { + if (self.full_message) |msg| { + self.allocator.free(msg); + } + } + } + + /// Get the formatted message (with context if available) + pub fn getMessage(self: *const PugError) []const u8 { + if (self.full_message) |msg| { + return msg; + } + return self.msg; + } + + /// Format as JSON-like structure for serialization + pub fn toJson(self: *const PugError, allocator: Allocator) ![]const u8 { + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + + try result.appendSlice(allocator, "{\"code\":\""); + try result.appendSlice(allocator, self.code); + try result.appendSlice(allocator, "\",\"msg\":\""); + try appendJsonEscaped(allocator, &result, self.msg); + try result.appendSlice(allocator, "\",\"line\":"); + + var buf: [32]u8 = undefined; + const line_str = std.fmt.bufPrint(&buf, "{d}", .{self.line}) catch return error.FormatError; + try result.appendSlice(allocator, line_str); + + try result.appendSlice(allocator, ",\"column\":"); + const col_str = std.fmt.bufPrint(&buf, "{d}", .{self.column}) catch return error.FormatError; + try result.appendSlice(allocator, col_str); + + if (self.filename) |fname| { + try result.appendSlice(allocator, ",\"filename\":\""); + try appendJsonEscaped(allocator, &result, fname); + try result.append(allocator, '"'); + } + + try result.append(allocator, '}'); + return try result.toOwnedSlice(allocator); + } +}; + +/// Append JSON-escaped string to result +fn appendJsonEscaped(allocator: Allocator, result: *ArrayListUnmanaged(u8), s: []const u8) !void { + for (s) |c| { + switch (c) { + '"' => try result.appendSlice(allocator, "\\\""), + '\\' => try result.appendSlice(allocator, "\\\\"), + '\n' => try result.appendSlice(allocator, "\\n"), + '\r' => try result.appendSlice(allocator, "\\r"), + '\t' => try result.appendSlice(allocator, "\\t"), + else => { + if (c < 0x20) { + // Control character - encode as \uXXXX + var hex_buf: [6]u8 = undefined; + _ = std.fmt.bufPrint(&hex_buf, "\\u{x:0>4}", .{c}) catch unreachable; + try result.appendSlice(allocator, &hex_buf); + } else { + try result.append(allocator, c); + } + }, + } + } +} + +/// Create a Pug error with formatted message and source context. +/// Equivalent to pug-error's makeError function. +pub fn makeError( + allocator: Allocator, + code: []const u8, + message: []const u8, + options: struct { + line: usize, + column: usize = 0, + filename: ?[]const u8 = null, + src: ?[]const u8 = null, + }, +) !PugError { + var err = PugError{ + .code = code, + .msg = message, + .line = options.line, + .column = options.column, + .filename = options.filename, + .src = options.src, + .full_message = null, + .allocator = allocator, + .owns_full_message = false, + }; + + // Format full message with context + err.full_message = try formatErrorMessage( + allocator, + code, + message, + options.line, + options.column, + options.filename, + options.src, + ); + err.owns_full_message = true; + + return err; +} + +/// Format error message with source context (±3 lines) +fn formatErrorMessage( + allocator: Allocator, + code: []const u8, + message: []const u8, + line: usize, + column: usize, + filename: ?[]const u8, + src: ?[]const u8, +) ![]const u8 { + _ = code; // Code is embedded in PugError struct + + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + + // Header: filename:line:column or Pug:line:column + if (filename) |fname| { + try result.appendSlice(allocator, fname); + } else { + try result.appendSlice(allocator, "Pug"); + } + try result.append(allocator, ':'); + + var buf: [32]u8 = undefined; + const line_str = std.fmt.bufPrint(&buf, "{d}", .{line}) catch return error.FormatError; + try result.appendSlice(allocator, line_str); + + if (column > 0) { + try result.append(allocator, ':'); + const col_str = std.fmt.bufPrint(&buf, "{d}", .{column}) catch return error.FormatError; + try result.appendSlice(allocator, col_str); + } + try result.append(allocator, '\n'); + + // Source context if available + if (src) |source| { + const lines = try splitLines(allocator, source); + defer allocator.free(lines); + + if (line >= 1 and line <= lines.len) { + // Show ±3 lines around error + const start = if (line > 3) line - 3 else 1; + const end = @min(lines.len, line + 3); + + var i = start; + while (i <= end) : (i += 1) { + const line_idx = i - 1; + if (line_idx >= lines.len) break; + + const src_line = lines[line_idx]; + + // Preamble: " > 5| " or " 5| " + if (i == line) { + try result.appendSlice(allocator, " > "); + } else { + try result.appendSlice(allocator, " "); + } + + // Line number (right-aligned) + const num_str = std.fmt.bufPrint(&buf, "{d}", .{i}) catch return error.FormatError; + try result.appendSlice(allocator, num_str); + try result.appendSlice(allocator, "| "); + + // Source line + try result.appendSlice(allocator, src_line); + try result.append(allocator, '\n'); + + // Column marker for error line + if (i == line and column > 0) { + // Calculate preamble length + const preamble_len = 4 + num_str.len + 2; // " > " + num + "| " + var j: usize = 0; + while (j < preamble_len + column - 1) : (j += 1) { + try result.append(allocator, '-'); + } + try result.append(allocator, '^'); + try result.append(allocator, '\n'); + } + } + try result.append(allocator, '\n'); + } + } else { + try result.append(allocator, '\n'); + } + + // Error message + try result.appendSlice(allocator, message); + + return try result.toOwnedSlice(allocator); +} + +/// Split source into lines (handles \n, \r\n, \r) +fn splitLines(allocator: Allocator, src: []const u8) ![][]const u8 { + var lines: ArrayListUnmanaged([]const u8) = .{}; + errdefer lines.deinit(allocator); + + var start: usize = 0; + var i: usize = 0; + + while (i < src.len) { + if (src[i] == '\n') { + try lines.append(allocator, src[start..i]); + start = i + 1; + i += 1; + } else if (src[i] == '\r') { + try lines.append(allocator, src[start..i]); + // Handle \r\n + if (i + 1 < src.len and src[i + 1] == '\n') { + i += 2; + } else { + i += 1; + } + start = i; + } else { + i += 1; + } + } + + // Last line (may not end with newline) + if (start <= src.len) { + try lines.append(allocator, src[start..]); + } + + return try lines.toOwnedSlice(allocator); +} + +// ============================================================================ +// Common error codes +// ============================================================================ + +pub const ErrorCode = struct { + pub const SYNTAX_ERROR = "PUG:SYNTAX_ERROR"; + pub const INVALID_TOKEN = "PUG:INVALID_TOKEN"; + pub const UNEXPECTED_TOKEN = "PUG:UNEXPECTED_TOKEN"; + pub const INVALID_INDENTATION = "PUG:INVALID_INDENTATION"; + pub const INCONSISTENT_INDENTATION = "PUG:INCONSISTENT_INDENTATION"; + pub const EXTENDS_NOT_FIRST = "PUG:EXTENDS_NOT_FIRST"; + pub const UNEXPECTED_BLOCK = "PUG:UNEXPECTED_BLOCK"; + pub const UNEXPECTED_NODES_IN_EXTENDING_ROOT = "PUG:UNEXPECTED_NODES_IN_EXTENDING_ROOT"; + pub const NO_EXTENDS_PATH = "PUG:NO_EXTENDS_PATH"; + pub const NO_INCLUDE_PATH = "PUG:NO_INCLUDE_PATH"; + pub const MALFORMED_EXTENDS = "PUG:MALFORMED_EXTENDS"; + pub const MALFORMED_INCLUDE = "PUG:MALFORMED_INCLUDE"; + pub const FILTER_NOT_FOUND = "PUG:FILTER_NOT_FOUND"; + pub const INVALID_FILTER = "PUG:INVALID_FILTER"; +}; + +// ============================================================================ +// Tests +// ============================================================================ + +test "makeError - basic error without source" { + const allocator = std.testing.allocator; + var err = try makeError(allocator, "PUG:TEST", "test error", .{ + .line = 5, + .column = 10, + .filename = "test.pug", + }); + defer err.deinit(); + + try std.testing.expectEqualStrings("PUG:TEST", err.code); + try std.testing.expectEqualStrings("test error", err.msg); + try std.testing.expectEqual(@as(usize, 5), err.line); + try std.testing.expectEqual(@as(usize, 10), err.column); + try std.testing.expectEqualStrings("test.pug", err.filename.?); + + const msg = err.getMessage(); + try std.testing.expect(mem.indexOf(u8, msg, "test.pug:5:10") != null); + try std.testing.expect(mem.indexOf(u8, msg, "test error") != null); +} + +test "makeError - error with source context" { + const allocator = std.testing.allocator; + const src = "line 1\nline 2\nline 3 with error\nline 4\nline 5"; + var err = try makeError(allocator, "PUG:SYNTAX_ERROR", "unexpected token", .{ + .line = 3, + .column = 8, + .filename = "template.pug", + .src = src, + }); + defer err.deinit(); + + const msg = err.getMessage(); + // Should contain filename:line:column + try std.testing.expect(mem.indexOf(u8, msg, "template.pug:3:8") != null); + // Should contain the error line with marker + try std.testing.expect(mem.indexOf(u8, msg, "line 3 with error") != null); + // Should contain the error message + try std.testing.expect(mem.indexOf(u8, msg, "unexpected token") != null); + // Should have column marker + try std.testing.expect(mem.indexOf(u8, msg, "^") != null); +} + +test "makeError - error with source shows context lines" { + const allocator = std.testing.allocator; + const src = "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8"; + var err = try makeError(allocator, "PUG:TEST", "test", .{ + .line = 5, + .filename = null, + .src = src, + }); + defer err.deinit(); + + const msg = err.getMessage(); + // Should show lines 2-8 (5 ± 3) + try std.testing.expect(mem.indexOf(u8, msg, "line 2") != null); + try std.testing.expect(mem.indexOf(u8, msg, "line 5") != null); + try std.testing.expect(mem.indexOf(u8, msg, "line 8") != null); + // Line 1 should not be shown (too far before) + // Note: line 1 might appear in context depending on implementation +} + +test "makeError - no filename uses Pug" { + const allocator = std.testing.allocator; + var err = try makeError(allocator, "PUG:TEST", "test error", .{ + .line = 1, + }); + defer err.deinit(); + + const msg = err.getMessage(); + try std.testing.expect(mem.indexOf(u8, msg, "Pug:1") != null); +} + +test "PugError.toJson" { + const allocator = std.testing.allocator; + var err = try makeError(allocator, "PUG:TEST", "test message", .{ + .line = 10, + .column = 5, + .filename = "file.pug", + }); + defer err.deinit(); + + const json = try err.toJson(allocator); + defer allocator.free(json); + + try std.testing.expect(mem.indexOf(u8, json, "\"code\":\"PUG:TEST\"") != null); + try std.testing.expect(mem.indexOf(u8, json, "\"msg\":\"test message\"") != null); + try std.testing.expect(mem.indexOf(u8, json, "\"line\":10") != null); + try std.testing.expect(mem.indexOf(u8, json, "\"column\":5") != null); + try std.testing.expect(mem.indexOf(u8, json, "\"filename\":\"file.pug\"") != null); +} + +test "splitLines - basic" { + const allocator = std.testing.allocator; + const lines = try splitLines(allocator, "a\nb\nc"); + defer allocator.free(lines); + + try std.testing.expectEqual(@as(usize, 3), lines.len); + try std.testing.expectEqualStrings("a", lines[0]); + try std.testing.expectEqualStrings("b", lines[1]); + try std.testing.expectEqualStrings("c", lines[2]); +} + +test "splitLines - windows line endings" { + const allocator = std.testing.allocator; + const lines = try splitLines(allocator, "a\r\nb\r\nc"); + defer allocator.free(lines); + + try std.testing.expectEqual(@as(usize, 3), lines.len); + try std.testing.expectEqualStrings("a", lines[0]); + try std.testing.expectEqualStrings("b", lines[1]); + try std.testing.expectEqualStrings("c", lines[2]); +} diff --git a/src/lexer.zig b/src/lexer.zig index e1d3f37..71bfedf 100644 --- a/src/lexer.zig +++ b/src/lexer.zig @@ -1,1804 +1,2686 @@ -//! Pug Lexer - Tokenizes Pug template source into a stream of tokens. -//! -//! The lexer handles indentation-based nesting (emitting indent/dedent tokens), -//! Pug-specific syntax (tags, classes, IDs, attributes), and text content -//! including interpolation markers. -//! -//! ## Error Diagnostics -//! When tokenization fails, call `getDiagnostic()` to get rich error info: -//! ```zig -//! var lexer = Lexer.init(allocator, source); -//! const tokens = lexer.tokenize() catch |err| { -//! if (lexer.getDiagnostic()) |diag| { -//! std.debug.print("{}\n", .{diag}); -//! } -//! return err; -//! }; -//! ``` - const std = @import("std"); -const diagnostic = @import("diagnostic.zig"); +const mem = std.mem; +const Allocator = std.mem.Allocator; -pub const Diagnostic = diagnostic.Diagnostic; +// ============================================================================ +// Token Types +// ============================================================================ -/// All possible token types produced by the lexer. pub const TokenType = enum { - // Structure tokens for indentation-based nesting - indent, // Increased indentation level - dedent, // Decreased indentation level - newline, // Line terminator - eof, // End of source - - // Element tokens - tag, // HTML tag name: div, p, a, span, etc. - class, // Class selector: .classname - id, // ID selector: #idname - - // Attribute tokens for (attr=value) syntax - lparen, // Opening paren: ( - rparen, // Closing paren: ) - attr_name, // Attribute name: href, class, data-id - attr_eq, // Assignment: = or != - attr_value, // Attribute value (quoted or unquoted) - comma, // Attribute separator: , - - // Text content tokens - text, // Plain text content - buffered_text, // Escaped output: = expr - unescaped_text, // Raw output: != expr - pipe_text, // Piped text: | text - dot_block, // Text block marker: . - literal_html, // Literal HTML: <tag>... - self_close, // Self-closing marker: / - - // Interpolation tokens for #{} and !{} syntax - interp_start, // Escaped interpolation: #{ - interp_start_unesc, // Unescaped interpolation: !{ - interp_end, // Interpolation end: } - - // Tag interpolation tokens for #[tag text] syntax - tag_interp_start, // Tag interpolation start: #[ - tag_interp_end, // Tag interpolation end: ] - - // Control flow keywords - kw_if, - kw_else, - kw_unless, - kw_each, - kw_for, // alias for each - kw_while, - kw_in, - kw_case, - kw_when, - kw_default, - - // Template structure keywords - kw_doctype, - kw_mixin, - kw_block, - kw_extends, - kw_include, - kw_append, - kw_prepend, - - // Mixin invocation: +mixinName - mixin_call, - - // Comment tokens - 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 + tag, + id, + class, + text, + text_html, + comment, + doctype, + filter, + extends, + include, + path, + block, + mixin_block, + mixin, + call, + yield, + code, + blockcode, + interpolation, + interpolated_code, + @"if", + else_if, + @"else", + case, + when, + default, + each, + each_of, + @"while", + indent, + outdent, + newline, + eos, + dot, + colon, + slash, + start_attributes, + end_attributes, + attribute, + @"&attributes", + start_pug_interpolation, + end_pug_interpolation, + start_pipeless_text, + end_pipeless_text, }; -/// A single token with its type, value, and source location. -pub const Token = struct { - type: TokenType, - value: []const u8, // Slice into source (no allocation) - line: usize, - column: usize, -}; +// ============================================================================ +// Token Value - Tagged Union for type-safe token values +// ============================================================================ -/// Errors that can occur during lexing. -pub const LexerError = error{ - UnterminatedString, - UnmatchedBrace, - OutOfMemory, -}; +pub const TokenValue = union(enum) { + none, + string: []const u8, + boolean: bool, -/// Static map for keyword lookup. Using comptime perfect hashing would be ideal, -/// but a simple StaticStringMap is efficient for small keyword sets. -const keywords = std.StaticStringMap(TokenType).initComptime(.{ - .{ "if", .kw_if }, - .{ "else", .kw_else }, - .{ "unless", .kw_unless }, - .{ "each", .kw_each }, - .{ "for", .kw_for }, - .{ "while", .kw_while }, - .{ "case", .kw_case }, - .{ "when", .kw_when }, - .{ "default", .kw_default }, - .{ "doctype", .kw_doctype }, - .{ "mixin", .kw_mixin }, - .{ "block", .kw_block }, - .{ "extends", .kw_extends }, - .{ "include", .kw_include }, - .{ "append", .kw_append }, - .{ "prepend", .kw_prepend }, - .{ "in", .kw_in }, -}); + pub fn isNone(self: TokenValue) bool { + return self == .none; + } -/// Lexer for Pug template syntax. -/// -/// Converts source text into a sequence of tokens. Handles: -/// - Indentation tracking with indent/dedent tokens -/// - Tag, class, and ID shorthand syntax -/// - Attribute parsing within parentheses -/// - Text content and interpolation -/// - Comments and keywords -pub const Lexer = struct { - source: []const u8, - pos: usize, - line: usize, - column: usize, - indent_stack: std.ArrayList(usize), - tokens: std.ArrayList(Token), - allocator: std.mem.Allocator, - at_line_start: bool, - current_indent: usize, - 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, - - /// Creates a new lexer for the given source. - /// Does not allocate; allocations happen during tokenize(). - pub fn init(allocator: std.mem.Allocator, source: []const u8) Lexer { - return .{ - .source = source, - .pos = 0, - .line = 1, - .column = 1, - .indent_stack = .empty, - .tokens = .empty, - .allocator = allocator, - .at_line_start = true, - .current_indent = 0, - .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, + pub fn getString(self: TokenValue) ?[]const u8 { + return switch (self) { + .string => |s| s, + else => null, }; } - /// Returns the last error diagnostic, if any. - /// Call this after tokenize() returns an error to get detailed error info. - pub fn getDiagnostic(self: *const Lexer) ?Diagnostic { - return self.last_diagnostic; + pub fn getBool(self: TokenValue) ?bool { + return switch (self) { + .boolean => |b| b, + else => null, + }; } - /// Sets a diagnostic error with full context. - fn setDiagnostic(self: *Lexer, message: []const u8, suggestion: ?[]const u8) void { - self.last_diagnostic = Diagnostic.withContext( - @intCast(self.line), - @intCast(self.column), - message, - diagnostic.extractSourceLine(self.source, self.pos) orelse "", - suggestion, - ); + pub fn fromString(s: []const u8) TokenValue { + return .{ .string = s }; } - /// Releases all allocated memory (tokens and indent stack). - /// Call this when done with the lexer, typically via defer. - pub fn deinit(self: *Lexer) void { - self.indent_stack.deinit(self.allocator); - self.tokens.deinit(self.allocator); + pub fn fromBool(b: bool) TokenValue { + return .{ .boolean = b }; } - /// Tokenizes the source and returns the token slice. - /// - /// Returns a slice of tokens owned by the Lexer. The slice remains valid - /// 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); - try self.indent_stack.ensureTotalCapacity(self.allocator, 16); // Reasonable nesting depth - - try self.indent_stack.append(self.allocator, 0); - errdefer self.reset(); - - while (!self.isAtEnd()) { - try self.scanToken(); - } - - // Emit dedents for any remaining indentation levels - while (self.indent_stack.items.len > 1) { - _ = self.indent_stack.pop(); - try self.addToken(.dedent, ""); - } - - try self.addToken(.eof, ""); - return self.tokens.items; - } - - /// Resets lexer state while retaining allocated capacity. - /// Called on error to restore clean state for reuse. - pub fn reset(self: *Lexer) void { - self.tokens.clearRetainingCapacity(); - self.indent_stack.clearRetainingCapacity(); - self.pos = 0; - self.line = 1; - self.column = 1; - self.at_line_start = true; - self.current_indent = 0; - self.last_diagnostic = null; - } - - /// Appends a token to the output list. - fn addToken(self: *Lexer, token_type: TokenType, value: []const u8) !void { - try self.tokens.append(self.allocator, .{ - .type = token_type, - .value = value, - .line = self.line, - .column = self.column, - }); - } - - /// Main token dispatch. Processes one token based on current character. - /// 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 - const line_start = self.pos; - const indent = self.measureIndent(); - self.current_indent = indent; - - if (indent > self.raw_block_indent) { - // First line in raw block - emit indent token - if (!self.raw_block_started) { - self.raw_block_started = true; - try self.indent_stack.append(self.allocator, indent); - try self.addToken(.indent, ""); - } - // Scan line as raw text, preserving relative indentation - try self.scanRawLineFrom(line_start); - self.at_line_start = false; - return; - } else { - // Exiting raw block - emit dedent and process normally - self.in_raw_block = false; - self.raw_block_started = false; - if (self.indent_stack.items.len > 1) { - _ = self.indent_stack.pop(); - try self.addToken(.dedent, ""); - } - try self.processIndentation(); - self.at_line_start = false; - return; - } - } - - try self.processIndentation(); - self.at_line_start = false; - } - - if (self.isAtEnd()) return; - - const c = self.peek(); - - // Whitespace (not at line start - already handled) - if (c == ' ' or c == '\t') { - self.advance(); - return; - } - - // Newline: emit token and mark next line start - if (c == '\n') { - try self.addToken(.newline, "\n"); - self.advance(); - self.line += 1; - self.column = 1; - self.at_line_start = true; - return; - } - - // Handle \r\n (Windows) and \r (old Mac) - if (c == '\r') { - self.advance(); - if (self.peek() == '\n') { - self.advance(); - } - try self.addToken(.newline, "\n"); - self.line += 1; - self.column = 1; - self.at_line_start = true; - return; - } - - // Comments: // or //- - if (c == '/' and self.peekNext() == '/') { - try self.scanComment(); - return; - } - - // Self-closing marker: / at end of tag (before newline or space) - if (c == '/') { - const next = self.peekNext(); - if (next == '\n' or next == '\r' or next == ' ' or next == 0) { - self.advance(); - try self.addToken(.self_close, "/"); - return; - } - } - - // Dot: either .class or . (text block) - if (c == '.') { - const next = self.peekNext(); - if (next == '\n' or next == '\r' or next == 0) { - self.advance(); - try self.addToken(.dot_block, "."); - // Mark that we're entering a raw text block - self.in_raw_block = true; - self.raw_block_indent = self.current_indent; - return; - } - if (isAlpha(next) or next == '-' or next == '_') { - try self.scanClass(); - return; - } - } - - // Hash: either #id, #{interpolation}, or #[tag interpolation] - if (c == '#') { - const next = self.peekNext(); - if (next == '{') { - self.advance(); - self.advance(); - try self.addToken(.interp_start, "#{"); - return; - } - if (next == '[') { - self.advance(); - self.advance(); - try self.addToken(.tag_interp_start, "#["); - return; - } - if (isAlpha(next) or next == '-' or next == '_') { - try self.scanId(); - return; - } - } - - // Unescaped interpolation: !{ - if (c == '!' and self.peekNext() == '{') { - self.advance(); - self.advance(); - try self.addToken(.interp_start_unesc, "!{"); - return; - } - - // Attributes: (...) - if (c == '(') { - try self.scanAttributes(); - return; - } - - // Pipe text: | text - if (c == '|') { - try self.scanPipeText(); - return; - } - - // Literal HTML: lines starting with < - if (c == '<') { - try self.scanLiteralHtml(); - return; - } - - // Buffered output: = expression - if (c == '=') { - self.advance(); - try self.addToken(.buffered_text, "="); - try self.scanInlineText(); - return; - } - - // Unescaped output: != expression - if (c == '!' and self.peekNext() == '=') { - self.advance(); - self.advance(); - try self.addToken(.unescaped_text, "!="); - try self.scanInlineText(); - return; - } - - // Mixin call: +name - if (c == '+') { - try self.scanMixinCall(); - 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(); - try self.addToken(.colon, ":"); - return; - } - - // Attribute spread: &attributes(obj) - if (c == '&') { - try self.scanAmpersandAttrs(); - return; - } - - // Interpolation end - if (c == '}') { - self.advance(); - try self.addToken(.interp_end, "}"); - return; - } - - // Tag name or keyword - if (isAlpha(c) or c == '_') { - try self.scanTagOrKeyword(); - return; - } - - // Fallback: treat remaining content as text - try self.scanInlineText(); - } - - /// Processes leading whitespace at line start to emit indent/dedent tokens. - /// Measures indentation at current position and advances past whitespace. - /// Returns the indent level (spaces=1, tabs=2). - fn measureIndent(self: *Lexer) usize { - var indent: usize = 0; - - // Count spaces (1 each) and tabs (2 each) - while (!self.isAtEnd()) { - const c = self.peek(); - if (c == ' ') { - indent += 1; - self.advance(); - } else if (c == '\t') { - indent += 2; - self.advance(); - } else { - break; - } - } - - return indent; - } - - /// Processes leading whitespace at line start to emit indent/dedent tokens. - /// Tracks indentation levels on a stack to handle nested blocks. - fn processIndentation(self: *Lexer) !void { - const indent = self.measureIndent(); - - // Empty lines don't affect indentation - if (!self.isAtEnd() and (self.peek() == '\n' or self.peek() == '\r')) { - return; - } - - self.current_indent = indent; - const current_stack_indent = self.indent_stack.items[self.indent_stack.items.len - 1]; - - if (indent > current_stack_indent) { - // Deeper nesting: push new level and emit indent - try self.indent_stack.append(self.allocator, indent); - try self.addToken(.indent, ""); - } else if (indent < current_stack_indent) { - // Shallower nesting: pop levels and emit dedents - 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, ""); - - // Exit raw block mode when dedenting to or below original level - if (self.in_raw_block and indent <= self.raw_block_indent) { - self.in_raw_block = false; - } - } + pub fn format( + self: TokenValue, + comptime fmt: []const u8, + options: std.fmt.FormatOptions, + writer: anytype, + ) !void { + _ = fmt; + _ = options; + switch (self) { + .none => try writer.writeAll("none"), + .string => |s| try writer.print("\"{s}\"", .{s}), + .boolean => |b| try writer.print("{}", .{b}), } } +}; - /// 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 / +// ============================================================================ +// Location and Token +// ============================================================================ - const is_unbuffered = self.peek() == '-'; - if (is_unbuffered) { - self.advance(); - } +pub const Location = struct { + line: usize, + column: usize, +}; - const start = self.pos; - while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { - self.advance(); - } +pub const TokenLoc = struct { + start: Location, + end: ?Location = null, + filename: ?[]const u8 = null, +}; - const value = self.source[start..self.pos]; - try self.addToken(if (is_unbuffered) .comment_unbuffered else .comment, value); +pub const Token = struct { + type: TokenType, + val: TokenValue = .none, + loc: TokenLoc, + // Additional fields for specific token types + buffer: TokenValue = .none, // boolean for comment/code tokens + must_escape: TokenValue = .none, // boolean for code/attribute tokens + mode: TokenValue = .none, // string: "prepend", "append", "replace" for block + args: TokenValue = .none, // string for mixin/call + key: TokenValue = .none, // string for each + code: TokenValue = .none, // string for each/eachOf + name: TokenValue = .none, // string for attribute - // 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; + /// Helper to get val as string + pub fn getVal(self: Token) ?[]const u8 { + return self.val.getString(); } - /// 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); + /// Helper to check if buffer is true + pub fn isBuffered(self: Token) bool { + return self.buffer.getBool() orelse false; } - /// Scans a class selector: .classname - /// After the class, checks for inline text if no more selectors follow. - fn scanClass(self: *Lexer) !void { - self.advance(); // skip . - const start = self.pos; - - while (!self.isAtEnd()) { - const c = self.peek(); - if (isAlphaNumeric(c) or c == '-' or c == '_') { - self.advance(); - } else { - break; - } - } - - try self.addToken(.class, self.source[start..self.pos]); - - // Check for inline text after class (if no more selectors/attrs follow) - try self.tryInlineTextAfterSelector(); + /// Helper to check if must_escape is true + pub fn shouldEscape(self: Token) bool { + return self.must_escape.getBool() orelse true; } - /// Scans an ID selector: #idname - /// After the ID, checks for inline text if no more selectors follow. - fn scanId(self: *Lexer) !void { - self.advance(); // skip # - const start = self.pos; - - while (!self.isAtEnd()) { - const c = self.peek(); - if (isAlphaNumeric(c) or c == '-' or c == '_') { - self.advance(); - } else { - break; - } - } - - try self.addToken(.id, self.source[start..self.pos]); - - // Check for inline text after ID (if no more selectors/attrs follow) - try self.tryInlineTextAfterSelector(); + /// Helper to get mode as string + pub fn getMode(self: Token) ?[]const u8 { + return self.mode.getString(); } - /// Scans attribute list: (name=value, name2=value2, boolean) - /// Also handles mixin arguments: ('value', expr, name=value) - /// Handles quoted strings, expressions, and boolean attributes. - fn scanAttributes(self: *Lexer) !void { - self.advance(); // skip ( - try self.addToken(.lparen, "("); + /// Helper to get args as string + pub fn getArgs(self: Token) ?[]const u8 { + return self.args.getString(); + } - while (!self.isAtEnd() and self.peek() != ')') { - self.skipWhitespaceInAttrs(); - if (self.peek() == ')') break; + /// Helper to get key as string + pub fn getKey(self: Token) ?[]const u8 { + return self.key.getString(); + } - // Comma separator - if (self.peek() == ',') { - self.advance(); - try self.addToken(.comma, ","); - continue; - } + /// Helper to get code as string + pub fn getCode(self: Token) ?[]const u8 { + return self.code.getString(); + } - const c = self.peek(); + /// Helper to get attribute name as string + pub fn getName(self: Token) ?[]const u8 { + return self.name.getString(); + } +}; - // Check for quoted attribute name: '(click)'='play()' or "(click)"="play()" - if (c == '"' or c == '\'') { - // Look ahead to see if this is a quoted attribute name (followed by =) - const quote = c; - var lookahead = self.pos + 1; - while (lookahead < self.source.len and self.source[lookahead] != quote) { - lookahead += 1; - } - if (lookahead < self.source.len) { - lookahead += 1; // skip closing quote - // Skip whitespace - while (lookahead < self.source.len and (self.source[lookahead] == ' ' or self.source[lookahead] == '\t')) { - lookahead += 1; - } - // Check if followed by = (attribute name) or not (bare value) - if (lookahead < self.source.len and (self.source[lookahead] == '=' or - (self.source[lookahead] == '!' and lookahead + 1 < self.source.len and self.source[lookahead + 1] == '='))) - { - // This is a quoted attribute name - self.advance(); // skip opening quote - const name_start = self.pos; - while (!self.isAtEnd() and self.peek() != quote) { - self.advance(); - } - const attr_name = self.source[name_start..self.pos]; - if (self.peek() == quote) self.advance(); // skip closing quote - try self.addToken(.attr_name, attr_name); +// ============================================================================ +// Character Parser State (simplified) - Zig 0.15 style with ArrayListUnmanaged +// ============================================================================ - self.skipWhitespaceInAttrs(); +const BracketType = enum { paren, brace, bracket }; - // Value assignment: = or != - if (self.peek() == '!' and self.peekNext() == '=') { - self.advance(); - self.advance(); - try self.addToken(.attr_eq, "!="); - self.skipWhitespaceInAttrs(); - try self.scanAttrValue(); - } else if (self.peek() == '=') { - self.advance(); - try self.addToken(.attr_eq, "="); - self.skipWhitespaceInAttrs(); - try self.scanAttrValue(); - } - continue; - } - } - // Not followed by =, treat as bare value (mixin argument) - try self.scanAttrValue(); - continue; - } +const CharParserState = struct { + nesting_stack: std.ArrayListUnmanaged(BracketType) = .{}, + in_string: bool = false, + string_char: ?u8 = null, + in_template: bool = false, + escape_next: bool = false, - // Check for bare value (mixin argument): starts with backtick, brace, bracket, or digit - if (c == '`' or c == '{' or c == '[' or isDigit(c)) { - // This is a bare value (mixin argument), not name=value - try self.scanAttrValue(); - continue; - } + pub fn deinit(self: *CharParserState, allocator: Allocator) void { + self.nesting_stack.deinit(allocator); + } - // Check for parenthesized attribute name: (click)='play()' - // This is valid when preceded by comma or at start of attributes - if (c == '(') { - const name_start = self.pos; - self.advance(); // skip ( - var paren_depth: usize = 1; - while (!self.isAtEnd() and paren_depth > 0) { - const ch = self.peek(); - if (ch == '(') { - paren_depth += 1; - } else if (ch == ')') { - paren_depth -= 1; - } - if (paren_depth > 0) self.advance(); - } - if (self.peek() == ')') self.advance(); // skip closing ) - const attr_name = self.source[name_start..self.pos]; - try self.addToken(.attr_name, attr_name); + pub fn isNesting(self: *const CharParserState) bool { + return self.nesting_stack.items.len > 0; + } - self.skipWhitespaceInAttrs(); + pub fn isString(self: *const CharParserState) bool { + return self.in_string or self.in_template; + } - // Value assignment: = or != - if (self.peek() == '!' and self.peekNext() == '=') { - self.advance(); - self.advance(); - try self.addToken(.attr_eq, "!="); - self.skipWhitespaceInAttrs(); - try self.scanAttrValue(); - } else if (self.peek() == '=') { - self.advance(); - try self.addToken(.attr_eq, "="); - self.skipWhitespaceInAttrs(); - try self.scanAttrValue(); - } - continue; - } + pub fn getStringChar(self: *const CharParserState) ?u8 { + if (self.in_string) return self.string_char; + if (self.in_template) return '`'; + return null; + } - // Check for rest parameter: ...name - const name_start = self.pos; - if (c == '.' and self.peekAt(1) == '.' and self.peekAt(2) == '.') { - // Skip the three dots, include them in attr_name - self.advance(); - self.advance(); - self.advance(); - } - - // Attribute name (supports data-*, @event, :bind) - while (!self.isAtEnd()) { - const ch = self.peek(); - if (isAlphaNumeric(ch) or ch == '-' or ch == '_' or ch == ':' or ch == '@') { - self.advance(); - } else { - break; - } - } - - if (self.pos > name_start) { - try self.addToken(.attr_name, self.source[name_start..self.pos]); - } else { - // No attribute name found - skip unknown character to prevent infinite loop - // This can happen with operators like + in expressions - self.advance(); - continue; - } - - self.skipWhitespaceInAttrs(); - - // Value assignment: = or != - if (self.peek() == '!' and self.peekNext() == '=') { - self.advance(); - self.advance(); - try self.addToken(.attr_eq, "!="); - self.skipWhitespaceInAttrs(); - try self.scanAttrValue(); - } else if (self.peek() == '=') { - self.advance(); - try self.addToken(.attr_eq, "="); - self.skipWhitespaceInAttrs(); - try self.scanAttrValue(); - } - // No = means boolean attribute (e.g., checked, disabled) + pub fn parseChar(self: *CharParserState, allocator: Allocator, char: u8) !void { + if (self.escape_next) { + self.escape_next = false; + return; } - if (self.peek() == ')') { - self.advance(); - try self.addToken(.rparen, ")"); + if (char == '\\') { + self.escape_next = true; + return; + } - // Check for inline text after attributes: a(href='...') Click me - if (self.peek() == ' ') { - const next = self.peekAt(1); - // Don't consume if followed by selector, attr, or special syntax - if (next != '.' and next != '#' and next != '(' and next != '=' and next != ':' and - next != '\n' and next != '\r' and next != 0) + if (self.in_string) { + if (char == self.string_char.?) { + self.in_string = false; + self.string_char = null; + } + return; + } + + if (self.in_template) { + if (char == '`') { + self.in_template = false; + } + return; + } + + switch (char) { + '"', '\'' => { + self.in_string = true; + self.string_char = char; + }, + '`' => { + self.in_template = true; + }, + '(' => try self.nesting_stack.append(allocator, .paren), + '{' => try self.nesting_stack.append(allocator, .brace), + '[' => try self.nesting_stack.append(allocator, .bracket), + ')' => { + if (self.nesting_stack.items.len > 0 and + self.nesting_stack.items[self.nesting_stack.items.len - 1] == .paren) { - self.advance(); // skip space - try self.scanInlineText(); - } - } - } - } - - /// Scans an attribute value: "string", 'string', `template`, {object}, or expression. - /// Handles expression continuation with operators like + for string concatenation. - /// Emits a single token for the entire expression (e.g., "btn btn-" + type). - fn scanAttrValue(self: *Lexer) !void { - const start = self.pos; - var after_operator = false; // Track if we just passed an operator - - // Scan the complete expression including operators - while (!self.isAtEnd()) { - const c = self.peek(); - - if (c == '"' or c == '\'') { - // Quoted string - const quote = c; - self.advance(); - while (!self.isAtEnd() and self.peek() != quote) { - if (self.peek() == '\\' and self.peekNext() == quote) { - self.advance(); // skip backslash - } - self.advance(); - } - if (self.peek() == quote) self.advance(); - after_operator = false; - } else if (c == '`') { - // Template literal - self.advance(); - while (!self.isAtEnd() and self.peek() != '`') { - self.advance(); - } - if (self.peek() == '`') self.advance(); - after_operator = false; - } else if (c == '{') { - // Object literal - scan matching braces - var depth: usize = 0; - while (!self.isAtEnd()) { - const ch = self.peek(); - if (ch == '{') depth += 1; - if (ch == '}') { - depth -= 1; - self.advance(); - if (depth == 0) break; - continue; - } - self.advance(); - } - after_operator = false; - } else if (c == '[') { - // Array literal - scan matching brackets - var depth: usize = 0; - while (!self.isAtEnd()) { - const ch = self.peek(); - if (ch == '[') depth += 1; - if (ch == ']') { - depth -= 1; - self.advance(); - if (depth == 0) break; - continue; - } - self.advance(); - } - after_operator = false; - } else if (c == '(') { - // Function call - scan matching parens - var depth: usize = 0; - while (!self.isAtEnd()) { - const ch = self.peek(); - if (ch == '(') depth += 1; - if (ch == ')') { - depth -= 1; - self.advance(); - if (depth == 0) break; - continue; - } - self.advance(); - } - after_operator = false; - } else if (c == ')' or c == ',') { - // End of attribute value - break; - } else if (c == ' ' or c == '\t') { - // Whitespace handling depends on context - if (after_operator) { - // After an operator, skip whitespace and continue to get the operand - while (self.peek() == ' ' or self.peek() == '\t') { - self.advance(); - } - after_operator = false; - continue; - } else { - // Not after operator - check if followed by operator (continue) or not (end) - const ws_start = self.pos; - while (self.peek() == ' ' or self.peek() == '\t') { - self.advance(); - } - const next = self.peek(); - if (next == '+' or next == '-' or next == '*' or next == '/') { - // Operator follows - continue scanning (include whitespace) - continue; - } else { - // Not an operator - rewind and end - self.pos = ws_start; - break; - } - } - } else if (c == '+' or c == '-' or c == '*' or c == '/') { - // Operator - include it and mark that we need to continue for the operand - self.advance(); - after_operator = true; - } else if (c == '\n' or c == '\r') { - // Newline ends the value - break; - } else { - // Regular character (alphanumeric, etc.) - self.advance(); - after_operator = false; - } - } - - const value = std.mem.trim(u8, self.source[start..self.pos], " \t"); - if (value.len > 0) { - try self.addToken(.attr_value, value); - } - } - - /// Scans an object literal {...} handling nested braces. - /// Returns error if braces are unmatched. - fn scanObjectLiteral(self: *Lexer) !void { - const start = self.pos; - var brace_depth: usize = 0; - - while (!self.isAtEnd()) { - const c = self.peek(); - if (c == '{') { - brace_depth += 1; - } else if (c == '}') { - if (brace_depth == 0) { - self.setDiagnostic( - "Unmatched closing brace '}'", - "Remove the extra '}' or add a matching '{'", - ); - return LexerError.UnmatchedBrace; - } - brace_depth -= 1; - if (brace_depth == 0) { - self.advance(); - break; - } - } - self.advance(); - } - - // Check for unterminated object literal - if (brace_depth > 0) { - self.setDiagnostic( - "Unterminated object literal - missing closing '}'", - "Add a closing '}' to complete the object", - ); - return LexerError.UnterminatedString; - } - - try self.addToken(.attr_value, self.source[start..self.pos]); - } - - /// Scans an array literal [...] handling nested brackets. - fn scanArrayLiteral(self: *Lexer) !void { - const start = self.pos; - var bracket_depth: usize = 0; - - while (!self.isAtEnd()) { - const c = self.peek(); - if (c == '[') { - bracket_depth += 1; - } else if (c == ']') { - if (bracket_depth == 0) { - self.setDiagnostic( - "Unmatched closing bracket ']'", - "Remove the extra ']' or add a matching '['", - ); - return LexerError.UnmatchedBrace; - } - bracket_depth -= 1; - if (bracket_depth == 0) { - self.advance(); - break; - } - } - self.advance(); - } - - if (bracket_depth > 0) { - self.setDiagnostic( - "Unterminated array literal - missing closing ']'", - "Add a closing ']' to complete the array", - ); - return LexerError.UnterminatedString; - } - - try self.addToken(.attr_value, self.source[start..self.pos]); - } - - /// Skips whitespace within attribute lists (allows multi-line attributes). - /// Properly tracks line and column for error reporting. - fn skipWhitespaceInAttrs(self: *Lexer) void { - while (!self.isAtEnd()) { - const c = self.peek(); - switch (c) { - ' ', '\t' => self.advance(), - '\n' => { - self.pos += 1; - self.line += 1; - self.column = 1; - }, - '\r' => { - self.pos += 1; - if (!self.isAtEnd() and self.source[self.pos] == '\n') { - self.pos += 1; - } - self.line += 1; - self.column = 1; - }, - else => break, - } - } - } - - /// Scans pipe text: | followed by text content. - fn scanPipeText(self: *Lexer) !void { - self.advance(); // skip | - if (self.peek() == ' ') self.advance(); // skip optional space - - try self.addToken(.pipe_text, "|"); - try self.scanInlineText(); - } - - /// Scans literal HTML: lines starting with < are passed through as-is. - fn scanLiteralHtml(self: *Lexer) !void { - const start = self.pos; - - // Scan until end of line - while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { - self.advance(); - } - - const html = self.source[start..self.pos]; - try self.addToken(.literal_html, html); - } - - /// Scans a raw line of text (used inside dot blocks). - /// Captures everything until end of line as a single text token. - /// Preserves indentation relative to the base raw block indent. - /// Takes line_start position to include proper indentation from source. - fn scanRawLineFrom(self: *Lexer, line_start: usize) !void { - // Scan until end of line - while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { - self.advance(); - } - - // Include all content from line_start, preserving the indentation from source - if (self.pos > line_start) { - const text = self.source[line_start..self.pos]; - try self.addToken(.text, text); - } - } - - /// 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 - - outer: while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { - const start = self.pos; - - // Scan until interpolation or end of line - while (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { - 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; - } - if (c == '#' and next == '[') { - break; - } - self.advance(); - } - - // Emit text before interpolation (if any) - if (self.pos > start) { - try self.addToken(.text, self.source[start..self.pos]); - } - - // Handle interpolation if found - if (!self.isAtEnd() and self.peek() != '\n' and self.peek() != '\r') { - const c = self.peek(); - if (c == '#' and self.peekNext() == '{') { - self.advance(); - self.advance(); - try self.addToken(.interp_start, "#{"); - try self.scanInterpolationContent(); - } else if (c == '!' and self.peekNext() == '{') { - self.advance(); - self.advance(); - try self.addToken(.interp_start_unesc, "!{"); - try self.scanInterpolationContent(); - } else if (c == '#' and self.peekNext() == '[') { - self.advance(); - self.advance(); - try self.addToken(.tag_interp_start, "#["); - try self.scanTagInterpolation(); - } - } - } - } - - /// Scans tag interpolation content: #[tag(attrs) text] - /// This needs to handle the tag, optional attributes, optional text, and closing ] - fn scanTagInterpolation(self: *Lexer) !void { - // Skip whitespace - while (self.peek() == ' ' or self.peek() == '\t') { - self.advance(); - } - - // Scan tag name - if (isAlpha(self.peek()) or self.peek() == '_') { - const tag_start = self.pos; - while (!self.isAtEnd()) { - const c = self.peek(); - if (isAlphaNumeric(c) or c == '-' or c == '_') { - self.advance(); - } else { - break; - } - } - try self.addToken(.tag, self.source[tag_start..self.pos]); - } - - // Scan classes and ids (inline to avoid circular dependencies) - while (self.peek() == '.' or self.peek() == '#') { - if (self.peek() == '.') { - // Inline class scanning - self.advance(); // skip . - const class_start = self.pos; - while (!self.isAtEnd()) { - const c = self.peek(); - if (isAlphaNumeric(c) or c == '-' or c == '_') { - self.advance(); - } else { - break; - } - } - try self.addToken(.class, self.source[class_start..self.pos]); - } else if (self.peek() == '#' and self.peekNext() != '[' and self.peekNext() != '{') { - // Inline id scanning - self.advance(); // skip # - const id_start = self.pos; - while (!self.isAtEnd()) { - const c = self.peek(); - if (isAlphaNumeric(c) or c == '-' or c == '_') { - self.advance(); - } else { - break; - } - } - try self.addToken(.id, self.source[id_start..self.pos]); - } else { - break; - } - } - - // Scan attributes if present (inline to avoid circular dependencies) - if (self.peek() == '(') { - self.advance(); // skip ( - try self.addToken(.lparen, "("); - - while (!self.isAtEnd() and self.peek() != ')') { - // Skip whitespace - while (self.peek() == ' ' or self.peek() == '\t' or self.peek() == '\n' or self.peek() == '\r') { - if (self.peek() == '\n' or self.peek() == '\r') { - self.line += 1; - self.column = 1; - } - self.advance(); - } - if (self.peek() == ')') break; - - // Comma separator - if (self.peek() == ',') { - self.advance(); - try self.addToken(.comma, ","); - continue; - } - - // Attribute name - const name_start = self.pos; - while (!self.isAtEnd()) { - const c = self.peek(); - if (isAlphaNumeric(c) or c == '-' or c == '_' or c == ':' or c == '@') { - self.advance(); - } else { - break; - } - } - if (self.pos > name_start) { - try self.addToken(.attr_name, self.source[name_start..self.pos]); - } - - // Skip whitespace - while (self.peek() == ' ' or self.peek() == '\t') { - self.advance(); - } - - // Value assignment - if (self.peek() == '!' and self.peekNext() == '=') { - self.advance(); - self.advance(); - try self.addToken(.attr_eq, "!="); - while (self.peek() == ' ' or self.peek() == '\t') { - self.advance(); - } - try self.scanAttrValue(); - } else if (self.peek() == '=') { - self.advance(); - try self.addToken(.attr_eq, "="); - while (self.peek() == ' ' or self.peek() == '\t') { - self.advance(); - } - try self.scanAttrValue(); - } - } - - if (self.peek() == ')') { - self.advance(); - try self.addToken(.rparen, ")"); - } - } - - // Skip whitespace before text content - while (self.peek() == ' ' or self.peek() == '\t') { - self.advance(); - } - - // Scan text content until ] (handling nested #[ ]) - if (self.peek() != ']') { - const text_start = self.pos; - var bracket_depth: usize = 1; - - while (!self.isAtEnd() and bracket_depth > 0) { - const c = self.peek(); - if (c == '#' and self.peekNext() == '[') { - bracket_depth += 1; - self.advance(); - } else if (c == ']') { - bracket_depth -= 1; - if (bracket_depth == 0) break; - } else if (c == '\n' or c == '\r') { - break; - } - self.advance(); - } - - if (self.pos > text_start) { - try self.addToken(.text, self.source[text_start..self.pos]); - } - } - - // Emit closing ] - if (self.peek() == ']') { - self.advance(); - try self.addToken(.tag_interp_end, "]"); - } - } - - /// Scans interpolation content between { and }, handling nested braces. - fn scanInterpolationContent(self: *Lexer) !void { - const start = self.pos; - var brace_depth: usize = 1; - - while (!self.isAtEnd() and brace_depth > 0) { - const c = self.peek(); - if (c == '{') { - brace_depth += 1; - } else if (c == '}') { - brace_depth -= 1; - if (brace_depth == 0) break; - } - self.advance(); - } - - try self.addToken(.text, self.source[start..self.pos]); - - if (!self.isAtEnd() and self.peek() == '}') { - self.advance(); - try self.addToken(.interp_end, "}"); - } - } - - /// Scans a mixin call: +mixinName - fn scanMixinCall(self: *Lexer) !void { - self.advance(); // skip + - const start = self.pos; - - while (!self.isAtEnd()) { - const c = self.peek(); - if (isAlphaNumeric(c) or c == '-' or c == '_') { - self.advance(); - } else { - break; - } - } - - try self.addToken(.mixin_call, self.source[start..self.pos]); - } - - /// Scans &attributes syntax for attribute spreading. - fn scanAmpersandAttrs(self: *Lexer) !void { - const start = self.pos; - const remaining = self.source.len - self.pos; - - if (remaining >= 11 and std.mem.eql(u8, self.source[self.pos..][0..11], "&attributes")) { - self.pos += 11; - self.column += 11; - try self.addToken(.ampersand_attrs, "&attributes"); - - // Parse the (...) that follows &attributes - if (self.peek() == '(') { - self.advance(); // skip ( - const obj_start = self.pos; - var paren_depth: usize = 1; - - while (!self.isAtEnd() and paren_depth > 0) { - const c = self.peek(); - if (c == '(') { - paren_depth += 1; - } else if (c == ')') { - paren_depth -= 1; - } - if (paren_depth > 0) self.advance(); - } - - try self.addToken(.attr_value, self.source[obj_start..self.pos]); - if (self.peek() == ')') self.advance(); // skip ) - } - } else { - // Lone & treated as text - self.advance(); - try self.addToken(.text, self.source[start..self.pos]); - } - } - - /// Checks if inline text follows after a class/ID selector. - /// Only scans inline text if the next char is space followed by non-selector content. - fn tryInlineTextAfterSelector(self: *Lexer) !void { - if (self.peek() != ' ') return; - - const next = self.peekAt(1); - const next2 = self.peekAt(2); - - // Don't consume if followed by another selector, attribute, or special syntax - // BUT: #{...} and #[...] are interpolation, not ID selectors - const is_id_selector = next == '#' and next2 != '{' and next2 != '['; - if (next == '.' or is_id_selector or next == '(' or next == '=' or next == ':' or - next == '\n' or next == '\r' or next == 0) - { - return; - } - - self.advance(); // skip space - try self.scanInlineText(); - } - - /// Scans a tag name or keyword, then optionally inline text. - /// Uses static map for O(1) keyword lookup. - fn scanTagOrKeyword(self: *Lexer) !void { - const start = self.pos; - - 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; - } - } - - const value = self.source[start..self.pos]; - - // O(1) keyword lookup using static map - const token_type = keywords.get(value) orelse .tag; - - try self.addToken(token_type, value); - - // Keywords that take expressions: scan rest of line as text - // This allows `if user.description` to keep the dot notation intact - switch (token_type) { - .kw_if, .kw_unless, .kw_each, .kw_for, .kw_while, .kw_case, .kw_when, .kw_doctype, .kw_extends, .kw_include => { - // Skip whitespace after keyword - while (self.peek() == ' ' or self.peek() == '\t') { - self.advance(); - } - // Scan rest of line as expression/path text - if (!self.isAtEnd() and self.peek() != '\n') { - try self.scanExpressionText(); + _ = self.nesting_stack.pop(); } }, - .tag => { - // 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 { 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(); - } + '}' => { + if (self.nesting_stack.items.len > 0 and + self.nesting_stack.items[self.nesting_stack.items.len - 1] == .brace) + { + _ = self.nesting_stack.pop(); + } + }, + ']' => { + if (self.nesting_stack.items.len > 0 and + self.nesting_stack.items[self.nesting_stack.items.len - 1] == .bracket) + { + _ = self.nesting_stack.pop(); } }, else => {}, } } +}; - /// Scans expression text (rest of line) preserving dots and other chars. - fn scanExpressionText(self: *Lexer) !void { - const start = self.pos; +// ============================================================================ +// Lexer Error +// ============================================================================ - // Scan until end of line - while (!self.isAtEnd() and self.peek() != '\n') { - self.advance(); +pub const LexerErrorCode = enum { + ASSERT_FAILED, + SYNTAX_ERROR, + INCORRECT_NESTING, + NO_END_BRACKET, + BRACKET_MISMATCH, + INVALID_ID, + INVALID_CLASS_NAME, + NO_EXTENDS_PATH, + MALFORMED_EXTENDS, + NO_INCLUDE_PATH, + MALFORMED_INCLUDE, + NO_CASE_EXPRESSION, + NO_WHEN_EXPRESSION, + DEFAULT_WITH_EXPRESSION, + NO_WHILE_EXPRESSION, + MALFORMED_EACH, + MALFORMED_EACH_OF_LVAL, + INVALID_INDENTATION, + INCONSISTENT_INDENTATION, + UNEXPECTED_TEXT, + INVALID_KEY_CHARACTER, + ELSE_CONDITION, +}; + +pub const LexerError = struct { + code: LexerErrorCode, + message: []const u8, + line: usize, + column: usize, + filename: ?[]const u8, +}; + +// ============================================================================ +// BracketExpression Result +// ============================================================================ + +const BracketExpressionResult = struct { + src: []const u8, + end: usize, +}; + +// ============================================================================ +// Lexer - Zig 0.15 style with ArrayListUnmanaged +// ============================================================================ + +pub const Lexer = struct { + allocator: Allocator, + input: []const u8, + input_allocated: []const u8, // Keep reference to allocated memory for cleanup + original_input: []const u8, + filename: ?[]const u8, + interpolated: bool, + lineno: usize, + colno: usize, + indent_stack: std.ArrayListUnmanaged(usize) = .{}, + indent_re_type: ?IndentType = null, + interpolation_allowed: bool, + tokens: std.ArrayListUnmanaged(Token) = .{}, + ended: bool, + last_error: ?LexerError = null, + + const IndentType = enum { tabs, spaces }; + + pub fn init(allocator: Allocator, str: []const u8, options: LexerOptions) !Lexer { + // Strip UTF-8 BOM if present + var input = str; + if (input.len >= 3 and input[0] == 0xEF and input[1] == 0xBB and input[2] == 0xBF) { + input = input[3..]; } - const text = self.source[start..self.pos]; - if (text.len > 0) { - try self.addToken(.text, text); + // Normalize line endings + var normalized: std.ArrayListUnmanaged(u8) = .{}; + errdefer normalized.deinit(allocator); + + var i: usize = 0; + while (i < input.len) { + if (input[i] == '\r') { + if (i + 1 < input.len and input[i + 1] == '\n') { + try normalized.append(allocator, '\n'); + i += 2; + } else { + try normalized.append(allocator, '\n'); + i += 1; + } + } else { + try normalized.append(allocator, input[i]); + i += 1; + } + } + + var indent_stack: std.ArrayListUnmanaged(usize) = .{}; + try indent_stack.append(allocator, 0); + + const input_slice = try normalized.toOwnedSlice(allocator); + + return Lexer{ + .allocator = allocator, + .input = input_slice, + .input_allocated = input_slice, + .original_input = str, + .filename = options.filename, + .interpolated = options.interpolated, + .lineno = options.starting_line, + .colno = options.starting_column, + .indent_stack = indent_stack, + .interpolation_allowed = true, + .tokens = .{}, + .ended = false, + }; + } + + pub fn deinit(self: *Lexer) void { + self.indent_stack.deinit(self.allocator); + self.tokens.deinit(self.allocator); + if (self.input_allocated.len > 0) { + self.allocator.free(self.input_allocated); } } - // ───────────────────────────────────────────────────────────────────────── - // Helper functions for character inspection and position management - // ───────────────────────────────────────────────────────────────────────── + // ======================================================================== + // Error handling + // ======================================================================== - /// Returns true if at end of source. - inline fn isAtEnd(self: *const Lexer) bool { - return self.pos >= self.source.len; + fn setError(self: *Lexer, err_code: LexerErrorCode, message: []const u8) void { + self.last_error = LexerError{ + .code = err_code, + .message = message, + .line = self.lineno, + .column = self.colno, + .filename = self.filename, + }; } - /// Returns current character or 0 if at end. - inline fn peek(self: *const Lexer) u8 { - if (self.pos >= self.source.len) return 0; - return self.source[self.pos]; + /// Set error and return false - common pattern for scan functions + fn failWith(self: *Lexer, err_code: LexerErrorCode, message: []const u8) bool { + self.setError(err_code, message); + return false; } - /// Returns next character or 0 if at/past end. - inline fn peekNext(self: *const Lexer) u8 { - if (self.pos + 1 >= self.source.len) return 0; - return self.source[self.pos + 1]; + /// Set error and return LexerError - for functions with error unions + fn failWithError(self: *Lexer, err_code: LexerErrorCode, message: []const u8) error{LexerError} { + self.setError(err_code, message); + return error.LexerError; } - /// Returns character at pos + offset or 0 if out of bounds. - inline fn peekAt(self: *const Lexer, offset: usize) u8 { - const target = self.pos + offset; - if (target >= self.source.len) return 0; - return self.source[target]; + // ======================================================================== + // Token creation + // ======================================================================== + + fn tok(self: *Lexer, token_type: TokenType, val: TokenValue) Token { + return Token{ + .type = token_type, + .val = val, + .loc = TokenLoc{ + .start = Location{ + .line = self.lineno, + .column = self.colno, + }, + .filename = self.filename, + }, + }; } - /// Advances position and column by one. - inline fn advance(self: *Lexer) void { - if (self.pos < self.source.len) { - self.pos += 1; - self.column += 1; + fn tokWithString(self: *Lexer, token_type: TokenType, val: ?[]const u8) Token { + return self.tok(token_type, if (val) |v| TokenValue.fromString(v) else .none); + } + + fn tokEnd(self: *Lexer, token: *Token) void { + token.loc.end = Location{ + .line = self.lineno, + .column = self.colno, + }; + } + + /// Helper to emit a token with common boilerplate: + /// 1. Creates token with type and string value + /// 2. Appends to tokens list + /// 3. Increments column by specified amount + /// 4. Sets token end location + /// Returns false on allocation failure. + fn emitToken(self: *Lexer, token_type: TokenType, val: ?[]const u8, col_increment: usize) bool { + var token = self.tokWithString(token_type, val); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(col_increment); + self.tokEnd(&token); + return true; + } + + /// Helper to emit a token with a TokenValue (for non-string values) + fn emitTokenVal(self: *Lexer, token_type: TokenType, val: TokenValue, col_increment: usize) bool { + var token = self.tok(token_type, val); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(col_increment); + self.tokEnd(&token); + return true; + } + + // ======================================================================== + // Position tracking + // ======================================================================== + + fn incrementLine(self: *Lexer, increment: usize) void { + self.lineno += increment; + if (increment > 0) { + self.colno = 1; } } + + fn incrementColumn(self: *Lexer, increment: usize) void { + self.colno += increment; + } + + fn consume(self: *Lexer, len: usize) void { + self.input = self.input[len..]; + } + + // ======================================================================== + // Scanning helpers + // ======================================================================== + + fn isWhitespace(char: u8) bool { + return char == ' ' or char == '\n' or char == '\t'; + } + + // ======================================================================== + // Bracket expression parsing + // ======================================================================== + + fn bracketExpression(self: *Lexer, skip: usize) !BracketExpressionResult { + if (skip >= self.input.len) { + return self.failWithError(.NO_END_BRACKET, "Empty input for bracket expression"); + } + + const start_char = self.input[skip]; + const end_char: u8 = switch (start_char) { + '(' => ')', + '{' => '}', + '[' => ']', + else => { + return self.failWithError(.ASSERT_FAILED, "The start character should be '(', '{' or '['"); + }, + }; + + var state: CharParserState = .{}; + defer state.deinit(self.allocator); + + var i = skip + 1; + + // Use fixed-size stack buffer for bracket tracking (avoids allocations) + // 256 levels of nesting should be more than enough for any real code + var bracket_stack: [256]u8 = undefined; + var bracket_depth: usize = 1; + bracket_stack[0] = start_char; + + while (i < self.input.len) { + const char = self.input[i]; + + try state.parseChar(self.allocator, char); + + if (!state.isString()) { + // Check for opening brackets + if (char == '(' or char == '[' or char == '{') { + if (bracket_depth >= bracket_stack.len) { + return self.failWithError(.BRACKET_MISMATCH, "Bracket nesting too deep (max 256 levels)"); + } + bracket_stack[bracket_depth] = char; + bracket_depth += 1; + } + // Check for closing brackets + else if (char == ')' or char == ']' or char == '}') { + // Check for bracket type mismatch + if (bracket_depth > 0) { + const last_open = bracket_stack[bracket_depth - 1]; + const expected_close: u8 = switch (last_open) { + '(' => ')', + '[' => ']', + '{' => '}', + else => 0, + }; + if (char != expected_close) { + return self.failWithError(.BRACKET_MISMATCH, "Mismatched bracket - expected different closing bracket"); + } + bracket_depth -= 1; + } + + if (char == end_char and bracket_depth == 0) { + return BracketExpressionResult{ + .src = self.input[skip + 1 .. i], + .end = i, + }; + } + } + } + + i += 1; + } + + return self.failWithError(.NO_END_BRACKET, "The end of the string reached with no closing bracket found."); + } + + // ======================================================================== + // Indentation scanning + // ======================================================================== + + fn scanIndentation(self: *Lexer) ?struct { indent: []const u8, total_len: usize } { + if (self.input.len == 0 or self.input[0] != '\n') { + return null; + } + + const indent_start: usize = 1; + + // Single-pass: detect indent type from first whitespace character + if (indent_start >= self.input.len) { + return .{ .indent = "", .total_len = 1 }; + } + + const first_char = self.input[indent_start]; + + // Determine indent type from first character (or use existing type) + if (first_char == '\t') { + // Tab-based indentation + if (self.indent_re_type == .spaces) { + // Already using spaces, but found tab - scan tabs then trailing spaces + var i = indent_start; + while (i < self.input.len and self.input[i] == '\t') : (i += 1) {} + const tab_end = i; + // Skip trailing spaces after tabs + while (i < self.input.len and self.input[i] == ' ') : (i += 1) {} + return .{ .indent = self.input[indent_start..tab_end], .total_len = i }; + } + // Using tabs or undetermined + self.indent_re_type = .tabs; + var i = indent_start; + while (i < self.input.len and self.input[i] == '\t') : (i += 1) {} + const tab_end = i; + // Skip trailing spaces after tabs + while (i < self.input.len and self.input[i] == ' ') : (i += 1) {} + return .{ .indent = self.input[indent_start..tab_end], .total_len = i }; + } else if (first_char == ' ') { + // Space-based indentation + self.indent_re_type = .spaces; + var i = indent_start; + while (i < self.input.len and self.input[i] == ' ') : (i += 1) {} + return .{ .indent = self.input[indent_start..i], .total_len = i }; + } + + // Just a newline with no indentation + return .{ .indent = "", .total_len = 1 }; + } + + // ======================================================================== + // Token parsing methods + // ======================================================================== + + fn eos(self: *Lexer) bool { + if (self.input.len > 0) return false; + + if (self.interpolated) { + self.setError(.NO_END_BRACKET, "End of line was reached with no closing bracket for interpolation."); + return false; + } + + // Add outdent tokens for remaining indentation + var i: usize = 0; + while (i < self.indent_stack.items.len and self.indent_stack.items[i] > 0) : (i += 1) { + var outdent_tok = self.tok(.outdent, .none); + self.tokEnd(&outdent_tok); + self.tokens.append(self.allocator, outdent_tok) catch return false; + } + + var eos_tok = self.tok(.eos, .none); + self.tokEnd(&eos_tok); + self.tokens.append(self.allocator, eos_tok) catch return false; + self.ended = true; + return true; + } + + fn blank(self: *Lexer) bool { + // Match /^\n[ \t]*\n/ + if (self.input.len < 2 or self.input[0] != '\n') return false; + + var i: usize = 1; + while (i < self.input.len and (self.input[i] == ' ' or self.input[i] == '\t')) { + i += 1; + } + + if (i < self.input.len and self.input[i] == '\n') { + self.consume(i); // Don't consume the second newline + self.incrementLine(1); + return true; + } + + return false; + } + + fn comment(self: *Lexer) bool { + // Match /^\/\/(-)?([^\n]*)/ + if (self.input.len < 2 or self.input[0] != '/' or self.input[1] != '/') { + return false; + } + + var i: usize = 2; + var buffer = true; + + if (i < self.input.len and self.input[i] == '-') { + buffer = false; + i += 1; + } + + const comment_start = i; + while (i < self.input.len and self.input[i] != '\n') { + i += 1; + } + + const comment_text = self.input[comment_start..i]; + self.consume(i); + + var token = self.tokWithString(.comment, comment_text); + token.buffer = TokenValue.fromBool(buffer); + self.interpolation_allowed = buffer; + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(i); + self.tokEnd(&token); + + _ = self.pipelessText(null); + return true; + } + + fn interpolation(self: *Lexer) bool { + // Match /^#\{/ + if (self.input.len < 2 or self.input[0] != '#' or self.input[1] != '{') { + return false; + } + + const match = self.bracketExpression(1) catch return false; + self.consume(match.end + 1); + + var token = self.tokWithString(.interpolation, match.src); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(2); // '#{' + + // Count newlines in expression + var lines: usize = 0; + var last_line_len: usize = 0; + for (match.src) |c| { + if (c == '\n') { + lines += 1; + last_line_len = 0; + } else { + last_line_len += 1; + } + } + + self.incrementLine(lines); + self.incrementColumn(last_line_len + 1); // + 1 for '}' + self.tokEnd(&token); + return true; + } + + fn tag(self: *Lexer) bool { + // Match /^(\w(?:[-:\w]*\w)?)/ + if (self.input.len == 0) return false; + + const first = self.input[0]; + if (!isWordChar(first)) return false; + + var end: usize = 1; + while (end < self.input.len) { + const c = self.input[end]; + if (isWordChar(c) or c == '-' or c == ':') { + end += 1; + } else { + break; + } + } + + // Ensure it doesn't end with - or : + while (end > 1 and (self.input[end - 1] == '-' or self.input[end - 1] == ':')) { + end -= 1; + } + + if (end == 0) return false; + + const name = self.input[0..end]; + self.consume(end); + + var token = self.tokWithString(.tag, name); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + fn isWordChar(c: u8) bool { + return (c >= 'a' and c <= 'z') or + (c >= 'A' and c <= 'Z') or + (c >= '0' and c <= '9') or + c == '_'; + } + + fn filter(self: *Lexer, in_include: bool) bool { + // Match /^:([\w\-]+)/ + if (self.input.len < 2 or self.input[0] != ':') return false; + + var end: usize = 1; + while (end < self.input.len) { + const c = self.input[end]; + if (isWordChar(c) or c == '-') { + end += 1; + } else { + break; + } + } + + if (end == 1) return false; + + const filter_name = self.input[1..end]; + self.consume(end); + + var token = self.tokWithString(.filter, filter_name); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(filter_name.len); + self.tokEnd(&token); + _ = self.attrs(); + + if (!in_include) { + self.interpolation_allowed = false; + _ = self.pipelessText(null); + } + return true; + } + + fn doctype(self: *Lexer) bool { + // Match /^doctype *([^\n]*)/ + const prefix = "doctype"; + if (!mem.startsWith(u8, self.input, prefix)) return false; + + var i = prefix.len; + + // Skip spaces + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + // Find end of line + var end = i; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + const doctype_val = self.input[i..end]; + self.consume(end); + + var token = self.tokWithString(.doctype, if (doctype_val.len > 0) doctype_val else null); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + fn id(self: *Lexer) bool { + // Match /^#([\w-]+)/ + if (self.input.len < 2 or self.input[0] != '#') return false; + + // Check it's not #{ + if (self.input[1] == '{') return false; + + var end: usize = 1; + while (end < self.input.len) { + const c = self.input[end]; + if (isWordChar(c) or c == '-') { + end += 1; + } else { + break; + } + } + + if (end == 1) { + self.setError(.INVALID_ID, "Invalid ID"); + return false; + } + + const id_val = self.input[1..end]; + self.consume(end); + + var token = self.tokWithString(.id, id_val); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(id_val.len); + self.tokEnd(&token); + return true; + } + + fn className(self: *Lexer) bool { + // Match /^\.([_a-z0-9\-]*[_a-z][_a-z0-9\-]*)/i + if (self.input.len < 2 or self.input[0] != '.') return false; + + var end: usize = 1; + var has_letter = false; + + while (end < self.input.len) { + const c = self.input[end]; + if (isWordChar(c) or c == '-') { + if ((c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or c == '_') { + has_letter = true; + } + end += 1; + } else { + break; + } + } + + if (end == 1 or !has_letter) { + if (end > 1) { + self.setError(.INVALID_CLASS_NAME, "Class names must contain at least one letter or underscore."); + } + return false; + } + + const class_name = self.input[1..end]; + self.consume(end); + + var token = self.tokWithString(.class, class_name); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(class_name.len); + self.tokEnd(&token); + return true; + } + + fn endInterpolation(self: *Lexer) bool { + if (self.interpolated and self.input.len > 0 and self.input[0] == ']') { + self.consume(1); + self.ended = true; + return true; + } + return false; + } + + fn text(self: *Lexer) bool { + // Match /^(?:\| ?| )([^\n]+)/ or /^( )/ or /^\|( ?)/ + // This handles: + // 1. "| text" - piped text + // 2. " text" - inline text after tag (space followed by text) + // 3. "|" or "| " - empty pipe + if (self.input.len == 0) return false; + + // Case 1: Pipe syntax "| text" or "|" + if (self.input[0] == '|') { + var i: usize = 1; + // Skip optional space after | + if (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + // Find end of line + var end = i; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + const text_val = self.input[i..end]; + self.consume(end); + + self.addText(.text, text_val, "", 0); + return true; + } + + // Case 2: Inline text after tag " text" (space followed by content) + if (self.input[0] == ' ') { + // Find end of potential text (until newline) + var end: usize = 1; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + // Check what's in the rest of the line after the space + const rest = self.input[1..end]; + + // If it's only whitespace, don't treat as text (let indent handle newlines) + var all_whitespace = true; + for (rest) |c| { + if (c != ' ' and c != '\t') { + all_whitespace = false; + break; + } + } + + if (all_whitespace) { + // Only whitespace until newline - consume it but don't create text token + self.consume(end); + self.incrementColumn(end); + return true; + } + + // Check if it's just " /" pattern (self-closing tag with space) + var trimmed_start: usize = 0; + while (trimmed_start < rest.len and rest[trimmed_start] == ' ') { + trimmed_start += 1; + } + if (trimmed_start < rest.len and rest[trimmed_start] == '/' and + (trimmed_start + 1 >= rest.len or rest[trimmed_start + 1] == ' ' or rest[trimmed_start + 1] == '\n')) + { + // This is "tag /" pattern - consume spaces, let slash handler deal with / + self.consume(1 + trimmed_start); + self.incrementColumn(1 + trimmed_start); + return true; + } + + const text_val = self.input[1..end]; + self.consume(end); + + self.addText(.text, text_val, "", 0); + return true; + } + + return false; + } + + fn textHtml(self: *Lexer) bool { + // Match /^(<[^\n]*)/ + if (self.input.len == 0 or self.input[0] != '<') return false; + + var end: usize = 1; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + const html_val = self.input[0..end]; + self.consume(end); + + self.addText(.text_html, html_val, "", 0); + return true; + } + + fn dot(self: *Lexer) bool { + // Match /^\./ + if (self.input.len == 0 or self.input[0] != '.') return false; + + // Check if it's followed by end of line or colon + if (self.input.len == 1 or self.input[1] == '\n' or self.input[1] == ':') { + self.consume(1); + var token = self.tok(.dot, .none); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(1); + self.tokEnd(&token); + _ = self.pipelessText(null); + return true; + } + + return false; + } + + fn extendsToken(self: *Lexer) bool { + // Match /^extends?(?= |$|\n)/ + if (mem.startsWith(u8, self.input, "extends")) { + const after = if (self.input.len > 7) self.input[7] else 0; + if (after == 0 or after == ' ' or after == '\n') { + self.consume(7); + var token = self.tok(.extends, .none); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(7); + self.tokEnd(&token); + + if (!self.path()) { + self.setError(.NO_EXTENDS_PATH, "missing path for extends"); + return true; + } + return true; + } + // "extends" followed by something else (like "(") - malformed + if (after != 0) { + self.setError(.MALFORMED_EXTENDS, "malformed extends"); + return true; + } + } else if (mem.startsWith(u8, self.input, "extend")) { + const after = if (self.input.len > 6) self.input[6] else 0; + if (after == 0 or after == ' ' or after == '\n') { + self.consume(6); + var token = self.tok(.extends, .none); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(6); + self.tokEnd(&token); + + if (!self.path()) { + self.setError(.NO_EXTENDS_PATH, "missing path for extends"); + return true; + } + return true; + } + // "extend" followed by something else (like "(") - malformed + if (after != 0 and after != 's') { + self.setError(.MALFORMED_EXTENDS, "malformed extends"); + return true; + } + } + return false; + } + + fn prepend(self: *Lexer) bool { + return self.blockHelper("prepend", .prepend); + } + + fn append(self: *Lexer) bool { + return self.blockHelper("append", .append); + } + + fn blockToken(self: *Lexer) bool { + return self.blockHelper("block", .replace); + } + + const BlockMode = enum { prepend, append, replace }; + + fn blockHelper(self: *Lexer, keyword: []const u8, mode: BlockMode) bool { + const full_prefix = switch (mode) { + .prepend => "prepend ", + .append => "append ", + .replace => "block ", + }; + const block_prefix = switch (mode) { + .prepend => "block prepend ", + .append => "block append ", + .replace => "block ", + }; + + var name_start: usize = 0; + + if (mem.startsWith(u8, self.input, block_prefix)) { + name_start = block_prefix.len; + } else if (mem.startsWith(u8, self.input, full_prefix)) { + name_start = full_prefix.len; + } else { + _ = keyword; + return false; + } + + // Find end of line + var end = name_start; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + // Extract name (trim and handle comments) + var name_end = end; + // Check for comment + var i = name_start; + while (i < end) { + if (i + 1 < end and self.input[i] == '/' and self.input[i + 1] == '/') { + name_end = i; + break; + } + i += 1; + } + + // Trim whitespace + while (name_end > name_start and isWhitespace(self.input[name_end - 1])) { + name_end -= 1; + } + + if (name_end <= name_start) return false; + + const name = self.input[name_start..name_end]; + self.consume(end); + + var token = self.tokWithString(.block, name); + token.mode = TokenValue.fromString(switch (mode) { + .prepend => "prepend", + .append => "append", + .replace => "replace", + }); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + fn mixinBlock(self: *Lexer) bool { + if (!mem.startsWith(u8, self.input, "block")) return false; + + // Check if followed by end of line, colon, or only whitespace until newline + var consume_len: usize = 5; + var is_mixin_block = false; + + if (self.input.len == 5 or self.input[5] == '\n' or self.input[5] == ':') { + is_mixin_block = true; + } else if (self.input[5] == ' ' or self.input[5] == '\t') { + // Check if only whitespace until newline + var i: usize = 5; + while (i < self.input.len and (self.input[i] == ' ' or self.input[i] == '\t')) { + i += 1; + } + if (i >= self.input.len or self.input[i] == '\n') { + is_mixin_block = true; + consume_len = i; + } + } + + if (is_mixin_block) { + self.consume(consume_len); + var token = self.tok(.mixin_block, .none); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(consume_len); + self.tokEnd(&token); + return true; + } + + return false; + } + + fn yieldToken(self: *Lexer) bool { + if (!mem.startsWith(u8, self.input, "yield")) return false; + + if (self.input.len == 5 or self.input[5] == '\n' or self.input[5] == ':') { + self.consume(5); + var token = self.tok(.yield, .none); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(5); + self.tokEnd(&token); + return true; + } + + return false; + } + + fn includeToken(self: *Lexer) bool { + if (!mem.startsWith(u8, self.input, "include")) return false; + + const after = if (self.input.len > 7) self.input[7] else 0; + if (after != 0 and after != ' ' and after != ':' and after != '\n') { + // "include" followed by something else (like "(") - malformed + self.setError(.MALFORMED_INCLUDE, "malformed include"); + return true; + } + + self.consume(7); + var token = self.tok(.include, .none); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(7); + self.tokEnd(&token); + + // Parse filters + while (self.filter(true)) {} + + if (!self.path()) { + self.setError(.NO_INCLUDE_PATH, "missing path for include"); + return true; + } + return true; + } + + fn path(self: *Lexer) bool { + // Match /^ ([^\n]+)/ + if (self.input.len == 0 or self.input[0] != ' ') return false; + + var i: usize = 1; + // Skip leading spaces + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + var end = i; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + // Trim trailing spaces + var path_end = end; + while (path_end > i and self.input[path_end - 1] == ' ') { + path_end -= 1; + } + + if (path_end <= i) return false; + + const path_val = self.input[i..path_end]; + self.consume(end); + + var token = self.tokWithString(.path, path_val); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + fn caseToken(self: *Lexer) bool { + // Match /^case +([^\n]+)/ + if (!mem.startsWith(u8, self.input, "case")) return false; + + // Check if followed by word boundary + if (self.input.len > 4 and self.input[4] != ' ' and self.input[4] != '\n') { + return false; + } + + // Check for "case" without expression + if (self.input.len == 4 or self.input[4] == '\n') { + self.consume(4); + self.incrementColumn(4); + self.setError(.NO_CASE_EXPRESSION, "missing expression for case"); + return false; + } + + var i: usize = 5; + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + // If only spaces after "case", that's also an error + if (i >= self.input.len or self.input[i] == '\n') { + self.consume(i); + self.incrementColumn(i); + self.setError(.NO_CASE_EXPRESSION, "missing expression for case"); + return false; + } + + var end = i; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + if (end <= i) { + self.setError(.NO_CASE_EXPRESSION, "missing expression for case"); + return false; + } + + const expr = self.input[i..end]; + + // Validate brackets are balanced in the expression + if (!self.validateExpressionBrackets(expr)) { + self.consume(end); + self.incrementColumn(end); + return true; // Error already set + } + + self.consume(end); + + var token = self.tokWithString(.case, expr); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + /// Validates that brackets in an expression are balanced + fn validateExpressionBrackets(self: *Lexer, expr: []const u8) bool { + var bracket_stack = std.ArrayListUnmanaged(u8){}; + defer bracket_stack.deinit(self.allocator); + + var in_string: u8 = 0; + var i: usize = 0; + + while (i < expr.len) { + const c = expr[i]; + if (in_string != 0) { + if (c == in_string and (i == 0 or expr[i - 1] != '\\')) { + in_string = 0; + } + } else { + if (c == '"' or c == '\'' or c == '`') { + in_string = c; + } else if (c == '(' or c == '[' or c == '{') { + bracket_stack.append(self.allocator, c) catch return false; + } else if (c == ')' or c == ']' or c == '}') { + if (bracket_stack.items.len == 0) { + self.setError(.BRACKET_MISMATCH, "Unexpected closing bracket in expression"); + return false; + } + const last_open = bracket_stack.items[bracket_stack.items.len - 1]; + const expected_close: u8 = switch (last_open) { + '(' => ')', + '[' => ']', + '{' => '}', + else => 0, + }; + if (c != expected_close) { + self.setError(.BRACKET_MISMATCH, "Mismatched bracket in expression"); + return false; + } + _ = bracket_stack.pop(); + } + } + i += 1; + } + + if (bracket_stack.items.len > 0) { + self.setError(.NO_END_BRACKET, "Unclosed bracket in expression"); + return false; + } + + return true; + } + + fn when(self: *Lexer) bool { + // Match /^when +([^:\n]+)/ but handle colons inside strings + if (!mem.startsWith(u8, self.input, "when")) return false; + + // Check if followed by word boundary (space, newline, or end) + if (self.input.len > 4 and self.input[4] != ' ' and self.input[4] != '\n') { + return false; + } + + // Check for "when" without expression (just "when" or "when\n") + if (self.input.len == 4 or self.input[4] == '\n') { + self.consume(4); + self.incrementColumn(4); + self.setError(.NO_WHEN_EXPRESSION, "missing expression for when"); + return false; + } + + var i: usize = 5; + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + // If only spaces after "when", that's also an error + if (i >= self.input.len or self.input[i] == '\n') { + self.consume(i); + self.incrementColumn(i); + self.setError(.NO_WHEN_EXPRESSION, "missing expression for when"); + return false; + } + + // Parse until colon or newline, but handle strings properly + var end = i; + var in_string = false; + var string_char: u8 = 0; + var escape_next = false; + var brace_depth: usize = 0; + + while (end < self.input.len and self.input[end] != '\n') { + const c = self.input[end]; + + if (escape_next) { + escape_next = false; + end += 1; + continue; + } + + if (c == '\\') { + escape_next = true; + end += 1; + continue; + } + + if (in_string) { + if (c == string_char) { + in_string = false; + } + end += 1; + continue; + } + + // Not in string + if (c == '\'' or c == '"' or c == '`') { + in_string = true; + string_char = c; + end += 1; + continue; + } + + // Track braces for object literals like {tim: 'g'} + if (c == '{') { + brace_depth += 1; + end += 1; + continue; + } + if (c == '}') { + if (brace_depth > 0) brace_depth -= 1; + end += 1; + continue; + } + + // Colon outside string and outside braces ends the expression + if (c == ':' and brace_depth == 0) { + break; + } + + end += 1; + } + + if (end <= i) { + self.setError(.NO_WHEN_EXPRESSION, "missing expression for when"); + return false; + } + + const expr = self.input[i..end]; + self.consume(end); + + var token = self.tokWithString(.when, expr); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + fn defaultToken(self: *Lexer) bool { + if (!mem.startsWith(u8, self.input, "default")) return false; + + if (self.input.len == 7 or self.input[7] == '\n' or self.input[7] == ':') { + self.consume(7); + var token = self.tok(.default, .none); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(7); + self.tokEnd(&token); + return true; + } + + // Check if "default" is followed by something other than whitespace/newline/colon + // "default foo" should error + if (self.input[7] == ' ') { + // Skip spaces and check if there's content after + var i: usize = 8; + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + if (i < self.input.len and self.input[i] != '\n' and self.input[i] != ':') { + self.consume(i); + self.incrementColumn(i); + self.setError(.DEFAULT_WITH_EXPRESSION, "`default` cannot have an expression"); + return true; // Return true to stop advance chain, error is set + } + // Just spaces then newline/colon or end of input is fine + self.consume(7); + var token = self.tok(.default, .none); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(7); + self.tokEnd(&token); + return true; + } + + return false; + } + + fn call(self: *Lexer) bool { + // Match /^\+(\s*)(([-\w]+)|(#\{))/ + if (self.input.len < 2 or self.input[0] != '+') return false; + + var i: usize = 1; + // Skip whitespace + while (i < self.input.len and (self.input[i] == ' ' or self.input[i] == '\t')) { + i += 1; + } + + // Check for interpolated call #{ + if (i + 1 < self.input.len and self.input[i] == '#' and self.input[i + 1] == '{') { + const match = self.bracketExpression(i + 1) catch return false; + const increment = match.end + 1; + self.consume(increment); + + var token = self.tok(.call, .none); + // Store the interpolated expression - use the original slice from input + // Format: #{expression} - we store just the expression part, prefixed with #{ + // The value points to input[i..match.end+1] which includes #{ and } + token.val = TokenValue.fromString(self.original_input[self.original_input.len - self.input.len - increment + i .. self.original_input.len - self.input.len - increment + match.end + 1]); + self.incrementColumn(increment); + token.args = .none; + + // Check for args + if (self.input.len > 0 and self.input[0] == '(') { + if (self.bracketExpression(0)) |args_match| { + self.incrementColumn(1); + self.consume(args_match.end + 1); + token.args = TokenValue.fromString(args_match.src); + } else |_| {} + } + + self.tokens.append(self.allocator, token) catch return false; + self.tokEnd(&token); + return true; + } + + // Simple call + var end = i; + while (end < self.input.len) { + const c = self.input[end]; + if (isWordChar(c) or c == '-') { + end += 1; + } else { + break; + } + } + + if (end == i) return false; + + const name = self.input[i..end]; + self.consume(end); + + var token = self.tokWithString(.call, name); + self.incrementColumn(end); + token.args = .none; + + // Check for args (not attributes) + if (self.input.len > 0) { + var j: usize = 0; + while (j < self.input.len and self.input[j] == ' ') { + j += 1; + } + if (j < self.input.len and self.input[j] == '(') { + if (self.bracketExpression(j)) |args_match| { + // Check if it looks like args, not attributes + var is_args = true; + var k: usize = 0; + while (k < args_match.src.len and (args_match.src[k] == ' ' or args_match.src[k] == '\t')) { + k += 1; + } + // Check for key= pattern (attributes) + var key_end = k; + while (key_end < args_match.src.len and (isWordChar(args_match.src[key_end]) or args_match.src[key_end] == '-')) { + key_end += 1; + } + if (key_end < args_match.src.len) { + var eq_pos = key_end; + while (eq_pos < args_match.src.len and args_match.src[eq_pos] == ' ') { + eq_pos += 1; + } + if (eq_pos < args_match.src.len and args_match.src[eq_pos] == '=') { + is_args = false; + } + } + + if (is_args) { + self.incrementColumn(j + 1); + self.consume(j + args_match.end + 1); + token.args = TokenValue.fromString(args_match.src); + } + } else |_| {} + } + } + + self.tokens.append(self.allocator, token) catch return false; + self.tokEnd(&token); + return true; + } + + fn mixin(self: *Lexer) bool { + // Match /^mixin +([-\w]+)(?: *\((.*)\))? */ + if (!mem.startsWith(u8, self.input, "mixin ")) return false; + + var i: usize = 6; + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + // Get mixin name + var name_end = i; + while (name_end < self.input.len) { + const c = self.input[name_end]; + if (isWordChar(c) or c == '-') { + name_end += 1; + } else { + break; + } + } + + if (name_end == i) return false; + + const name = self.input[i..name_end]; + var end = name_end; + + // Skip spaces + while (end < self.input.len and self.input[end] == ' ') { + end += 1; + } + + var args: TokenValue = .none; + + // Check for args + if (end < self.input.len and self.input[end] == '(') { + const bracket_result = self.bracketExpression(end) catch return false; + args = TokenValue.fromString(bracket_result.src); + end = bracket_result.end + 1; + } + + self.consume(end); + + var token = self.tokWithString(.mixin, name); + token.args = args; + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + fn conditional(self: *Lexer) bool { + // Match /^(if|unless|else if|else)\b([^\n]*)/ + var keyword: []const u8 = undefined; + var token_type: TokenType = undefined; + + if (mem.startsWith(u8, self.input, "else if")) { + keyword = "else if"; + token_type = .else_if; + } else if (mem.startsWith(u8, self.input, "if")) { + keyword = "if"; + token_type = .@"if"; + } else if (mem.startsWith(u8, self.input, "unless")) { + keyword = "unless"; + token_type = .@"if"; // unless becomes if with negated condition + } else if (mem.startsWith(u8, self.input, "else")) { + keyword = "else"; + token_type = .@"else"; + } else { + return false; + } + + // Check word boundary + if (self.input.len > keyword.len) { + const next = self.input[keyword.len]; + if (isWordChar(next)) return false; + } + + const i = keyword.len; + + // Get expression + var end = i; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + var js = self.input[i..end]; + // Trim + while (js.len > 0 and (js[0] == ' ' or js[0] == '\t')) { + js = js[1..]; + } + while (js.len > 0 and (js[js.len - 1] == ' ' or js[js.len - 1] == '\t')) { + js = js[0 .. js.len - 1]; + } + + self.consume(end); + + var token = self.tokWithString(token_type, if (js.len > 0) js else null); + + // Handle else with condition + if (token_type == .@"else" and js.len > 0) { + self.setError(.ELSE_CONDITION, "`else` cannot have a condition, perhaps you meant `else if`"); + return true; // Return true to stop advance chain, error is set + } + + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + fn whileToken(self: *Lexer) bool { + // Match /^while +([^\n]+)/ + if (!mem.startsWith(u8, self.input, "while")) return false; + + // Check if followed by word boundary + if (self.input.len > 5 and self.input[5] != ' ' and self.input[5] != '\n') { + return false; + } + + // Check for "while" without expression + if (self.input.len == 5 or self.input[5] == '\n') { + self.consume(5); + self.incrementColumn(5); + self.setError(.NO_WHILE_EXPRESSION, "missing expression for while"); + return false; + } + + var i: usize = 6; + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + // If only spaces after "while", that's also an error + if (i >= self.input.len or self.input[i] == '\n') { + self.consume(i); + self.incrementColumn(i); + self.setError(.NO_WHILE_EXPRESSION, "missing expression for while"); + return false; + } + + var end = i; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + if (end <= i) { + self.setError(.NO_WHILE_EXPRESSION, "missing expression for while"); + return false; + } + + const expr = self.input[i..end]; + self.consume(end); + + var token = self.tokWithString(.@"while", expr); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + fn each(self: *Lexer) bool { + const is_each = mem.startsWith(u8, self.input, "each "); + const is_for = mem.startsWith(u8, self.input, "for "); + + if (!is_each and !is_for) return false; + + const prefix_len: usize = if (is_each) 5 else 4; + var i = prefix_len; + + // Skip spaces + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + // Get first identifier + if (i >= self.input.len or !isIdentStart(self.input[i])) { + return self.eachOf(); + } + + var ident_end = i + 1; + while (ident_end < self.input.len and isIdentChar(self.input[ident_end])) { + ident_end += 1; + } + + const val_name = self.input[i..ident_end]; + i = ident_end; + + // Skip spaces + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + var key_name: TokenValue = .none; + + // Check for , key + if (i < self.input.len and self.input[i] == ',') { + i += 1; + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + if (i < self.input.len and isIdentStart(self.input[i])) { + var key_end = i + 1; + while (key_end < self.input.len and isIdentChar(self.input[key_end])) { + key_end += 1; + } + key_name = TokenValue.fromString(self.input[i..key_end]); + i = key_end; + } + } + + // Skip spaces + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + // Check for 'in' or 'of' + if (mem.startsWith(u8, self.input[i..], "of ")) { + return self.eachOf(); + } + + if (!mem.startsWith(u8, self.input[i..], "in ")) { + self.setError(.MALFORMED_EACH, "Malformed each statement"); + return false; + } + + i += 3; // skip "in " + + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + // Get expression + var end = i; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + if (end <= i) { + self.setError(.MALFORMED_EACH, "missing expression for each"); + return false; + } + + const expr = self.input[i..end]; + self.consume(end); + + var token = self.tokWithString(.each, val_name); + token.key = key_name; + token.code = TokenValue.fromString(expr); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + fn isIdentStart(c: u8) bool { + return (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z') or c == '_' or c == '$'; + } + + fn isIdentChar(c: u8) bool { + return isIdentStart(c) or (c >= '0' and c <= '9'); + } + + fn eachOf(self: *Lexer) bool { + const is_each = mem.startsWith(u8, self.input, "each "); + const is_for = mem.startsWith(u8, self.input, "for "); + + if (!is_each and !is_for) return false; + + const prefix_len: usize = if (is_each) 5 else 4; + var i = prefix_len; + + // Find " of " + var of_pos: ?usize = null; + var j = i; + while (j + 3 < self.input.len) { + if (self.input[j] == ' ' and self.input[j + 1] == 'o' and self.input[j + 2] == 'f' and self.input[j + 3] == ' ') { + of_pos = j; + break; + } + if (self.input[j] == '\n') break; + j += 1; + } + + if (of_pos == null) return false; + + const value = self.input[i..of_pos.?]; + + i = of_pos.? + 4; // skip " of " + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + var end = i; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + if (end <= i) return false; + + const expr = self.input[i..end]; + self.consume(end); + + var token = self.tokWithString(.each_of, value); + token.code = TokenValue.fromString(expr); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + fn code(self: *Lexer) bool { + if (self.input.len == 0) return false; + + var flags_end: usize = 0; + var must_escape = false; + var buffer = false; + + if (self.input[0] == '-') { + flags_end = 1; + buffer = false; + } else if (self.input[0] == '=') { + flags_end = 1; + must_escape = true; + buffer = true; + } else if (self.input.len >= 2 and self.input[0] == '!' and self.input[1] == '=') { + flags_end = 2; + must_escape = false; + buffer = true; + } else { + return false; + } + + var i = flags_end; + // Skip spaces/tabs + while (i < self.input.len and (self.input[i] == ' ' or self.input[i] == '\t')) { + i += 1; + } + + // Check for old-style "- each" or "- for" prefixed syntax + if (flags_end == 1 and self.input[0] == '-') { + const rest = self.input[i..]; + // Match: each/for VAR(, VAR)? in EXPR + if (mem.startsWith(u8, rest, "each ") or mem.startsWith(u8, rest, "for ")) { + // Check if it looks like the old prefixed each/for syntax + var j: usize = 0; + if (mem.startsWith(u8, rest, "each ")) { + j = 5; + } else { + j = 4; + } + // Skip whitespace + while (j < rest.len and (rest[j] == ' ' or rest[j] == '\t')) { + j += 1; + } + // Check for identifier + if (j < rest.len and (std.ascii.isAlphabetic(rest[j]) or rest[j] == '_' or rest[j] == '$')) { + // This looks like "- each var in expr" which is old syntax + self.setError(.MALFORMED_EACH, "Pug each and for should no longer be prefixed with a dash (\"-\"). They are pug keywords and not part of JavaScript."); + return true; + } + } + } + + var end = i; + while (end < self.input.len and self.input[end] != '\n') { + end += 1; + } + + const code_val = self.input[i..end]; + self.consume(end); + + var token = self.tokWithString(.code, code_val); + token.must_escape = TokenValue.fromBool(must_escape); + token.buffer = TokenValue.fromBool(buffer); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(end); + self.tokEnd(&token); + return true; + } + + fn blockCode(self: *Lexer) bool { + if (self.input.len == 0 or self.input[0] != '-') return false; + + // Must be followed by end of line + if (self.input.len > 1 and self.input[1] != '\n' and self.input[1] != ':') { + return false; + } + + self.consume(1); + var token = self.tok(.blockcode, .none); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(1); + self.tokEnd(&token); + self.interpolation_allowed = false; + _ = self.pipelessText(null); + return true; + } + + fn attrs(self: *Lexer) bool { + if (self.input.len == 0 or self.input[0] != '(') return false; + + var token = self.tok(.start_attributes, .none); + const bracket_result = self.bracketExpression(0) catch return false; + const str = self.input[1..bracket_result.end]; + + self.incrementColumn(1); + self.tokens.append(self.allocator, token) catch return false; + self.tokEnd(&token); + self.consume(bracket_result.end + 1); + + // Parse attributes from str + self.parseAttributes(str); + + // Check if parseAttributes set an error + if (self.last_error != null) { + return true; // Error is set, return true to stop further parsing + } + + var end_token = self.tok(.end_attributes, .none); + self.incrementColumn(1); + self.tokens.append(self.allocator, end_token) catch return false; + self.tokEnd(&end_token); + return true; + } + + fn parseAttributes(self: *Lexer, str: []const u8) void { + var i: usize = 0; + + while (i < str.len) { + // Skip whitespace + while (i < str.len and isWhitespace(str[i])) { + if (str[i] == '\n') { + self.incrementLine(1); + } else { + self.incrementColumn(1); + } + i += 1; + } + + if (i >= str.len) break; + + var attr_token = self.tok(.attribute, .none); + + // Check for quoted key + var key: []const u8 = undefined; + + if (str[i] == '"' or str[i] == '\'') { + const quote = str[i]; + self.incrementColumn(1); + i += 1; + const key_start = i; + while (i < str.len and str[i] != quote) { + if (str[i] == '\n') { + self.incrementLine(1); + } else { + self.incrementColumn(1); + } + i += 1; + } + key = str[key_start..i]; + if (i < str.len) { + self.incrementColumn(1); + i += 1; + } + } else { + // Unquoted key + const key_start = i; + while (i < str.len and !isWhitespace(str[i]) and str[i] != '!' and str[i] != '=' and str[i] != ',') { + if (str[i] == '\n') { + self.incrementLine(1); + } else { + self.incrementColumn(1); + } + i += 1; + } + key = str[key_start..i]; + } + + attr_token.name = TokenValue.fromString(key); + + // Skip whitespace + while (i < str.len and (str[i] == ' ' or str[i] == '\t')) { + self.incrementColumn(1); + i += 1; + } + + // Check for value + var must_escape = true; + if (i < str.len and str[i] == '!') { + must_escape = false; + self.incrementColumn(1); + i += 1; + } + + if (i < str.len and str[i] == '=') { + self.incrementColumn(1); + i += 1; + + // Skip whitespace (including newlines) + while (i < str.len and isWhitespace(str[i])) { + if (str[i] == '\n') { + self.incrementLine(1); + } else { + self.incrementColumn(1); + } + i += 1; + } + + // Parse value + var state: CharParserState = .{}; + defer state.deinit(self.allocator); + + const val_start = i; + var has_content = false; // Track if we've seen non-whitespace + while (i < str.len) { + const char = str[i]; + state.parseChar(self.allocator, char) catch break; + + if (!isWhitespace(char)) { + has_content = true; + } + + if (!state.isNesting() and !state.isString() and has_content) { + if (isWhitespace(char) or char == ',') { + break; + } + } + + // Check for invalid newline inside single/double quoted string + // (template literals with backticks can have newlines) + if (char == '\n') { + if (state.isString()) { + const quote_char = state.getStringChar(); + if (quote_char) |qc| { + if (qc == '\'' or qc == '"') { + self.setError(.SYNTAX_ERROR, "Invalid newline in string literal"); + return; + } + } + } + } + + if (str[i] == '\n') { + self.incrementLine(1); + } else { + self.incrementColumn(1); + } + i += 1; + } + + attr_token.val = TokenValue.fromString(str[val_start..i]); + attr_token.must_escape = TokenValue.fromBool(must_escape); + } else { + // Boolean attribute + attr_token.val = TokenValue.fromBool(true); + attr_token.must_escape = TokenValue.fromBool(true); + } + + self.tokens.append(self.allocator, attr_token) catch return; + self.tokEnd(&attr_token); + + // Skip whitespace and comma + while (i < str.len and (isWhitespace(str[i]) or str[i] == ',')) { + if (str[i] == '\n') { + self.incrementLine(1); + } else { + self.incrementColumn(1); + } + i += 1; + } + } + } + + fn attributesBlock(self: *Lexer) bool { + if (!mem.startsWith(u8, self.input, "&attributes")) return false; + + if (self.input.len > 11 and isWordChar(self.input[11])) return false; + + self.consume(11); + var token = self.tok(.@"&attributes", .none); + self.incrementColumn(11); + + const args = self.bracketExpression(0) catch return false; + self.consume(args.end + 1); + token.val = TokenValue.fromString(args.src); + self.incrementColumn(args.end + 1); + + self.tokens.append(self.allocator, token) catch return false; + self.tokEnd(&token); + return true; + } + + fn indent(self: *Lexer) bool { + const captures = self.scanIndentation() orelse return false; + + const indents = captures.indent.len; + + self.incrementLine(1); + self.consume(captures.total_len); + + // Blank line + if (self.input.len > 0 and self.input[0] == '\n') { + self.interpolation_allowed = true; + var newline_token = self.tok(.newline, .none); + self.tokEnd(&newline_token); + return true; + } + + // Outdent + if (indents < self.indent_stack.items[0]) { + var outdent_count: usize = 0; + while (self.indent_stack.items[0] > indents) { + if (self.indent_stack.items.len > 1 and self.indent_stack.items[1] < indents) { + self.setError(.INCONSISTENT_INDENTATION, "Inconsistent indentation"); + return false; + } + outdent_count += 1; + _ = self.indent_stack.orderedRemove(0); + } + while (outdent_count > 0) : (outdent_count -= 1) { + self.colno = 1; + var outdent_token = self.tok(.outdent, .none); + self.colno = self.indent_stack.items[0] + 1; + self.tokens.append(self.allocator, outdent_token) catch return false; + self.tokEnd(&outdent_token); + } + } else if (indents > 0 and indents != self.indent_stack.items[0]) { + // Indent + var indent_token = self.tok(.indent, .none); + self.colno = 1 + indents; + self.tokens.append(self.allocator, indent_token) catch return false; + self.tokEnd(&indent_token); + self.indent_stack.insert(self.allocator, 0, indents) catch return false; + } else { + // Newline + var newline_token = self.tok(.newline, .none); + self.colno = 1 + @min(self.indent_stack.items[0], indents); + self.tokens.append(self.allocator, newline_token) catch return false; + self.tokEnd(&newline_token); + } + + self.interpolation_allowed = true; + return true; + } + + fn pipelessText(self: *Lexer, forced_indents: ?usize) bool { + while (self.blank()) {} + + const captures = self.scanIndentation() orelse return false; + const indents = forced_indents orelse captures.indent.len; + + if (indents <= self.indent_stack.items[0]) return false; + + var start_token = self.tok(.start_pipeless_text, .none); + self.tokEnd(&start_token); + self.tokens.append(self.allocator, start_token) catch return false; + + var string_ptr: usize = 0; + var tokens_list: std.ArrayListUnmanaged([]const u8) = .{}; + var token_indent_list: std.ArrayListUnmanaged(bool) = .{}; + defer tokens_list.deinit(self.allocator); + defer token_indent_list.deinit(self.allocator); + + while (string_ptr < self.input.len) { + // text has `\n` as a prefix + const line_start = string_ptr + 1; // skip the \n + if (string_ptr >= self.input.len or self.input[string_ptr] != '\n') { + break; + } + + // Find end of line + var line_end = line_start; + while (line_end < self.input.len and self.input[line_end] != '\n') { + line_end += 1; + } + + const str = self.input[line_start..line_end]; + + // Check indentation of this line (count leading whitespace) + var line_indent: usize = 0; + for (str) |c| { + if (c == ' ' or c == '\t') { + line_indent += 1; + } else { + break; + } + } + + const is_match = line_indent >= indents; + token_indent_list.append(self.allocator, is_match) catch return false; + + // Match if indented enough OR if line is empty/whitespace + const trimmed = mem.trim(u8, str, " \t"); + if (is_match or trimmed.len == 0) { + // consume line along with `\n` prefix + string_ptr = line_end; + // Extract text after the indent + const text_content = if (str.len > indents) str[indents..] else ""; + tokens_list.append(self.allocator, text_content) catch return false; + } else if (line_indent > self.indent_stack.items[0]) { + // line is indented less than the first line but is still indented + // need to retry lexing the text block with new indent level + _ = self.tokens.pop(); + return self.pipelessText(line_indent); + } else { + break; + } + } + + self.consume(string_ptr); + + // Remove trailing empty lines when input is exhausted + while (self.input.len == 0 and tokens_list.items.len > 0 and tokens_list.items[tokens_list.items.len - 1].len == 0) { + _ = tokens_list.pop(); + } + + for (tokens_list.items, 0..) |token_text, ii| { + self.incrementLine(1); + if (ii != 0) { + var newline_token = self.tok(.newline, .none); + self.tokens.append(self.allocator, newline_token) catch return false; + self.tokEnd(&newline_token); + } + if (ii < token_indent_list.items.len and token_indent_list.items[ii]) { + self.incrementColumn(indents); + } + self.addText(.text, token_text, "", 0); + } + + var end_token = self.tok(.end_pipeless_text, .none); + self.tokEnd(&end_token); + self.tokens.append(self.allocator, end_token) catch return false; + return true; + } + + fn slash(self: *Lexer) bool { + if (self.input.len == 0 or self.input[0] != '/') return false; + + self.consume(1); + var token = self.tok(.slash, .none); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(1); + self.tokEnd(&token); + return true; + } + + fn colon(self: *Lexer) bool { + if (self.input.len < 2 or self.input[0] != ':' or self.input[1] != ' ') return false; + + var i: usize = 2; + while (i < self.input.len and self.input[i] == ' ') { + i += 1; + } + + self.consume(i); + var token = self.tok(.colon, .none); + self.tokens.append(self.allocator, token) catch return false; + self.incrementColumn(i); + self.tokEnd(&token); + return true; + } + + fn fail(self: *Lexer) void { + self.setError(.UNEXPECTED_TEXT, "unexpected text"); + } + + fn addText(self: *Lexer, token_type: TokenType, value: []const u8, prefix: []const u8, escaped: usize) void { + if (value.len + prefix.len == 0) return; + + // Check for unclosed or mismatched tag interpolations #[...] + // Note: Inside #[...] is full Pug syntax, so we need to track ALL bracket types + if (self.interpolation_allowed) { + var i: usize = 0; + while (i + 1 < value.len) { + // Skip escaped \#[ + if (value[i] == '\\' and i + 2 < value.len and value[i + 1] == '#' and value[i + 2] == '[') { + i += 3; + continue; + } + if (value[i] == '#' and value[i + 1] == '[') { + // Found start of tag interpolation, look for matching ] + var j = i + 2; + var in_string: u8 = 0; + + // Track bracket stack - inside #[...] you can have (...) and {...} for attrs/code + var bracket_stack = std.ArrayListUnmanaged(u8){}; + defer bracket_stack.deinit(self.allocator); + bracket_stack.append(self.allocator, '[') catch return; + + while (j < value.len and bracket_stack.items.len > 0) { + const c = value[j]; + if (in_string != 0) { + if (c == in_string and (j == i + 2 or value[j - 1] != '\\')) { + in_string = 0; + } + } else { + if (c == '"' or c == '\'' or c == '`') { + in_string = c; + } else if (c == '[' or c == '(' or c == '{') { + bracket_stack.append(self.allocator, c) catch return; + } else if (c == ']' or c == ')' or c == '}') { + if (bracket_stack.items.len > 0) { + const last_open = bracket_stack.items[bracket_stack.items.len - 1]; + const expected_close: u8 = switch (last_open) { + '[' => ']', + '(' => ')', + '{' => '}', + else => 0, + }; + if (c != expected_close) { + // Mismatched bracket type + self.setError(.BRACKET_MISMATCH, "Mismatched bracket in tag interpolation"); + return; + } + _ = bracket_stack.pop(); + } + } + } + j += 1; + } + if (bracket_stack.items.len > 0) { + // Unclosed interpolation + self.setError(.NO_END_BRACKET, "Unclosed tag interpolation - missing ]"); + return; + } + i = j; + } else { + i += 1; + } + } + } + + var token = self.tokWithString(token_type, value); + self.incrementColumn(value.len + escaped); + self.tokens.append(self.allocator, token) catch return; + self.tokEnd(&token); + } + + // ======================================================================== + // Main advance and getTokens + // ======================================================================== + + fn advance(self: *Lexer) bool { + return self.blank() or + self.eos() or + self.endInterpolation() or + self.yieldToken() or + self.doctype() or + self.interpolation() or + self.caseToken() or + self.when() or + self.defaultToken() or + self.extendsToken() or + self.append() or + self.prepend() or + self.blockToken() or + self.mixinBlock() or + self.includeToken() or + self.mixin() or + self.call() or + self.conditional() or + self.eachOf() or + self.each() or + self.whileToken() or + self.tag() or + self.filter(false) or + self.blockCode() or + self.code() or + self.id() or + self.dot() or + self.className() or + self.attrs() or + self.attributesBlock() or + self.indent() or + self.text() or + self.textHtml() or + self.comment() or + self.slash() or + self.colon() or + blk: { + self.fail(); + break :blk false; + }; + } + + pub fn getTokens(self: *Lexer) ![]Token { + while (!self.ended) { + const advanced = self.advance(); + // Check for errors after every advance, regardless of return value + if (self.last_error) |err| { + std.debug.print("Lexer error at {d}:{d}: {s}\n", .{ err.line, err.column, err.message }); + return error.LexerError; + } + if (!advanced) { + break; + } + } + return self.tokens.items; + } }; -// ───────────────────────────────────────────────────────────────────────────── -// Character classification utilities (inlined for performance) -// ───────────────────────────────────────────────────────────────────────────── +// ============================================================================ +// Options +// ============================================================================ -inline fn isAlpha(c: u8) bool { - return (c >= 'a' and c <= 'z') or (c >= 'A' and c <= 'Z'); +pub const LexerOptions = struct { + filename: ?[]const u8 = null, + interpolated: bool = false, + starting_line: usize = 1, + starting_column: usize = 1, +}; + +// ============================================================================ +// Public API +// ============================================================================ + +/// Lexes the input string and returns a slice of tokens. +/// IMPORTANT: The caller must keep the Lexer alive while using the returned tokens, +/// as token string values are slices into the lexer's input buffer. +/// For simpler usage, use Lexer.init() and Lexer.getTokens() directly. +pub fn lex(allocator: Allocator, str: []const u8, options: LexerOptions) !struct { tokens: []Token, lexer: *Lexer } { + const lexer = try allocator.create(Lexer); + lexer.* = try Lexer.init(allocator, str, options); + const tokens = try lexer.getTokens(); + return .{ .tokens = tokens, .lexer = lexer }; } -inline fn isDigit(c: u8) bool { - return c >= '0' and c <= '9'; +/// Frees resources from a lex() call +pub fn freeLexResult(allocator: Allocator, lexer: *Lexer) void { + lexer.deinit(); + allocator.destroy(lexer); } -inline fn isAlphaNumeric(c: u8) bool { - return isAlpha(c) or isDigit(c); -} - -// ───────────────────────────────────────────────────────────────────────────── +// ============================================================================ // Tests -// ───────────────────────────────────────────────────────────────────────────── +// ============================================================================ -test "tokenize simple tag" { - const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "div"); - defer lexer.deinit(); - - const tokens = try lexer.tokenize(); - try std.testing.expectEqual(@as(usize, 2), tokens.len); - try std.testing.expectEqual(TokenType.tag, tokens[0].type); - try std.testing.expectEqualStrings("div", tokens[0].value); +test "TokenValue - none" { + const val: TokenValue = .none; + try std.testing.expect(val.isNone()); + try std.testing.expect(val.getString() == null); + try std.testing.expect(val.getBool() == null); } -test "tokenize tag with class" { - const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "div.container"); - defer lexer.deinit(); - - const tokens = try lexer.tokenize(); - try std.testing.expectEqual(TokenType.tag, tokens[0].type); - try std.testing.expectEqual(TokenType.class, tokens[1].type); - try std.testing.expectEqualStrings("container", tokens[1].value); +test "TokenValue - string" { + const val = TokenValue.fromString("hello"); + try std.testing.expect(!val.isNone()); + try std.testing.expectEqualStrings("hello", val.getString().?); + try std.testing.expect(val.getBool() == null); } -test "tokenize tag with id" { +test "TokenValue - boolean" { + const val_true = TokenValue.fromBool(true); + const val_false = TokenValue.fromBool(false); + + try std.testing.expect(!val_true.isNone()); + try std.testing.expect(val_true.getBool().? == true); + try std.testing.expect(val_true.getString() == null); + + try std.testing.expect(val_false.getBool().? == false); +} + +test "basic tag lexing" { const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "div#main"); + var lexer = try Lexer.init(allocator, "div", .{}); defer lexer.deinit(); - const tokens = try lexer.tokenize(); + const tokens = try lexer.getTokens(); + + try std.testing.expect(tokens.len >= 2); + try std.testing.expectEqual(TokenType.tag, tokens[0].type); + try std.testing.expectEqualStrings("div", tokens[0].getVal().?); +} + +test "tag with id" { + const allocator = std.testing.allocator; + var lexer = try Lexer.init(allocator, "div#main", .{}); + defer lexer.deinit(); + + const tokens = try lexer.getTokens(); + + try std.testing.expect(tokens.len >= 3); try std.testing.expectEqual(TokenType.tag, tokens[0].type); try std.testing.expectEqual(TokenType.id, tokens[1].type); - try std.testing.expectEqualStrings("main", tokens[1].value); + try std.testing.expectEqualStrings("main", tokens[1].getVal().?); } -test "tokenize nested tags" { +test "tag with class" { const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, - \\div - \\ p Hello - ); + var lexer = try Lexer.init(allocator, "div.container", .{}); defer lexer.deinit(); - const tokens = try lexer.tokenize(); - - var found_indent = false; - var found_dedent = false; - for (tokens) |token| { - if (token.type == .indent) found_indent = true; - if (token.type == .dedent) found_dedent = true; - } - try std.testing.expect(found_indent); - try std.testing.expect(found_dedent); -} - -test "tokenize attributes" { - const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "a(href=\"/link\" target=\"_blank\")"); - defer lexer.deinit(); - - const tokens = try lexer.tokenize(); + const tokens = try lexer.getTokens(); + try std.testing.expect(tokens.len >= 3); try std.testing.expectEqual(TokenType.tag, tokens[0].type); - try std.testing.expectEqual(TokenType.lparen, tokens[1].type); - try std.testing.expectEqual(TokenType.attr_name, tokens[2].type); - try std.testing.expectEqualStrings("href", tokens[2].value); - try std.testing.expectEqual(TokenType.attr_eq, tokens[3].type); - try std.testing.expectEqual(TokenType.attr_value, tokens[4].type); - // Quotes are preserved in token value for expression evaluation - try std.testing.expectEqualStrings("\"/link\"", tokens[4].value); + try std.testing.expectEqual(TokenType.class, tokens[1].type); + try std.testing.expectEqualStrings("container", tokens[1].getVal().?); } -test "tokenize interpolation" { +test "doctype" { const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "p Hello #{name}!"); + var lexer = try Lexer.init(allocator, "doctype html", .{}); defer lexer.deinit(); - const tokens = try lexer.tokenize(); + const tokens = try lexer.getTokens(); - var found_interp_start = false; - var found_interp_end = false; - for (tokens) |token| { - if (token.type == .interp_start) found_interp_start = true; - if (token.type == .interp_end) found_interp_end = true; - } - try std.testing.expect(found_interp_start); - try std.testing.expect(found_interp_end); + try std.testing.expect(tokens.len >= 2); + try std.testing.expectEqual(TokenType.doctype, tokens[0].type); + try std.testing.expectEqualStrings("html", tokens[0].getVal().?); } -test "tokenize multiple interpolations" { +test "comment with buffer" { const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "p #{a} and #{b} and #{c}"); + var lexer = try Lexer.init(allocator, "// this is a comment", .{}); defer lexer.deinit(); - const tokens = try lexer.tokenize(); + const tokens = try lexer.getTokens(); - var interp_count: usize = 0; - for (tokens) |token| { - if (token.type == .interp_start) interp_count += 1; - } - try std.testing.expectEqual(@as(usize, 3), interp_count); -} - -test "tokenize if keyword" { - const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "if condition"); - defer lexer.deinit(); - - const tokens = try lexer.tokenize(); - try std.testing.expectEqual(TokenType.kw_if, tokens[0].type); -} - -test "tokenize each keyword" { - const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "each item in items"); - defer lexer.deinit(); - - const tokens = try lexer.tokenize(); - try std.testing.expectEqual(TokenType.kw_each, tokens[0].type); - // Rest of line is captured as text for parser to handle - try std.testing.expectEqual(TokenType.text, tokens[1].type); - try std.testing.expectEqualStrings("item in items", tokens[1].value); -} - -test "tokenize mixin call" { - const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "+button"); - defer lexer.deinit(); - - const tokens = try lexer.tokenize(); - try std.testing.expectEqual(TokenType.mixin_call, tokens[0].type); - try std.testing.expectEqualStrings("button", tokens[0].value); -} - -test "tokenize comment" { - const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "// This is a comment"); - defer lexer.deinit(); - - const tokens = try lexer.tokenize(); + try std.testing.expect(tokens.len >= 2); try std.testing.expectEqual(TokenType.comment, tokens[0].type); + try std.testing.expect(tokens[0].isBuffered() == true); } -test "tokenize unbuffered comment" { +test "comment without buffer" { const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "//- Hidden comment"); + var lexer = try Lexer.init(allocator, "//- this is a silent comment", .{}); defer lexer.deinit(); - const tokens = try lexer.tokenize(); - try std.testing.expectEqual(TokenType.comment_unbuffered, tokens[0].type); + const tokens = try lexer.getTokens(); + + try std.testing.expect(tokens.len >= 2); + try std.testing.expectEqual(TokenType.comment, tokens[0].type); + try std.testing.expect(tokens[0].isBuffered() == false); } -test "tokenize object literal in attributes" { +test "code with escape" { const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, "div(style={color: 'red', nested: {a: 1}})"); + var lexer = try Lexer.init(allocator, "= foo", .{}); defer lexer.deinit(); - const tokens = try lexer.tokenize(); + const tokens = try lexer.getTokens(); - // Find the attr_value token with object literal - var found_object = false; - for (tokens) |token| { - if (token.type == .attr_value and token.value.len > 0 and token.value[0] == '{') { - found_object = true; + try std.testing.expect(tokens.len >= 2); + try std.testing.expectEqual(TokenType.code, tokens[0].type); + try std.testing.expect(tokens[0].shouldEscape() == true); + try std.testing.expect(tokens[0].isBuffered() == true); +} + +test "code without escape" { + const allocator = std.testing.allocator; + var lexer = try Lexer.init(allocator, "!= foo", .{}); + defer lexer.deinit(); + + const tokens = try lexer.getTokens(); + + try std.testing.expect(tokens.len >= 2); + try std.testing.expectEqual(TokenType.code, tokens[0].type); + try std.testing.expect(tokens[0].shouldEscape() == false); + try std.testing.expect(tokens[0].isBuffered() == true); +} + +test "boolean attribute" { + const allocator = std.testing.allocator; + var lexer = try Lexer.init(allocator, "input(disabled)", .{}); + defer lexer.deinit(); + + const tokens = try lexer.getTokens(); + + // Find the attribute token + var attr_found = false; + for (tokens) |tok| { + if (tok.type == .attribute) { + attr_found = true; + try std.testing.expectEqualStrings("disabled", tok.getName().?); + // Boolean attribute should have boolean true value + try std.testing.expect(tok.val.getBool().? == true); break; } } - try std.testing.expect(found_object); -} - -test "tokenize dot block" { - const allocator = std.testing.allocator; - var lexer = Lexer.init(allocator, - \\script. - \\ if (usingPug) - \\ console.log('hi') - ); - defer lexer.deinit(); - - const tokens = try lexer.tokenize(); - - var found_dot_block = false; - var text_count: usize = 0; - for (tokens) |token| { - if (token.type == .dot_block) found_dot_block = true; - if (token.type == .text) text_count += 1; - } - try std.testing.expect(found_dot_block); - try std.testing.expectEqual(@as(usize, 2), text_count); + try std.testing.expect(attr_found); } diff --git a/src/linker.zig b/src/linker.zig new file mode 100644 index 0000000..969d344 --- /dev/null +++ b/src/linker.zig @@ -0,0 +1,699 @@ +// linker.zig - Zig port of pug-linker +// +// Handles template inheritance and linking: +// - Resolves extends (parent template inheritance) +// - Handles named blocks (replace/append/prepend modes) +// - Processes includes with yield blocks +// - Manages mixin hoisting from child to parent + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const mem = std.mem; + +// Import AST types from parser +const parser = @import("parser.zig"); +pub const Node = parser.Node; +pub const NodeType = parser.NodeType; + +// Import walk module +const walk_mod = @import("walk.zig"); +pub const WalkOptions = walk_mod.WalkOptions; +pub const WalkContext = walk_mod.WalkContext; +pub const WalkError = walk_mod.WalkError; +pub const ReplaceResult = walk_mod.ReplaceResult; + +// Import error types +const pug_error = @import("error.zig"); +pub const PugError = pug_error.PugError; + +// ============================================================================ +// Linker Errors +// ============================================================================ + +pub const LinkerError = error{ + OutOfMemory, + InvalidAST, + ExtendsNotFirst, + UnexpectedNodesInExtending, + UnexpectedBlock, + WalkError, +}; + +// ============================================================================ +// Block Definitions Map +// ============================================================================ + +/// Map of block names to their definition nodes +pub const BlockDefinitions = std.StringHashMapUnmanaged(*Node); + +// ============================================================================ +// Linker Result +// ============================================================================ + +pub const LinkerResult = struct { + ast: *Node, + declared_blocks: BlockDefinitions, + has_extends: bool = false, + err: ?PugError = null, + + pub fn deinit(self: *LinkerResult, allocator: Allocator) void { + self.declared_blocks.deinit(allocator); + if (self.err) |*e| { + e.deinit(); + } + } +}; + +// ============================================================================ +// Link Implementation +// ============================================================================ + +/// Link an AST, resolving extends and includes +pub fn link(allocator: Allocator, ast: *Node) LinkerError!LinkerResult { + // Top level must be a Block + if (ast.type != .Block) { + return error.InvalidAST; + } + + var result = LinkerResult{ + .ast = ast, + .declared_blocks = .{}, + }; + + // Check for extends + var extends_node: ?*Node = null; + if (ast.nodes.items.len > 0) { + const first_node = ast.nodes.items[0]; + if (first_node.type == .Extends) { + // Verify extends position + try checkExtendsPosition(allocator, ast); + + // Remove extends node from the list + extends_node = ast.nodes.orderedRemove(0); + } + } + + // Apply includes (convert RawInclude to Text, link Include ASTs) + result.ast = try applyIncludes(allocator, ast); + + // Find declared blocks + result.declared_blocks = try findDeclaredBlocks(allocator, result.ast); + + // Handle extends + if (extends_node) |ext_node| { + // Get mixins and expected blocks from current template + var mixins = std.ArrayListUnmanaged(*Node){}; + defer mixins.deinit(allocator); + + var expected_blocks = std.ArrayListUnmanaged(*Node){}; + defer expected_blocks.deinit(allocator); + + try collectMixinsAndBlocks(allocator, result.ast, &mixins, &expected_blocks); + + // Link the parent template + if (ext_node.file) |file| { + _ = file; + // In a real implementation, we would: + // 1. Get file.ast (the loaded parent AST) + // 2. Recursively link it + // 3. Extend parent blocks with child blocks + // 4. Verify all expected blocks exist + // 5. Merge mixin definitions + + // For now, mark that we have extends + result.has_extends = true; + } + } + + return result; +} + +/// Find all declared blocks (NamedBlock with mode="replace") +fn findDeclaredBlocks(allocator: Allocator, ast: *Node) LinkerError!BlockDefinitions { + var definitions = BlockDefinitions{}; + + const FindContext = struct { + defs: *BlockDefinitions, + alloc: Allocator, + + fn before(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult { + const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?)); + + if (node.type == .NamedBlock) { + // Check mode - default is "replace" + const mode = node.mode orelse "replace"; + if (mem.eql(u8, mode, "replace")) { + if (node.name) |name| { + self.defs.put(self.alloc, name, node) catch return error.OutOfMemory; + } + } + } + return null; + } + }; + + var find_ctx = FindContext{ + .defs = &definitions, + .alloc = allocator, + }; + + var walk_options = WalkOptions{}; + defer walk_options.deinit(allocator); + + _ = walk_mod.walkASTWithUserData( + allocator, + ast, + FindContext.before, + null, + &walk_options, + &find_ctx, + ) catch { + return error.WalkError; + }; + + return definitions; +} + +/// Collect mixin definitions and named blocks from the AST +fn collectMixinsAndBlocks( + allocator: Allocator, + ast: *Node, + mixins: *std.ArrayListUnmanaged(*Node), + expected_blocks: *std.ArrayListUnmanaged(*Node), +) LinkerError!void { + for (ast.nodes.items) |node| { + switch (node.type) { + .NamedBlock => { + try expected_blocks.append(allocator, node); + }, + .Block => { + // Recurse into nested blocks + try collectMixinsAndBlocks(allocator, node, mixins, expected_blocks); + }, + .Mixin => { + // Only collect mixin definitions (not calls) + if (!node.call) { + try mixins.append(allocator, node); + } + }, + else => { + // In extending template, only named blocks and mixins allowed at top level + // This would be an error in strict mode + }, + } + } +} + +/// Extend parent blocks with child block content +fn extendBlocks( + allocator: Allocator, + parent_blocks: *BlockDefinitions, + child_ast: *Node, +) LinkerError!void { + const ExtendContext = struct { + parent: *BlockDefinitions, + stack: std.StringHashMapUnmanaged(void), + alloc: Allocator, + + fn before(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult { + const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?)); + + if (node.type == .NamedBlock) { + if (node.name) |name| { + // Check for circular reference + if (self.stack.contains(name)) { + return null; // Skip to avoid infinite loop + } + + self.stack.put(self.alloc, name, {}) catch return error.OutOfMemory; + + // Find parent block + if (self.parent.get(name)) |parent_block| { + const mode = node.mode orelse "replace"; + + if (mem.eql(u8, mode, "append")) { + // Append child nodes to parent + for (node.nodes.items) |child_node| { + parent_block.nodes.append(self.alloc, child_node) catch return error.OutOfMemory; + } + } else if (mem.eql(u8, mode, "prepend")) { + // Prepend child nodes to parent + for (node.nodes.items, 0..) |child_node, i| { + parent_block.nodes.insert(self.alloc, i, child_node) catch return error.OutOfMemory; + } + } else { + // Replace - clear parent and add child nodes + parent_block.nodes.clearRetainingCapacity(); + for (node.nodes.items) |child_node| { + parent_block.nodes.append(self.alloc, child_node) catch return error.OutOfMemory; + } + } + } + } + } + return null; + } + + fn after(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult { + const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?)); + + if (node.type == .NamedBlock) { + if (node.name) |name| { + _ = self.stack.remove(name); + } + } + return null; + } + }; + + var extend_ctx = ExtendContext{ + .parent = parent_blocks, + .stack = .{}, + .alloc = allocator, + }; + defer extend_ctx.stack.deinit(allocator); + + var walk_options = WalkOptions{}; + defer walk_options.deinit(allocator); + + _ = walk_mod.walkASTWithUserData( + allocator, + child_ast, + ExtendContext.before, + ExtendContext.after, + &walk_options, + &extend_ctx, + ) catch { + return error.WalkError; + }; +} + +/// Apply includes - convert RawInclude to Text, process Include nodes +fn applyIncludes(allocator: Allocator, ast: *Node) LinkerError!*Node { + const IncludeContext = struct { + alloc: Allocator, + + fn before(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult { + const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?)); + _ = self; + + // Convert RawInclude to Text + if (node.type == .RawInclude) { + // In a real implementation: + // - Get file.str (the loaded file content) + // - Create a Text node with that content + // For now, just keep the node as-is + node.type = .Text; + // node.val = file.str with \r removed + } + return null; + } + + fn after(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult { + const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?)); + _ = self; + + // Process Include nodes + if (node.type == .Include) { + // In a real implementation: + // 1. Link the included file's AST + // 2. If it has extends, remove named blocks + // 3. Apply yield block + // For now, keep the node as-is + } + return null; + } + }; + + var include_ctx = IncludeContext{ + .alloc = allocator, + }; + + var walk_options = WalkOptions{}; + defer walk_options.deinit(allocator); + + const result = walk_mod.walkASTWithUserData( + allocator, + ast, + IncludeContext.before, + IncludeContext.after, + &walk_options, + &include_ctx, + ) catch { + return error.WalkError; + }; + + return result; +} + +/// Check that extends is the first thing in the file +fn checkExtendsPosition(allocator: Allocator, ast: *Node) LinkerError!void { + var found_legit_extends = false; + + const CheckContext = struct { + legit_extends: *bool, + has_extends: bool, + alloc: Allocator, + + fn before(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult { + const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?)); + + if (node.type == .Extends) { + if (self.has_extends and !self.legit_extends.*) { + self.legit_extends.* = true; + } else { + // This would be an error - extends not first or multiple extends + // For now we just skip + } + } + return null; + } + }; + + var check_ctx = CheckContext{ + .legit_extends = &found_legit_extends, + .has_extends = true, + .alloc = allocator, + }; + + var walk_options = WalkOptions{}; + defer walk_options.deinit(allocator); + + _ = walk_mod.walkASTWithUserData( + allocator, + ast, + CheckContext.before, + null, + &walk_options, + &check_ctx, + ) catch { + return error.WalkError; + }; +} + +/// Remove named blocks (convert to regular blocks) +pub fn removeNamedBlocks(allocator: Allocator, ast: *Node) LinkerError!*Node { + const RemoveContext = struct { + alloc: Allocator, + + fn before(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult { + const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?)); + _ = self; + + if (node.type == .NamedBlock) { + node.type = .Block; + node.name = null; + node.mode = null; + } + return null; + } + }; + + var remove_ctx = RemoveContext{ + .alloc = allocator, + }; + + var walk_options = WalkOptions{}; + defer walk_options.deinit(allocator); + + return walk_mod.walkASTWithUserData( + allocator, + ast, + RemoveContext.before, + null, + &walk_options, + &remove_ctx, + ) catch error.WalkError; +} + +/// Apply yield block to included content +pub fn applyYield(allocator: Allocator, ast: *Node, block: ?*Node) LinkerError!*Node { + if (block == null or block.?.nodes.items.len == 0) { + return ast; + } + + var replaced = false; + + const YieldContext = struct { + yield_block: *Node, + was_replaced: *bool, + alloc: Allocator, + + fn after(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult { + const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?)); + + if (node.type == .YieldBlock) { + self.was_replaced.* = true; + node.type = .Block; + node.nodes.clearRetainingCapacity(); + node.nodes.append(self.alloc, self.yield_block) catch return error.OutOfMemory; + } + return null; + } + }; + + var yield_ctx = YieldContext{ + .yield_block = block.?, + .was_replaced = &replaced, + .alloc = allocator, + }; + + var walk_options = WalkOptions{}; + defer walk_options.deinit(allocator); + + const result = walk_mod.walkASTWithUserData( + allocator, + ast, + null, + YieldContext.after, + &walk_options, + &yield_ctx, + ) catch { + return error.WalkError; + }; + + // If no yield block found, append to default location + if (!replaced) { + const default_loc = findDefaultYieldLocation(result); + default_loc.nodes.append(allocator, block.?) catch return error.OutOfMemory; + } + + return result; +} + +/// Find the default yield location (deepest block) +fn findDefaultYieldLocation(node: *Node) *Node { + var result = node; + + for (node.nodes.items) |child| { + if (child.text_only) continue; + + if (child.type == .Block) { + result = findDefaultYieldLocation(child); + } else if (child.nodes.items.len > 0) { + result = findDefaultYieldLocation(child); + } + } + + return result; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "link - basic block" { + const allocator = std.testing.allocator; + + // Create a simple AST + const text_node = try allocator.create(Node); + text_node.* = Node{ + .type = .Text, + .val = "Hello", + .line = 1, + .column = 1, + }; + + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, text_node); + + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + var result = try link(allocator, root); + defer result.deinit(allocator); + + try std.testing.expectEqual(root, result.ast); + try std.testing.expectEqual(false, result.has_extends); +} + +test "link - with named block" { + const allocator = std.testing.allocator; + + // Create named block + const text_node = try allocator.create(Node); + text_node.* = Node{ + .type = .Text, + .val = "content", + .line = 2, + .column = 3, + }; + + const named_block = try allocator.create(Node); + named_block.* = Node{ + .type = .NamedBlock, + .name = "content", + .mode = "replace", + .line = 2, + .column = 1, + }; + try named_block.nodes.append(allocator, text_node); + + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, named_block); + + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + var result = try link(allocator, root); + defer result.deinit(allocator); + + // Should find the declared block + try std.testing.expect(result.declared_blocks.contains("content")); +} + +test "findDeclaredBlocks - multiple blocks" { + const allocator = std.testing.allocator; + + const block1 = try allocator.create(Node); + block1.* = Node{ + .type = .NamedBlock, + .name = "header", + .mode = "replace", + .line = 1, + .column = 1, + }; + + const block2 = try allocator.create(Node); + block2.* = Node{ + .type = .NamedBlock, + .name = "footer", + .mode = "replace", + .line = 5, + .column = 1, + }; + + const block3 = try allocator.create(Node); + block3.* = Node{ + .type = .NamedBlock, + .name = "sidebar", + .mode = "append", // Should not be in declared blocks + .line = 10, + .column = 1, + }; + + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, block1); + try root.nodes.append(allocator, block2); + try root.nodes.append(allocator, block3); + + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + var blocks = try findDeclaredBlocks(allocator, root); + defer blocks.deinit(allocator); + + try std.testing.expect(blocks.contains("header")); + try std.testing.expect(blocks.contains("footer")); + try std.testing.expect(!blocks.contains("sidebar")); // append mode +} + +test "removeNamedBlocks" { + const allocator = std.testing.allocator; + + const named_block = try allocator.create(Node); + named_block.* = Node{ + .type = .NamedBlock, + .name = "content", + .mode = "replace", + .line = 1, + .column = 1, + }; + + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, named_block); + + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + const result = try removeNamedBlocks(allocator, root); + + // Named block should now be a regular Block + try std.testing.expectEqual(NodeType.Block, result.nodes.items[0].type); + try std.testing.expectEqual(@as(?[]const u8, null), result.nodes.items[0].name); +} + +test "findDefaultYieldLocation - nested blocks" { + const allocator = std.testing.allocator; + + const inner_block = try allocator.create(Node); + inner_block.* = Node{ + .type = .Block, + .line = 3, + .column = 1, + }; + + const outer_block = try allocator.create(Node); + outer_block.* = Node{ + .type = .Block, + .line = 2, + .column = 1, + }; + try outer_block.nodes.append(allocator, inner_block); + + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, outer_block); + + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + const location = findDefaultYieldLocation(root); + + // Should find the innermost block + try std.testing.expectEqual(inner_block, location); +} diff --git a/src/load.zig b/src/load.zig new file mode 100644 index 0000000..48cd449 --- /dev/null +++ b/src/load.zig @@ -0,0 +1,412 @@ +// load.zig - Zig port of pug-load +// +// Handles loading of include/extends files during AST processing. +// Walks the AST and loads file dependencies. + +const std = @import("std"); +const fs = std.fs; +const Allocator = std.mem.Allocator; +const mem = std.mem; + +// Import AST types from parser +const parser = @import("parser.zig"); +pub const Node = parser.Node; +pub const NodeType = parser.NodeType; +pub const FileReference = parser.FileReference; + +// Import walk module +const walk_mod = @import("walk.zig"); +pub const walkAST = walk_mod.walkAST; +pub const WalkOptions = walk_mod.WalkOptions; +pub const WalkContext = walk_mod.WalkContext; +pub const WalkError = walk_mod.WalkError; +pub const ReplaceResult = walk_mod.ReplaceResult; + +// Import lexer for lexing includes +const lexer = @import("lexer.zig"); +pub const Token = lexer.Token; +pub const Lexer = lexer.Lexer; + +// Import error types +const pug_error = @import("error.zig"); +pub const PugError = pug_error.PugError; + +// ============================================================================ +// Load Options +// ============================================================================ + +/// Function type for resolving file paths +pub const ResolveFn = *const fn ( + filename: []const u8, + source: ?[]const u8, + options: *const LoadOptions, +) LoadError![]const u8; + +/// Function type for reading file contents +pub const ReadFn = *const fn ( + allocator: Allocator, + filename: []const u8, + options: *const LoadOptions, +) LoadError![]const u8; + +/// Function type for lexing source +pub const LexFn = *const fn ( + allocator: Allocator, + src: []const u8, + options: *const LoadOptions, +) LoadError![]const Token; + +/// Function type for parsing tokens +pub const ParseFn = *const fn ( + allocator: Allocator, + tokens: []const Token, + options: *const LoadOptions, +) LoadError!*Node; + +pub const LoadOptions = struct { + /// Base directory for absolute paths + basedir: ?[]const u8 = null, + /// Source filename + filename: ?[]const u8 = null, + /// Source content + src: ?[]const u8 = null, + /// Path resolution function + resolve: ?ResolveFn = null, + /// File reading function + read: ?ReadFn = null, + /// Lexer function + lex: ?LexFn = null, + /// Parser function + parse: ?ParseFn = null, + /// User data for callbacks + user_data: ?*anyopaque = null, +}; + +// ============================================================================ +// Load Errors +// ============================================================================ + +pub const LoadError = error{ + OutOfMemory, + FileNotFound, + AccessDenied, + InvalidPath, + MissingFilename, + MissingBasedir, + InvalidFileReference, + LexError, + ParseError, + WalkError, + InvalidUtf8, +}; + +// ============================================================================ +// Load Result +// ============================================================================ + +pub const LoadResult = struct { + ast: *Node, + err: ?PugError = null, + + pub fn deinit(self: *LoadResult, allocator: Allocator) void { + if (self.err) |*e| { + e.deinit(); + } + self.ast.deinit(allocator); + allocator.destroy(self.ast); + } +}; + +// ============================================================================ +// Default Implementations +// ============================================================================ + +/// Default path resolution - handles relative and absolute paths +pub fn defaultResolve( + filename: []const u8, + source: ?[]const u8, + options: *const LoadOptions, +) LoadError![]const u8 { + const trimmed = mem.trim(u8, filename, " \t\r\n"); + + if (trimmed.len == 0) { + return error.InvalidPath; + } + + // Absolute path (starts with /) + if (trimmed[0] == '/') { + if (options.basedir == null) { + return error.MissingBasedir; + } + // Join basedir with filename (without leading /) + // Note: In a real implementation, we'd use path.join + // For now, return the path as-is for testing + return trimmed; + } + + // Relative path + if (source == null) { + return error.MissingFilename; + } + + // In a real implementation, join dirname(source) with filename + // For now, return the path as-is for testing + return trimmed; +} + +/// Default file reading using std.fs +pub fn defaultRead( + allocator: Allocator, + filename: []const u8, + options: *const LoadOptions, +) LoadError![]const u8 { + _ = options; + + const file = fs.cwd().openFile(filename, .{}) catch |err| { + return switch (err) { + error.FileNotFound => error.FileNotFound, + error.AccessDenied => error.AccessDenied, + else => error.FileNotFound, + }; + }; + defer file.close(); + + const content = file.readToEndAlloc(allocator, 1024 * 1024 * 10) catch { + return error.OutOfMemory; + }; + + return content; +} + +// ============================================================================ +// Load Implementation +// ============================================================================ + +/// Load file dependencies from an AST +/// Walks the AST and loads Include, RawInclude, and Extends nodes +pub fn load( + allocator: Allocator, + ast: *Node, + options: LoadOptions, +) LoadError!*Node { + // Create a context for the walk + const LoadContext = struct { + allocator: Allocator, + options: LoadOptions, + err: ?PugError = null, + + fn beforeCallback(node: *Node, _: bool, ctx: *WalkContext) WalkError!?ReplaceResult { + const self: *@This() = @ptrCast(@alignCast(ctx.user_data.?)); + + // Only process Include, RawInclude, and Extends nodes + if (node.type != .Include and node.type != .RawInclude and node.type != .Extends) { + return null; + } + + // Check if already loaded (str is set) + if (node.file) |*file| { + // Load the file content + self.loadFileReference(file, node) catch { + // Store error but continue walking + return null; + }; + } + + return null; + } + + fn loadFileReference(self: *@This(), file: *FileReference, node: *Node) LoadError!void { + _ = node; + + if (file.path == null) { + return error.InvalidFileReference; + } + + // Resolve the path + const resolve_fn = self.options.resolve orelse defaultResolve; + const resolved_path = try resolve_fn(file.path.?, self.options.filename, &self.options); + + // Read the file + const read_fn = self.options.read orelse defaultRead; + const content = try read_fn(self.allocator, resolved_path, &self.options); + _ = content; + + // For Include/Extends, parse the content into an AST + // This would require lexer and parser functions to be provided + // For now, we just load the raw content + } + }; + + var load_ctx = LoadContext{ + .allocator = allocator, + .options = options, + }; + + var walk_options = WalkOptions{}; + defer walk_options.deinit(allocator); + + const result = walk_mod.walkASTWithUserData( + allocator, + ast, + LoadContext.beforeCallback, + null, + &walk_options, + &load_ctx, + ) catch { + return error.WalkError; + }; + + if (load_ctx.err) |*e| { + e.deinit(); + return error.FileNotFound; + } + + return result; +} + +/// Load from a string source +pub fn loadString( + allocator: Allocator, + src: []const u8, + options: LoadOptions, +) LoadError!*Node { + // Need lex and parse functions + const lex_fn = options.lex orelse return error.LexError; + const parse_fn = options.parse orelse return error.ParseError; + + // Lex the source + const tokens = try lex_fn(allocator, src, &options); + + // Parse the tokens + var parse_options = options; + parse_options.src = src; + const ast = try parse_fn(allocator, tokens, &parse_options); + + // Load dependencies + return load(allocator, ast, parse_options); +} + +/// Load from a file +pub fn loadFile( + allocator: Allocator, + filename: []const u8, + options: LoadOptions, +) LoadError!*Node { + // Read the file + const read_fn = options.read orelse defaultRead; + const content = try read_fn(allocator, filename, &options); + defer allocator.free(content); + + // Load from string with filename set + var file_options = options; + file_options.filename = filename; + return loadString(allocator, content, file_options); +} + +// ============================================================================ +// Path Utilities +// ============================================================================ + +/// Get the directory name from a path +pub fn dirname(path: []const u8) []const u8 { + if (mem.lastIndexOf(u8, path, "/")) |idx| { + if (idx == 0) return "/"; + return path[0..idx]; + } + return "."; +} + +/// Join two path components +pub fn pathJoin(allocator: Allocator, base: []const u8, relative: []const u8) ![]const u8 { + if (relative.len > 0 and relative[0] == '/') { + return allocator.dupe(u8, relative); + } + + const base_dir = dirname(base); + + // Handle .. and . components + var result = std.ArrayListUnmanaged(u8){}; + errdefer result.deinit(allocator); + + try result.appendSlice(allocator, base_dir); + if (base_dir.len > 0 and base_dir[base_dir.len - 1] != '/') { + try result.append(allocator, '/'); + } + try result.appendSlice(allocator, relative); + + return result.toOwnedSlice(allocator); +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "dirname - basic paths" { + try std.testing.expectEqualStrings(".", dirname("file.pug")); + try std.testing.expectEqualStrings("/home/user", dirname("/home/user/file.pug")); + try std.testing.expectEqualStrings("views", dirname("views/file.pug")); + try std.testing.expectEqualStrings("/", dirname("/file.pug")); + try std.testing.expectEqualStrings(".", dirname("")); +} + +test "pathJoin - relative paths" { + const allocator = std.testing.allocator; + + const result1 = try pathJoin(allocator, "/home/user/views/index.pug", "partials/header.pug"); + defer allocator.free(result1); + try std.testing.expectEqualStrings("/home/user/views/partials/header.pug", result1); + + const result2 = try pathJoin(allocator, "views/index.pug", "footer.pug"); + defer allocator.free(result2); + try std.testing.expectEqualStrings("views/footer.pug", result2); +} + +test "pathJoin - absolute paths" { + const allocator = std.testing.allocator; + + const result = try pathJoin(allocator, "/home/user/views/index.pug", "/absolute/path.pug"); + defer allocator.free(result); + try std.testing.expectEqualStrings("/absolute/path.pug", result); +} + +test "defaultResolve - missing basedir for absolute path" { + const options = LoadOptions{}; + const result = defaultResolve("/absolute/path.pug", null, &options); + try std.testing.expectError(error.MissingBasedir, result); +} + +test "defaultResolve - missing filename for relative path" { + const options = LoadOptions{ .basedir = "/base" }; + const result = defaultResolve("relative/path.pug", null, &options); + try std.testing.expectError(error.MissingFilename, result); +} + +test "load - basic AST without includes" { + const allocator = std.testing.allocator; + + // Create a simple AST with no includes + const text_node = try allocator.create(Node); + text_node.* = Node{ + .type = .Text, + .val = "Hello", + .line = 1, + .column = 1, + }; + + var root = try allocator.create(Node); + root.* = Node{ + .type = .Block, + .line = 1, + .column = 1, + }; + try root.nodes.append(allocator, text_node); + + defer { + root.deinit(allocator); + allocator.destroy(root); + } + + // Load should succeed with no changes + const result = try load(allocator, root, .{}); + try std.testing.expectEqual(root, result); +} diff --git a/src/mixin.zig b/src/mixin.zig new file mode 100644 index 0000000..342d285 --- /dev/null +++ b/src/mixin.zig @@ -0,0 +1,581 @@ +// mixin.zig - Mixin registry and expansion +// +// Handles mixin definitions and calls: +// - Collects mixin definitions from AST into a registry +// - Expands mixin calls by substituting arguments and block content +// +// Usage pattern in Pug: +// mixin button(text, type) +// button(class="btn btn-" + type)= text +// +// +button("Click", "primary") +// +// Include pattern: +// include mixins/_buttons.pug +// +primary-button("Click") + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const mem = std.mem; + +const parser = @import("parser.zig"); +pub const Node = parser.Node; +pub const NodeType = parser.NodeType; + +// ============================================================================ +// Mixin Registry +// ============================================================================ + +/// Registry for mixin definitions +pub const MixinRegistry = struct { + allocator: Allocator, + mixins: std.StringHashMapUnmanaged(*Node), + + pub fn init(allocator: Allocator) MixinRegistry { + return .{ + .allocator = allocator, + .mixins = .{}, + }; + } + + pub fn deinit(self: *MixinRegistry) void { + self.mixins.deinit(self.allocator); + } + + /// Register a mixin definition + pub fn register(self: *MixinRegistry, name: []const u8, node: *Node) !void { + try self.mixins.put(self.allocator, name, node); + } + + /// Get a mixin definition by name + pub fn get(self: *const MixinRegistry, name: []const u8) ?*Node { + return self.mixins.get(name); + } + + /// Check if a mixin exists + pub fn contains(self: *const MixinRegistry, name: []const u8) bool { + return self.mixins.contains(name); + } +}; + +// ============================================================================ +// Mixin Collector - Collect definitions from AST +// ============================================================================ + +/// Collect all mixin definitions from an AST into the registry +pub fn collectMixins(allocator: Allocator, ast: *Node, registry: *MixinRegistry) !void { + try collectMixinsFromNode(allocator, ast, registry); +} + +fn collectMixinsFromNode(allocator: Allocator, node: *Node, registry: *MixinRegistry) !void { + // If this is a mixin definition (not a call), register it + if (node.type == .Mixin and !node.call) { + if (node.name) |name| { + try registry.register(name, node); + } + } + + // Recurse into children + for (node.nodes.items) |child| { + try collectMixinsFromNode(allocator, child, registry); + } +} + +// ============================================================================ +// Mixin Expander - Expand mixin calls in AST +// ============================================================================ + +/// Error types for mixin expansion +pub const MixinError = error{ + OutOfMemory, + MixinNotFound, + InvalidMixinCall, +}; + +/// Expand all mixin calls in an AST using the registry +/// Returns a new AST with mixin calls replaced by their expanded content +pub fn expandMixins(allocator: Allocator, ast: *Node, registry: *const MixinRegistry) MixinError!*Node { + return expandNode(allocator, ast, registry, null); +} + +fn expandNode( + allocator: Allocator, + node: *Node, + registry: *const MixinRegistry, + caller_block: ?*Node, +) MixinError!*Node { + // Handle mixin call + if (node.type == .Mixin and node.call) { + return expandMixinCall(allocator, node, registry, caller_block); + } + + // Handle MixinBlock - replace with caller's block content + if (node.type == .MixinBlock) { + if (caller_block) |block| { + // Clone the caller's block + return cloneNode(allocator, block); + } else { + // No block provided, return empty block + const empty = allocator.create(Node) catch return error.OutOfMemory; + empty.* = Node{ + .type = .Block, + .line = node.line, + .column = node.column, + }; + return empty; + } + } + + // For other nodes, clone and recurse into children + const new_node = allocator.create(Node) catch return error.OutOfMemory; + new_node.* = node.*; + new_node.nodes = .{}; + + // Clone and expand children + for (node.nodes.items) |child| { + const expanded_child = try expandNode(allocator, child, registry, caller_block); + new_node.nodes.append(allocator, expanded_child) catch return error.OutOfMemory; + } + + return new_node; +} + +fn expandMixinCall( + allocator: Allocator, + call_node: *Node, + registry: *const MixinRegistry, + _: ?*Node, +) MixinError!*Node { + const mixin_name = call_node.name orelse return error.InvalidMixinCall; + + // Look up mixin definition + const mixin_def = registry.get(mixin_name) orelse { + // Mixin not found - return a comment node indicating the error + const error_node = allocator.create(Node) catch return error.OutOfMemory; + error_node.* = Node{ + .type = .Comment, + .val = mixin_name, + .buffer = true, + .line = call_node.line, + .column = call_node.column, + }; + return error_node; + }; + + // Get the block content from the call (if any) + var call_block: ?*Node = null; + if (call_node.nodes.items.len > 0) { + // Create a block node containing the call's children + const block = allocator.create(Node) catch return error.OutOfMemory; + block.* = Node{ + .type = .Block, + .line = call_node.line, + .column = call_node.column, + }; + for (call_node.nodes.items) |child| { + const cloned = try cloneNode(allocator, child); + block.nodes.append(allocator, cloned) catch return error.OutOfMemory; + } + call_block = block; + } + + // Create argument bindings + var arg_bindings = std.StringHashMapUnmanaged([]const u8){}; + defer arg_bindings.deinit(allocator); + + // Bind call arguments to mixin parameters + if (mixin_def.args) |params| { + if (call_node.args) |args| { + try bindArguments(allocator, params, args, &arg_bindings); + } + } + + // Clone and expand the mixin body + const result = allocator.create(Node) catch return error.OutOfMemory; + result.* = Node{ + .type = .Block, + .line = call_node.line, + .column = call_node.column, + }; + + // Expand each node in the mixin definition's body + for (mixin_def.nodes.items) |child| { + const expanded = try expandNodeWithArgs(allocator, child, registry, call_block, &arg_bindings); + result.nodes.append(allocator, expanded) catch return error.OutOfMemory; + } + + return result; +} + +fn expandNodeWithArgs( + allocator: Allocator, + node: *Node, + registry: *const MixinRegistry, + caller_block: ?*Node, + arg_bindings: *const std.StringHashMapUnmanaged([]const u8), +) MixinError!*Node { + // Handle mixin call (nested) + if (node.type == .Mixin and node.call) { + return expandMixinCall(allocator, node, registry, caller_block); + } + + // Handle MixinBlock - replace with caller's block content + if (node.type == .MixinBlock) { + if (caller_block) |block| { + return cloneNode(allocator, block); + } else { + const empty = allocator.create(Node) catch return error.OutOfMemory; + empty.* = Node{ + .type = .Block, + .line = node.line, + .column = node.column, + }; + return empty; + } + } + + // Clone the node + const new_node = allocator.create(Node) catch return error.OutOfMemory; + new_node.* = node.*; + new_node.nodes = .{}; + new_node.attrs = .{}; + + // Substitute argument references in text/val + if (node.val) |val| { + new_node.val = try substituteArgs(allocator, val, arg_bindings); + } + + // Clone attributes with argument substitution + for (node.attrs.items) |attr| { + var new_attr = attr; + if (attr.val) |val| { + new_attr.val = try substituteArgs(allocator, val, arg_bindings); + } + new_node.attrs.append(allocator, new_attr) catch return error.OutOfMemory; + } + + // Recurse into children + for (node.nodes.items) |child| { + const expanded = try expandNodeWithArgs(allocator, child, registry, caller_block, arg_bindings); + new_node.nodes.append(allocator, expanded) catch return error.OutOfMemory; + } + + return new_node; +} + +/// Substitute argument references in a string and evaluate simple expressions +fn substituteArgs( + allocator: Allocator, + text: []const u8, + bindings: *const std.StringHashMapUnmanaged([]const u8), +) MixinError![]const u8 { + // Quick check - if no bindings or text doesn't contain any param names, return as-is + if (bindings.count() == 0) { + return text; + } + + // Check if any substitution is needed + var needs_substitution = false; + var iter = bindings.iterator(); + while (iter.next()) |entry| { + if (mem.indexOf(u8, text, entry.key_ptr.*) != null) { + needs_substitution = true; + break; + } + } + + if (!needs_substitution) { + return text; + } + + // Perform substitution + var result = std.ArrayListUnmanaged(u8){}; + errdefer result.deinit(allocator); + + var i: usize = 0; + while (i < text.len) { + var found_match = false; + + // Check for parameter match at current position + var iter2 = bindings.iterator(); + while (iter2.next()) |entry| { + const param = entry.key_ptr.*; + const value = entry.value_ptr.*; + + if (i + param.len <= text.len and mem.eql(u8, text[i .. i + param.len], param)) { + // Check it's a word boundary (not part of a larger identifier) + const before_ok = i == 0 or !isIdentChar(text[i - 1]); + const after_ok = i + param.len >= text.len or !isIdentChar(text[i + param.len]); + + if (before_ok and after_ok) { + result.appendSlice(allocator, value) catch return error.OutOfMemory; + i += param.len; + found_match = true; + break; + } + } + } + + if (!found_match) { + result.append(allocator, text[i]) catch return error.OutOfMemory; + i += 1; + } + } + + const substituted = result.toOwnedSlice(allocator) catch return error.OutOfMemory; + + // Evaluate string concatenation expressions like "btn btn-" + "primary" + return evaluateStringConcat(allocator, substituted) catch return error.OutOfMemory; +} + +/// Evaluate simple string concatenation expressions +/// Handles: "btn btn-" + primary -> "btn btn-primary" +/// Also handles: "btn btn-" + "primary" -> "btn btn-primary" +fn evaluateStringConcat(allocator: Allocator, expr: []const u8) ![]const u8 { + // Check if there's a + operator (string concat) + _ = mem.indexOf(u8, expr, " + ") orelse return expr; + + var result = std.ArrayListUnmanaged(u8){}; + errdefer result.deinit(allocator); + + var remaining = expr; + var is_first_part = true; + + while (remaining.len > 0) { + const next_plus = mem.indexOf(u8, remaining, " + "); + const part = if (next_plus) |pos| remaining[0..pos] else remaining; + + // Extract string value (strip quotes and whitespace) + const stripped = mem.trim(u8, part, " \t"); + const unquoted = stripQuotes(stripped); + + // For the first part, we might want to keep it quoted in the final output + // For subsequent parts, just append the value + if (is_first_part) { + // If the first part is a quoted string, we'll build an unquoted result + result.appendSlice(allocator, unquoted) catch return error.OutOfMemory; + is_first_part = false; + } else { + result.appendSlice(allocator, unquoted) catch return error.OutOfMemory; + } + + if (next_plus) |pos| { + remaining = remaining[pos + 3 ..]; // Skip " + " + } else { + break; + } + } + + // Free original and return concatenated result + allocator.free(expr); + return result.toOwnedSlice(allocator); +} + +fn isIdentChar(c: u8) bool { + return (c >= 'a' and c <= 'z') or + (c >= 'A' and c <= 'Z') or + (c >= '0' and c <= '9') or + c == '_' or c == '-'; +} + +/// Bind call arguments to mixin parameters +fn bindArguments( + allocator: Allocator, + params: []const u8, + args: []const u8, + bindings: *std.StringHashMapUnmanaged([]const u8), +) MixinError!void { + // Parse parameter names from definition: "text, type" or "text, type='primary'" + var param_names = std.ArrayListUnmanaged([]const u8){}; + defer param_names.deinit(allocator); + + var param_iter = mem.splitSequence(u8, params, ","); + while (param_iter.next()) |param_part| { + const trimmed = mem.trim(u8, param_part, " \t"); + if (trimmed.len == 0) continue; + + // Handle default values: "type='primary'" -> just get "type" + var param_name = trimmed; + if (mem.indexOf(u8, trimmed, "=")) |eq_pos| { + param_name = mem.trim(u8, trimmed[0..eq_pos], " \t"); + } + + // Handle rest args: "...items" -> "items" + if (mem.startsWith(u8, param_name, "...")) { + param_name = param_name[3..]; + } + + param_names.append(allocator, param_name) catch return error.OutOfMemory; + } + + // Parse argument values from call: "'Click', 'primary'" or "text='Click'" + var arg_values = std.ArrayListUnmanaged([]const u8){}; + defer arg_values.deinit(allocator); + + // Simple argument parsing - split by comma but respect quotes + var in_string = false; + var string_char: u8 = 0; + var paren_depth: usize = 0; + var start: usize = 0; + + for (args, 0..) |c, idx| { + if (!in_string) { + if (c == '"' or c == '\'') { + in_string = true; + string_char = c; + } else if (c == '(') { + paren_depth += 1; + } else if (c == ')') { + if (paren_depth > 0) paren_depth -= 1; + } else if (c == ',' and paren_depth == 0) { + const arg_val = mem.trim(u8, args[start..idx], " \t"); + arg_values.append(allocator, stripQuotes(arg_val)) catch return error.OutOfMemory; + start = idx + 1; + } + } else { + if (c == string_char) { + in_string = false; + } + } + } + + // Add last argument + if (start < args.len) { + const arg_val = mem.trim(u8, args[start..], " \t"); + if (arg_val.len > 0) { + arg_values.append(allocator, stripQuotes(arg_val)) catch return error.OutOfMemory; + } + } + + // Bind positional arguments + const min_len = @min(param_names.items.len, arg_values.items.len); + for (0..min_len) |i| { + bindings.put(allocator, param_names.items[i], arg_values.items[i]) catch return error.OutOfMemory; + } +} + +fn stripQuotes(val: []const u8) []const u8 { + if (val.len < 2) return val; + const first = val[0]; + const last = val[val.len - 1]; + if ((first == '"' and last == '"') or (first == '\'' and last == '\'')) { + return val[1 .. val.len - 1]; + } + return val; +} + +/// Clone a node and all its children +fn cloneNode(allocator: Allocator, node: *Node) MixinError!*Node { + const new_node = allocator.create(Node) catch return error.OutOfMemory; + new_node.* = node.*; + new_node.nodes = .{}; + new_node.attrs = .{}; + + // Clone attributes + for (node.attrs.items) |attr| { + new_node.attrs.append(allocator, attr) catch return error.OutOfMemory; + } + + // Clone children recursively + for (node.nodes.items) |child| { + const cloned_child = try cloneNode(allocator, child); + new_node.nodes.append(allocator, cloned_child) catch return error.OutOfMemory; + } + + return new_node; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "MixinRegistry - basic operations" { + const allocator = std.testing.allocator; + + var registry = MixinRegistry.init(allocator); + defer registry.deinit(); + + // Create a mock mixin node + var mixin_node = Node{ + .type = .Mixin, + .name = "button", + .line = 1, + .column = 1, + }; + + try registry.register("button", &mixin_node); + + try std.testing.expect(registry.contains("button")); + try std.testing.expect(!registry.contains("nonexistent")); + + const retrieved = registry.get("button"); + try std.testing.expect(retrieved != null); + try std.testing.expectEqualStrings("button", retrieved.?.name.?); +} + +test "bindArguments - simple positional" { + const allocator = std.testing.allocator; + + var bindings = std.StringHashMapUnmanaged([]const u8){}; + defer bindings.deinit(allocator); + + try bindArguments(allocator, "text, type", "'Click', 'primary'", &bindings); + + try std.testing.expectEqualStrings("Click", bindings.get("text").?); + try std.testing.expectEqualStrings("primary", bindings.get("type").?); +} + +test "substituteArgs - basic substitution" { + const allocator = std.testing.allocator; + + var bindings = std.StringHashMapUnmanaged([]const u8){}; + defer bindings.deinit(allocator); + + bindings.put(allocator, "title", "Hello") catch unreachable; + bindings.put(allocator, "name", "World") catch unreachable; + + const result = try substituteArgs(allocator, "title is title and name is name", &bindings); + defer allocator.free(result); + + try std.testing.expectEqualStrings("Hello is Hello and World is World", result); +} + +test "stripQuotes" { + try std.testing.expectEqualStrings("hello", stripQuotes("'hello'")); + try std.testing.expectEqualStrings("hello", stripQuotes("\"hello\"")); + try std.testing.expectEqualStrings("hello", stripQuotes("hello")); + try std.testing.expectEqualStrings("", stripQuotes("''")); +} + +test "substituteArgs - string concatenation expression" { + const allocator = std.testing.allocator; + + var bindings = std.StringHashMapUnmanaged([]const u8){}; + defer bindings.deinit(allocator); + + try bindings.put(allocator, "type", "primary"); + + // Test the exact format that comes from the parser + const input = "\"btn btn-\" + type"; + const result = try substituteArgs(allocator, input, &bindings); + defer allocator.free(result); + + // After substitution and concatenation evaluation, should be: btn btn-primary + try std.testing.expectEqualStrings("btn btn-primary", result); +} + +test "evaluateStringConcat - basic" { + const allocator = std.testing.allocator; + + // Test with quoted + unquoted + const input1 = try allocator.dupe(u8, "\"btn btn-\" + primary"); + const result1 = try evaluateStringConcat(allocator, input1); + defer allocator.free(result1); + try std.testing.expectEqualStrings("btn btn-primary", result1); + + // Test with both quoted + const input2 = try allocator.dupe(u8, "\"btn btn-\" + \"primary\""); + const result2 = try evaluateStringConcat(allocator, input2); + defer allocator.free(result2); + try std.testing.expectEqualStrings("btn btn-primary", result2); +} diff --git a/src/parser.zig b/src/parser.zig index d2f3925..53f97c8 100644 --- a/src/parser.zig +++ b/src/parser.zig @@ -1,1397 +1,1646 @@ -//! Pug Parser - Converts token stream into an AST. -//! -//! The parser processes tokens from the lexer and builds a hierarchical -//! AST representing the document structure. It handles: -//! - Indentation-based nesting via indent/dedent tokens -//! - Element construction (tag, classes, id, attributes) -//! - Control flow (if/else, each, while) -//! - Mixins, includes, and template inheritance -//! -//! ## Error Diagnostics -//! When parsing fails, call `getDiagnostic()` to get rich error info: -//! ```zig -//! var parser = Parser.init(allocator, tokens); -//! const doc = parser.parse() catch |err| { -//! if (parser.getDiagnostic()) |diag| { -//! std.debug.print("{}\n", .{diag}); -//! } -//! return err; -//! }; -//! ``` - const std = @import("std"); +const mem = std.mem; +const Allocator = std.mem.Allocator; + +// Import token types from lexer const lexer = @import("lexer.zig"); -const ast = @import("ast.zig"); -const diagnostic = @import("diagnostic.zig"); +pub const TokenType = lexer.TokenType; +pub const TokenValue = lexer.TokenValue; +pub const Location = lexer.Location; +pub const TokenLoc = lexer.TokenLoc; +pub const Token = lexer.Token; -const Token = lexer.Token; -const TokenType = lexer.TokenType; -const Node = ast.Node; -const Attribute = ast.Attribute; -const TextSegment = ast.TextSegment; +// ============================================================================ +// Inline Tags (tags that are typically inline in HTML) +// ============================================================================ -pub const Diagnostic = diagnostic.Diagnostic; - -/// Errors that can occur during parsing. -pub const ParserError = error{ - UnexpectedToken, - UnexpectedEof, - InvalidSyntax, - MissingCondition, - MissingIterator, - MissingCollection, - MissingMixinName, - MissingBlockName, - MissingPath, - OutOfMemory, +const inline_tags = [_][]const u8{ + "a", + "abbr", + "acronym", + "b", + "br", + "code", + "em", + "font", + "i", + "img", + "ins", + "kbd", + "map", + "samp", + "small", + "span", + "strong", + "sub", + "sup", }; -/// Combined error set for all parser operations. -pub const Error = ParserError || std.mem.Allocator.Error; - -/// Parser for Pug templates. -/// -/// Converts a token slice into an AST. Uses an arena allocator for all -/// AST node allocations, making cleanup simple and efficient. -pub const Parser = struct { - tokens: []const Token, - pos: usize, - allocator: std.mem.Allocator, - /// Original source text (for error snippets) - source: ?[]const u8, - /// Last error diagnostic (populated on error) - last_diagnostic: ?Diagnostic, - - /// Creates a new parser for the given tokens. - pub fn init(allocator: std.mem.Allocator, tokens: []const Token) Parser { - return .{ - .tokens = tokens, - .pos = 0, - .allocator = allocator, - .source = null, - .last_diagnostic = null, - }; +fn isInlineTag(name: []const u8) bool { + for (inline_tags) |tag| { + if (mem.eql(u8, name, tag)) return true; } + return false; +} - /// Creates a parser with source text for better error messages. - pub fn initWithSource(allocator: std.mem.Allocator, tokens: []const Token, source: []const u8) Parser { - return .{ - .tokens = tokens, - .pos = 0, - .allocator = allocator, - .source = source, - .last_diagnostic = null, - }; - } +// ============================================================================ +// AST Node Types +// ============================================================================ - /// Returns the last error diagnostic, if any. - /// Call this after parse() returns an error to get detailed error info. - pub fn getDiagnostic(self: *const Parser) ?Diagnostic { - return self.last_diagnostic; - } +pub const NodeType = enum { + Block, + NamedBlock, + Tag, + InterpolatedTag, + Text, + Code, + Comment, + BlockComment, + Doctype, + Mixin, + MixinBlock, + Case, + When, + Conditional, + While, + Each, + EachOf, + Extends, + Include, + RawInclude, + Filter, + IncludeFilter, + FileReference, + YieldBlock, + AttributeBlock, +}; - /// Sets a diagnostic error with context from the current token. - fn setDiagnostic(self: *Parser, message: []const u8, suggestion: ?[]const u8) void { - const token = if (self.pos < self.tokens.len) self.tokens[self.pos] else self.tokens[self.tokens.len - 1]; - const source_line = if (self.source) |src| - diagnostic.extractSourceLine(src, 0) // Would need position mapping - else - null; +// ============================================================================ +// AST Node - A tagged union representing all possible AST nodes +// ============================================================================ - self.last_diagnostic = .{ - .line = @intCast(token.line), - .column = @intCast(token.column), - .message = message, - .source_line = source_line, - .suggestion = suggestion, - }; - } +pub const Attribute = struct { + name: []const u8, + val: ?[]const u8, + line: usize, + column: usize, + filename: ?[]const u8, + must_escape: bool, + val_owned: bool = false, // true if val was allocated and needs to be freed +}; - /// Sets a diagnostic error for a specific token. - fn setDiagnosticAtToken(self: *Parser, token: Token, message: []const u8, suggestion: ?[]const u8) void { - self.last_diagnostic = .{ - .line = @intCast(token.line), - .column = @intCast(token.column), - .message = message, - .source_line = null, - .suggestion = suggestion, - }; - } +pub const AttributeBlock = struct { + val: []const u8, + line: usize, + column: usize, + filename: ?[]const u8, +}; - /// Parses all tokens and returns the document AST. - pub fn parse(self: *Parser) Error!ast.Document { - var nodes = std.ArrayList(Node).empty; - errdefer nodes.deinit(self.allocator); +pub const FileReference = struct { + path: ?[]const u8, + line: usize, + column: usize, + filename: ?[]const u8, +}; - var extends_path: ?[]const u8 = null; +pub const Node = struct { + type: NodeType, + line: usize = 0, + column: usize = 0, + filename: ?[]const u8 = null, - // Check for extends directive (must be first) - if (self.check(.kw_extends)) { - extends_path = try self.parseExtends(); - self.skipNewlines(); - } + // Block fields + nodes: std.ArrayListUnmanaged(*Node) = .{}, - // Parse all top-level nodes - while (!self.isAtEnd()) { - self.skipNewlines(); - if (self.isAtEnd()) break; + // NamedBlock additional fields + name: ?[]const u8 = null, // Also used for Tag, Mixin, Filter + mode: ?[]const u8 = null, // "prepend", "append", "replace" - const node = try self.parseNode(); - if (node) |n| { - try nodes.append(self.allocator, n); + // Tag fields + self_closing: bool = false, + attrs: std.ArrayListUnmanaged(Attribute) = .{}, + attribute_blocks: std.ArrayListUnmanaged(AttributeBlock) = .{}, + is_inline: bool = false, + text_only: bool = false, + self_closing_allowed: bool = false, + + // Text fields + val: ?[]const u8 = null, // Also used for Code, Comment, Doctype, Case expr, When expr, Conditional test, While test + is_html: bool = false, + + // Code fields + buffer: bool = false, + must_escape: bool = true, + is_inline_code: bool = false, + + // Mixin fields + args: ?[]const u8 = null, + call: bool = false, + + // Each fields + obj: ?[]const u8 = null, + key: ?[]const u8 = null, + + // Conditional fields + test_expr: ?[]const u8 = null, // "test" in JS + consequent: ?*Node = null, + alternate: ?*Node = null, + + // Extends/Include fields + file: ?FileReference = null, + + // Include fields + filters: std.ArrayListUnmanaged(*Node) = .{}, + + // InterpolatedTag fields + expr: ?[]const u8 = null, + + // When/Conditional debug field + debug: bool = true, + + // Memory ownership flags + val_owned: bool = false, // true if val was allocated and needs to be freed + + pub fn deinit(self: *Node, allocator: Allocator) void { + // Free owned val string + if (self.val_owned) { + if (self.val) |v| { + allocator.free(v); } } + // Free child nodes recursively + for (self.nodes.items) |child| { + child.deinit(allocator); + allocator.destroy(child); + } + self.nodes.deinit(allocator); + + // Free attrs (including owned val strings) + for (self.attrs.items) |attr| { + if (attr.val_owned) { + if (attr.val) |v| { + allocator.free(v); + } + } + } + self.attrs.deinit(allocator); + + // Free attribute_blocks + self.attribute_blocks.deinit(allocator); + + // Free filters + for (self.filters.items) |filter| { + filter.deinit(allocator); + allocator.destroy(filter); + } + self.filters.deinit(allocator); + + // Free consequent and alternate + if (self.consequent) |c| { + c.deinit(allocator); + allocator.destroy(c); + } + if (self.alternate) |a| { + a.deinit(allocator); + allocator.destroy(a); + } + } + + pub fn addNode(self: *Node, allocator: Allocator, node: *Node) !void { + try self.nodes.append(allocator, node); + } +}; + +// ============================================================================ +// Parser Error +// ============================================================================ + +pub const ParserErrorCode = enum { + INVALID_TOKEN, + BLOCK_IN_BUFFERED_CODE, + BLOCK_OUTISDE_MIXIN, + MIXIN_WITHOUT_BODY, + RAW_INCLUDE_BLOCK, + DUPLICATE_ID, + DUPLICATE_ATTRIBUTE, + UNEXPECTED_END, +}; + +pub const ParserError = struct { + code: ParserErrorCode, + message: []const u8, + line: usize, + column: usize, + filename: ?[]const u8, +}; + +// ============================================================================ +// Parser +// ============================================================================ + +pub const Parser = struct { + allocator: Allocator, + tokens: []const Token, + pos: usize = 0, + deferred: std.ArrayListUnmanaged(Token) = .{}, + filename: ?[]const u8 = null, + src: ?[]const u8 = null, + in_mixin: usize = 0, + err: ?ParserError = null, + + pub fn init(allocator: Allocator, tokens: []const Token, filename: ?[]const u8, src: ?[]const u8) Parser { return .{ - .nodes = try nodes.toOwnedSlice(self.allocator), - .extends_path = extends_path, + .allocator = allocator, + .tokens = tokens, + .filename = filename, + .src = src, }; } - /// Parses a single node based on current token. - fn parseNode(self: *Parser) Error!?Node { - self.skipNewlines(); - if (self.isAtEnd()) return null; + pub fn deinit(self: *Parser) void { + self.deferred.deinit(self.allocator); + } - const token = self.peek(); + // ======================================================================== + // Token Stream Methods + // ======================================================================== - return switch (token.type) { - .tag => try self.parseElement(), - .class, .id => try self.parseElement(), // div-less element - .kw_doctype => try self.parseDoctype(), - .kw_if => try self.parseConditional(), - .kw_unless => try self.parseConditional(), - .kw_each, .kw_for => try self.parseEach(), - .kw_while => try self.parseWhile(), - .kw_case => try self.parseCase(), - .kw_mixin => try self.parseMixinDef(), - .mixin_call => try self.parseMixinCall(), - .kw_include => try self.parseInclude(), - .kw_block => try self.parseBlock(), - .kw_append => try self.parseBlockShorthand(.append), - .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 + /// Return the next token without consuming it + pub fn peek(self: *Parser) Token { + if (self.deferred.items.len > 0) { + return self.deferred.items[0]; + } + if (self.pos < self.tokens.len) { + return self.tokens[self.pos]; + } + // Return EOS token if past end + return .{ + .type = .eos, + .loc = .{ .start = .{ .line = 0, .column = 0 } }, + }; + } + + /// Return the token at offset n from current position (0 = current) + pub fn lookahead(self: *Parser, n: usize) Token { + const deferred_len = self.deferred.items.len; + if (n < deferred_len) { + return self.deferred.items[n]; + } + const index = self.pos + (n - deferred_len); + if (index < self.tokens.len) { + return self.tokens[index]; + } + return .{ + .type = .eos, + .loc = .{ .start = .{ .line = 0, .column = 0 } }, + }; + } + + /// Consume and return the next token + pub fn advance(self: *Parser) Token { + if (self.deferred.items.len > 0) { + return self.deferred.orderedRemove(0); + } + if (self.pos < self.tokens.len) { + const tok = self.tokens[self.pos]; + self.pos += 1; + return tok; + } + return .{ + .type = .eos, + .loc = .{ .start = .{ .line = 0, .column = 0 } }, + }; + } + + /// Push a token to the front of the stream + pub fn defer_token(self: *Parser, token: Token) !void { + try self.deferred.insert(self.allocator, 0, token); + } + + /// Expect a specific token type, return error if not found + pub fn expect(self: *Parser, token_type: TokenType) !Token { + const tok = self.peek(); + if (tok.type == token_type) { + return self.advance(); + } + self.setError(.INVALID_TOKEN, "expected different token type", tok); + return error.InvalidToken; + } + + /// Accept a token if it matches, otherwise return null + pub fn accept(self: *Parser, token_type: TokenType) ?Token { + if (self.peek().type == token_type) { + return self.advance(); + } + return null; + } + + // ======================================================================== + // Error Handling + // ======================================================================== + + fn setError(self: *Parser, code: ParserErrorCode, message: []const u8, token: Token) void { + self.err = .{ + .code = code, + .message = message, + .line = token.loc.start.line, + .column = token.loc.start.column, + .filename = self.filename, + }; + } + + pub fn getError(self: *const Parser) ?ParserError { + return self.err; + } + + // ======================================================================== + // Block Helpers + // ======================================================================== + + fn initBlock(self: *Parser, line: usize) !*Node { + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Block, + .line = line, + .filename = self.filename, + }; + return node; + } + + fn emptyBlock(self: *Parser, line: usize) !*Node { + return self.initBlock(line); + } + + // ======================================================================== + // Main Parse Entry Point + // ======================================================================== + + pub fn parse(self: *Parser) !*Node { + var block = try self.emptyBlock(0); + + while (self.peek().type != .eos) { + if (self.peek().type == .newline) { _ = self.advance(); - return null; + } else if (self.peek().type == .text_html) { + var html_nodes = try self.parseTextHtml(); + for (html_nodes.items) |node| { + try block.addNode(self.allocator, node); + } + html_nodes.deinit(self.allocator); + } else { + const expr = try self.parseExpr(); + if (expr.type == .Block) { + // Flatten block nodes into parent + for (expr.nodes.items) |node| { + try block.addNode(self.allocator, node); + } + // Clear the expr's nodes list (already moved) + expr.nodes.clearAndFree(self.allocator); + self.allocator.destroy(expr); + } else { + try block.addNode(self.allocator, expr); + } + } + } + + return block; + } + + // ======================================================================== + // Expression Parsing + // ======================================================================== + + fn parseExpr(self: *Parser) anyerror!*Node { + const tok = self.peek(); + return switch (tok.type) { + .tag => self.parseTag(), + .mixin => self.parseMixin(), + .block => self.parseBlock(), + .mixin_block => self.parseMixinBlock(), + .case => self.parseCase(), + .extends => self.parseExtends(), + .include => self.parseInclude(), + .doctype => self.parseDoctype(), + .filter => self.parseFilter(), + .comment => self.parseComment(), + .text, .interpolated_code, .start_pug_interpolation => self.parseText(true), + .text_html => blk: { + var html_nodes = try self.parseTextHtml(); + const block = try self.initBlock(tok.loc.start.line); + for (html_nodes.items) |node| { + try block.addNode(self.allocator, node); + } + html_nodes.deinit(self.allocator); + break :blk block; }, - .buffered_text => try self.parseBufferedCode(true), - .unescaped_text => try self.parseBufferedCode(false), - .text => try self.parseText(), - .literal_html => try self.parseLiteralHtml(), - .newline, .eof => null, - .indent, .dedent => { - // Consume structural tokens to prevent infinite loops - _ = self.advance(); - return null; + .dot => self.parseDot(), + .each => self.parseEach(), + .each_of => self.parseEachOf(), + .code => self.parseCode(false), + .blockcode => self.parseBlockCode(), + .@"if" => self.parseConditional(), + .@"while" => self.parseWhile(), + .call => self.parseCall(), + .interpolation => self.parseInterpolation(), + .yield => self.parseYield(), + .id, .class => blk: { + // Implicit div tag for #id or .class + try self.defer_token(.{ + .type = .tag, + .val = .{ .string = "div" }, + .loc = tok.loc, + }); + break :blk self.parseExpr(); }, else => { - // Skip unknown tokens to prevent infinite loops - _ = self.advance(); - return null; + self.setError(.INVALID_TOKEN, "unexpected token", tok); + return error.InvalidToken; }, }; } - /// Parses an HTML element with optional tag, classes, id, attributes, and children. - fn parseElement(self: *Parser) Error!Node { - var tag: []const u8 = "div"; // default tag - var classes = std.ArrayList([]const u8).empty; - var id: ?[]const u8 = null; - var attributes = std.ArrayList(Attribute).empty; - var spread_attributes: ?[]const u8 = null; - var self_closing = false; + fn parseDot(self: *Parser) !*Node { + _ = self.advance(); + return self.parseTextBlock() orelse try self.emptyBlock(self.peek().loc.start.line); + } - errdefer classes.deinit(self.allocator); - errdefer attributes.deinit(self.allocator); + // ======================================================================== + // Text Parsing + // ======================================================================== - // Parse tag name if present - if (self.check(.tag)) { - tag = self.advance().value; - } + fn parseText(self: *Parser, allow_block: bool) !*Node { + const lineno = self.peek().loc.start.line; + var tags = std.ArrayListUnmanaged(*Node){}; + defer tags.deinit(self.allocator); - // Parse classes and ids in any order - 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(.lparen)) { - _ = self.advance(); // skip ( - try self.parseAttributes(&attributes); - if (self.check(.rparen)) { - _ = self.advance(); // skip ) - } - } - - // 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 - if (self.check(.attr_value)) { - spread_attributes = self.advance().value; - } - } - - // Check for self-closing marker (foo/ or foo(attr)/) - if (self.check(.self_close)) { - _ = self.advance(); - self_closing = true; - } - - // Check for block expansion (`:`) - if (self.check(.colon)) { - _ = self.advance(); - self.skipWhitespace(); - - // Parse the inline nested element - var children = std.ArrayList(Node).empty; - errdefer children.deinit(self.allocator); - - if (try self.parseNode()) |child| { - try children.append(self.allocator, child); - } - - return .{ - .element = .{ - .tag = tag, - .classes = try classes.toOwnedSlice(self.allocator), - .id = id, - .attributes = try attributes.toOwnedSlice(self.allocator), - .spread_attributes = spread_attributes, - .children = try children.toOwnedSlice(self.allocator), - .self_closing = self_closing, - .inline_text = null, - .buffered_code = null, - .is_inline = true, // Block expansion renders children inline + while (true) { + const next_tok = self.peek(); + switch (next_tok.type) { + .text => { + const tok = self.advance(); + const text_node = try self.allocator.create(Node); + text_node.* = .{ + .type = .Text, + .val = tok.val.getString(), + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + try tags.append(self.allocator, text_node); }, + .interpolated_code => { + const tok = self.advance(); + const code_node = try self.allocator.create(Node); + code_node.* = .{ + .type = .Code, + .val = tok.val.getString(), + .buffer = tok.isBuffered(), + .must_escape = tok.shouldEscape(), + .is_inline_code = true, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + try tags.append(self.allocator, code_node); + }, + .newline => { + if (!allow_block) break; + const tok = self.advance(); + const next_type = self.peek().type; + if (next_type == .text or next_type == .interpolated_code) { + const nl_node = try self.allocator.create(Node); + nl_node.* = .{ + .type = .Text, + .val = "\n", + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + try tags.append(self.allocator, nl_node); + } + }, + .start_pug_interpolation => { + _ = self.advance(); + const expr = try self.parseExpr(); + try tags.append(self.allocator, expr); + _ = try self.expect(.end_pug_interpolation); + }, + else => break, + } + } + + if (tags.items.len == 1) { + const result = tags.items[0]; + tags.clearAndFree(self.allocator); + return result; + } else { + const block = try self.initBlock(lineno); + for (tags.items) |node| { + try block.addNode(self.allocator, node); + } + tags.clearAndFree(self.allocator); + return block; + } + } + + fn parseTextHtml(self: *Parser) !std.ArrayListUnmanaged(*Node) { + var nodes = std.ArrayListUnmanaged(*Node){}; + var current_node: ?*Node = null; + + while (true) { + switch (self.peek().type) { + .text_html => { + const text = self.advance(); + if (current_node == null) { + current_node = try self.allocator.create(Node); + current_node.?.* = .{ + .type = .Text, + .val = text.val.getString(), + .filename = self.filename, + .line = text.loc.start.line, + .column = text.loc.start.column, + .is_html = true, + }; + try nodes.append(self.allocator, current_node.?); + } else { + // Concatenate with newline - need to allocate new string + // For now, create a new text node (simplified) + const new_node = try self.allocator.create(Node); + new_node.* = .{ + .type = .Text, + .val = text.val.getString(), + .filename = self.filename, + .line = text.loc.start.line, + .column = text.loc.start.column, + .is_html = true, + }; + try nodes.append(self.allocator, new_node); + } + }, + .indent => { + const block_nodes = try self.block_(); + for (block_nodes.nodes.items) |node| { + if (node.is_html) { + if (current_node == null) { + current_node = node; + try nodes.append(self.allocator, current_node.?); + } else { + try nodes.append(self.allocator, node); + } + } else { + current_node = null; + try nodes.append(self.allocator, node); + } + } + block_nodes.nodes.deinit(self.allocator); + self.allocator.destroy(block_nodes); + }, + .code => { + current_node = null; + const code_node = try self.parseCode(true); + try nodes.append(self.allocator, code_node); + }, + .newline => { + _ = self.advance(); + }, + else => break, + } + } + + return nodes; + } + + fn parseTextBlock(self: *Parser) ?*Node { + const tok = self.accept(.start_pipeless_text) orelse return null; + var block = self.emptyBlock(tok.loc.start.line) catch return null; + + while (self.peek().type != .end_pipeless_text) { + const cur_tok = self.advance(); + switch (cur_tok.type) { + .text => { + const text_node = self.allocator.create(Node) catch return null; + text_node.* = .{ + .type = .Text, + .val = cur_tok.val.getString(), + .line = cur_tok.loc.start.line, + .column = cur_tok.loc.start.column, + .filename = self.filename, + }; + block.addNode(self.allocator, text_node) catch return null; + }, + .newline => { + const nl_node = self.allocator.create(Node) catch return null; + nl_node.* = .{ + .type = .Text, + .val = "\n", + .line = cur_tok.loc.start.line, + .column = cur_tok.loc.start.column, + .filename = self.filename, + }; + block.addNode(self.allocator, nl_node) catch return null; + }, + .start_pug_interpolation => { + const expr = self.parseExpr() catch return null; + block.addNode(self.allocator, expr) catch return null; + _ = self.expect(.end_pug_interpolation) catch return null; + }, + .interpolated_code => { + const code_node = self.allocator.create(Node) catch return null; + code_node.* = .{ + .type = .Code, + .val = cur_tok.val.getString(), + .buffer = cur_tok.isBuffered(), + .must_escape = cur_tok.shouldEscape(), + .is_inline_code = true, + .line = cur_tok.loc.start.line, + .column = cur_tok.loc.start.column, + .filename = self.filename, + }; + block.addNode(self.allocator, code_node) catch return null; + }, + else => { + self.setError(.INVALID_TOKEN, "Unexpected token in text block", cur_tok); + return null; + }, + } + } + _ = self.advance(); // consume end_pipeless_text + return block; + } + + // ======================================================================== + // Block Expansion + // ======================================================================== + + fn parseBlockExpansion(self: *Parser) !*Node { + if (self.accept(.colon)) |tok| { + const expr = try self.parseExpr(); + if (expr.type == .Block) { + return expr; + } + const block = try self.initBlock(tok.loc.start.line); + try block.addNode(self.allocator, expr); + return block; + } + return self.block_(); + } + + // ======================================================================== + // Case/When/Default + // ======================================================================== + + fn parseCase(self: *Parser) !*Node { + const tok = try self.expect(.case); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Case, + .expr = tok.val.getString(), + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + + var block = try self.emptyBlock(tok.loc.start.line + 1); + _ = try self.expect(.indent); + + while (self.peek().type != .outdent) { + switch (self.peek().type) { + .comment, .newline => { + _ = self.advance(); + }, + .when => { + const when_node = try self.parseWhen(); + try block.addNode(self.allocator, when_node); + }, + .default => { + const default_node = try self.parseDefault(); + try block.addNode(self.allocator, default_node); + }, + else => { + self.setError(.INVALID_TOKEN, "Expected 'when', 'default' or 'newline'", self.peek()); + return error.InvalidToken; + }, + } + } + _ = try self.expect(.outdent); + + // Move block nodes to case node + for (block.nodes.items) |n| { + try node.addNode(self.allocator, n); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); + + return node; + } + + fn parseWhen(self: *Parser) !*Node { + const tok = try self.expect(.when); + const node = try self.allocator.create(Node); + + if (self.peek().type != .newline) { + node.* = .{ + .type = .When, + .expr = tok.val.getString(), + .debug = false, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + const block = try self.parseBlockExpansion(); + for (block.nodes.items) |n| { + try node.addNode(self.allocator, n); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); + } else { + node.* = .{ + .type = .When, + .expr = tok.val.getString(), + .debug = false, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, }; } - // Parse inline text or buffered code if present - var inline_text: ?[]TextSegment = null; - var buffered_code: ?ast.Code = null; - - if (self.check(.buffered_text) or self.check(.unescaped_text)) { - // Handle p= expr or p!= expr - const escaped = self.peek().type == .buffered_text; - _ = self.advance(); // skip = or != - - // Get the expression - var expr: []const u8 = ""; - if (self.check(.text)) { - expr = self.advance().value; - } - buffered_code = .{ .expression = expr, .escaped = escaped }; - } else if (self.check(.text) or self.check(.interp_start) or self.check(.interp_start_unesc)) { - inline_text = try self.parseTextSegments(); - } - - // Check for dot block (raw text) - if (self.check(.dot_block)) { - _ = self.advance(); - self.skipNewlines(); - - // Parse raw text block - if (self.check(.indent)) { - _ = self.advance(); - const raw_content = try self.parseRawTextBlock(); - - var children = std.ArrayList(Node).empty; - errdefer children.deinit(self.allocator); - try children.append(self.allocator, .{ .raw_text = .{ .content = raw_content } }); - - return .{ .element = .{ - .tag = tag, - .classes = try classes.toOwnedSlice(self.allocator), - .id = id, - .attributes = try attributes.toOwnedSlice(self.allocator), - .spread_attributes = spread_attributes, - .children = try children.toOwnedSlice(self.allocator), - .self_closing = self_closing, - .inline_text = inline_text, - .buffered_code = buffered_code, - } }; - } - } - - // Skip newline after element declaration - self.skipNewlines(); - - // Parse children if indented - var children = std.ArrayList(Node).empty; - errdefer children.deinit(self.allocator); - - if (self.check(.indent)) { - _ = self.advance(); - try self.parseChildren(&children); - } - - return .{ .element = .{ - .tag = tag, - .classes = try classes.toOwnedSlice(self.allocator), - .id = id, - .attributes = try attributes.toOwnedSlice(self.allocator), - .spread_attributes = spread_attributes, - .children = try children.toOwnedSlice(self.allocator), - .self_closing = self_closing, - .inline_text = inline_text, - .buffered_code = buffered_code, - } }; + return node; } - /// Parses attributes within parentheses. - fn parseAttributes(self: *Parser, attributes: *std.ArrayList(Attribute)) Error!void { - while (!self.check(.rparen) and !self.isAtEnd()) { - // Skip commas - if (self.check(.comma)) { - _ = self.advance(); - continue; - } - - // Parse attribute name - if (!self.check(.attr_name)) break; - const name = self.advance().value; - - // Check for value - var value: ?[]const u8 = null; - var escaped = true; - - if (self.check(.attr_eq)) { - const eq_token = self.advance(); - escaped = !std.mem.eql(u8, eq_token.value, "!="); - - if (self.check(.attr_value)) { - value = self.advance().value; - } - } - - try attributes.append(self.allocator, .{ - .name = name, - .value = value, - .escaped = escaped, - }); - } - } - - /// Parses text segments (literals and interpolations). - fn parseTextSegments(self: *Parser) Error![]TextSegment { - var segments = std.ArrayList(TextSegment).empty; - errdefer segments.deinit(self.allocator); - - while (self.check(.text) or self.check(.interp_start) or self.check(.interp_start_unesc) or self.check(.tag_interp_start)) { - if (self.check(.text)) { - try segments.append(self.allocator, .{ .literal = self.advance().value }); - } else if (self.check(.interp_start)) { - _ = self.advance(); // skip #{ - if (self.check(.text)) { - try segments.append(self.allocator, .{ .interp_escaped = self.advance().value }); - } - if (self.check(.interp_end)) { - _ = self.advance(); // skip } - } - } else if (self.check(.interp_start_unesc)) { - _ = self.advance(); // skip !{ - if (self.check(.text)) { - try segments.append(self.allocator, .{ .interp_unescaped = self.advance().value }); - } - if (self.check(.interp_end)) { - _ = self.advance(); // skip } - } - } else if (self.check(.tag_interp_start)) { - const inline_tag = try self.parseTagInterpolation(); - try segments.append(self.allocator, .{ .interp_tag = inline_tag }); - } - } - - return segments.toOwnedSlice(self.allocator); - } - - /// Parses tag interpolation: #[tag.class#id(attrs) text] - fn parseTagInterpolation(self: *Parser) Error!ast.InlineTag { - _ = self.advance(); // skip #[ - - var tag: []const u8 = "span"; // default tag - var classes = std.ArrayList([]const u8).empty; - var id: ?[]const u8 = null; - var attributes = std.ArrayList(Attribute).empty; - - errdefer classes.deinit(self.allocator); - errdefer attributes.deinit(self.allocator); - - // Parse tag name if present - if (self.check(.tag)) { - tag = self.advance().value; - } - - // Parse classes and ids - 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 present - if (self.check(.lparen)) { - _ = self.advance(); // skip ( - try self.parseAttributes(&attributes); - if (self.check(.rparen)) { - _ = self.advance(); // skip ) - } - } - - // Parse inner text segments (may contain nested interpolations) - var text_segments = std.ArrayList(TextSegment).empty; - errdefer text_segments.deinit(self.allocator); - - while (!self.check(.tag_interp_end) and !self.check(.newline) and !self.isAtEnd()) { - if (self.check(.text)) { - try text_segments.append(self.allocator, .{ .literal = self.advance().value }); - } else if (self.check(.interp_start)) { - _ = self.advance(); // skip #{ - if (self.check(.text)) { - try text_segments.append(self.allocator, .{ .interp_escaped = self.advance().value }); - } - if (self.check(.interp_end)) { - _ = self.advance(); // skip } - } - } else if (self.check(.interp_start_unesc)) { - _ = self.advance(); // skip !{ - if (self.check(.text)) { - try text_segments.append(self.allocator, .{ .interp_unescaped = self.advance().value }); - } - if (self.check(.interp_end)) { - _ = self.advance(); // skip } - } - } else if (self.check(.tag_interp_start)) { - // Nested tag interpolation - const nested_tag = try self.parseTagInterpolation(); - try text_segments.append(self.allocator, .{ .interp_tag = nested_tag }); - } else { - break; - } - } - - // Skip closing ] - if (self.check(.tag_interp_end)) { - _ = self.advance(); - } - - return .{ - .tag = tag, - .classes = try classes.toOwnedSlice(self.allocator), - .id = id, - .attributes = try attributes.toOwnedSlice(self.allocator), - .text_segments = try text_segments.toOwnedSlice(self.allocator), + fn parseDefault(self: *Parser) !*Node { + const tok = try self.expect(.default); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .When, + .expr = "default", + .debug = false, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, }; + const block = try self.parseBlockExpansion(); + for (block.nodes.items) |n| { + try node.addNode(self.allocator, n); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); + return node; } - /// Parses children within an indented block. - fn parseChildren(self: *Parser, children: *std.ArrayList(Node)) Error!void { - while (!self.check(.dedent) and !self.isAtEnd()) { - self.skipNewlines(); - if (self.check(.dedent) or self.isAtEnd()) break; + // ======================================================================== + // Code Parsing + // ======================================================================== - if (try self.parseNode()) |child| { - try children.append(self.allocator, child); + fn parseCode(self: *Parser, no_block: bool) !*Node { + const tok = try self.expect(.code); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Code, + .val = tok.val.getString(), + .buffer = tok.isBuffered(), + .must_escape = tok.shouldEscape(), + .is_inline_code = no_block, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + + // Check for "else" pattern - disable debug + if (node.val) |v| { + if (mem.indexOf(u8, v, "else") != null) { + node.debug = false; } } - // Consume dedent - if (self.check(.dedent)) { - _ = self.advance(); + if (no_block) return node; + + // Handle block + if (self.peek().type == .indent) { + if (tok.isBuffered()) { + self.setError(.BLOCK_IN_BUFFERED_CODE, "Buffered code cannot have a block attached", self.peek()); + return error.BlockInBufferedCode; + } + const block = try self.block_(); + for (block.nodes.items) |n| { + try node.addNode(self.allocator, n); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); } + + return node; } - /// Parses a raw text block (after `.`). - fn parseRawTextBlock(self: *Parser) Error![]const u8 { - var lines = std.ArrayList(u8).empty; - errdefer lines.deinit(self.allocator); + fn parseConditional(self: *Parser) !*Node { + const tok = try self.expect(.@"if"); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Conditional, + .test_expr = tok.val.getString(), + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + node.consequent = try self.emptyBlock(tok.loc.start.line); - 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'); + // Handle block + if (self.peek().type == .indent) { + const block = try self.block_(); + // Replace empty consequent with actual block + self.allocator.destroy(node.consequent.?); + node.consequent = block; + } + + var current_node = node; + while (true) { + if (self.peek().type == .newline) { + _ = try self.expect(.newline); + } else if (self.peek().type == .else_if) { + const else_if_tok = try self.expect(.else_if); + const else_if_node = try self.allocator.create(Node); + else_if_node.* = .{ + .type = .Conditional, + .test_expr = else_if_tok.val.getString(), + .line = else_if_tok.loc.start.line, + .column = else_if_tok.loc.start.column, + .filename = self.filename, + }; + else_if_node.consequent = try self.emptyBlock(else_if_tok.loc.start.line); + current_node.alternate = else_if_node; + current_node = else_if_node; + + if (self.peek().type == .indent) { + const block = try self.block_(); + self.allocator.destroy(current_node.consequent.?); + current_node.consequent = block; } - line_count += 1; - const text = self.advance().value; - try lines.appendSlice(self.allocator, text); - } else if (self.check(.newline)) { - _ = self.advance(); + } else if (self.peek().type == .@"else") { + _ = try self.expect(.@"else"); + if (self.peek().type == .indent) { + current_node.alternate = try self.block_(); + } + break; } else { break; } } - // 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(); - } - - return lines.toOwnedSlice(self.allocator); + return node; } - /// Parses doctype declaration. - fn parseDoctype(self: *Parser) Error!Node { - _ = self.advance(); // skip 'doctype' + fn parseWhile(self: *Parser) !*Node { + const tok = try self.expect(.@"while"); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .While, + .test_expr = tok.val.getString(), + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; - // Get the doctype value (rest of line), defaults to "html" if empty - var value: []const u8 = "html"; - if (self.check(.text)) { - value = self.advance().value; - } - - return .{ .doctype = .{ .value = value } }; - } - - /// Parses conditional (if/else if/else/unless). - fn parseConditional(self: *Parser) Error!Node { - var branches = std.ArrayList(ast.Conditional.Branch).empty; - errdefer branches.deinit(self.allocator); - - // Parse initial if/unless - const is_unless = self.check(.kw_unless); - _ = self.advance(); // skip if/unless - - // Parse condition (rest of line as text) - const condition = try self.parseRestOfLine(); - - self.skipNewlines(); - - // Parse body - var body = std.ArrayList(Node).empty; - errdefer body.deinit(self.allocator); - - if (self.check(.indent)) { - _ = self.advance(); - try self.parseChildren(&body); - } - - try branches.append(self.allocator, .{ - .condition = condition, - .is_unless = is_unless, - .children = try body.toOwnedSlice(self.allocator), - }); - - // Parse else if / else branches - while (self.check(.kw_else)) { - _ = self.advance(); // skip else - - var else_condition: ?[]const u8 = null; - const else_is_unless = false; - - // Check for "else if" - if (self.check(.kw_if)) { - _ = self.advance(); - else_condition = try self.parseRestOfLine(); + // Handle block + if (self.peek().type == .indent) { + const block = try self.block_(); + for (block.nodes.items) |n| { + try node.addNode(self.allocator, n); } - - self.skipNewlines(); - - var else_body = std.ArrayList(Node).empty; - errdefer else_body.deinit(self.allocator); - - if (self.check(.indent)) { - _ = self.advance(); - try self.parseChildren(&else_body); - } - - try branches.append(self.allocator, .{ - .condition = else_condition, - .is_unless = else_is_unless, - .children = try else_body.toOwnedSlice(self.allocator), - }); - - // Plain else (no condition) is the last branch - if (else_condition == null) break; + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); } - return .{ .conditional = .{ - .branches = try branches.toOwnedSlice(self.allocator), - } }; + return node; } - /// Parses each loop. - fn parseEach(self: *Parser) Error!Node { - _ = self.advance(); // skip 'each' or 'for' + fn parseBlockCode(self: *Parser) !*Node { + const tok = try self.expect(.blockcode); + const line = tok.loc.start.line; + const column = tok.loc.start.column; - // Parse: each value[, index] in collection - var value_name: []const u8 = ""; - var index_name: ?[]const u8 = null; - var collection: []const u8 = ""; + var text = std.ArrayListUnmanaged(u8){}; + defer text.deinit(self.allocator); - // The lexer captures "item in items" or "item, idx in items" as a single text token - if (self.check(.text)) { - const text = self.advance().value; - - // Parse: value[, index] in collection - // Find "in " to split the text - if (std.mem.indexOf(u8, text, " in ")) |in_pos| { - const before_in = std.mem.trim(u8, text[0..in_pos], " \t"); - collection = std.mem.trim(u8, text[in_pos + 4 ..], " \t"); - - // Check for comma (index variable) - if (std.mem.indexOf(u8, before_in, ",")) |comma_pos| { - value_name = std.mem.trim(u8, before_in[0..comma_pos], " \t"); - index_name = std.mem.trim(u8, before_in[comma_pos + 1 ..], " \t"); - } else { - value_name = before_in; + if (self.peek().type == .start_pipeless_text) { + _ = self.advance(); + while (self.peek().type != .end_pipeless_text) { + const inner_tok = self.advance(); + switch (inner_tok.type) { + .text => { + if (inner_tok.val.getString()) |s| { + try text.appendSlice(self.allocator, s); + } + }, + .newline => { + try text.append(self.allocator, '\n'); + }, + else => { + self.setError(.INVALID_TOKEN, "Unexpected token in block code", inner_tok); + return error.InvalidToken; + }, } + } + _ = self.advance(); + } + + const node = try self.allocator.create(Node); + // Need to dupe the text to persist it + const text_slice = try self.allocator.dupe(u8, text.items); + node.* = .{ + .type = .Code, + .val = text_slice, + .val_owned = true, // We allocated this string + .buffer = false, + .must_escape = false, + .is_inline_code = false, + .line = line, + .column = column, + .filename = self.filename, + }; + return node; + } + + // ======================================================================== + // Comment Parsing + // ======================================================================== + + fn parseComment(self: *Parser) !*Node { + const tok = try self.expect(.comment); + + if (self.parseTextBlock()) |block| { + const node = try self.allocator.create(Node); + node.* = .{ + .type = .BlockComment, + .val = tok.val.getString(), + .buffer = tok.isBuffered(), + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + // Move block nodes to comment + for (block.nodes.items) |n| { + try node.addNode(self.allocator, n); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); + return node; + } else { + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Comment, + .val = tok.val.getString(), + .buffer = tok.isBuffered(), + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + return node; + } + } + + // ======================================================================== + // Doctype Parsing + // ======================================================================== + + fn parseDoctype(self: *Parser) !*Node { + const tok = try self.expect(.doctype); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Doctype, + .val = tok.val.getString(), + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + return node; + } + + // ======================================================================== + // Filter Parsing + // ======================================================================== + + fn parseIncludeFilter(self: *Parser) !*Node { + const tok = try self.expect(.filter); + var filter_attrs = std.ArrayListUnmanaged(Attribute){}; + + if (self.peek().type == .start_attributes) { + filter_attrs = try self.attrs(null); + } + + const node = try self.allocator.create(Node); + node.* = .{ + .type = .IncludeFilter, + .name = tok.val.getString(), + .attrs = filter_attrs, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + return node; + } + + fn parseFilter(self: *Parser) !*Node { + const tok = try self.expect(.filter); + var filter_attrs = std.ArrayListUnmanaged(Attribute){}; + + if (self.peek().type == .start_attributes) { + filter_attrs = try self.attrs(null); + } + + var block: *Node = undefined; + if (self.peek().type == .text) { + const text_token = self.advance(); + block = try self.initBlock(text_token.loc.start.line); + const text_node = try self.allocator.create(Node); + text_node.* = .{ + .type = .Text, + .val = text_token.val.getString(), + .line = text_token.loc.start.line, + .column = text_token.loc.start.column, + .filename = self.filename, + }; + try block.addNode(self.allocator, text_node); + } else if (self.peek().type == .filter) { + block = try self.initBlock(tok.loc.start.line); + const nested_filter = try self.parseFilter(); + try block.addNode(self.allocator, nested_filter); + } else { + block = self.parseTextBlock() orelse try self.emptyBlock(tok.loc.start.line); + } + + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Filter, + .name = tok.val.getString(), + .attrs = filter_attrs, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + for (block.nodes.items) |n| { + try node.addNode(self.allocator, n); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); + return node; + } + + // ======================================================================== + // Each Parsing + // ======================================================================== + + fn parseEach(self: *Parser) !*Node { + const tok = try self.expect(.each); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Each, + .obj = tok.code.getString(), + .val = tok.val.getString(), + .key = tok.key.getString(), + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + + const block = try self.block_(); + for (block.nodes.items) |n| { + try node.addNode(self.allocator, n); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); + + if (self.peek().type == .@"else") { + _ = self.advance(); + node.alternate = try self.block_(); + } + + return node; + } + + fn parseEachOf(self: *Parser) !*Node { + const tok = try self.expect(.each_of); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .EachOf, + .obj = tok.code.getString(), + .val = tok.val.getString(), + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + + const block = try self.block_(); + for (block.nodes.items) |n| { + try node.addNode(self.allocator, n); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); + + return node; + } + + // ======================================================================== + // Extends Parsing + // ======================================================================== + + fn parseExtends(self: *Parser) !*Node { + const tok = try self.expect(.extends); + const path_tok = try self.expect(.path); + + const path_val = if (path_tok.val.getString()) |s| mem.trim(u8, s, " \t") else null; + + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Extends, + .file = .{ + .path = path_val, + .line = path_tok.loc.start.line, + .column = path_tok.loc.start.column, + .filename = self.filename, + }, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + return node; + } + + // ======================================================================== + // Block Parsing + // ======================================================================== + + fn parseBlock(self: *Parser) !*Node { + const tok = try self.expect(.block); + + var node: *Node = undefined; + if (self.peek().type == .indent) { + node = try self.block_(); + } else { + node = try self.emptyBlock(tok.loc.start.line); + } + + node.type = .NamedBlock; + node.name = if (tok.val.getString()) |s| mem.trim(u8, s, " \t") else null; + node.mode = tok.mode.getString(); + node.line = tok.loc.start.line; + node.column = tok.loc.start.column; + + return node; + } + + fn parseMixinBlock(self: *Parser) !*Node { + const tok = try self.expect(.mixin_block); + if (self.in_mixin == 0) { + self.setError(.BLOCK_OUTISDE_MIXIN, "Anonymous blocks are not allowed unless they are part of a mixin.", tok); + return error.BlockOutsideMixin; + } + const node = try self.allocator.create(Node); + node.* = .{ + .type = .MixinBlock, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + return node; + } + + fn parseYield(self: *Parser) !*Node { + const tok = try self.expect(.yield); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .YieldBlock, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + return node; + } + + // ======================================================================== + // Include Parsing + // ======================================================================== + + fn parseInclude(self: *Parser) !*Node { + const tok = try self.expect(.include); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Include, + .file = .{ + .path = null, + .line = 0, + .column = 0, + .filename = self.filename, + }, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + + // Parse filters + while (self.peek().type == .filter) { + const filter_node = try self.parseIncludeFilter(); + try node.filters.append(self.allocator, filter_node); + } + + const path_tok = try self.expect(.path); + const path_val = if (path_tok.val.getString()) |s| mem.trim(u8, s, " \t") else null; + + node.file = .{ + .path = path_val, + .line = path_tok.loc.start.line, + .column = path_tok.loc.start.column, + .filename = self.filename, + }; + + const has_filters = node.filters.items.len > 0; + const is_pug_file = if (path_val) |p| (mem.endsWith(u8, p, ".jade") or mem.endsWith(u8, p, ".pug")) else false; + + if (is_pug_file and !has_filters) { + // Pug include with block + if (self.peek().type == .indent) { + const block = try self.block_(); + for (block.nodes.items) |n| { + try node.addNode(self.allocator, n); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); + } + } else { + // Raw include + node.type = .RawInclude; + if (self.peek().type == .indent) { + self.setError(.RAW_INCLUDE_BLOCK, "Raw inclusion cannot contain a block", self.peek()); + return error.RawIncludeBlock; + } + } + + return node; + } + + // ======================================================================== + // Mixin/Call Parsing + // ======================================================================== + + fn parseCall(self: *Parser) !*Node { + const tok = try self.expect(.call); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Mixin, + .name = tok.val.getString(), + .args = tok.args.getString(), + .call = true, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + + try self.tag_(node, true); + + // If code was added, move it to block + // (simplified - the JS version has special handling for mixin.code) + + // If block is empty, set to null (matching JS behavior) + if (node.nodes.items.len == 0) { + // Keep empty block as is - JS sets block to null but we don't have optional block + } + + return node; + } + + fn parseMixin(self: *Parser) !*Node { + const tok = try self.expect(.mixin); + + if (self.peek().type == .indent) { + self.in_mixin += 1; + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Mixin, + .name = tok.val.getString(), + .args = tok.args.getString(), + .call = false, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + const block = try self.block_(); + for (block.nodes.items) |n| { + try node.addNode(self.allocator, n); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); + self.in_mixin -= 1; + return node; + } else { + self.setError(.MIXIN_WITHOUT_BODY, "Mixin declared without body", tok); + return error.MixinWithoutBody; + } + } + + // ======================================================================== + // Block (indent/outdent) + // ======================================================================== + + fn block_(self: *Parser) anyerror!*Node { + const tok = try self.expect(.indent); + var block = try self.emptyBlock(tok.loc.start.line); + + while (self.peek().type != .outdent) { + if (self.peek().type == .newline) { + _ = self.advance(); + } else if (self.peek().type == .text_html) { + var html_nodes = try self.parseTextHtml(); + for (html_nodes.items) |node| { + try block.addNode(self.allocator, node); + } + html_nodes.deinit(self.allocator); } else { - self.setDiagnostic( - "Missing collection in 'each' loop - expected 'in' keyword", - "Use syntax: each item in collection", - ); - return ParserError.MissingCollection; - } - } else if (self.check(.tag)) { - // Fallback: lexer produced individual tokens - value_name = self.advance().value; - - // Check for index: each val, idx in ... - if (self.check(.comma)) { - _ = self.advance(); - if (self.check(.tag)) { - index_name = self.advance().value; + const expr = try self.parseExpr(); + if (expr.type == .Block) { + for (expr.nodes.items) |node| { + try block.addNode(self.allocator, node); + } + expr.nodes.clearAndFree(self.allocator); + self.allocator.destroy(expr); + } else { + try block.addNode(self.allocator, expr); } } - - // Expect 'in' - if (self.check(.kw_in)) { - _ = self.advance(); - } - - // Parse collection expression - collection = try self.parseRestOfLine(); - } else { - self.setDiagnostic( - "Missing iterator variable in 'each' loop", - "Use syntax: each item in collection", - ); - return ParserError.MissingIterator; } - - self.skipNewlines(); - - // Parse body - var body = std.ArrayList(Node).empty; - errdefer body.deinit(self.allocator); - - if (self.check(.indent)) { - _ = self.advance(); - try self.parseChildren(&body); - } - - // Check for else branch - var else_children = std.ArrayList(Node).empty; - errdefer else_children.deinit(self.allocator); - - if (self.check(.kw_else)) { - _ = self.advance(); - self.skipNewlines(); - - if (self.check(.indent)) { - _ = self.advance(); - try self.parseChildren(&else_children); - } - } - - return .{ .each = .{ - .value_name = value_name, - .index_name = index_name, - .collection = collection, - .children = try body.toOwnedSlice(self.allocator), - .else_children = try else_children.toOwnedSlice(self.allocator), - } }; + _ = try self.expect(.outdent); + return block; } - /// Parses while loop. - fn parseWhile(self: *Parser) Error!Node { - _ = self.advance(); // skip 'while' + // ======================================================================== + // Interpolation/Tag Parsing + // ======================================================================== - const condition = try self.parseRestOfLine(); - - self.skipNewlines(); - - var body = std.ArrayList(Node).empty; - errdefer body.deinit(self.allocator); - - if (self.check(.indent)) { - _ = self.advance(); - try self.parseChildren(&body); - } - - return .{ .@"while" = .{ - .condition = condition, - .children = try body.toOwnedSlice(self.allocator), - } }; + fn parseInterpolation(self: *Parser) !*Node { + const tok = self.advance(); + const node = try self.allocator.create(Node); + node.* = .{ + .type = .InterpolatedTag, + .expr = tok.val.getString(), + .self_closing = false, + .is_inline = false, + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + try self.tag_(node, true); + return node; } - /// Parses case/switch statement. - fn parseCase(self: *Parser) Error!Node { - _ = self.advance(); // skip 'case' + fn parseTag(self: *Parser) !*Node { + const tok = self.advance(); + const tag_name = tok.val.getString() orelse "div"; + const node = try self.allocator.create(Node); + node.* = .{ + .type = .Tag, + .name = tag_name, + .self_closing = false, + .is_inline = isInlineTag(tag_name), + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }; + try self.tag_(node, true); + return node; + } - const expression = try self.parseRestOfLine(); + fn tag_(self: *Parser, tag: *Node, self_closing_allowed: bool) !void { + var seen_attrs = false; + var attribute_names = std.ArrayListUnmanaged([]const u8){}; + defer attribute_names.deinit(self.allocator); - self.skipNewlines(); - - var whens = std.ArrayList(ast.Case.When).empty; - errdefer whens.deinit(self.allocator); - - var default_children = std.ArrayList(Node).empty; - errdefer default_children.deinit(self.allocator); - - // Parse indented when/default clauses - if (self.check(.indent)) { - _ = self.advance(); - - while (!self.check(.dedent) and !self.isAtEnd()) { - self.skipNewlines(); - - if (self.check(.kw_when)) { - _ = self.advance(); // skip 'when' - - // Parse the value (rest of line or until colon for block expansion) - var value: []const u8 = ""; - if (self.check(.tag) or self.check(.text)) { - value = self.advance().value; - } else { - value = try self.parseRestOfLine(); - } - - var when_children = std.ArrayList(Node).empty; - errdefer when_children.deinit(self.allocator); - var has_break = false; - - // Check for block expansion (: element) - if (self.check(.colon)) { - _ = self.advance(); - self.skipWhitespace(); - if (try self.parseNode()) |child| { - try when_children.append(self.allocator, child); - } - } else { - self.skipNewlines(); - - // Parse indented children - if (self.check(.indent)) { - _ = self.advance(); - - // Check for explicit break (- break) - if (self.check(.buffered_text)) { - const next_tok = self.peek(); - if (next_tok.type == .text and std.mem.eql(u8, std.mem.trim(u8, next_tok.value, " \t"), "break")) { - _ = self.advance(); // skip = - _ = self.advance(); // skip break - has_break = true; - } - } - - if (!has_break) { - try self.parseChildren(&when_children); - } else { - // Skip remaining children after break - while (!self.check(.dedent) and !self.isAtEnd()) { - _ = self.advance(); - } - } - - if (self.check(.dedent)) { - _ = self.advance(); + // (attrs | class | id)* + outer: while (true) { + switch (self.peek().type) { + .id, .class => { + const tok = self.advance(); + if (tok.type == .id) { + // Check for duplicate id + for (attribute_names.items) |name| { + if (mem.eql(u8, name, "id")) { + self.setError(.DUPLICATE_ID, "Duplicate attribute \"id\" is not allowed.", tok); + return error.DuplicateId; } } - // Empty body = fall-through (children stays empty) + try attribute_names.append(self.allocator, "id"); } + // Create quoted value + const val_str = tok.val.getString() orelse ""; + var quoted_val = std.ArrayListUnmanaged(u8){}; + defer quoted_val.deinit(self.allocator); + try quoted_val.append(self.allocator, '\''); + try quoted_val.appendSlice(self.allocator, val_str); + try quoted_val.append(self.allocator, '\''); + const final_val = try self.allocator.dupe(u8, quoted_val.items); - try whens.append(self.allocator, .{ - .value = value, - .children = try when_children.toOwnedSlice(self.allocator), - .has_break = has_break, + 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 }); - } else if (self.check(.kw_default)) { - _ = self.advance(); // skip 'default' + }, + .start_attributes => { + if (seen_attrs) { + // Warning: multiple attributes - but continue + } + seen_attrs = true; + var new_attrs = try self.attrs(&attribute_names); + for (new_attrs.items) |attr| { + try tag.attrs.append(self.allocator, attr); + } + new_attrs.deinit(self.allocator); + }, + .@"&attributes" => { + const tok = self.advance(); + try tag.attribute_blocks.append(self.allocator, .{ + .val = tok.val.getString() orelse "", + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + }); + }, + else => break :outer, + } + } - // Check for block expansion (: element) - if (self.check(.colon)) { - _ = self.advance(); - self.skipWhitespace(); - if (try self.parseNode()) |child| { - try default_children.append(self.allocator, child); - } - } else { - self.skipNewlines(); + // Check for textOnly (.) + if (self.peek().type == .dot) { + tag.text_only = true; + _ = self.advance(); + } - if (self.check(.indent)) { - _ = self.advance(); - try self.parseChildren(&default_children); - if (self.check(.dedent)) { - _ = self.advance(); - } + // (text | code | ':')? + switch (self.peek().type) { + .text, .interpolated_code => { + const text = try self.parseText(false); + if (text.type == .Block) { + for (text.nodes.items) |node| { + try tag.addNode(self.allocator, node); + } + text.nodes.deinit(self.allocator); + self.allocator.destroy(text); + } else { + try tag.addNode(self.allocator, text); + } + }, + .code => { + const code_node = try self.parseCode(true); + try tag.addNode(self.allocator, code_node); + }, + .colon => { + _ = self.advance(); + const expr = try self.parseExpr(); + if (expr.type == .Block) { + for (expr.nodes.items) |node| { + try tag.addNode(self.allocator, node); + } + expr.nodes.deinit(self.allocator); + self.allocator.destroy(expr); + } else { + try tag.addNode(self.allocator, expr); + } + }, + .newline, .indent, .outdent, .eos, .start_pipeless_text, .end_pug_interpolation => {}, + .slash => { + if (self_closing_allowed) { + _ = self.advance(); + tag.self_closing = true; + } else { + self.setError(.INVALID_TOKEN, "Unexpected token", self.peek()); + return error.InvalidToken; + } + }, + else => { + // Accept other tokens without error for now + }, + } + + // newline* + while (self.peek().type == .newline) { + _ = self.advance(); + } + + // block? + if (tag.text_only) { + if (self.parseTextBlock()) |block| { + for (block.nodes.items) |node| { + try tag.addNode(self.allocator, node); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); + } + } else if (self.peek().type == .indent) { + const block = try self.block_(); + for (block.nodes.items) |node| { + try tag.addNode(self.allocator, node); + } + block.nodes.deinit(self.allocator); + self.allocator.destroy(block); + } + } + + fn attrs(self: *Parser, attribute_names: ?*std.ArrayListUnmanaged([]const u8)) !std.ArrayListUnmanaged(Attribute) { + _ = try self.expect(.start_attributes); + + var result = std.ArrayListUnmanaged(Attribute){}; + var tok = self.advance(); + + while (tok.type == .attribute) { + const attr_name = tok.name.getString() orelse ""; + + // Check for duplicates (except class) + if (!mem.eql(u8, attr_name, "class")) { + if (attribute_names) |names| { + for (names.items) |name| { + if (mem.eql(u8, name, attr_name)) { + self.setError(.DUPLICATE_ATTRIBUTE, "Duplicate attribute is not allowed.", tok); + return error.DuplicateAttribute; } } - } else if (self.check(.dedent)) { - break; - } else { - // Skip unknown tokens - _ = self.advance(); + try names.append(self.allocator, attr_name); } } - if (self.check(.dedent)) { - _ = self.advance(); - } + try result.append(self.allocator, .{ + .name = attr_name, + .val = tok.val.getString(), + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = self.filename, + .must_escape = tok.shouldEscape(), + }); + tok = self.advance(); } - return .{ .case = .{ - .expression = expression, - .whens = try whens.toOwnedSlice(self.allocator), - .default_children = try default_children.toOwnedSlice(self.allocator), - } }; - } + try self.defer_token(tok); + _ = try self.expect(.end_attributes); - /// Parses mixin definition. - fn parseMixinDef(self: *Parser) Error!Node { - _ = self.advance(); // skip 'mixin' - - // Parse mixin name - var name: []const u8 = ""; - if (self.check(.tag)) { - name = self.advance().value; - } else { - self.setDiagnostic( - "Missing mixin name after 'mixin' keyword", - "Use syntax: mixin name(params)", - ); - return ParserError.MissingMixinName; - } - - // Parse parameters if present - var params = std.ArrayList([]const u8).empty; - var defaults = std.ArrayList(?[]const u8).empty; - errdefer params.deinit(self.allocator); - errdefer defaults.deinit(self.allocator); - - var has_rest = false; - - if (self.check(.lparen)) { - _ = self.advance(); - - while (!self.check(.rparen) and !self.isAtEnd()) { - if (self.check(.comma)) { - _ = self.advance(); - continue; - } - - if (self.check(.attr_name) or self.check(.tag)) { - const param_name = self.advance().value; - - // Check for rest parameter - if (std.mem.startsWith(u8, param_name, "...")) { - try params.append(self.allocator, param_name[3..]); - try defaults.append(self.allocator, null); - has_rest = true; - } else { - try params.append(self.allocator, param_name); - - // Check for default value - if (self.check(.attr_eq)) { - _ = self.advance(); - if (self.check(.attr_value)) { - try defaults.append(self.allocator, self.advance().value); - } else { - try defaults.append(self.allocator, null); - } - } else { - try defaults.append(self.allocator, null); - } - } - } else { - break; - } - } - - if (self.check(.rparen)) { - _ = self.advance(); - } - } - - self.skipNewlines(); - - // Parse body - var body = std.ArrayList(Node).empty; - errdefer body.deinit(self.allocator); - - if (self.check(.indent)) { - _ = self.advance(); - try self.parseChildren(&body); - } - - return .{ .mixin_def = .{ - .name = name, - .params = try params.toOwnedSlice(self.allocator), - .defaults = try defaults.toOwnedSlice(self.allocator), - .has_rest = has_rest, - .children = try body.toOwnedSlice(self.allocator), - } }; - } - - /// Parses mixin call. - fn parseMixinCall(self: *Parser) Error!Node { - const name = self.advance().value; // +name - - var args = std.ArrayList([]const u8).empty; - var attributes = std.ArrayList(Attribute).empty; - errdefer args.deinit(self.allocator); - errdefer attributes.deinit(self.allocator); - - // Parse arguments - if (self.check(.lparen)) { - _ = self.advance(); - - while (!self.check(.rparen) and !self.isAtEnd()) { - if (self.check(.comma)) { - _ = self.advance(); - continue; - } - - if (self.check(.attr_value)) { - try args.append(self.allocator, self.advance().value); - } else if (self.check(.attr_name)) { - // Could be named arg or regular arg - const val = self.advance().value; - try args.append(self.allocator, val); - } else { - break; - } - } - - if (self.check(.rparen)) { - _ = self.advance(); - } - } - - // Parse attributes passed to mixin - if (self.check(.lparen)) { - _ = self.advance(); - try self.parseAttributes(&attributes); - if (self.check(.rparen)) { - _ = self.advance(); - } - } - - self.skipNewlines(); - - // Parse block content - var block_children = std.ArrayList(Node).empty; - errdefer block_children.deinit(self.allocator); - - if (self.check(.indent)) { - _ = self.advance(); - try self.parseChildren(&block_children); - } - - return .{ .mixin_call = .{ - .name = name, - .args = try args.toOwnedSlice(self.allocator), - .attributes = try attributes.toOwnedSlice(self.allocator), - .block_children = try block_children.toOwnedSlice(self.allocator), - } }; - } - - /// Parses include directive. - fn parseInclude(self: *Parser) Error!Node { - _ = self.advance(); // skip 'include' - - var filter: ?[]const u8 = null; - - // Check for filter :markdown - if (self.check(.colon)) { - _ = self.advance(); - if (self.check(.tag)) { - filter = self.advance().value; - } - } - - // Parse path - const path = try self.parseRestOfLine(); - - return .{ .include = .{ - .path = path, - .filter = filter, - } }; - } - - /// Parses extends directive. - fn parseExtends(self: *Parser) Error![]const u8 { - _ = self.advance(); // skip 'extends' - return try self.parseRestOfLine(); - } - - /// Parses block directive. - fn parseBlock(self: *Parser) Error!Node { - _ = self.advance(); // skip 'block' - - var mode: ast.Block.Mode = .replace; - - // Check for append/prepend (may be tokenized as tag or keyword) - if (self.check(.tag)) { - const modifier = self.peek().value; - if (std.mem.eql(u8, modifier, "append")) { - mode = .append; - _ = self.advance(); - } else if (std.mem.eql(u8, modifier, "prepend")) { - mode = .prepend; - _ = self.advance(); - } - } else if (self.check(.kw_append)) { - mode = .append; - _ = self.advance(); - } else if (self.check(.kw_prepend)) { - mode = .prepend; - _ = self.advance(); - } - - // Parse block name - if no name follows, this is a mixin block placeholder - var name: []const u8 = ""; - if (self.check(.tag)) { - name = self.advance().value; - } else if (self.check(.text)) { - name = std.mem.trim(u8, self.advance().value, " \t"); - } else if (self.check(.newline) or self.check(.eof) or self.check(.indent) or self.check(.dedent)) { - // No name - this is a mixin block placeholder - return .{ .mixin_block = {} }; - } else { - self.setDiagnostic( - "Missing block name after 'block' keyword", - "Use syntax: block name", - ); - return ParserError.MissingBlockName; - } - - self.skipNewlines(); - - // Parse body - var body = std.ArrayList(Node).empty; - errdefer body.deinit(self.allocator); - - if (self.check(.indent)) { - _ = self.advance(); - try self.parseChildren(&body); - } - - return .{ .block = .{ - .name = name, - .mode = mode, - .children = try body.toOwnedSlice(self.allocator), - } }; - } - - /// Parses shorthand block syntax: `append name` or `prepend name` - fn parseBlockShorthand(self: *Parser, mode: ast.Block.Mode) Error!Node { - _ = self.advance(); // skip 'append' or 'prepend' - - // Parse block name - var name: []const u8 = ""; - if (self.check(.tag)) { - name = self.advance().value; - } else if (self.check(.text)) { - name = std.mem.trim(u8, self.advance().value, " \t"); - } else { - self.setDiagnostic( - "Missing block name after 'append' or 'prepend'", - "Use syntax: append blockname or prepend blockname", - ); - return ParserError.MissingBlockName; - } - - self.skipNewlines(); - - // Parse body - var body = std.ArrayList(Node).empty; - errdefer body.deinit(self.allocator); - - if (self.check(.indent)) { - _ = self.advance(); - try self.parseChildren(&body); - } - - return .{ .block = .{ - .name = name, - .mode = mode, - .children = try body.toOwnedSlice(self.allocator), - } }; - } - - /// Parses pipe text. - fn parsePipeText(self: *Parser) Error!Node { - _ = self.advance(); // skip | - - const segments = try self.parseTextSegments(); - - return .{ .text = .{ .segments = segments } }; - } - - /// Parses literal HTML (lines starting with <). - fn parseLiteralHtml(self: *Parser) Error!Node { - const html = self.advance().value; - return .{ .raw_text = .{ .content = html } }; - } - - /// Parses comment. - fn parseComment(self: *Parser) Error!Node { - const rendered = self.check(.comment); - const content = self.advance().value; // Preserve content exactly as captured (including leading space) - - self.skipNewlines(); - - // 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(); - // 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 = .{ - .content = content, - .rendered = rendered, - .children = try children.toOwnedSlice(self.allocator), - } }; - } - - /// 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 != - - const expression = try self.parseRestOfLine(); - - return .{ .code = .{ - .expression = expression, - .escaped = escaped, - } }; - } - - /// Parses plain text node. - fn parseText(self: *Parser) Error!Node { - const segments = try self.parseTextSegments(); - return .{ .text = .{ .segments = segments } }; - } - - /// Parses rest of line as text. - fn parseRestOfLine(self: *Parser) Error![]const u8 { - var result = std.ArrayList(u8).empty; - errdefer result.deinit(self.allocator); - - while (!self.check(.newline) and !self.check(.indent) and !self.check(.dedent) and !self.isAtEnd()) { - const token = self.advance(); - if (token.value.len > 0) { - if (result.items.len > 0) { - try result.append(self.allocator, ' '); - } - try result.appendSlice(self.allocator, token.value); - } - } - - return result.toOwnedSlice(self.allocator); - } - - // ───────────────────────────────────────────────────────────────────────── - // Helper functions - // ───────────────────────────────────────────────────────────────────────── - - /// Returns true if at end of tokens. - fn isAtEnd(self: *const Parser) bool { - return self.pos >= self.tokens.len or self.peek().type == .eof; - } - - /// Returns current token without advancing. - fn peek(self: *const Parser) Token { - if (self.pos >= self.tokens.len) { - return .{ .type = .eof, .value = "", .line = 0, .column = 0 }; - } - return self.tokens[self.pos]; - } - - /// Returns true if current token matches the given type. - fn check(self: *const Parser, token_type: TokenType) bool { - if (self.isAtEnd()) return false; - return self.peek().type == token_type; - } - - /// Returns true if current token matches the given type and value. - fn checkValue(self: *const Parser, token_type: TokenType, value: []const u8) bool { - if (self.isAtEnd()) return false; - const token = self.peek(); - return token.type == token_type and std.mem.eql(u8, token.value, value); - } - - /// Advances and returns current token. - fn advance(self: *Parser) Token { - if (!self.isAtEnd()) { - const token = self.tokens[self.pos]; - self.pos += 1; - return token; - } - return .{ .type = .eof, .value = "", .line = 0, .column = 0 }; - } - - /// Skips newline tokens. - fn skipNewlines(self: *Parser) void { - while (self.check(.newline)) { - _ = self.advance(); - } - } - - /// Skips whitespace (spaces in tokens). - fn skipWhitespace(self: *Parser) void { - // Whitespace is mostly handled by lexer, but skip any stray newlines - self.skipNewlines(); + return result; } }; -// ───────────────────────────────────────────────────────────────────────────── +// ============================================================================ // Tests -// ───────────────────────────────────────────────────────────────────────────── +// ============================================================================ -test "parse simple element" { +test "parser basic" { const allocator = std.testing.allocator; - var lex = lexer.Lexer.init(allocator, "div"); - defer lex.deinit(); - const tokens = try lex.tokenize(); + // Simulate tokens for: html\n body\n h1 Title + var tokens = [_]Token{ + .{ .type = .tag, .val = .{ .string = "html" }, .loc = .{ .start = .{ .line = 1, .column = 1 } } }, + .{ .type = .indent, .val = .{ .string = "2" }, .loc = .{ .start = .{ .line = 2, .column = 1 } } }, + .{ .type = .tag, .val = .{ .string = "body" }, .loc = .{ .start = .{ .line = 2, .column = 3 } } }, + .{ .type = .indent, .val = .{ .string = "4" }, .loc = .{ .start = .{ .line = 3, .column = 1 } } }, + .{ .type = .tag, .val = .{ .string = "h1" }, .loc = .{ .start = .{ .line = 3, .column = 5 } } }, + .{ .type = .text, .val = .{ .string = "Title" }, .loc = .{ .start = .{ .line = 3, .column = 8 } } }, + .{ .type = .outdent, .loc = .{ .start = .{ .line = 3, .column = 13 } } }, + .{ .type = .outdent, .loc = .{ .start = .{ .line = 3, .column = 13 } } }, + .{ .type = .eos, .loc = .{ .start = .{ .line = 3, .column = 13 } } }, + }; - var parser = Parser.init(allocator, tokens); - const doc = try parser.parse(); + var parser = Parser.init(allocator, &tokens, "test.pug", null); + defer parser.deinit(); - try std.testing.expectEqual(@as(usize, 1), doc.nodes.len); - try std.testing.expectEqualStrings("div", doc.nodes[0].element.tag); + const ast = try parser.parse(); + defer { + ast.deinit(allocator); + allocator.destroy(ast); + } - // Clean up - allocator.free(doc.nodes[0].element.classes); - allocator.free(doc.nodes[0].element.attributes); - allocator.free(doc.nodes[0].element.children); - allocator.free(doc.nodes); + try std.testing.expectEqual(NodeType.Block, ast.type); + try std.testing.expectEqual(@as(usize, 1), ast.nodes.items.len); + + const html_tag = ast.nodes.items[0]; + try std.testing.expectEqual(NodeType.Tag, html_tag.type); + try std.testing.expectEqualStrings("html", html_tag.name.?); } -test "parse element with class and id" { +test "parser doctype" { const allocator = std.testing.allocator; - var lex = lexer.Lexer.init(allocator, "div#main.container.active"); - defer lex.deinit(); - const tokens = try lex.tokenize(); + var tokens = [_]Token{ + .{ .type = .doctype, .val = .{ .string = "html" }, .loc = .{ .start = .{ .line = 1, .column = 1 } } }, + .{ .type = .eos, .loc = .{ .start = .{ .line = 1, .column = 13 } } }, + }; - var parser = Parser.init(allocator, tokens); - const doc = try parser.parse(); + var parser = Parser.init(allocator, &tokens, "test.pug", null); + defer parser.deinit(); - const elem = doc.nodes[0].element; - try std.testing.expectEqualStrings("div", elem.tag); - try std.testing.expectEqualStrings("main", elem.id.?); - try std.testing.expectEqual(@as(usize, 2), elem.classes.len); - try std.testing.expectEqualStrings("container", elem.classes[0]); - try std.testing.expectEqualStrings("active", elem.classes[1]); + const ast = try parser.parse(); + defer { + ast.deinit(allocator); + allocator.destroy(ast); + } - // Clean up - allocator.free(elem.classes); - allocator.free(elem.attributes); - allocator.free(elem.children); - allocator.free(doc.nodes); -} - -test "parse nested elements" { - const allocator = std.testing.allocator; - - var lex = lexer.Lexer.init(allocator, - \\div - \\ p Hello - ); - defer lex.deinit(); - const tokens = try lex.tokenize(); - - var parser = Parser.init(allocator, tokens); - const doc = try parser.parse(); - - try std.testing.expectEqual(@as(usize, 1), doc.nodes.len); - - const div = doc.nodes[0].element; - try std.testing.expectEqualStrings("div", div.tag); - try std.testing.expectEqual(@as(usize, 1), div.children.len); - - const p = div.children[0].element; - try std.testing.expectEqualStrings("p", p.tag); - - // Clean up nested structures - if (p.inline_text) |text| allocator.free(text); - allocator.free(p.classes); - allocator.free(p.attributes); - allocator.free(p.children); - allocator.free(div.classes); - allocator.free(div.attributes); - allocator.free(div.children); - allocator.free(doc.nodes); + try std.testing.expectEqual(@as(usize, 1), ast.nodes.items.len); + try std.testing.expectEqual(NodeType.Doctype, ast.nodes.items[0].type); + try std.testing.expectEqualStrings("html", ast.nodes.items[0].val.?); } diff --git a/src/playground/benchmark b/src/playground/benchmark new file mode 100755 index 0000000..a4d3400 Binary files /dev/null and b/src/playground/benchmark differ diff --git a/src/playground/benchmark.zig b/src/playground/benchmark.zig new file mode 100644 index 0000000..162bd57 --- /dev/null +++ b/src/playground/benchmark.zig @@ -0,0 +1,66 @@ +// benchmark.zig - Benchmark for pugz (Zig Pug implementation) +// +// This benchmark matches the JavaScript pug benchmark for comparison +// Uses exact same templates as packages/pug/support/benchmark.js + +const std = @import("std"); +const pug = @import("../pug.zig"); + +const MIN_ITERATIONS: usize = 200; +const MIN_TIME_NS: u64 = 200_000_000; // 200ms minimum + +fn benchmark(comptime name: []const u8, template: []const u8, iterations: *usize, elapsed_ns: *u64) !void { + const allocator = std.heap.page_allocator; + + // Warmup + for (0..10) |_| { + var result = try pug.compile(allocator, template, .{}); + result.deinit(allocator); + } + + var timer = try std.time.Timer.start(); + + var count: usize = 0; + while (count < MIN_ITERATIONS or timer.read() < MIN_TIME_NS) { + var result = try pug.compile(allocator, template, .{}); + result.deinit(allocator); + count += 1; + } + + const elapsed = timer.read(); + iterations.* = count; + elapsed_ns.* = elapsed; + + const ops_per_sec = @as(f64, @floatFromInt(count)) * 1_000_000_000.0 / @as(f64, @floatFromInt(elapsed)); + std.debug.print("{s}: {d:.0}\n", .{ name, ops_per_sec }); +} + +pub fn main() !void { + var iterations: usize = 0; + var elapsed_ns: u64 = 0; + + // Tiny template - exact match to JS: 'html\n body\n h1 Title' + const tiny = "html\n body\n h1 Title"; + try benchmark("tiny", tiny, &iterations, &elapsed_ns); + + // Small template - exact match to JS (note trailing \n on each line) + const small = + "html\n" ++ + " body\n" ++ + " h1 Title\n" ++ + " ul#menu\n" ++ + " li: a(href=\"#\") Home\n" ++ + " li: a(href=\"#\") About Us\n" ++ + " li: a(href=\"#\") Store\n" ++ + " li: a(href=\"#\") FAQ\n" ++ + " li: a(href=\"#\") Contact\n"; + try benchmark("small", small, &iterations, &elapsed_ns); + + // Medium template - Array(30).join(str) creates 29 copies in JS + const medium = small ** 29; + try benchmark("medium", medium, &iterations, &elapsed_ns); + + // Large template - Array(100).join(str) creates 99 copies in JS + const large = small ** 99; + try benchmark("large", large, &iterations, &elapsed_ns); +} diff --git a/src/playground/benchmark_examples.zig b/src/playground/benchmark_examples.zig new file mode 100644 index 0000000..21a434b --- /dev/null +++ b/src/playground/benchmark_examples.zig @@ -0,0 +1,274 @@ +// benchmark_examples.zig - Benchmark pug example files +// +// Tests the same example files as the JS benchmark + +const std = @import("std"); +const pug = @import("../pug.zig"); + +const Example = struct { + name: []const u8, + source: []const u8, +}; + +// Example templates (matching JS pug examples that don't use includes/extends) +const examples = [_]Example{ + .{ + .name = "attributes.pug", + .source = + \\div#id.left.container(class='user user-' + name) + \\ h1.title= name + \\ form + \\ //- unbuffered comment :) + \\ // An example of attributes. + \\ input(type='text' name='user[name]' value=name) + \\ input(checked, type='checkbox', name='user[blocked]') + \\ input(type='submit', value='Update') + , + }, + .{ + .name = "code.pug", + .source = + \\- var title = "Things" + \\ + \\- + \\ var subtitle = ["Really", "long", + \\ "list", "of", + \\ "words"] + \\h1= title + \\h2= subtitle.join(" ") + \\ + \\ul#users + \\ each user, name in users + \\ // expands to if (user.isA == 'ferret') + \\ if user.isA == 'ferret' + \\ li(class='user-' + name) #{name} is just a ferret + \\ else + \\ li(class='user-' + name) #{name} #{user.email} + , + }, + .{ + .name = "dynamicscript.pug", + .source = + \\html + \\ head + \\ title Dynamic Inline JavaScript + \\ script. + \\ var users = !{JSON.stringify(users).replace(/<\//g, "<\\/")} + , + }, + .{ + .name = "each.pug", + .source = + \\ul#users + \\ each user, name in users + \\ li(class='user-' + name) #{name} #{user.email} + , + }, + .{ + .name = "extend-layout.pug", + .source = + \\html + \\ head + \\ h1 My Site - #{title} + \\ block scripts + \\ script(src='/jquery.js') + \\ body + \\ block content + \\ block foot + \\ #footer + \\ p some footer content + , + }, + .{ + .name = "form.pug", + .source = + \\form(method="post") + \\ fieldset + \\ legend General + \\ p + \\ label(for="user[name]") Username: + \\ input(type="text", name="user[name]", value=user.name) + \\ p + \\ label(for="user[email]") Email: + \\ input(type="text", name="user[email]", value=user.email) + \\ .tip. + \\ Enter a valid + \\ email address + \\ such as <em>tj@vision-media.ca</em>. + \\ fieldset + \\ legend Location + \\ p + \\ label(for="user[city]") City: + \\ input(type="text", name="user[city]", value=user.city) + \\ p + \\ select(name="user[province]") + \\ option(value="") -- Select Province -- + \\ option(value="AB") Alberta + \\ option(value="BC") British Columbia + \\ option(value="SK") Saskatchewan + \\ option(value="MB") Manitoba + \\ option(value="ON") Ontario + \\ option(value="QC") Quebec + \\ p.buttons + \\ input(type="submit", value="Save") + , + }, + .{ + .name = "layout.pug", + .source = + \\doctype html + \\html(lang="en") + \\ head + \\ title Example + \\ script. + \\ if (foo) { + \\ bar(); + \\ } + \\ body + \\ h1 Pug - node template engine + \\ #container + , + }, + .{ + .name = "pet.pug", + .source = + \\.pet + \\ h2= pet.name + \\ p #{pet.name} is <em>#{pet.age}</em> year(s) old. + , + }, + .{ + .name = "rss.pug", + .source = + \\doctype xml + \\rss(version='2.0') + \\channel + \\ title RSS Title + \\ description Some description here + \\ link http://google.com + \\ lastBuildDate Mon, 06 Sep 2010 00:01:00 +0000 + \\ pubDate Mon, 06 Sep 2009 16:45:00 +0000 + \\ + \\ each item in items + \\ item + \\ title= item.title + \\ description= item.description + \\ link= item.link + , + }, + .{ + .name = "text.pug", + .source = + \\| An example of an + \\a(href='#') inline + \\| link. + \\ + \\form + \\ label Username: + \\ input(type='text', name='user[name]') + \\ p + \\ | Just an example of some text usage. + \\ | You can have <em>inline</em> html, + \\ | as well as + \\ strong tags + \\ | . + \\ + \\ | Interpolation is also supported. The + \\ | username is currently "#{name}". + \\ + \\ label Email: + \\ input(type='text', name='user[email]') + \\ p + \\ | Email is currently + \\ em= email + \\ | . + , + }, + .{ + .name = "whitespace.pug", + .source = + \\- var js = '<script></script>' + \\doctype html + \\html + \\ + \\ head + \\ title= "Some " + "JavaScript" + \\ != js + \\ + \\ + \\ + \\ body + , + }, +}; + +pub fn main() !void { + const allocator = std.heap.page_allocator; + + std.debug.print("=== Zig Pugz Example Benchmark ===\n\n", .{}); + + var passed: usize = 0; + var failed: usize = 0; + var total_time_ns: u64 = 0; + var html_outputs: [examples.len]?[]const u8 = undefined; + for (&html_outputs) |*h| h.* = null; + + for (examples, 0..) |example, idx| { + const iterations: usize = 100; + var success = false; + var time_ns: u64 = 0; + + // Warmup + for (0..5) |_| { + var result = pug.compile(allocator, example.source, .{}) catch continue; + result.deinit(allocator); + } + + // Benchmark + var timer = try std.time.Timer.start(); + var i: usize = 0; + while (i < iterations) : (i += 1) { + var result = pug.compile(allocator, example.source, .{}) catch break; + if (i == iterations - 1) { + // Keep last HTML for output + html_outputs[idx] = result.html; + } else { + result.deinit(allocator); + } + success = true; + } + time_ns = timer.read(); + + if (success and i == iterations) { + const time_ms = @as(f64, @floatFromInt(time_ns)) / 1_000_000.0 / @as(f64, @floatFromInt(iterations)); + std.debug.print("{s}: OK ({d:.3} ms)\n", .{ example.name, time_ms }); + passed += 1; + total_time_ns += time_ns; + } else { + std.debug.print("{s}: FAILED\n", .{example.name}); + failed += 1; + } + } + + std.debug.print("\n=== Summary ===\n", .{}); + std.debug.print("Passed: {d}/{d}\n", .{ passed, examples.len }); + std.debug.print("Failed: {d}/{d}\n", .{ failed, examples.len }); + + if (passed > 0) { + const total_ms = @as(f64, @floatFromInt(total_time_ns)) / 1_000_000.0 / 100.0; + std.debug.print("Total time (successful): {d:.3} ms\n", .{total_ms}); + std.debug.print("Average time: {d:.3} ms\n", .{total_ms / @as(f64, @floatFromInt(passed))}); + } + + // Output HTML for comparison + std.debug.print("\n=== HTML Output ===\n", .{}); + for (examples, 0..) |example, idx| { + if (html_outputs[idx]) |html| { + std.debug.print("\n--- {s} ---\n", .{example.name}); + const max_len = @min(html.len, 500); + std.debug.print("{s}", .{html[0..max_len]}); + if (html.len > 500) std.debug.print("...", .{}); + std.debug.print("\n", .{}); + } + } +} diff --git a/src/playground/examples/attributes.pug b/src/playground/examples/attributes.pug new file mode 100644 index 0000000..b8baa5f --- /dev/null +++ b/src/playground/examples/attributes.pug @@ -0,0 +1,8 @@ +div#id.left.container(class='user user-' + name) + h1.title= name + form + //- unbuffered comment :) + // An example of attributes. + input(type='text' name='user[name]' value=name) + input(checked, type='checkbox', name='user[blocked]') + input(type='submit', value='Update') \ No newline at end of file diff --git a/src/playground/examples/code.pug b/src/playground/examples/code.pug new file mode 100644 index 0000000..d0cab66 --- /dev/null +++ b/src/playground/examples/code.pug @@ -0,0 +1,17 @@ + +- var title = "Things" + +- + var subtitle = ["Really", "long", + "list", "of", + "words"] +h1= title +h2= subtitle.join(" ") + +ul#users + each user, name in users + // expands to if (user.isA == 'ferret') + if user.isA == 'ferret' + li(class='user-' + name) #{name} is just a ferret + else + li(class='user-' + name) #{name} #{user.email} \ No newline at end of file diff --git a/src/playground/examples/dynamicscript.pug b/src/playground/examples/dynamicscript.pug new file mode 100644 index 0000000..4e1c895 --- /dev/null +++ b/src/playground/examples/dynamicscript.pug @@ -0,0 +1,5 @@ +html + head + title Dynamic Inline JavaScript + script. + var users = !{JSON.stringify(users).replace(/<\//g, "<\\/")} diff --git a/src/playground/examples/each.pug b/src/playground/examples/each.pug new file mode 100644 index 0000000..206c740 --- /dev/null +++ b/src/playground/examples/each.pug @@ -0,0 +1,3 @@ +ul#users + each user, name in users + li(class='user-' + name) #{name} #{user.email} \ No newline at end of file diff --git a/src/playground/examples/extend-layout.pug b/src/playground/examples/extend-layout.pug new file mode 100644 index 0000000..0767f5f --- /dev/null +++ b/src/playground/examples/extend-layout.pug @@ -0,0 +1,10 @@ +html + head + h1 My Site - #{title} + block scripts + script(src='/jquery.js') + body + block content + block foot + #footer + p some footer content \ No newline at end of file diff --git a/src/playground/examples/extend.pug b/src/playground/examples/extend.pug new file mode 100644 index 0000000..0de55bd --- /dev/null +++ b/src/playground/examples/extend.pug @@ -0,0 +1,11 @@ + +extends extend-layout.pug + +block scripts + script(src='/jquery.js') + script(src='/pets.js') + +block content + h1= title + each pet in pets + include pet.pug diff --git a/src/playground/examples/form.pug b/src/playground/examples/form.pug new file mode 100644 index 0000000..afe3249 --- /dev/null +++ b/src/playground/examples/form.pug @@ -0,0 +1,29 @@ +form(method="post") + fieldset + legend General + p + label(for="user[name]") Username: + input(type="text", name="user[name]", value=user.name) + p + label(for="user[email]") Email: + input(type="text", name="user[email]", value=user.email) + .tip. + Enter a valid + email address + such as <em>tj@vision-media.ca</em>. + fieldset + legend Location + p + label(for="user[city]") City: + input(type="text", name="user[city]", value=user.city) + p + select(name="user[province]") + option(value="") -- Select Province -- + option(value="AB") Alberta + option(value="BC") British Columbia + option(value="SK") Saskatchewan + option(value="MB") Manitoba + option(value="ON") Ontario + option(value="QC") Quebec + p.buttons + input(type="submit", value="Save") \ No newline at end of file diff --git a/src/playground/examples/includes.pug b/src/playground/examples/includes.pug new file mode 100644 index 0000000..470c476 --- /dev/null +++ b/src/playground/examples/includes.pug @@ -0,0 +1,7 @@ + +html + include includes/head.pug + body + h1 My Site + p Welcome to my super lame site. + include includes/foot.pug diff --git a/src/playground/examples/layout.pug b/src/playground/examples/layout.pug new file mode 100644 index 0000000..767f99a --- /dev/null +++ b/src/playground/examples/layout.pug @@ -0,0 +1,14 @@ +doctype html +html(lang="en") + head + title Example + script. + if (foo) { + bar(); + } + body + h1 Pug - node template engine + #container + :markdown-it + Pug is a _high performance_ template engine for [node](http://nodejs.org), + inspired by [haml](http://haml-lang.com/), and written by [TJ Holowaychuk](http://github.com/visionmedia). diff --git a/src/playground/examples/mixins.pug b/src/playground/examples/mixins.pug new file mode 100644 index 0000000..09f00fd --- /dev/null +++ b/src/playground/examples/mixins.pug @@ -0,0 +1,14 @@ +include mixins/dialog.pug +include mixins/profile.pug + +.one + +dialog + +.two + +dialog-title('Whoop') + +.three + +dialog-title-desc('Whoop', 'Just a mixin') + +#profile + +profile(user) diff --git a/src/playground/examples/pet.pug b/src/playground/examples/pet.pug new file mode 100644 index 0000000..e5dbab9 --- /dev/null +++ b/src/playground/examples/pet.pug @@ -0,0 +1,3 @@ +.pet + h2= pet.name + p #{pet.name} is <em>#{pet.age}</em> year(s) old. \ No newline at end of file diff --git a/src/playground/examples/rss.pug b/src/playground/examples/rss.pug new file mode 100644 index 0000000..165dffb --- /dev/null +++ b/src/playground/examples/rss.pug @@ -0,0 +1,14 @@ +doctype xml +rss(version='2.0') +channel + title RSS Title + description Some description here + link http://google.com + lastBuildDate Mon, 06 Sep 2010 00:01:00 +0000 + pubDate Mon, 06 Sep 2009 16:45:00 +0000 + + each item in items + item + title= item.title + description= item.description + link= item.link diff --git a/src/playground/examples/text.pug b/src/playground/examples/text.pug new file mode 100644 index 0000000..6e99a89 --- /dev/null +++ b/src/playground/examples/text.pug @@ -0,0 +1,36 @@ +| An example of an +a(href='#') inline +| link. + +form + label Username: + input(type='text', name='user[name]') + p + | Just an example of some text usage. + | You can have <em>inline</em> html, + | as well as + strong tags + | . + + | Interpolation is also supported. The + | username is currently "#{name}". + + label Email: + input(type='text', name='user[email]') + p + | Email is currently + em= email + | . + + // alternatively, if we plan on having only + // text or inline-html, we can use a trailing + // "." to let pug know we want to omit pipes + + label Username: + input(type='text') + p. + Just an example, like before + however now we can omit those + annoying pipes!. + + Wahoo. \ No newline at end of file diff --git a/src/playground/examples/whitespace.pug b/src/playground/examples/whitespace.pug new file mode 100644 index 0000000..ae7ebd9 --- /dev/null +++ b/src/playground/examples/whitespace.pug @@ -0,0 +1,11 @@ +- var js = '<script></script>' +doctype html +html + + head + title= "Some " + "JavaScript" + != js + + + + body \ No newline at end of file diff --git a/src/playground/run_js.js b/src/playground/run_js.js new file mode 100644 index 0000000..f1c6819 --- /dev/null +++ b/src/playground/run_js.js @@ -0,0 +1,70 @@ +/** + * JS Pug - Process all .pug files in playground folder + */ + +const fs = require('fs'); +const path = require('path'); +const pug = require('../../pug'); + +const dir = path.join(__dirname, 'examples'); + +// Get all .pug files +const pugFiles = fs.readdirSync(dir) + .filter(f => f.endsWith('.pug')) + .sort(); + +console.log('=== JS Pug Playground ===\n'); +console.log(`Found ${pugFiles.length} .pug files\n`); + +let passed = 0; +let failed = 0; +let totalTimeMs = 0; + +for (const file of pugFiles) { + const filePath = path.join(dir, file); + const source = fs.readFileSync(filePath, 'utf8'); + + const iterations = 100; + let success = false; + let html = ''; + let error = ''; + let timeMs = 0; + + try { + const start = process.hrtime.bigint(); + + for (let i = 0; i < iterations; i++) { + html = pug.render(source, { + filename: filePath, + basedir: dir + }); + } + + const end = process.hrtime.bigint(); + timeMs = Number(end - start) / 1_000_000 / iterations; + success = true; + passed++; + totalTimeMs += timeMs; + } catch (e) { + error = e.message.split('\n')[0]; + failed++; + } + + if (success) { + console.log(`✓ ${file} (${timeMs.toFixed(3)} ms)`); + // Show first 200 chars of output + const preview = html.replace(/\s+/g, ' ').substring(0, 200); + console.log(` → ${preview}${html.length > 200 ? '...' : ''}\n`); + } else { + console.log(`✗ ${file}`); + console.log(` → ${error}\n`); + } +} + +console.log('=== Summary ==='); +console.log(`Passed: ${passed}/${pugFiles.length}`); +console.log(`Failed: ${failed}/${pugFiles.length}`); +if (passed > 0) { + console.log(`Total time: ${totalTimeMs.toFixed(3)} ms`); + console.log(`Average: ${(totalTimeMs / passed).toFixed(3)} ms per file`); +} diff --git a/src/playground/run_zig b/src/playground/run_zig new file mode 100755 index 0000000..e69de29 diff --git a/src/playground/run_zig.zig b/src/playground/run_zig.zig new file mode 100644 index 0000000..c4d73cb --- /dev/null +++ b/src/playground/run_zig.zig @@ -0,0 +1,120 @@ +// Zig Pugz - Process all .pug files in playground/examples folder + +const std = @import("std"); +const pug = @import("../pug.zig"); +const fs = std.fs; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + std.debug.print("=== Zig Pugz Playground ===\n\n", .{}); + + // Open the examples directory relative to cwd + var dir = fs.cwd().openDir("playground/examples", .{ .iterate = true }) catch |err| { + // Try from playground directory + dir = fs.cwd().openDir("examples", .{ .iterate = true }) catch { + std.debug.print("Error opening examples directory: {}\n", .{err}); + return; + }; + }; + defer dir.close(); + + // Collect .pug files + var files = std.ArrayList([]const u8).init(allocator); + defer { + for (files.items) |f| allocator.free(f); + files.deinit(); + } + + var iter = dir.iterate(); + while (try iter.next()) |entry| { + if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".pug")) { + const name = try allocator.dupe(u8, entry.name); + try files.append(name); + } + } + + // Sort files + std.mem.sort([]const u8, files.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lessThan); + + std.debug.print("Found {d} .pug files\n\n", .{files.items.len}); + + var passed: usize = 0; + var failed: usize = 0; + var total_time_ns: u64 = 0; + + for (files.items) |filename| { + // Read file + const file = dir.openFile(filename, .{}) catch { + std.debug.print("✗ {s}\n → Could not open file\n\n", .{filename}); + failed += 1; + continue; + }; + defer file.close(); + + const source = file.readToEndAlloc(allocator, 1024 * 1024) catch { + std.debug.print("✗ {s}\n → Could not read file\n\n", .{filename}); + failed += 1; + continue; + }; + defer allocator.free(source); + + // Benchmark + const iterations: usize = 100; + var success = false; + var last_html: ?[]const u8 = null; + + // Warmup + for (0..5) |_| { + var result = pug.compile(allocator, source, .{}) catch continue; + result.deinit(allocator); + } + + var timer = try std.time.Timer.start(); + var i: usize = 0; + while (i < iterations) : (i += 1) { + var result = pug.compile(allocator, source, .{}) catch break; + if (i == iterations - 1) { + last_html = result.html; + } else { + result.deinit(allocator); + } + success = true; + } + const elapsed_ns = timer.read(); + + if (success and i == iterations) { + const time_ms = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000.0 / @as(f64, @floatFromInt(iterations)); + std.debug.print("✓ {s} ({d:.3} ms)\n", .{ filename, time_ms }); + + // Show preview + if (last_html) |html| { + const max_len = @min(html.len, 200); + std.debug.print(" → {s}{s}\n\n", .{ html[0..max_len], if (html.len > 200) "..." else "" }); + allocator.free(html); + } + + passed += 1; + total_time_ns += elapsed_ns; + } else { + std.debug.print("✗ {s}\n → Compilation failed\n\n", .{filename}); + failed += 1; + } + } + + std.debug.print("=== Summary ===\n", .{}); + std.debug.print("Passed: {d}/{d}\n", .{ passed, files.items.len }); + std.debug.print("Failed: {d}/{d}\n", .{ failed, files.items.len }); + + if (passed > 0) { + const total_ms = @as(f64, @floatFromInt(total_time_ns)) / 1_000_000.0 / 100.0; + std.debug.print("Total time: {d:.3} ms\n", .{total_ms}); + std.debug.print("Average: {d:.3} ms per file\n", .{total_ms / @as(f64, @floatFromInt(passed))}); + } +} diff --git a/src/pug.zig b/src/pug.zig new file mode 100644 index 0000000..8d9704d --- /dev/null +++ b/src/pug.zig @@ -0,0 +1,457 @@ +// pug.zig - Main entry point for Pug template engine in Zig +// +// This is the main module that ties together all the Pug compilation stages: +// 1. Lexer - tokenizes the source +// 2. Parser - builds the AST +// 3. Strip Comments - removes comment tokens +// 4. Load - loads includes and extends +// 5. Linker - resolves template inheritance +// 6. Codegen - generates HTML output + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const mem = std.mem; + +// ============================================================================ +// Module Exports +// ============================================================================ + +pub const lexer = @import("lexer.zig"); +pub const parser = @import("parser.zig"); +pub const runtime = @import("runtime.zig"); +pub const pug_error = @import("error.zig"); +pub const walk = @import("walk.zig"); +pub const strip_comments = @import("strip_comments.zig"); +pub const load = @import("load.zig"); +pub const linker = @import("linker.zig"); +pub const codegen = @import("codegen.zig"); + +// Re-export commonly used types +pub const Token = lexer.Token; +pub const TokenType = lexer.TokenType; +pub const Lexer = lexer.Lexer; +pub const Parser = parser.Parser; +pub const Node = parser.Node; +pub const NodeType = parser.NodeType; +pub const PugError = pug_error.PugError; +pub const Compiler = codegen.Compiler; + +// ============================================================================ +// Compile Options +// ============================================================================ + +pub const CompileOptions = struct { + /// Source filename for error messages + filename: ?[]const u8 = null, + /// Base directory for absolute includes + basedir: ?[]const u8 = null, + /// Pretty print output with indentation + pretty: bool = false, + /// Strip unbuffered comments + strip_unbuffered_comments: bool = true, + /// Strip buffered comments + strip_buffered_comments: bool = false, + /// Include debug information + debug: bool = false, + /// Doctype to use + doctype: ?[]const u8 = null, +}; + +// ============================================================================ +// Compile Result +// ============================================================================ + +pub const CompileResult = struct { + html: []const u8, + err: ?PugError = null, + + pub fn deinit(self: *CompileResult, allocator: Allocator) void { + allocator.free(self.html); + if (self.err) |*e| { + e.deinit(); + } + } +}; + +// ============================================================================ +// Compilation Errors +// ============================================================================ + +pub const CompileError = error{ + OutOfMemory, + LexerError, + ParserError, + LoadError, + LinkerError, + CodegenError, + FileNotFound, + AccessDenied, + InvalidUtf8, +}; + +// ============================================================================ +// Main Compilation Functions +// ============================================================================ + +/// Compile a Pug template string to HTML +pub fn compile( + allocator: Allocator, + source: []const u8, + options: CompileOptions, +) CompileError!CompileResult { + var result = CompileResult{ + .html = &[_]u8{}, + }; + + // Stage 1: Lex the source + var lex_inst = Lexer.init(allocator, source, .{ + .filename = options.filename, + }) catch { + return error.LexerError; + }; + defer lex_inst.deinit(); + + const tokens = lex_inst.getTokens() catch { + if (lex_inst.last_error) |err| { + // Try to create detailed error, fall back to basic error if allocation fails + result.err = pug_error.makeError( + allocator, + "PUG:LEXER_ERROR", + err.message, + .{ + .line = err.line, + .column = err.column, + .filename = options.filename, + .src = source, + }, + ) catch blk: { + // If error creation fails, create minimal error without source context + break :blk pug_error.makeError(allocator, "PUG:LEXER_ERROR", err.message, .{ + .line = err.line, + .column = err.column, + .filename = options.filename, + .src = null, // Skip source to reduce allocation + }) catch null; + }; + } + return error.LexerError; + }; + + // Stage 2: Strip comments + var stripped = strip_comments.stripComments( + allocator, + tokens, + .{ + .strip_unbuffered = options.strip_unbuffered_comments, + .strip_buffered = options.strip_buffered_comments, + .filename = options.filename, + }, + ) catch { + return error.LexerError; + }; + defer stripped.deinit(allocator); + + // Stage 3: Parse tokens to AST + var parse = Parser.init(allocator, stripped.tokens.items, options.filename, source); + defer parse.deinit(); + + const ast = parse.parse() catch { + if (parse.err) |err| { + // Try to create detailed error, fall back to basic error if allocation fails + result.err = pug_error.makeError( + allocator, + "PUG:PARSER_ERROR", + err.message, + .{ + .line = err.line, + .column = err.column, + .filename = options.filename, + .src = source, + }, + ) catch blk: { + // If error creation fails, create minimal error without source context + break :blk pug_error.makeError(allocator, "PUG:PARSER_ERROR", err.message, .{ + .line = err.line, + .column = err.column, + .filename = options.filename, + .src = null, + }) catch null; + }; + } + return error.ParserError; + }; + defer { + ast.deinit(allocator); + allocator.destroy(ast); + } + + // Stage 4: Link (resolve extends/blocks) + var link_result = linker.link(allocator, ast) catch { + return error.LinkerError; + }; + defer link_result.deinit(allocator); + + // Stage 5: Generate HTML + var compiler = Compiler.init(allocator, .{ + .pretty = options.pretty, + .doctype = options.doctype, + .debug = options.debug, + }); + defer compiler.deinit(); + + const html = compiler.compile(link_result.ast) catch { + return error.CodegenError; + }; + + result.html = html; + return result; +} + +/// Compile a Pug file to HTML +pub fn compileFile( + allocator: Allocator, + filename: []const u8, + options: CompileOptions, +) CompileError!CompileResult { + // Read the file + const file = std.fs.cwd().openFile(filename, .{}) catch |err| { + return switch (err) { + error.FileNotFound => error.FileNotFound, + error.AccessDenied => error.AccessDenied, + else => error.FileNotFound, + }; + }; + defer file.close(); + + const source = file.readToEndAlloc(allocator, 10 * 1024 * 1024) catch { + return error.OutOfMemory; + }; + defer allocator.free(source); + + // Compile with filename set + var file_options = options; + file_options.filename = filename; + + return compile(allocator, source, file_options); +} + +/// Render a Pug template string to HTML (convenience function) +pub fn render( + allocator: Allocator, + source: []const u8, +) CompileError![]const u8 { + var result = try compile(allocator, source, .{}); + if (result.err) |*e| { + e.deinit(); + } + return result.html; +} + +/// Render a Pug template string to pretty-printed HTML +pub fn renderPretty( + allocator: Allocator, + source: []const u8, +) CompileError![]const u8 { + var result = try compile(allocator, source, .{ .pretty = true }); + if (result.err) |*e| { + e.deinit(); + } + return result.html; +} + +/// Render a Pug file to HTML +pub fn renderFile( + allocator: Allocator, + filename: []const u8, +) CompileError![]const u8 { + var result = try compileFile(allocator, filename, .{}); + if (result.err) |*e| { + e.deinit(); + } + return result.html; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "compile - simple text" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "| Hello, World!", .{}); + defer result.deinit(allocator); + + try std.testing.expectEqualStrings("Hello, World!", result.html); +} + +test "compile - simple tag" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "div", .{}); + defer result.deinit(allocator); + + try std.testing.expectEqualStrings("<div></div>", result.html); +} + +test "compile - tag with text" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "p Hello", .{}); + defer result.deinit(allocator); + + try std.testing.expectEqualStrings("<p>Hello</p>", result.html); +} + +test "compile - tag with class shorthand" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "div.container", .{}); + defer result.deinit(allocator); + + // Parser stores class values with quotes, verify class attribute is present + try std.testing.expect(mem.indexOf(u8, result.html, "class=") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "container") != null); +} + +test "compile - tag with id shorthand" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "div#main", .{}); + defer result.deinit(allocator); + + // Parser stores id values with quotes, verify id attribute is present + try std.testing.expect(mem.indexOf(u8, result.html, "id=") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "main") != null); +} + +test "compile - tag with attributes" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "a(href=\"/home\") Home", .{}); + defer result.deinit(allocator); + + // Parser stores attribute values with quotes, verify attribute is present + try std.testing.expect(mem.indexOf(u8, result.html, "href=") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "/home") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "Home") != null); +} + +test "compile - nested tags" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "div\n span Hello", .{}); + defer result.deinit(allocator); + + try std.testing.expectEqualStrings("<div><span>Hello</span></div>", result.html); +} + +test "compile - self-closing tag" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "br", .{}); + defer result.deinit(allocator); + + try std.testing.expectEqualStrings("<br>", result.html); +} + +test "compile - doctype" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "doctype html\nhtml", .{}); + defer result.deinit(allocator); + + try std.testing.expect(mem.startsWith(u8, result.html, "<!DOCTYPE html>")); +} + +test "compile - unbuffered comment stripped" { + const allocator = std.testing.allocator; + + // Unbuffered comments (//-) are stripped by default + var result = try compile(allocator, "//- This is stripped\ndiv", .{}); + defer result.deinit(allocator); + + // The comment text should not appear + try std.testing.expect(mem.indexOf(u8, result.html, "stripped") == null); + // But the div should + try std.testing.expect(mem.indexOf(u8, result.html, "<div>") != null); +} + +test "compile - buffered comment visible" { + const allocator = std.testing.allocator; + + // Buffered comments (//) are kept by default + var result = try compile(allocator, "// This is visible", .{}); + defer result.deinit(allocator); + + // Buffered comments should be in output + try std.testing.expect(mem.indexOf(u8, result.html, "<!--") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "visible") != null); +} + +test "render - convenience function" { + const allocator = std.testing.allocator; + + const html = try render(allocator, "p test"); + defer allocator.free(html); + + try std.testing.expectEqualStrings("<p>test</p>", html); +} + +test "compile - multiple tags" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "p First\np Second", .{}); + defer result.deinit(allocator); + + try std.testing.expectEqualStrings("<p>First</p><p>Second</p>", result.html); +} + +test "compile - interpolation text" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "p Hello, World!", .{}); + defer result.deinit(allocator); + + try std.testing.expectEqualStrings("<p>Hello, World!</p>", result.html); +} + +test "compile - multiple classes" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "div.foo.bar", .{}); + defer result.deinit(allocator); + + try std.testing.expect(mem.indexOf(u8, result.html, "class=\"") != null); +} + +test "compile - class and id" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, "div#main.container", .{}); + defer result.deinit(allocator); + + // Parser stores values with quotes, check that both id and class are present + try std.testing.expect(mem.indexOf(u8, result.html, "id=") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "main") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "class=") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "container") != null); +} + +test "compile - deeply nested" { + const allocator = std.testing.allocator; + + var result = try compile(allocator, + \\html + \\ head + \\ title Test + \\ body + \\ div Hello + , .{}); + defer result.deinit(allocator); + + try std.testing.expect(mem.indexOf(u8, result.html, "<html>") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "<head>") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "<title>Test") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "") != null); + try std.testing.expect(mem.indexOf(u8, result.html, "
Hello
") != null); +} diff --git a/src/root.zig b/src/root.zig index eb40fd9..ec6062e 100644 --- a/src/root.zig +++ b/src/root.zig @@ -1,69 +1,23 @@ -//! Pugz - A Pug-like HTML template engine written in Zig. -//! -//! Pugz provides a clean, indentation-based syntax for writing HTML templates, -//! inspired by Pug (formerly Jade). It supports: -//! - Indentation-based nesting -//! - Tag, class, and ID shorthand syntax -//! - Attributes and text interpolation -//! - Control flow (if/else, each, while) -//! - Mixins and template inheritance -//! -//! ## Quick Start (Server Usage) -//! -//! ```zig -//! const pugz = @import("pugz"); -//! -//! // Initialize view engine once at startup -//! var engine = try pugz.ViewEngine.init(allocator, .{ -//! .views_dir = "src/views", -//! }); -//! defer engine.deinit(); -//! -//! // Render templates (use arena allocator per request) -//! var arena = std.heap.ArenaAllocator.init(allocator); -//! defer arena.deinit(); -//! -//! const html = try engine.render(arena.allocator(), "pages/home", .{ -//! .title = "Home", -//! }); -//! ``` +// Pugz - A Pug-like HTML template engine written in Zig +// +// Quick Start: +// const pugz = @import("pugz"); +// const engine = pugz.ViewEngine.init(.{ .views_dir = "views" }); +// const html = try engine.render(allocator, "index", .{ .title = "Home" }); -pub const lexer = @import("lexer.zig"); -pub const ast = @import("ast.zig"); -pub const parser = @import("parser.zig"); -pub const codegen = @import("codegen.zig"); -pub const runtime = @import("runtime.zig"); +pub const pug = @import("pug.zig"); pub const view_engine = @import("view_engine.zig"); -pub const diagnostic = @import("diagnostic.zig"); +pub const template = @import("template.zig"); -// Re-export main types for convenience -pub const Lexer = lexer.Lexer; -pub const Token = lexer.Token; -pub const TokenType = lexer.TokenType; - -pub const Parser = parser.Parser; -pub const Node = ast.Node; -pub const Document = ast.Document; - -pub const CodeGen = codegen.CodeGen; -pub const generate = codegen.generate; - -pub const Runtime = runtime.Runtime; -pub const Context = runtime.Context; -pub const Value = runtime.Value; -pub const render = runtime.render; -pub const renderWithOptions = runtime.renderWithOptions; -pub const RenderOptions = runtime.RenderOptions; -pub const renderTemplate = runtime.renderTemplate; - -// High-level API +// Re-export main types pub const ViewEngine = view_engine.ViewEngine; -pub const CompiledTemplate = view_engine.CompiledTemplate; +pub const compile = pug.compile; +pub const compileFile = pug.compileFile; +pub const render = pug.render; +pub const renderFile = pug.renderFile; +pub const CompileOptions = pug.CompileOptions; +pub const CompileResult = pug.CompileResult; +pub const CompileError = pug.CompileError; -// Build-time template compilation -pub const build_templates = @import("build_templates.zig"); -pub const compileTemplates = build_templates.compileTemplates; - -test { - _ = @import("std").testing.refAllDecls(@This()); -} +// Convenience function for inline templates with data +pub const renderTemplate = template.renderWithData; diff --git a/src/run_playground.zig b/src/run_playground.zig new file mode 100644 index 0000000..0c3e7ef --- /dev/null +++ b/src/run_playground.zig @@ -0,0 +1,118 @@ +// Zig Pugz - Process all .pug files in playground/examples folder + +const std = @import("std"); +const pug = @import("pug.zig"); +const fs = std.fs; + +pub fn main() !void { + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + defer _ = gpa.deinit(); + const allocator = gpa.allocator(); + + std.debug.print("=== Zig Pugz Playground ===\n\n", .{}); + + // Open the examples directory + var dir = fs.cwd().openDir("playground/examples", .{ .iterate = true }) catch { + std.debug.print("Error: Could not open playground/examples directory\n", .{}); + std.debug.print("Run from packages/pugz/ directory\n", .{}); + return; + }; + defer dir.close(); + + // Collect .pug files + var files = std.ArrayListUnmanaged([]const u8){}; + defer { + for (files.items) |f| allocator.free(f); + files.deinit(allocator); + } + + var iter = dir.iterate(); + while (try iter.next()) |entry| { + if (entry.kind == .file and std.mem.endsWith(u8, entry.name, ".pug")) { + const name = try allocator.dupe(u8, entry.name); + try files.append(allocator, name); + } + } + + // Sort files + std.mem.sort([]const u8, files.items, {}, struct { + fn lessThan(_: void, a: []const u8, b: []const u8) bool { + return std.mem.lessThan(u8, a, b); + } + }.lessThan); + + std.debug.print("Found {d} .pug files\n\n", .{files.items.len}); + + var passed: usize = 0; + var failed: usize = 0; + var total_time_ns: u64 = 0; + + for (files.items) |filename| { + // Read file + const file = dir.openFile(filename, .{}) catch { + std.debug.print("x {s}\n -> Could not open file\n\n", .{filename}); + failed += 1; + continue; + }; + defer file.close(); + + const source = file.readToEndAlloc(allocator, 1024 * 1024) catch { + std.debug.print("x {s}\n -> Could not read file\n\n", .{filename}); + failed += 1; + continue; + }; + defer allocator.free(source); + + // Benchmark + const iterations: usize = 100; + var success = false; + var last_html: ?[]const u8 = null; + + // Warmup + for (0..5) |_| { + var result = pug.compile(allocator, source, .{}) catch continue; + result.deinit(allocator); + } + + var timer = try std.time.Timer.start(); + var i: usize = 0; + while (i < iterations) : (i += 1) { + var result = pug.compile(allocator, source, .{}) catch break; + if (i == iterations - 1) { + last_html = result.html; + } else { + result.deinit(allocator); + } + success = true; + } + const elapsed_ns = timer.read(); + + if (success and i == iterations) { + const time_ms = @as(f64, @floatFromInt(elapsed_ns)) / 1_000_000.0 / @as(f64, @floatFromInt(iterations)); + std.debug.print("OK {s} ({d:.3} ms)\n", .{ filename, time_ms }); + + // Show preview + if (last_html) |html| { + const max_len = @min(html.len, 200); + std.debug.print(" -> {s}{s}\n\n", .{ html[0..max_len], if (html.len > 200) "..." else "" }); + allocator.free(html); + } + + passed += 1; + total_time_ns += elapsed_ns; + } else { + std.debug.print("FAIL {s}\n -> Compilation failed\n\n", .{filename}); + failed += 1; + } + } + + std.debug.print("=== Summary ===\n", .{}); + std.debug.print("Passed: {d}/{d}\n", .{ passed, files.items.len }); + std.debug.print("Failed: {d}/{d}\n", .{ failed, files.items.len }); + + if (passed > 0) { + const total_ms = @as(f64, @floatFromInt(total_time_ns)) / 1_000_000.0 / 100.0; + std.debug.print("Total time: {d:.3} ms\n", .{total_ms}); + std.debug.print("Average: {d:.3} ms per file\n", .{total_ms / @as(f64, @floatFromInt(passed))}); + } +} diff --git a/src/runtime.zig b/src/runtime.zig index be58d0a..9f9d931 100644 --- a/src/runtime.zig +++ b/src/runtime.zig @@ -1,2490 +1,1504 @@ -//! Pugz Runtime - Evaluates templates with data context. -//! -//! The runtime takes a parsed AST and a data context, then produces -//! the final HTML output by: -//! - Substituting variables in interpolations -//! - Evaluating conditionals -//! - Iterating over collections -//! - Calling mixins -//! - Template inheritance (extends/block) -//! - Includes -//! -//! **Memory Management**: Use an arena allocator for best performance and -//! automatic cleanup. The runtime allocates intermediate strings during -//! template processing that are cleaned up when the arena is reset/deinitialized. -//! -//! ```zig -//! var arena = std.heap.ArenaAllocator.init(gpa.allocator()); -//! defer arena.deinit(); -//! -//! const html = try engine.renderTpl(arena.allocator(), template, data); -//! // Use html... arena.deinit() frees everything -//! ``` - const std = @import("std"); -const ast = @import("ast.zig"); -const Lexer = @import("lexer.zig").Lexer; -const Parser = @import("parser.zig").Parser; +const mem = std.mem; +const Allocator = std.mem.Allocator; +const ArrayListUnmanaged = std.ArrayListUnmanaged; -const log = std.log.scoped(.@"pugz/runtime"); +// ============================================================================ +// Pug Runtime - HTML generation utilities +// ============================================================================ -/// A value in the template context. -pub const Value = union(enum) { - /// Null/undefined value. - null, - /// Boolean value. - bool: bool, - /// Integer value. - int: i64, - /// Floating point value. - float: f64, - /// String value. +/// Escape HTML special characters in a string. +/// Characters escaped: " & < > +pub fn escape(allocator: Allocator, html: []const u8) ![]const u8 { + // Quick check if escaping is needed + var needs_escape = false; + for (html) |c| { + if (c == '"' or c == '&' or c == '<' or c == '>') { + needs_escape = true; + break; + } + } + + if (!needs_escape) { + return try allocator.dupe(u8, html); + } + + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + + for (html) |c| { + switch (c) { + '"' => try result.appendSlice(allocator, """), + '&' => try result.appendSlice(allocator, "&"), + '<' => try result.appendSlice(allocator, "<"), + '>' => try result.appendSlice(allocator, ">"), + else => try result.append(allocator, c), + } + } + + return try result.toOwnedSlice(allocator); +} + +/// Style value types +pub const StyleValue = union(enum) { string: []const u8, - /// Array of values. - array: []const Value, - /// Object/map of string keys to values. - object: std.StringHashMapUnmanaged(Value), - - /// Returns the value as a string for output. - /// For integers, uses pre-computed strings for small values to avoid allocation. - pub fn toString(self: Value, allocator: std.mem.Allocator) ![]const u8 { - // Fast path: strings are most common in templates (branch hint) - if (self == .string) { - @branchHint(.likely); - return self.string; - } - return switch (self) { - .string => unreachable, // handled above - .null => "", - .bool => |b| if (b) "true" else "false", - .int => |i| blk: { - // Fast path for common small integers (0-99) - if (i >= 0 and i < 100) { - break :blk small_int_strings[@intCast(i)]; - } - // Allocate for larger integers - break :blk try std.fmt.allocPrint(allocator, "{d}", .{i}); - }, - .float => |f| try std.fmt.allocPrint(allocator, "{d}", .{f}), - .array => "[Array]", - .object => "[Object]", - }; - } - - /// Pre-computed strings for small integers 0-99 (common in loops) - const small_int_strings = [_][]const u8{ - "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", - "10", "11", "12", "13", "14", "15", "16", "17", "18", "19", - "20", "21", "22", "23", "24", "25", "26", "27", "28", "29", - "30", "31", "32", "33", "34", "35", "36", "37", "38", "39", - "40", "41", "42", "43", "44", "45", "46", "47", "48", "49", - "50", "51", "52", "53", "54", "55", "56", "57", "58", "59", - "60", "61", "62", "63", "64", "65", "66", "67", "68", "69", - "70", "71", "72", "73", "74", "75", "76", "77", "78", "79", - "80", "81", "82", "83", "84", "85", "86", "87", "88", "89", - "90", "91", "92", "93", "94", "95", "96", "97", "98", "99", - }; - - /// Returns the value as a boolean for conditionals. - pub fn isTruthy(self: Value) bool { - return switch (self) { - .null => false, - .bool => |b| b, - .int => |i| i != 0, - .float => |f| f != 0.0, - .string => |s| s.len > 0, - .array => |a| a.len > 0, - .object => true, - }; - } - - /// Creates a string value. - pub fn str(s: []const u8) Value { - return .{ .string = s }; - } - - /// Creates an integer value. - pub fn integer(i: i64) Value { - return .{ .int = i }; - } - - /// Creates a boolean value. - pub fn boolean(b: bool) Value { - return .{ .bool = b }; - } + object: []const StyleProperty, + none, }; -/// Runtime errors. -pub const RuntimeError = error{ - OutOfMemory, - UndefinedVariable, - 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. -pub const Context = struct { - allocator: std.mem.Allocator, - /// Stack of variable scopes (innermost last). - /// We keep all scopes allocated and track active depth with scope_depth. - scopes: std.ArrayList(std.StringHashMapUnmanaged(Value)), - /// Current active scope depth (scopes[0..scope_depth] are active). - scope_depth: usize, - /// Mixin definitions available in this context. - mixins: std.StringHashMapUnmanaged(ast.MixinDef), - - pub fn init(allocator: std.mem.Allocator) Context { - return .{ - .allocator = allocator, - .scopes = .empty, - .scope_depth = 0, - .mixins = .empty, - }; - } - - pub fn deinit(self: *Context) void { - for (self.scopes.items) |*scope| { - scope.*.deinit(self.allocator); - } - self.scopes.deinit(self.allocator); - self.mixins.deinit(self.allocator); - } - - /// Pushes a new scope onto the stack. - /// Reuses previously allocated scopes when possible to avoid allocation overhead. - pub fn pushScope(self: *Context) !void { - if (self.scope_depth < self.scopes.items.len) { - // Reuse existing scope slot (already cleared on pop) - } else { - // Need to allocate a new scope - try self.scopes.append(self.allocator, .empty); - } - self.scope_depth += 1; - } - - /// Pops the current scope from the stack. - /// Clears scope for reuse but does NOT deallocate. - pub fn popScope(self: *Context) void { - if (self.scope_depth > 0) { - self.scope_depth -= 1; - // Clear the scope so old values don't leak into next use - self.scopes.items[self.scope_depth].clearRetainingCapacity(); - } - } - - /// Sets a variable in the current scope. - pub fn set(self: *Context, name: []const u8, value: Value) !void { - if (self.scope_depth == 0) { - try self.pushScope(); - } - const current = &self.scopes.items[self.scope_depth - 1]; - try current.put(self.allocator, name, value); - } - - /// Gets or creates a slot for a variable, returning a pointer to the value. - /// Use this for loop variables that are updated repeatedly. - pub fn getOrPutPtr(self: *Context, name: []const u8) !*Value { - if (self.scope_depth == 0) { - try self.pushScope(); - } - const current = &self.scopes.items[self.scope_depth - 1]; - const gop = try current.getOrPut(self.allocator, name); - if (!gop.found_existing) { - gop.value_ptr.* = Value.null; - } - return gop.value_ptr; - } - - /// Gets a variable, searching from innermost to outermost scope. - pub fn get(self: *Context, name: []const u8) ?Value { - // Fast path: most lookups are in the innermost scope - if (self.scope_depth > 0) { - @branchHint(.likely); - if (self.scopes.items[self.scope_depth - 1].get(name)) |value| { - return value; - } - } - // Search remaining scopes (less common) - var i = self.scope_depth -| 1; - while (i > 0) { - i -= 1; - if (self.scopes.items[i].get(name)) |value| { - return value; - } - } - return null; - } - - /// Registers a mixin definition. - pub fn defineMixin(self: *Context, mixin: ast.MixinDef) !void { - try self.mixins.put(self.allocator, mixin.name, mixin); - } - - /// Gets a mixin definition by name. - pub fn getMixin(self: *Context, name: []const u8) ?ast.MixinDef { - return self.mixins.get(name); - } -}; - -/// File resolver function type for loading templates. -/// Takes a path and returns the file contents, or null if not found. -pub const FileResolver = *const fn (allocator: std.mem.Allocator, path: []const u8) ?[]const u8; - -/// Block definition collected from child templates. -const BlockDef = struct { +pub const StyleProperty = struct { name: []const u8, - mode: ast.Block.Mode, - children: []const ast.Node, + value: []const u8, }; -/// Runtime engine for evaluating templates. -pub const Runtime = struct { - allocator: std.mem.Allocator, - context: *Context, - output: std.ArrayList(u8), +/// Convert a style value to a CSS string. +/// If val is an object, formats as "key:value;key:value;" +/// If val is a string, returns it as-is. +pub fn style(allocator: Allocator, val: StyleValue) ![]const u8 { + switch (val) { + .none => return try allocator.dupe(u8, ""), + .string => |s| { + if (s.len == 0) return try allocator.dupe(u8, ""); + return try allocator.dupe(u8, s); + }, + .object => |props| { + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + + for (props) |prop| { + try result.appendSlice(allocator, prop.name); + try result.append(allocator, ':'); + try result.appendSlice(allocator, prop.value); + try result.append(allocator, ';'); + } + + return try result.toOwnedSlice(allocator); + }, + } +} + +/// Attribute value types +pub const AttrValue = union(enum) { + string: []const u8, + boolean: bool, + number: i64, + none, // null/undefined equivalent +}; + +/// Render a single HTML attribute. +/// Returns empty string for false/null values. +/// For true values, returns terse form " key" or full form " key="key"". +pub fn attr(allocator: Allocator, key: []const u8, val: AttrValue, escaped: bool, terse: bool) ![]const u8 { + switch (val) { + .none => return try allocator.dupe(u8, ""), + .boolean => |b| { + if (!b) return try allocator.dupe(u8, ""); + // true value + if (terse) { + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + try result.append(allocator, ' '); + try result.appendSlice(allocator, key); + return try result.toOwnedSlice(allocator); + } else { + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + try result.append(allocator, ' '); + try result.appendSlice(allocator, key); + try result.appendSlice(allocator, "=\""); + try result.appendSlice(allocator, key); + try result.append(allocator, '"'); + return try result.toOwnedSlice(allocator); + } + }, + .number => |n| { + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + try result.append(allocator, ' '); + try result.appendSlice(allocator, key); + try result.appendSlice(allocator, "=\""); + + // Format number + var buf: [32]u8 = undefined; + const num_str = std.fmt.bufPrint(&buf, "{d}", .{n}) catch return error.FormatError; + try result.appendSlice(allocator, num_str); + + try result.append(allocator, '"'); + return try result.toOwnedSlice(allocator); + }, + .string => |s| { + // Empty class or style returns empty + if (s.len == 0 and (mem.eql(u8, key, "class") or mem.eql(u8, key, "style"))) { + return try allocator.dupe(u8, ""); + } + + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + + try result.append(allocator, ' '); + try result.appendSlice(allocator, key); + try result.appendSlice(allocator, "=\""); + + if (escaped) { + const escaped_val = try escape(allocator, s); + defer allocator.free(escaped_val); + try result.appendSlice(allocator, escaped_val); + } else { + try result.appendSlice(allocator, s); + } + + try result.append(allocator, '"'); + return try result.toOwnedSlice(allocator); + }, + } +} + +/// Class value types for the classes function +pub const ClassValue = union(enum) { + string: []const u8, + array: []const ClassValue, + object: []const ClassCondition, + none, +}; + +pub const ClassCondition = struct { + name: []const u8, + condition: bool, +}; + +/// Process class values into a space-delimited string. +/// Arrays are flattened, objects include keys with truthy values. +/// Optimized to minimize allocations by writing directly to result buffer. +pub fn classes(allocator: Allocator, val: ClassValue, escaping: ?[]const bool) ![]const u8 { + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + + try classesInternal(allocator, val, escaping, &result, 0); + + if (result.items.len == 0) { + result.deinit(allocator); + return try allocator.dupe(u8, ""); + } + + return try result.toOwnedSlice(allocator); +} + +/// Internal recursive helper that writes directly to result buffer (avoids intermediate allocations) +fn classesInternal( + allocator: Allocator, + val: ClassValue, + escaping: ?[]const bool, + result: *ArrayListUnmanaged(u8), depth: usize, - options: Options, - /// File resolver for loading external templates. - file_resolver: ?FileResolver, - /// Base directory for resolving relative paths. - base_dir: []const u8, - /// Directory containing mixin files for lazy-loading. - mixins_dir: []const u8, - /// Block definitions from child template (for inheritance). - blocks: std.StringHashMapUnmanaged(BlockDef), - /// Current mixin block content (for `block` keyword inside mixins). - 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, +) !void { + switch (val) { + .none => {}, + .string => |s| { + if (s.len == 0) return; + // Add space separator if not first item + if (result.items.len > 0) try result.append(allocator, ' '); + try result.appendSlice(allocator, s); + }, + .object => |conditions| { + for (conditions) |cond| { + if (cond.condition and cond.name.len > 0) { + if (result.items.len > 0) try result.append(allocator, ' '); + try result.appendSlice(allocator, cond.name); + } + } + }, + .array => |items| { + for (items, 0..) |item, i| { + // Check if this item needs escaping (only at top level) + const should_escape = if (depth == 0) blk: { + break :blk if (escaping) |esc| (i < esc.len and esc[i]) else false; + } else false; - pub const Options = struct { - pretty: bool = true, - indent_str: []const u8 = " ", - self_closing: bool = true, - /// Base directory for resolving template paths. - base_dir: []const u8 = "", - /// File resolver for loading templates. - file_resolver: ?FileResolver = null, - /// 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, + if (should_escape) { + // Need to escape: collect item first, then escape and append + const start_len = result.items.len; + const had_content = start_len > 0; + + // Temporarily collect the class string + var temp: ArrayListUnmanaged(u8) = .{}; + defer temp.deinit(allocator); + try classesInternal(allocator, item, null, &temp, depth + 1); + + if (temp.items.len > 0) { + if (had_content) try result.append(allocator, ' '); + // Escape directly into result + try appendEscaped(allocator, result, temp.items); + } + } else { + // No escaping: write directly to result + try classesInternal(allocator, item, null, result, depth + 1); + } + } + }, + } +} + +/// Append escaped HTML directly to result buffer (avoids intermediate allocation) +/// Public for use by codegen and other modules +pub fn appendEscaped(allocator: Allocator, result: *ArrayListUnmanaged(u8), html: []const u8) !void { + for (html) |c| { + if (escapeChar(c)) |escaped| { + try result.appendSlice(allocator, escaped); + } else { + try result.append(allocator, c); + } + } +} + +/// Escape a single character, returning the escape sequence or null if no escaping needed +pub fn escapeChar(c: u8) ?[]const u8 { + return switch (c) { + '"' => """, + '&' => "&", + '<' => "<", + '>' => ">", + else => null, }; +} - /// Error type for runtime operations. - pub const Error = RuntimeError || std.mem.Allocator.Error || error{TemplateNotFound}; +/// Attribute entry for attrs function +pub const AttrEntry = struct { + key: []const u8, + value: AttrValue, + is_class: bool = false, + is_style: bool = false, + class_value: ?ClassValue = null, + style_value: ?StyleValue = null, +}; - pub fn init(allocator: std.mem.Allocator, context: *Context, options: Options) Runtime { +/// Render multiple attributes. +/// Class attributes are processed specially and placed first. +pub fn attrs(allocator: Allocator, entries: []const AttrEntry, terse: bool) ![]const u8 { + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + + // First pass: find and render class attribute + for (entries) |entry| { + if (entry.is_class) { + if (entry.class_value) |cv| { + const class_str = try classes(allocator, cv, null); + defer allocator.free(class_str); + + if (class_str.len > 0) { + const attr_str = try attr(allocator, "class", .{ .string = class_str }, false, terse); + defer allocator.free(attr_str); + try result.appendSlice(allocator, attr_str); + } + } + break; + } + } + + // Second pass: render other attributes + for (entries) |entry| { + if (entry.is_class) continue; + + if (entry.is_style) { + if (entry.style_value) |sv| { + const style_str = try style(allocator, sv); + defer allocator.free(style_str); + + if (style_str.len > 0) { + const attr_str = try attr(allocator, "style", .{ .string = style_str }, false, terse); + defer allocator.free(attr_str); + try result.appendSlice(allocator, attr_str); + } + } + } else { + const attr_str = try attr(allocator, entry.key, entry.value, false, terse); + defer allocator.free(attr_str); + try result.appendSlice(allocator, attr_str); + } + } + + return try result.toOwnedSlice(allocator); +} + +/// Merge entry for combining attribute objects +pub const MergeEntry = struct { + key: []const u8, + value: MergeValue, +}; + +pub const MergeValue = union(enum) { + string: []const u8, + class_array: []const []const u8, + style_object: []const StyleProperty, + none, +}; + +/// Merge result for a single key +pub const MergedValue = struct { + key: []const u8, + value: MergeValue, + allocator: Allocator, + owned_strings: ArrayListUnmanaged([]const u8), + + pub fn deinit(self: *MergedValue) void { + for (self.owned_strings.items) |s| { + self.allocator.free(s); + } + self.owned_strings.deinit(self.allocator); + } +}; + +/// Ensure style string ends with semicolon +fn ensureTrailingSemicolon(allocator: Allocator, s: []const u8) ![]const u8 { + if (s.len == 0) return try allocator.dupe(u8, ""); + if (s[s.len - 1] == ';') return try allocator.dupe(u8, s); + + var result: ArrayListUnmanaged(u8) = .{}; + errdefer result.deinit(allocator); + try result.appendSlice(allocator, s); + try result.append(allocator, ';'); + return try result.toOwnedSlice(allocator); +} + +/// Convert style value to string with trailing semicolon +fn styleToString(allocator: Allocator, val: StyleValue) ![]const u8 { + const s = try style(allocator, val); + defer allocator.free(s); + return try ensureTrailingSemicolon(allocator, s); +} + +// ============================================================================ +// Merge function +// ============================================================================ + +/// Merged attributes result with O(1) lookups for class/style +pub const MergedAttrs = struct { + allocator: Allocator, + entries: ArrayListUnmanaged(MergedAttrEntry), + owned_strings: ArrayListUnmanaged([]const u8), + owned_class_arrays: ArrayListUnmanaged([][]const u8), + // O(1) index tracking for special keys + class_idx: ?usize = null, + style_idx: ?usize = null, + + pub fn init(allocator: Allocator) MergedAttrs { return .{ .allocator = allocator, - .context = context, - .output = .empty, - .depth = 0, - .options = options, - .file_resolver = options.file_resolver, - .base_dir = options.base_dir, - .mixins_dir = options.mixins_dir, - .blocks = .empty, - .mixin_block_content = null, - .mixin_attributes = null, - .include_depth = 0, + .entries = .{}, + .owned_strings = .{}, + .owned_class_arrays = .{}, + .class_idx = null, + .style_idx = null, }; } - pub fn deinit(self: *Runtime) void { - self.output.deinit(self.allocator); - self.blocks.deinit(self.allocator); + pub fn deinit(self: *MergedAttrs) void { + for (self.owned_strings.items) |s| { + self.allocator.free(s); + } + self.owned_strings.deinit(self.allocator); + + for (self.owned_class_arrays.items) |arr| { + self.allocator.free(arr); + } + self.owned_class_arrays.deinit(self.allocator); + + self.entries.deinit(self.allocator); } - /// Renders the document and returns the HTML output. - pub fn render(self: *Runtime, doc: ast.Document) Error![]const u8 { - // Pre-allocate buffer - 256KB handles most large templates without realloc - try self.output.ensureTotalCapacity(self.allocator, 256 * 1024); - - // Handle template inheritance - if (doc.extends_path) |extends_path| { - // Collect blocks from child template - try self.collectBlocks(doc.nodes); - - // Load and render parent template - const parent_doc = try self.loadTemplate(extends_path); - return self.render(parent_doc); - } - - for (doc.nodes) |node| { - try self.visitNode(node); - } - - return self.output.items; - } - - /// Collects block definitions from child template nodes. - fn collectBlocks(self: *Runtime, nodes: []const ast.Node) Error!void { - for (nodes) |node| { - switch (node) { - .block => |blk| { - try self.blocks.put(self.allocator, blk.name, .{ - .name = blk.name, - .mode = blk.mode, - .children = blk.children, - }); - }, - else => {}, + pub fn get(self: *const MergedAttrs, key: []const u8) ?MergedAttrValue { + // O(1) lookup for class and style + if (mem.eql(u8, key, "class")) { + if (self.class_idx) |idx| { + return self.entries.items[idx].value; } - } - } - - /// 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) - var resolved_path: []const u8 = path; - if (!std.mem.endsWith(u8, path, ".pug")) { - 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) { - full_path = try std.fs.path.join(self.allocator, &.{ self.base_dir, resolved_path }); - } - - const source = resolver(self.allocator, full_path) orelse return error.TemplateNotFound; - - // Parse the template - var lexer = Lexer.init(self.allocator, source); - const tokens = lexer.tokenize() catch return error.TemplateNotFound; - - var parser = Parser.init(self.allocator, tokens); - return parser.parse() catch return error.TemplateNotFound; - } - - /// Renders and returns an owned copy of the output. - pub fn renderOwned(self: *Runtime, doc: ast.Document) Error![]u8 { - const result = try self.render(doc); - return try self.allocator.dupe(u8, result); - } - - fn visitNode(self: *Runtime, node: ast.Node) Error!void { - switch (node) { - .doctype => |dt| try self.visitDoctype(dt), - .element => |elem| try self.visitElement(elem), - .text => |text| try self.visitText(text), - .comment => |comment| try self.visitComment(comment), - .conditional => |cond| try self.visitConditional(cond), - .each => |each| try self.visitEach(each), - .@"while" => |whl| try self.visitWhile(whl), - .case => |c| try self.visitCase(c), - .mixin_def => |def| try self.context.defineMixin(def), - .mixin_call => |call| try self.visitMixinCall(call), - .mixin_block => try self.visitMixinBlock(), - .code => |code| try self.visitCode(code), - .raw_text => |raw| try self.visitRawText(raw), - .block => |blk| try self.visitBlock(blk), - .include => |inc| try self.visitInclude(inc), - .extends => {}, // Handled at document level - .document => |doc| { - for (doc.nodes) |child| { - try self.visitNode(child); - } - }, - } - } - - /// 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(""); - } - - /// Doctype shortcuts mapping - const doctype_shortcuts = std.StaticStringMap([]const u8).initComptime(.{ - .{ "html", "" }, - .{ "xml", "" }, - .{ "transitional", "" }, - .{ "strict", "" }, - .{ "frameset", "" }, - .{ "1.1", "" }, - .{ "basic", "" }, - .{ "mobile", "" }, - .{ "plist", "" }, - }); - - fn visitDoctype(self: *Runtime, dt: ast.Doctype) Error!void { - // Look up shortcut or use custom doctype - if (doctype_shortcuts.get(dt.value)) |output| { - try self.write(output); - } else { - // Custom doctype: output as-is with "); - } - try self.writeNewline(); - } - - fn visitElement(self: *Runtime, elem: ast.Element) Error!void { - // 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("<"); - try self.write(elem.tag); - - if (elem.id) |id| { - try self.write(" id=\""); - try self.writeEscaped(id); - try self.write("\""); - } - - // 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); - - // Add shorthand classes first (e.g., .bang) - for (elem.classes, 0..) |class, i| { - if (i > 0) try all_classes.append(self.allocator, ' '); - try all_classes.appendSlice(self.allocator, class); - } - - // Collect class values from attributes - for (elem.attributes) |attr| { - if (std.mem.eql(u8, attr.name, "class")) { - if (attr.value) |value| { - var evaluated: []const u8 = undefined; - - 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 { - const expr_value = self.evaluateExpression(value); - evaluated = try expr_value.toString(self.allocator); - } - - if (evaluated.len > 0) { - if (all_classes.items.len > 0) { - try all_classes.append(self.allocator, ' '); - } - try all_classes.appendSlice(self.allocator, evaluated); - } - } - } - } - - // 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")) { - 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")) { - continue; - } else { - 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 if (value.len >= 1 and (value[0] == '{' or value[0] == '[')) { - evaluated = value; - } else { - const expr_value = self.evaluateExpression(value); - evaluated = try expr_value.toString(self.allocator); - } - - if (std.mem.eql(u8, attr.name, "style") and evaluated.len > 0 and evaluated[0] == '{') { - evaluated = try parseObjectToCSS(self.allocator, evaluated); - } - - if (attr.escaped) { - try self.writeEscaped(evaluated); - } else { - try self.write(evaluated); - } - try self.write("\""); - } - } else { - // Boolean attribute: checked -> checked="checked" - try self.write(" "); - try self.write(attr.name); - try self.write("=\""); - try self.write(attr.name); - 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) - const value = self.evaluateExpression(spread); - switch (value) { - .object => |obj| { - // Render object properties as attributes - var iter = obj.iterator(); - while (iter.next()) |entry| { - const attr_value = entry.value_ptr.*; - const str = try attr_value.toString(self.allocator); - try self.write(" "); - try self.write(entry.key_ptr.*); - try self.write("=\""); - try self.writeEscaped(str); - try self.write("\""); - } - }, - else => { - // Fall back to parsing as object literal string - try self.writeSpreadAttributes(spread); - }, - } - } - - if (is_void and self.options.self_closing) { - try self.write("/>"); - try self.writeNewline(); - return; - } - - try self.write(">"); - - const has_inline = elem.inline_text != null and elem.inline_text.?.len > 0; - const has_buffered = elem.buffered_code != null; - const has_children = elem.children.len > 0; - - if (has_inline) { - try self.writeTextSegments(elem.inline_text.?); - } - - if (has_buffered) { - const code = elem.buffered_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); - } - } - - 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: - // Multi-line content has newline: - const has_multiple_lines = std.mem.indexOfScalar(u8, raw.content, '\n') != null; - if (has_multiple_lines and !has_inline and !has_buffered) { - try self.write("\n"); - try self.writeRawTextPreserved(raw.content); - } else { - // Single line - strip leading whitespace - const stripped = std.mem.trimLeft(u8, raw.content, " \t"); - try self.write(stripped); - } - }, - .element => |child_elem| { - // Nested element in whitespace-preserving context (e.g., pre > code) - try self.visitElementPreserved(child_elem); - }, - else => try self.visitNode(child), - } - } - } else { - if (!has_inline and !has_buffered) try self.writeNewline(); - self.depth += 1; - for (elem.children) |child| { - try self.visitNode(child); - } - self.depth -= 1; - if (!has_inline and !has_buffered) try self.writeIndent(); - } - } - - try self.write(""); - try self.writeNewline(); - } - - fn visitText(self: *Runtime, text: ast.Text) Error!void { - try self.writeIndent(); - try self.writeTextSegments(text.segments); - 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(""); - } - - fn visitComment(self: *Runtime, comment: ast.Comment) Error!void { - if (!comment.rendered) return; - - try self.writeIndent(); - 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(); - } - - fn visitConditional(self: *Runtime, cond: ast.Conditional) Error!void { - for (cond.branches) |branch| { - const should_render = if (branch.condition) |condition| blk: { - const value = self.evaluateExpression(condition); - const truthy = value.isTruthy(); - break :blk if (branch.is_unless) !truthy else truthy; - } else true; // else branch - - if (should_render) { - for (branch.children) |child| { - try self.visitNode(child); - } - return; // Only render first matching branch - } - } - } - - fn visitEach(self: *Runtime, each: ast.Each) Error!void { - const collection = self.evaluateExpression(each.collection); - - switch (collection) { - .array => |items| { - if (items.len == 0) { - // Render else branch if collection is empty - for (each.else_children) |child| { - try self.visitNode(child); - } - return; - } - - // Push scope once before the loop - reuse for all iterations - try self.context.pushScope(); - defer self.context.popScope(); - - // Get direct pointers to loop variables - avoids hash lookup per iteration - const value_ptr = try self.context.getOrPutPtr(each.value_name); - const index_ptr: ?*Value = if (each.index_name) |idx_name| - try self.context.getOrPutPtr(idx_name) - else - null; - - for (items, 0..) |item, index| { - // Direct pointer update - no hash lookup! - value_ptr.* = item; - if (index_ptr) |ptr| { - ptr.* = Value.integer(@intCast(index)); - } - - for (each.children) |child| { - try self.visitNode(child); - } - } - }, - .object => |obj| { - if (obj.count() == 0) { - for (each.else_children) |child| { - try self.visitNode(child); - } - return; - } - - // Push scope once before the loop - reuse for all iterations - try self.context.pushScope(); - defer self.context.popScope(); - - // Get direct pointers to loop variables - const value_ptr = try self.context.getOrPutPtr(each.value_name); - const index_ptr: ?*Value = if (each.index_name) |idx_name| - try self.context.getOrPutPtr(idx_name) - else - null; - - var iter = obj.iterator(); - while (iter.next()) |entry| { - // Direct pointer update - no hash lookup! - value_ptr.* = entry.value_ptr.*; - if (index_ptr) |ptr| { - ptr.* = Value.str(entry.key_ptr.*); - } - - for (each.children) |child| { - try self.visitNode(child); - } - } - }, - else => { - // Not iterable - render else branch - for (each.else_children) |child| { - try self.visitNode(child); - } - }, - } - } - - fn visitWhile(self: *Runtime, whl: ast.While) Error!void { - var iterations: usize = 0; - const max_iterations: usize = 10000; // Safety limit - - while (iterations < max_iterations) { - const condition = self.evaluateExpression(whl.condition); - if (!condition.isTruthy()) break; - - for (whl.children) |child| { - try self.visitNode(child); - } - iterations += 1; - } - } - - fn visitCase(self: *Runtime, c: ast.Case) Error!void { - const expr_value = self.evaluateExpression(c.expression); - - // Find matching when clause - var matched = false; - var fall_through = false; - - for (c.whens) |when| { - // Check if we're falling through from previous match - if (fall_through) { - if (when.has_break) { - // Explicit break - stop here without output - return; - } - if (when.children.len > 0) { - // Has content - render it - for (when.children) |child| { - try self.visitNode(child); - } - return; - } - // Empty body - continue falling through - continue; - } - - // Parse when value and compare - const when_value = self.evaluateExpression(when.value); - - if (self.valuesEqual(expr_value, when_value)) { - matched = true; - - if (when.has_break) { - // Explicit break - output nothing - return; - } - - if (when.children.len == 0) { - // Empty body - fall through to next - fall_through = true; - continue; - } - - // Render matching case - for (when.children) |child| { - try self.visitNode(child); - } - return; - } - } - - // No match - render default if present - if (!matched or fall_through) { - for (c.default_children) |child| { - try self.visitNode(child); - } - } - } - - /// Compares two Values for equality. - fn valuesEqual(self: *Runtime, a: Value, b: Value) bool { - _ = self; - return switch (a) { - .int => |ai| switch (b) { - .int => |bi| ai == bi, - .float => |bf| @as(f64, @floatFromInt(ai)) == bf, - .string => |bs| blk: { - const parsed = std.fmt.parseInt(i64, bs, 10) catch break :blk false; - break :blk ai == parsed; - }, - else => false, - }, - .float => |af| switch (b) { - .int => |bi| af == @as(f64, @floatFromInt(bi)), - .float => |bf| af == bf, - else => false, - }, - .string => |as| switch (b) { - .string => |bs| std.mem.eql(u8, as, bs), - .int => |bi| blk: { - const parsed = std.fmt.parseInt(i64, as, 10) catch break :blk false; - break :blk parsed == bi; - }, - else => false, - }, - .bool => |ab| switch (b) { - .bool => |bb| ab == bb, - else => false, - }, - else => false, - }; - } - - fn visitMixinCall(self: *Runtime, call: ast.MixinCall) Error!void { - // First check if mixin is defined in current context (same template or preloaded) - var mixin = self.context.getMixin(call.name); - - // If not found and mixins_dir is configured, try loading from mixins directory - if (mixin == null and self.mixins_dir.len > 0) { - if (self.loadMixinFromDir(call.name)) |loaded_mixin| { - try self.context.defineMixin(loaded_mixin); - mixin = loaded_mixin; - } - } - - // If still not found, log warning and skip this mixin call - const mixin_def = mixin orelse { - log.warn("skipping, mixin '{s}' not found", .{call.name}); - return; - }; - - try self.context.pushScope(); - defer self.context.popScope(); - - // Save previous mixin context - const prev_block_content = self.mixin_block_content; - const prev_attributes = self.mixin_attributes; - defer { - self.mixin_block_content = prev_block_content; - self.mixin_attributes = prev_attributes; - } - - // Set current mixin's block content and attributes - // 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 - if (call.attributes.len > 0) { - var attrs_obj = std.StringHashMapUnmanaged(Value).empty; - for (call.attributes) |attr| { - if (attr.value) |val| { - // Strip quotes from attribute value for the object - const clean_val = try self.evaluateString(val); - attrs_obj.put(self.allocator, attr.name, Value.str(clean_val)) catch |err| { - log.warn("skipping attribute, failed to set '{s}': {}", .{ attr.name, err }); - }; - } else { - attrs_obj.put(self.allocator, attr.name, Value.boolean(true)) catch |err| { - log.warn("skipping attribute, failed to set '{s}': {}", .{ attr.name, err }); - }; - } - } - try self.context.set("attributes", .{ .object = attrs_obj }); - } else { - try self.context.set("attributes", .{ .object = std.StringHashMapUnmanaged(Value).empty }); - } - - // Bind arguments to parameters - const regular_params = if (mixin_def.has_rest and mixin_def.params.len > 0) - mixin_def.params.len - 1 - else - mixin_def.params.len; - - // Bind regular parameters - for (mixin_def.params[0..regular_params], 0..) |param, i| { - const value = if (i < call.args.len) - self.evaluateExpression(call.args[i]) - else if (i < mixin_def.defaults.len and mixin_def.defaults[i] != null) - self.evaluateExpression(mixin_def.defaults[i].?) - else - Value.null; - - try self.context.set(param, value); - } - - // Bind rest parameter if present - if (mixin_def.has_rest and mixin_def.params.len > 0) { - const rest_param = mixin_def.params[mixin_def.params.len - 1]; - const rest_start = regular_params; - - if (rest_start < call.args.len) { - // Collect remaining arguments into an array - const rest_count = call.args.len - rest_start; - const rest_array = self.allocator.alloc(Value, rest_count) catch return error.OutOfMemory; - for (call.args[rest_start..], 0..) |arg, i| { - rest_array[i] = self.evaluateExpression(arg); - } - try self.context.set(rest_param, .{ .array = rest_array }); - } else { - // No rest arguments, set empty array - const empty = self.allocator.alloc(Value, 0) catch return error.OutOfMemory; - try self.context.set(rest_param, .{ .array = empty }); - } - } - - // Render mixin body - for (mixin_def.children) |child| { - try self.visitNode(child); - } - } - - /// Loads a mixin from the mixins directory by name. - /// Searches for files named {name}.pug or iterates through all .pug files. - /// Note: The source file memory is intentionally not freed to keep AST slices valid. - fn loadMixinFromDir(self: *Runtime, name: []const u8) ?ast.MixinDef { - const resolver = self.file_resolver orelse return null; - - // First try: look for a file named {name}.pug - const specific_path = std.fs.path.join(self.allocator, &.{ self.mixins_dir, name }) catch |err| { - log.warn("skipping mixin lookup, failed to join path for '{s}': {}", .{ name, err }); return null; - }; - defer self.allocator.free(specific_path); - - const with_ext = std.fmt.allocPrint(self.allocator, "{s}.pug", .{specific_path}) catch |err| { - log.warn("skipping mixin lookup, failed to allocate path for '{s}': {}", .{ name, err }); - return null; - }; - defer self.allocator.free(with_ext); - - if (resolver(self.allocator, with_ext)) |source| { - // Note: source is intentionally not freed - AST nodes contain slices into it - if (self.parseMixinFromSource(source, name)) |mixin_def| { - return mixin_def; - } - // Only free if we didn't find the mixin we wanted - self.allocator.free(source); } - - // Second try: iterate through all .pug files in mixins directory - // Use cwd().openDir for relative paths, openDirAbsolute for absolute paths - var dir = if (std.fs.path.isAbsolute(self.mixins_dir)) - std.fs.openDirAbsolute(self.mixins_dir, .{ .iterate = true }) catch |err| { - log.warn("skipping mixins directory scan, failed to open '{s}': {}", .{ self.mixins_dir, err }); - return null; + if (mem.eql(u8, key, "style")) { + if (self.style_idx) |idx| { + return self.entries.items[idx].value; } - else - std.fs.cwd().openDir(self.mixins_dir, .{ .iterate = true }) catch |err| { - log.warn("skipping mixins directory scan, failed to open '{s}': {}", .{ self.mixins_dir, err }); - return null; - }; - defer dir.close(); - - var iter = dir.iterate(); - while (iter.next() catch |err| { - log.warn("skipping mixins directory scan, iteration failed: {}", .{err}); return null; - }) |entry| { - if (entry.kind != .file) continue; - if (!std.mem.endsWith(u8, entry.name, ".pug")) continue; - - const file_path = std.fs.path.join(self.allocator, &.{ self.mixins_dir, entry.name }) catch |err| { - log.warn("skipping mixin file, failed to join path for '{s}': {}", .{ entry.name, err }); - continue; - }; - defer self.allocator.free(file_path); - - if (resolver(self.allocator, file_path)) |source| { - // Note: source is intentionally not freed - AST nodes contain slices into it - if (self.parseMixinFromSource(source, name)) |mixin_def| { - return mixin_def; - } - // Only free if we didn't find the mixin we wanted - self.allocator.free(source); + } + // Linear search for other keys + for (self.entries.items) |entry| { + if (mem.eql(u8, entry.key, key)) { + return entry.value; } } - return null; } - /// Parses a source file and extracts a mixin definition by name. - fn parseMixinFromSource(self: *Runtime, source: []const u8, name: []const u8) ?ast.MixinDef { - var lexer = Lexer.init(self.allocator, source); - const tokens = lexer.tokenize() catch |err| { - log.warn("skipping mixin file, tokenize failed for '{s}': {}", .{ name, err }); - return null; - }; - // Note: lexer is not deinitialized - tokens contain slices into source - - var parser = Parser.init(self.allocator, tokens); - const doc = parser.parse() catch |err| { - log.warn("skipping mixin file, parse failed for '{s}': {}", .{ name, err }); - return null; - }; - - // Find the mixin definition with the matching name - for (doc.nodes) |node| { - if (node == .mixin_def) { - if (std.mem.eql(u8, node.mixin_def.name, name)) { - return node.mixin_def; - } - } + /// Find index of a key (O(1) for class/style, O(n) for others) + fn findKey(self: *const MergedAttrs, key: []const u8) ?usize { + if (mem.eql(u8, key, "class")) return self.class_idx; + if (mem.eql(u8, key, "style")) return self.style_idx; + for (self.entries.items, 0..) |entry, i| { + if (mem.eql(u8, entry.key, key)) return i; } - return null; } +}; - /// Renders the mixin block content (for `block` keyword inside mixins). - fn visitMixinBlock(self: *Runtime) Error!void { - if (self.mixin_block_content) |block_children| { - for (block_children) |child| { - try self.visitNode(child); - } - } +pub const MergedAttrEntry = struct { + key: []const u8, + value: MergedAttrValue, +}; + +pub const MergedAttrValue = union(enum) { + string: []const u8, + class_array: [][]const u8, + none, +}; + +/// Merge two attribute objects. +/// class attributes are combined into arrays. +/// style attributes are concatenated with semicolons. +/// Optimized with O(1) lookups for class/style and branch prediction hints. +pub fn merge(allocator: Allocator, a: []const MergedAttrEntry, b: []const MergedAttrEntry) !MergedAttrs { + var result = MergedAttrs.init(allocator); + errdefer result.deinit(); + + // Pre-allocate capacity to avoid reallocations (cache-friendly) + const total_entries = a.len + b.len; + if (total_entries > 0) { + try result.entries.ensureTotalCapacity(allocator, total_entries); } - fn visitCode(self: *Runtime, code: ast.Code) Error!void { - const value = self.evaluateExpression(code.expression); - const str = try value.toString(self.allocator); - - try self.writeIndent(); - if (code.escaped) { - try self.writeTextEscaped(str); - } else { - try self.write(str); - } - try self.writeNewline(); + // Process first object + for (a) |entry| { + try mergeEntry(&result, entry); } - 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(); - } + // Process second object + for (b) |entry| { + try mergeEntry(&result, entry); } - /// Visits a block node, handling inheritance (replace/append/prepend). - fn visitBlock(self: *Runtime, blk: ast.Block) Error!void { - // Check if child template overrides this block - if (self.blocks.get(blk.name)) |child_block| { - switch (child_block.mode) { - .replace => { - // Child completely replaces parent block - for (child_block.children) |child| { - try self.visitNode(child); - } - }, - .append => { - // Parent content first, then child content - for (blk.children) |child| { - try self.visitNode(child); - } - for (child_block.children) |child| { - try self.visitNode(child); - } - }, - .prepend => { - // Child content first, then parent content - for (child_block.children) |child| { - try self.visitNode(child); - } - for (blk.children) |child| { - try self.visitNode(child); - } - }, - } - } else { - // No override - render default block content - for (blk.children) |child| { - try self.visitNode(child); - } - } + return result; +} + +/// Fast key classification for branch prediction +const KeyType = enum { class, style, other }; + +inline fn classifyKey(key: []const u8) KeyType { + // Most common case: short keys that aren't class/style + // Use length check first (branch-friendly, avoids string compare) + if (key.len == 5) { + if (key[0] == 'c' and mem.eql(u8, key, "class")) return .class; + if (key[0] == 's' and mem.eql(u8, key, "style")) return .style; } + return .other; +} - /// Visits an include node, loading and rendering the included template. - fn visitInclude(self: *Runtime, inc: ast.Include) Error!void { - const included_doc = try self.loadTemplate(inc.path); +fn mergeEntry(result: *MergedAttrs, entry: MergedAttrEntry) !void { + const allocator = result.allocator; - // TODO: Handle filters (inc.filter) like :markdown + // Branch prediction: classify key type once + const key_type = classifyKey(entry.key); - // Render included template inline - for (included_doc.nodes) |node| { - try self.visitNode(node); - } - } - - // ───────────────────────────────────────────────────────────────────────── - // Expression evaluation - // ───────────────────────────────────────────────────────────────────────── - - /// Evaluates a simple expression (variable lookup or literal). - /// Optimized for common cases: simple variable names without operators. - fn evaluateExpression(self: *Runtime, expr: []const u8) Value { - // Fast path: empty expression - if (expr.len == 0) return Value.null; - - const first = expr[0]; - - // Ultra-fast path: identifier starting with a-z (most common case) - // Covers: friend, name, friend.name, friend.email, tag, etc. - if (first >= 'a' and first <= 'z') { - // Scan for operators - if none found, direct variable lookup - for (expr) |c| { - // Check for operators that require complex evaluation - if (c == '+' or c == '[' or c == '(' or c == '{' or c == ' ' or c == '\t') { - break; - } - } else { - // No operators found - direct variable lookup (most common path) - return self.lookupVariable(expr); - } - } - - // Fast path: check if expression needs trimming - const last = expr[expr.len - 1]; - const needs_trim = first == ' ' or first == '\t' or last == ' ' or last == '\t'; - const trimmed = if (needs_trim) std.mem.trim(u8, expr, " \t") else expr; - - if (trimmed.len == 0) return Value.null; - - // Fast path: simple variable lookup (no special chars except dots) - // Most expressions in templates are just variable names like "name" or "friend.email" - const first_char = trimmed[0]; - if (first_char != '"' and first_char != '\'' and first_char != '-' and - (first_char < '0' or first_char > '9')) - { - // Quick scan: if no special operators, go straight to variable lookup - var has_operator = false; - for (trimmed) |c| { - if (c == '+' or c == '[' or c == '(' or c == '{') { - has_operator = true; - break; - } - } - if (!has_operator) { - // Check for boolean/null literals - if (trimmed.len <= 5) { - if (std.mem.eql(u8, trimmed, "true")) return Value.boolean(true); - if (std.mem.eql(u8, trimmed, "false")) return Value.boolean(false); - if (std.mem.eql(u8, trimmed, "null")) return Value.null; - } - // Simple variable lookup - return self.lookupVariable(trimmed); - } - } - - // Check for string concatenation with + operator - // e.g., "btn btn-" + type or "hello " + name + "!" - if (self.findConcatOperator(trimmed)) |op_pos| { - const left = std.mem.trim(u8, trimmed[0..op_pos], " \t"); - const right = std.mem.trim(u8, trimmed[op_pos + 1 ..], " \t"); - - const left_val = self.evaluateExpression(left); - const right_val = self.evaluateExpression(right); - - const left_str = left_val.toString(self.allocator) catch return Value.null; - const right_str = right_val.toString(self.allocator) catch return Value.null; - - const result = std.fmt.allocPrint(self.allocator, "{s}{s}", .{ left_str, right_str }) catch return Value.null; - return Value.str(result); - } - - // Check for string literal - if (trimmed.len >= 2) { - if ((first_char == '"' and trimmed[trimmed.len - 1] == '"') or - (first_char == '\'' and trimmed[trimmed.len - 1] == '\'')) - { - return Value.str(trimmed[1 .. trimmed.len - 1]); - } - } - - // Check for numeric literal - if (std.fmt.parseInt(i64, trimmed, 10)) |i| { - return Value.integer(i); - } else |_| {} - - // Check for boolean literals (fallback for complex expressions) - if (std.mem.eql(u8, trimmed, "true")) return Value.boolean(true); - if (std.mem.eql(u8, trimmed, "false")) return Value.boolean(false); - if (std.mem.eql(u8, trimmed, "null")) return Value.null; - - // Variable lookup (supports dot notation: user.name) - return self.lookupVariable(trimmed); - } - - /// Finds the position of a + operator that's not inside quotes or brackets. - /// Returns null if no such operator exists. - fn findConcatOperator(_: *Runtime, expr: []const u8) ?usize { - var in_string: u8 = 0; // 0 = not in string, '"' or '\'' = in that type of string - var bracket_depth: usize = 0; - var paren_depth: usize = 0; - var brace_depth: usize = 0; - - for (expr, 0..) |c, i| { - if (in_string != 0) { - if (c == in_string) { - in_string = 0; - } else if (c == '\\' and i + 1 < expr.len) { - // Skip escaped character - we'll handle it in next iteration - continue; - } - } else { - switch (c) { - '"', '\'' => in_string = c, - '[' => bracket_depth += 1, - ']' => bracket_depth -|= 1, - '(' => paren_depth += 1, - ')' => paren_depth -|= 1, - '{' => brace_depth += 1, - '}' => brace_depth -|= 1, - '+' => { - if (bracket_depth == 0 and paren_depth == 0 and brace_depth == 0) { - return i; - } - }, - else => {}, - } - } - } - - return null; - } - - /// Looks up a variable with dot notation support. - /// Optimized for the common case of single property access (e.g., "friend.name"). - fn lookupVariable(self: *Runtime, path: []const u8) Value { - // Fast path: find first dot position - var dot_pos: ?usize = null; - for (path, 0..) |c, i| { - if (c == '.') { - dot_pos = i; - break; - } - } - - if (dot_pos == null) { - // No dots - simple variable lookup - return self.context.get(path) orelse Value.null; - } - - // Has dots - get base variable first - const base_name = path[0..dot_pos.?]; - var current = self.context.get(base_name) orelse return Value.null; - - // Property access loop - objects are most common - var pos = dot_pos.? + 1; - while (pos < path.len) { - // Find next dot or end - var end = pos; - while (end < path.len and path[end] != '.') { - end += 1; - } - const prop = path[pos..end]; - - // Most values are objects in property chains (branch hint) - if (current == .object) { + switch (key_type) { + .class => { + // O(1) lookup using cached index + if (result.class_idx) |idx| { @branchHint(.likely); - current = current.object.get(prop) orelse return Value.null; + try mergeClassValue(result, idx, entry.value); } else { - return Value.null; + @branchHint(.unlikely); + try addNewClassEntry(result, entry.value); } - - pos = end + 1; - } - - return current; - } - - /// Evaluates a string value, stripping surrounding quotes and processing escape sequences. - /// Used for HTML attribute values. - 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]; - const last = str[str.len - 1]; - if ((first == '"' and last == '"') or - (first == '\'' and last == '\'') or - (first == '`' and last == '`')) - { - 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; - }, - } + }, + .style => { + // O(1) lookup using cached index + if (result.style_idx) |idx| { + @branchHint(.likely); + try mergeStyleValue(result, idx, entry.value); } else { - try result.append(self.allocator, str[i]); - i += 1; + @branchHint(.unlikely); + try addNewStyleEntry(result, entry.value); } - } - return result.items; + }, + .other => { + // Regular attribute - linear search but rare in typical usage + const found_idx = result.findKey(entry.key); + if (found_idx) |idx| { + result.entries.items[idx].value = entry.value; + } else { + try result.entries.append(allocator, entry); + } + }, } +} - // ───────────────────────────────────────────────────────────────────────── - // Output helpers - // ───────────────────────────────────────────────────────────────────────── +/// Merge a class value with existing class at index +fn mergeClassValue(result: *MergedAttrs, idx: usize, value: MergedAttrValue) !void { + const allocator = result.allocator; + const existing = result.entries.items[idx].value; - fn writeTextSegments(self: *Runtime, segments: []const ast.TextSegment) Error!void { - for (segments) |seg| { - switch (seg) { - .literal => |lit| try self.writeTextEscaped(lit), - .interp_escaped => |expr| { - const value = self.evaluateExpression(expr); - const str = try value.toString(self.allocator); - try self.writeTextEscaped(str); + switch (value) { + .string => |s| { + switch (existing) { + .class_array => |arr| { + const new_arr = try allocator.alloc([]const u8, arr.len + 1); + @memcpy(new_arr[0..arr.len], arr); + new_arr[arr.len] = s; + try result.owned_class_arrays.append(allocator, new_arr); + result.entries.items[idx].value = .{ .class_array = new_arr }; }, - .interp_unescaped => |expr| { - const value = self.evaluateExpression(expr); - const str = try value.toString(self.allocator); - try self.write(str); + .string => |existing_s| { + const new_arr = try allocator.alloc([]const u8, 2); + new_arr[0] = existing_s; + new_arr[1] = s; + try result.owned_class_arrays.append(allocator, new_arr); + result.entries.items[idx].value = .{ .class_array = new_arr }; }, - .interp_tag => |inline_tag| { - try self.writeInlineTag(inline_tag); + .none => { + const new_arr = try allocator.alloc([]const u8, 1); + new_arr[0] = s; + try result.owned_class_arrays.append(allocator, new_arr); + result.entries.items[idx].value = .{ .class_array = new_arr }; }, } + }, + .class_array => |arr| { + switch (existing) { + .class_array => |existing_arr| { + const new_arr = try allocator.alloc([]const u8, existing_arr.len + arr.len); + @memcpy(new_arr[0..existing_arr.len], existing_arr); + @memcpy(new_arr[existing_arr.len..], arr); + try result.owned_class_arrays.append(allocator, new_arr); + result.entries.items[idx].value = .{ .class_array = new_arr }; + }, + .string => |existing_s| { + const new_arr = try allocator.alloc([]const u8, 1 + arr.len); + new_arr[0] = existing_s; + @memcpy(new_arr[1..], arr); + try result.owned_class_arrays.append(allocator, new_arr); + result.entries.items[idx].value = .{ .class_array = new_arr }; + }, + .none => { + result.entries.items[idx].value = .{ .class_array = arr }; + }, + } + }, + .none => { + // null class, convert existing to array if string + switch (existing) { + .string => |existing_s| { + const new_arr = try allocator.alloc([]const u8, 1); + new_arr[0] = existing_s; + try result.owned_class_arrays.append(allocator, new_arr); + result.entries.items[idx].value = .{ .class_array = new_arr }; + }, + else => {}, + } + }, + } +} + +/// Add a new class entry (first occurrence) +fn addNewClassEntry(result: *MergedAttrs, value: MergedAttrValue) !void { + const allocator = result.allocator; + switch (value) { + .string => |s| { + const new_arr = try allocator.alloc([]const u8, 1); + new_arr[0] = s; + try result.owned_class_arrays.append(allocator, new_arr); + result.class_idx = result.entries.items.len; + try result.entries.append(allocator, .{ .key = "class", .value = .{ .class_array = new_arr } }); + }, + .class_array => |arr| { + result.class_idx = result.entries.items.len; + try result.entries.append(allocator, .{ .key = "class", .value = .{ .class_array = arr } }); + }, + .none => {}, + } +} + +/// Merge a style value with existing style at index +fn mergeStyleValue(result: *MergedAttrs, idx: usize, value: MergedAttrValue) !void { + const allocator = result.allocator; + const existing = result.entries.items[idx].value; + + switch (value) { + .string => |s| { + switch (existing) { + .string => |existing_s| { + // Concatenate styles with semicolons + const s1 = try ensureTrailingSemicolon(allocator, existing_s); + defer allocator.free(s1); + const s2 = try ensureTrailingSemicolon(allocator, s); + defer allocator.free(s2); + + var combined: ArrayListUnmanaged(u8) = .{}; + errdefer combined.deinit(allocator); + try combined.appendSlice(allocator, s1); + try combined.appendSlice(allocator, s2); + const combined_str = try combined.toOwnedSlice(allocator); + try result.owned_strings.append(allocator, combined_str); + result.entries.items[idx].value = .{ .string = combined_str }; + }, + .none => { + const s_with_semi = try ensureTrailingSemicolon(allocator, s); + try result.owned_strings.append(allocator, s_with_semi); + result.entries.items[idx].value = .{ .string = s_with_semi }; + }, + else => {}, + } + }, + .none => { + // null style, ensure existing has trailing semicolon + switch (existing) { + .string => |existing_s| { + const s_with_semi = try ensureTrailingSemicolon(allocator, existing_s); + try result.owned_strings.append(allocator, s_with_semi); + result.entries.items[idx].value = .{ .string = s_with_semi }; + }, + else => {}, + } + }, + else => {}, + } +} + +/// Add a new style entry (first occurrence) +fn addNewStyleEntry(result: *MergedAttrs, value: MergedAttrValue) !void { + const allocator = result.allocator; + switch (value) { + .string => |s| { + const s_with_semi = try ensureTrailingSemicolon(allocator, s); + try result.owned_strings.append(allocator, s_with_semi); + result.style_idx = result.entries.items.len; + try result.entries.append(allocator, .{ .key = "style", .value = .{ .string = s_with_semi } }); + }, + .none => {}, + else => {}, + } +} + +// ============================================================================ +// Rethrow function for error handling +// ============================================================================ + +pub const PugError = struct { + message: []const u8, + filename: ?[]const u8, + line: usize, + src: ?[]const u8, + formatted_message: ?[]const u8, + allocator: Allocator, + + pub fn init(allocator: Allocator, err_message: []const u8, filename: ?[]const u8, line: usize, src: ?[]const u8) !PugError { + var pug_err = PugError{ + .message = err_message, + .filename = filename, + .line = line, + .src = src, + .formatted_message = null, + .allocator = allocator, + }; + + // Format the error message with context + if (src) |s| { + pug_err.formatted_message = try formatErrorMessage(allocator, err_message, filename, line, s); + } + + return pug_err; + } + + pub fn deinit(self: *PugError) void { + if (self.formatted_message) |msg| { + self.allocator.free(msg); } } - /// Writes an inline tag from tag interpolation: #[em text] - fn writeInlineTag(self: *Runtime, tag: ast.InlineTag) Error!void { - try self.write("<"); - try self.write(tag.tag); - - // Write ID if present - if (tag.id) |id| { - try self.write(" id=\""); - try self.writeEscaped(id); - try self.write("\""); + pub fn getMessage(self: *const PugError) []const u8 { + if (self.formatted_message) |msg| { + return msg; } - - // Write classes if present - if (tag.classes.len > 0) { - try self.write(" class=\""); - for (tag.classes, 0..) |class, i| { - if (i > 0) try self.write(" "); - try self.writeEscaped(class); - } - try self.write("\""); - } - - // Write attributes - for (tag.attributes) |attr| { - if (attr.value) |value| { - try self.write(" "); - try self.write(attr.name); - try self.write("=\""); - const evaluated = try self.evaluateString(value); - if (attr.escaped) { - try self.writeEscaped(evaluated); - } else { - try self.write(evaluated); - } - try self.write("\""); - } else { - // Boolean attribute - try self.write(" "); - try self.write(attr.name); - try self.write("=\""); - try self.write(attr.name); - try self.write("\""); - } - } - - try self.write(">"); - - // Write text content (may contain nested interpolations) - try self.writeTextSegments(tag.text_segments); - - try self.write(""); + return self.message; } - - /// Writes spread attributes from an object literal: {'data-foo': 'bar', 'data-baz': 'qux'} - fn writeSpreadAttributes(self: *Runtime, spread: []const u8) Error!void { - const trimmed = std.mem.trim(u8, spread, " \t\n\r"); - - // Must start with { and end with } - if (trimmed.len < 2 or trimmed[0] != '{' or trimmed[trimmed.len - 1] != '}') { - return; - } - - const content = std.mem.trim(u8, trimmed[1 .. trimmed.len - 1], " \t\n\r"); - if (content.len == 0) return; - - 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 (may be quoted with ' or ") - var name_start = pos; - var name_end = pos; - if (content[pos] == '\'' or content[pos] == '"') { - const quote = content[pos]; - pos += 1; - name_start = pos; - while (pos < content.len and content[pos] != quote) { - pos += 1; - } - name_end = pos; - if (pos < content.len) pos += 1; // skip closing quote - } else { - // Unquoted name - while (pos < content.len and content[pos] != ':' and content[pos] != ' ') { - pos += 1; - } - name_end = pos; - } - const name = content[name_start..name_end]; - - // 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 (handle quoted strings) - 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 - while (pos < content.len and content[pos] != ',' and content[pos] != '}') { - pos += 1; - } - value_end = pos; - // Trim trailing whitespace - while (value_end > value_start and (content[value_end - 1] == ' ' or content[value_end - 1] == '\t')) { - value_end -= 1; - } - } - const value = content[value_start..value_end]; - - // Write attribute - if (name.len > 0) { - try self.write(" "); - try self.write(name); - try self.write("=\""); - try self.writeEscaped(value); - try self.write("\""); - } - - // Skip comma - while (pos < content.len and (content[pos] == ' ' or content[pos] == ',' or content[pos] == '\t' or content[pos] == '\n' or content[pos] == '\r')) { - pos += 1; - } - } - } - - fn writeIndent(self: *Runtime) Error!void { - if (!self.options.pretty) return; - for (0..self.depth) |_| { - try self.write(self.options.indent_str); - } - } - - fn writeNewline(self: *Runtime) Error!void { - if (!self.options.pretty) return; - try self.write("\n"); - } - - fn write(self: *Runtime, str: []const u8) Error!void { - // Use addManyAsSlice for potentially faster bulk copy - const dest = try self.output.addManyAsSlice(self.allocator, str.len); - @memcpy(dest, str); - } - - fn writeEscaped(self: *Runtime, str: []const u8) Error!void { - // Fast path: use SIMD-friendly byte scan for escape characters - // Check if any escaping needed using a simple loop (compiler can vectorize) - var escape_needed: usize = str.len; - for (str, 0..) |c, i| { - // Use a lookup instead of multiple comparisons - if (escape_table[c]) { - escape_needed = i; - break; - } - } - - // No escaping needed - single fast write - if (escape_needed == str.len) { - const dest = try self.output.addManyAsSlice(self.allocator, str.len); - @memcpy(dest, str); - return; - } - - // Write prefix that doesn't need escaping - if (escape_needed > 0) { - const dest = try self.output.addManyAsSlice(self.allocator, escape_needed); - @memcpy(dest, str[0..escape_needed]); - } - - // Slow path: escape remaining characters - var start = escape_needed; - for (str[escape_needed..], escape_needed..) |c, i| { - if (escape_table[c]) { - // Write accumulated non-escaped chars first - if (i > start) { - const chunk = str[start..i]; - const dest = try self.output.addManyAsSlice(self.allocator, chunk.len); - @memcpy(dest, chunk); - } - const esc = escape_strings[c]; - const dest = try self.output.addManyAsSlice(self.allocator, esc.len); - @memcpy(dest, esc); - start = i + 1; - } - } - // Write remaining non-escaped chars - if (start < str.len) { - const chunk = str[start..]; - const dest = try self.output.addManyAsSlice(self.allocator, chunk.len); - @memcpy(dest, chunk); - } - } - - /// 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; - table['<'] = true; - table['>'] = true; - table['"'] = true; - table['\''] = true; - break :blk table; - }; - - /// Escape strings for each character (for attributes) - const escape_strings = blk: { - var strings: [256][]const u8 = [_][]const u8{""} ** 256; - strings['&'] = "&"; - strings['<'] = "<"; - strings['>'] = ">"; - strings['"'] = """; - 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; - }; }; -// ───────────────────────────────────────────────────────────────────────────── -// Helpers -// ───────────────────────────────────────────────────────────────────────────── - -fn isVoidElement(tag: []const u8) bool { - const void_elements = std.StaticStringMap(void).initComptime(.{ - .{ "area", {} }, .{ "base", {} }, .{ "br", {} }, - .{ "col", {} }, .{ "embed", {} }, .{ "hr", {} }, - .{ "img", {} }, .{ "input", {} }, .{ "link", {} }, - .{ "meta", {} }, .{ "param", {} }, .{ "source", {} }, - .{ "track", {} }, .{ "wbr", {} }, - }); - 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 -fn parseArrayToSpaceSeparated(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 array, 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; +fn formatErrorMessage(allocator: Allocator, err_message: []const u8, filename: ?[]const u8, line: usize, src: []const u8) ![]const u8 { + var result: ArrayListUnmanaged(u8) = .{}; errdefer result.deinit(allocator); - var pos: usize = 0; - var first = true; - while (pos < content.len) { - // Skip whitespace and commas - while (pos < content.len and (content[pos] == ' ' or content[pos] == '\t' or content[pos] == ',' or content[pos] == '\n' or content[pos] == '\r')) { - pos += 1; - } - if (pos >= content.len) break; + // Add filename and line + if (filename) |f| { + try result.appendSlice(allocator, f); + } + try result.append(allocator, ':'); - // Parse value (handle quoted strings) - var value_start = pos; - var value_end = pos; - if (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 - while (pos < content.len and content[pos] != ',' and content[pos] != ']') { - pos += 1; - } - value_end = pos; - // Trim trailing whitespace - while (value_end > value_start and (content[value_end - 1] == ' ' or content[value_end - 1] == '\t')) { - value_end -= 1; - } + // Format line number + var line_buf: [32]u8 = undefined; + const line_str = std.fmt.bufPrint(&line_buf, "{d}", .{line}) catch return error.FormatError; + try result.appendSlice(allocator, line_str); + try result.append(allocator, '\n'); + + // Split source into lines and show context + var lines_iter = mem.splitSequence(u8, src, "\n"); + var line_num: usize = 1; + while (lines_iter.next()) |src_line| { + // Show lines around the error (context window) + const start_line = if (line > 3) line - 3 else 1; + const end_line = line + 3; + + if (line_num >= start_line and line_num <= end_line) { + // Line number prefix + var num_buf: [32]u8 = undefined; + const num_str = std.fmt.bufPrint(&num_buf, "{d: >4}| ", .{line_num}) catch return error.FormatError; + try result.appendSlice(allocator, num_str); + try result.appendSlice(allocator, src_line); + try result.append(allocator, '\n'); } - const value = content[value_start..value_end]; - if (value.len > 0) { - if (!first) { - try result.append(allocator, ' '); - } - try result.appendSlice(allocator, value); - first = false; - } + line_num += 1; + + if (line_num > end_line) break; } - return result.toOwnedSlice(allocator); + // Add the original error message + try result.appendSlice(allocator, err_message); + + return try result.toOwnedSlice(allocator); } -/// Parses a JS object literal and converts it to CSS style string. -/// Input: {color: 'red', background: 'green'} -/// Output: color:red;background:green; -fn parseObjectToCSS(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 - const name_start = pos; - while (pos < content.len 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 (handle quoted strings) - 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 - while (pos < content.len and content[pos] != ',' and content[pos] != '}') { - pos += 1; - } - value_end = pos; - // Trim trailing whitespace from value - while (value_end > value_start and (content[value_end - 1] == ' ' or content[value_end - 1] == '\t')) { - value_end -= 1; - } - } - const value = content[value_start..value_end]; - - // Append property:value; - try result.appendSlice(allocator, name); - try result.append(allocator, ':'); - try result.appendSlice(allocator, value); - try result.append(allocator, ';'); - - // Skip comma - 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); +/// Rethrow an error with file context. +/// Creates a PugError with formatted message including source line context. +pub fn rethrow(allocator: Allocator, err_message: []const u8, filename: ?[]const u8, line: usize, src: ?[]const u8) !PugError { + return try PugError.init(allocator, err_message, filename, line, src); } -/// 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 -// ───────────────────────────────────────────────────────────────────────────── - -/// Compiles and renders a template string with the given data context. -/// This is the simplest API for server use - one function call does everything. -/// -/// **Recommended:** Use an arena allocator for automatic cleanup: -/// ```zig -/// var arena = std.heap.ArenaAllocator.init(base_allocator); -/// defer arena.deinit(); // Frees all template memory at once -/// -/// const html = try pugz.renderTemplate(arena.allocator(), -/// \\html -/// \\ head -/// \\ title= title -/// \\ body -/// \\ h1 Hello, #{name}! -/// , .{ .title = "My Page", .name = "World" }); -/// // Use html... arena.deinit() frees everything -/// ``` -pub fn renderTemplate(allocator: std.mem.Allocator, source: []const u8, data: anytype) ![]u8 { - // Tokenize - var lexer = Lexer.init(allocator, source); - defer lexer.deinit(); - const tokens = lexer.tokenize() catch return error.ParseError; - - // Parse - var parser = Parser.init(allocator, tokens); - const doc = parser.parse() catch return error.ParseError; - - // Render with data - return render(allocator, doc, data); -} - -/// 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(); - - // Populate context from data struct - try ctx.pushScope(); - inline for (std.meta.fields(@TypeOf(data))) |field| { - const value = @field(data, field.name); - try ctx.set(field.name, toValue(allocator, value)); - } - - var runtime = Runtime.init(allocator, &ctx, .{ .pretty = opts.pretty }); - defer runtime.deinit(); - - return runtime.renderOwned(doc); -} - -/// Converts a Zig value to a runtime Value. -/// For best performance, use an arena allocator. -pub fn toValue(allocator: std.mem.Allocator, v: anytype) Value { - const T = @TypeOf(v); - - if (T == Value) return v; - - switch (@typeInfo(T)) { - .bool => return Value.boolean(v), - .int, .comptime_int => return Value.integer(@intCast(v)), - .float, .comptime_float => return .{ .float = @floatCast(v) }, - .pointer => |ptr| { - // Handle *const [N]u8 (string literals) - if (ptr.size == .one) { - const child_info = @typeInfo(ptr.child); - if (child_info == .array and child_info.array.child == u8) { - return Value.str(v); - } - // Handle pointer to array of non-u8 (e.g., *const [3][]const u8) - if (child_info == .array) { - const arr = allocator.alloc(Value, child_info.array.len) catch return Value.null; - for (v, 0..) |item, i| { - arr[i] = toValue(allocator, item); - } - return .{ .array = arr }; - } - } - // Handle []const u8 and []u8 - if (ptr.size == .slice and ptr.child == u8) { - return Value.str(v); - } - if (ptr.size == .slice) { - // Convert slice to array value - const arr = allocator.alloc(Value, v.len) catch return Value.null; - for (v, 0..) |item, i| { - arr[i] = toValue(allocator, item); - } - return .{ .array = arr }; - } - return Value.null; - }, - .optional => { - if (v) |inner| { - return toValue(allocator, inner); - } - return Value.null; - }, - .@"struct" => |info| { - // Convert struct to object - pre-allocate for known field count - var obj = std.StringHashMapUnmanaged(Value).empty; - obj.ensureTotalCapacity(allocator, info.fields.len) catch return Value.null; - inline for (info.fields) |field| { - const field_value = @field(v, field.name); - obj.putAssumeCapacity(field.name, toValue(allocator, field_value)); - } - return .{ .object = obj }; - }, - else => return Value.null, - } -} - -// ───────────────────────────────────────────────────────────────────────────── +// ============================================================================ // Tests -// ───────────────────────────────────────────────────────────────────────────── +// ============================================================================ -test "context variable lookup" { +test "escape - no escaping needed" { const allocator = std.testing.allocator; - var ctx = Context.init(allocator); - defer ctx.deinit(); - - try ctx.pushScope(); - try ctx.set("name", Value.str("World")); - try ctx.set("count", Value.integer(42)); - - try std.testing.expectEqualStrings("World", ctx.get("name").?.string); - try std.testing.expectEqual(@as(i64, 42), ctx.get("count").?.int); - try std.testing.expect(ctx.get("undefined") == null); + const result = try escape(allocator, "foo"); + defer allocator.free(result); + try std.testing.expectEqualStrings("foo", result); } -test "context scoping" { +test "escape - less than" { const allocator = std.testing.allocator; - var ctx = Context.init(allocator); - defer ctx.deinit(); - - try ctx.pushScope(); - try ctx.set("x", Value.integer(1)); - - try ctx.pushScope(); - try ctx.set("x", Value.integer(2)); - try std.testing.expectEqual(@as(i64, 2), ctx.get("x").?.int); - - ctx.popScope(); - try std.testing.expectEqual(@as(i64, 1), ctx.get("x").?.int); + const result = try escape(allocator, "fooHello, World!

\n", html); +test "escape - all special chars" { + const allocator = std.testing.allocator; + const result = try escape(allocator, "foo&<>\"bar\""); + defer allocator.free(result); + try std.testing.expectEqualStrings("foo&<>"bar"", result); +} + +test "style - empty string" { + const allocator = std.testing.allocator; + const result = try style(allocator, .{ .string = "" }); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "style - none" { + const allocator = std.testing.allocator; + const result = try style(allocator, .none); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "style - string passthrough" { + const allocator = std.testing.allocator; + const result = try style(allocator, .{ .string = "foo: bar" }); + defer allocator.free(result); + try std.testing.expectEqualStrings("foo: bar", result); +} + +test "style - object" { + const allocator = std.testing.allocator; + const props = [_]StyleProperty{ + .{ .name = "foo", .value = "bar" }, + }; + const result = try style(allocator, .{ .object = &props }); + defer allocator.free(result); + try std.testing.expectEqualStrings("foo:bar;", result); +} + +test "style - object multiple" { + const allocator = std.testing.allocator; + const props = [_]StyleProperty{ + .{ .name = "foo", .value = "bar" }, + .{ .name = "baz", .value = "bash" }, + }; + const result = try style(allocator, .{ .object = &props }); + defer allocator.free(result); + try std.testing.expectEqualStrings("foo:bar;baz:bash;", result); +} + +test "attr - boolean true terse" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .boolean = true }, true, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key", result); +} + +test "attr - boolean true not terse" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .boolean = true }, true, false); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"key\"", result); +} + +test "attr - boolean false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .boolean = false }, true, true); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "attr - none" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .none, true, true); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "attr - number" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .number = 500 }, true, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"500\"", result); +} + +test "attr - string" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .string = "foo" }, false, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"foo\"", result); +} + +test "attr - string escaped" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .string = "foo>bar" }, true, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"foo>bar\"", result); +} + +test "attr - empty class" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "class", .{ .string = "" }, false, true); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "attr - empty style" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "style", .{ .string = "" }, false, true); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "classes - string array" { + const allocator = std.testing.allocator; + const items = [_]ClassValue{ + .{ .string = "foo" }, + .{ .string = "bar" }, + }; + const result = try classes(allocator, .{ .array = &items }, null); + defer allocator.free(result); + try std.testing.expectEqualStrings("foo bar", result); +} + +test "classes - nested array" { + const allocator = std.testing.allocator; + const inner1 = [_]ClassValue{ + .{ .string = "foo" }, + .{ .string = "bar" }, + }; + const inner2 = [_]ClassValue{ + .{ .string = "baz" }, + .{ .string = "bash" }, + }; + const items = [_]ClassValue{ + .{ .array = &inner1 }, + .{ .array = &inner2 }, + }; + const result = try classes(allocator, .{ .array = &items }, null); + defer allocator.free(result); + try std.testing.expectEqualStrings("foo bar baz bash", result); +} + +test "classes - object" { + const allocator = std.testing.allocator; + const conditions = [_]ClassCondition{ + .{ .name = "baz", .condition = true }, + .{ .name = "bash", .condition = false }, + }; + const result = try classes(allocator, .{ .object = &conditions }, null); + defer allocator.free(result); + try std.testing.expectEqualStrings("baz", result); +} + +test "classes - mixed array and object" { + const allocator = std.testing.allocator; + const inner = [_]ClassValue{ + .{ .string = "foo" }, + .{ .string = "bar" }, + }; + const conditions = [_]ClassCondition{ + .{ .name = "baz", .condition = true }, + .{ .name = "bash", .condition = false }, + }; + const items = [_]ClassValue{ + .{ .array = &inner }, + .{ .object = &conditions }, + }; + const result = try classes(allocator, .{ .array = &items }, null); + defer allocator.free(result); + try std.testing.expectEqualStrings("foo bar baz", result); +} + +test "classes - with escaping" { + const allocator = std.testing.allocator; + const inner = [_]ClassValue{ + .{ .string = "foz", result); +} + +test "attrs - simple" { + const allocator = std.testing.allocator; + const entries = [_]AttrEntry{ + .{ .key = "foo", .value = .{ .string = "bar" } }, + }; + const result = try attrs(allocator, &entries, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" foo=\"bar\"", result); +} + +test "attrs - multiple" { + const allocator = std.testing.allocator; + const entries = [_]AttrEntry{ + .{ .key = "foo", .value = .{ .string = "bar" } }, + .{ .key = "hoo", .value = .{ .string = "boo" } }, + }; + const result = try attrs(allocator, &entries, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" foo=\"bar\" hoo=\"boo\"", result); +} + +test "attrs - with class" { + const allocator = std.testing.allocator; + const class_items = [_]ClassValue{ + .{ .string = "foo" }, + .{ .object = &[_]ClassCondition{.{ .name = "bar", .condition = true }} }, + }; + const entries = [_]AttrEntry{ + .{ .key = "class", .value = .none, .is_class = true, .class_value = .{ .array = &class_items } }, + .{ .key = "foo", .value = .{ .string = "bar" } }, + }; + const result = try attrs(allocator, &entries, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" class=\"foo bar\" foo=\"bar\"", result); +} + +test "attrs - with style object" { + const allocator = std.testing.allocator; + const style_props = [_]StyleProperty{ + .{ .name = "foo", .value = "bar" }, + }; + const entries = [_]AttrEntry{ + .{ .key = "style", .value = .none, .is_style = true, .style_value = .{ .object = &style_props } }, + }; + const result = try attrs(allocator, &entries, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" style=\"foo:bar;\"", result); +} + +// ============================================================================ +// Additional tests from index.test.js +// ============================================================================ + +// attr tests - boolean combinations +test "attr - boolean true escaped=false terse=true" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .boolean = true }, false, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key", result); +} + +test "attr - boolean true escaped=true terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .boolean = true }, true, false); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"key\"", result); +} + +test "attr - boolean true escaped=false terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .boolean = true }, false, false); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"key\"", result); +} + +test "attr - boolean false escaped=false terse=true" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .boolean = false }, false, true); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "attr - boolean false escaped=true terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .boolean = false }, true, false); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "attr - boolean false escaped=false terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .boolean = false }, false, false); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "attr - none escaped=false terse=true" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .none, false, true); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "attr - none escaped=true terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .none, true, false); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "attr - none escaped=false terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .none, false, false); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +// attr number combinations +test "attr - number escaped=false terse=true" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .number = 500 }, false, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"500\"", result); +} + +test "attr - number escaped=true terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .number = 500 }, true, false); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"500\"", result); +} + +test "attr - number escaped=false terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .number = 500 }, false, false); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"500\"", result); +} + +// attr string combinations +test "attr - string escaped=true terse=true" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .string = "foo" }, true, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"foo\"", result); +} + +test "attr - string escaped=true terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .string = "foo" }, true, false); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"foo\"", result); +} + +test "attr - string escaped=false terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .string = "foo" }, false, false); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"foo\"", result); +} + +test "attr - string with > escaped=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .string = "foo>bar" }, false, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"foo>bar\"", result); +} + +test "attr - string with > escaped=true terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .string = "foo>bar" }, true, false); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"foo>bar\"", result); +} + +test "attr - string with > escaped=false terse=false" { + const allocator = std.testing.allocator; + const result = try attr(allocator, "key", .{ .string = "foo>bar" }, false, false); + defer allocator.free(result); + try std.testing.expectEqualStrings(" key=\"foo>bar\"", result); +} + +// attrs tests +test "attrs - empty string value" { + const allocator = std.testing.allocator; + const entries = [_]AttrEntry{ + .{ .key = "foo", .value = .{ .string = "" } }, + }; + const result = try attrs(allocator, &entries, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" foo=\"\"", result); +} + +test "attrs - empty class" { + const allocator = std.testing.allocator; + const entries = [_]AttrEntry{ + .{ .key = "class", .value = .{ .string = "" } }, + }; + const result = try attrs(allocator, &entries, true); + defer allocator.free(result); + try std.testing.expectEqualStrings("", result); +} + +test "attrs - style string" { + const allocator = std.testing.allocator; + const entries = [_]AttrEntry{ + .{ .key = "style", .value = .{ .string = "foo: bar;" } }, + }; + const result = try attrs(allocator, &entries, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" style=\"foo: bar;\"", result); +} + +test "attrs - class first then foo" { + const allocator = std.testing.allocator; + const class_items = [_]ClassValue{ + .{ .string = "foo" }, + .{ .object = &[_]ClassCondition{.{ .name = "bar", .condition = true }} }, + }; + const entries = [_]AttrEntry{ + .{ .key = "class", .value = .none, .is_class = true, .class_value = .{ .array = &class_items } }, + .{ .key = "foo", .value = .{ .string = "bar" } }, + }; + const result = try attrs(allocator, &entries, true); + defer allocator.free(result); + try std.testing.expectEqualStrings(" class=\"foo bar\" foo=\"bar\"", result); +} + +test "attrs - foo then class reordered" { + const allocator = std.testing.allocator; + const class_items = [_]ClassValue{ + .{ .string = "foo" }, + .{ .object = &[_]ClassCondition{.{ .name = "bar", .condition = true }} }, + }; + const entries = [_]AttrEntry{ + .{ .key = "foo", .value = .{ .string = "bar" } }, + .{ .key = "class", .value = .none, .is_class = true, .class_value = .{ .array = &class_items } }, + }; + const result = try attrs(allocator, &entries, false); + defer allocator.free(result); + // Class should come first even if listed second + try std.testing.expectEqualStrings(" class=\"foo bar\" foo=\"bar\"", result); +} + +// style tests +test "style - string with trailing semicolon" { + const allocator = std.testing.allocator; + const result = try style(allocator, .{ .string = "foo: bar;" }); + defer allocator.free(result); + try std.testing.expectEqualStrings("foo: bar;", result); +} + +// escape tests - additional +test "escape - ampersand less than greater than" { + const allocator = std.testing.allocator; + const result = try escape(allocator, "foo&<>bar"); + defer allocator.free(result); + try std.testing.expectEqualStrings("foo&<>bar", result); +} + +test "escape - ampersand less than greater than quote" { + const allocator = std.testing.allocator; + const result = try escape(allocator, "foo&<>\"bar"); + defer allocator.free(result); + try std.testing.expectEqualStrings("foo&<>"bar", result); +} + +// ============================================================================ +// Merge tests from index.test.js +// ============================================================================ + +test "merge - simple merge" { + const allocator = std.testing.allocator; + const a = [_]MergedAttrEntry{ + .{ .key = "foo", .value = .{ .string = "bar" } }, + }; + const b = [_]MergedAttrEntry{ + .{ .key = "baz", .value = .{ .string = "bash" } }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + try std.testing.expectEqual(@as(usize, 2), result.entries.items.len); + try std.testing.expectEqualStrings("bar", result.get("foo").?.string); + try std.testing.expectEqualStrings("bash", result.get("baz").?.string); +} + +test "merge - class string + class string" { + const allocator = std.testing.allocator; + const a = [_]MergedAttrEntry{ + .{ .key = "class", .value = .{ .string = "bar" } }, + }; + const b = [_]MergedAttrEntry{ + .{ .key = "class", .value = .{ .string = "bash" } }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const class_val = result.get("class").?; + try std.testing.expectEqual(@as(usize, 2), class_val.class_array.len); + try std.testing.expectEqualStrings("bar", class_val.class_array[0]); + try std.testing.expectEqualStrings("bash", class_val.class_array[1]); +} + +test "merge - class array + class string" { + const allocator = std.testing.allocator; + const class_arr = [_][]const u8{"bar"}; + const a = [_]MergedAttrEntry{ + .{ .key = "class", .value = .{ .class_array = @constCast(&class_arr) } }, + }; + const b = [_]MergedAttrEntry{ + .{ .key = "class", .value = .{ .string = "bash" } }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const class_val = result.get("class").?; + try std.testing.expectEqual(@as(usize, 2), class_val.class_array.len); + try std.testing.expectEqualStrings("bar", class_val.class_array[0]); + try std.testing.expectEqualStrings("bash", class_val.class_array[1]); +} + +test "merge - class string + class array" { + const allocator = std.testing.allocator; + const class_arr = [_][]const u8{"bash"}; + const a = [_]MergedAttrEntry{ + .{ .key = "class", .value = .{ .string = "bar" } }, + }; + const b = [_]MergedAttrEntry{ + .{ .key = "class", .value = .{ .class_array = @constCast(&class_arr) } }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const class_val = result.get("class").?; + try std.testing.expectEqual(@as(usize, 2), class_val.class_array.len); + try std.testing.expectEqualStrings("bar", class_val.class_array[0]); + try std.testing.expectEqualStrings("bash", class_val.class_array[1]); +} + +test "merge - class string + class null" { + const allocator = std.testing.allocator; + const a = [_]MergedAttrEntry{ + .{ .key = "class", .value = .{ .string = "bar" } }, + }; + const b = [_]MergedAttrEntry{ + .{ .key = "class", .value = .none }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const class_val = result.get("class").?; + try std.testing.expectEqual(@as(usize, 1), class_val.class_array.len); + try std.testing.expectEqualStrings("bar", class_val.class_array[0]); +} + +test "merge - class null + class array" { + const allocator = std.testing.allocator; + const class_arr = [_][]const u8{"bar"}; + const a = [_]MergedAttrEntry{ + .{ .key = "class", .value = .none }, + }; + const b = [_]MergedAttrEntry{ + .{ .key = "class", .value = .{ .class_array = @constCast(&class_arr) } }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const class_val = result.get("class").?; + try std.testing.expectEqual(@as(usize, 1), class_val.class_array.len); + try std.testing.expectEqualStrings("bar", class_val.class_array[0]); +} + +test "merge - empty + class array" { + const allocator = std.testing.allocator; + const class_arr = [_][]const u8{"bar"}; + const a = [_]MergedAttrEntry{}; + const b = [_]MergedAttrEntry{ + .{ .key = "class", .value = .{ .class_array = @constCast(&class_arr) } }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const class_val = result.get("class").?; + try std.testing.expectEqual(@as(usize, 1), class_val.class_array.len); + try std.testing.expectEqualStrings("bar", class_val.class_array[0]); +} + +test "merge - class array + empty" { + const allocator = std.testing.allocator; + const class_arr = [_][]const u8{"bar"}; + const a = [_]MergedAttrEntry{ + .{ .key = "class", .value = .{ .class_array = @constCast(&class_arr) } }, + }; + const b = [_]MergedAttrEntry{}; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const class_val = result.get("class").?; + try std.testing.expectEqual(@as(usize, 1), class_val.class_array.len); + try std.testing.expectEqualStrings("bar", class_val.class_array[0]); +} + +test "merge - style string + style string" { + const allocator = std.testing.allocator; + const a = [_]MergedAttrEntry{ + .{ .key = "style", .value = .{ .string = "foo:bar" } }, + }; + const b = [_]MergedAttrEntry{ + .{ .key = "style", .value = .{ .string = "baz:bash" } }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const style_val = result.get("style").?; + try std.testing.expectEqualStrings("foo:bar;baz:bash;", style_val.string); +} + +test "merge - style with semicolon + style string" { + const allocator = std.testing.allocator; + const a = [_]MergedAttrEntry{ + .{ .key = "style", .value = .{ .string = "foo:bar;" } }, + }; + const b = [_]MergedAttrEntry{ + .{ .key = "style", .value = .{ .string = "baz:bash" } }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const style_val = result.get("style").?; + try std.testing.expectEqualStrings("foo:bar;baz:bash;", style_val.string); +} + +test "merge - style string + style null" { + const allocator = std.testing.allocator; + const a = [_]MergedAttrEntry{ + .{ .key = "style", .value = .{ .string = "foo:bar" } }, + }; + const b = [_]MergedAttrEntry{ + .{ .key = "style", .value = .none }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const style_val = result.get("style").?; + try std.testing.expectEqualStrings("foo:bar;", style_val.string); +} + +test "merge - style with semicolon + style null" { + const allocator = std.testing.allocator; + const a = [_]MergedAttrEntry{ + .{ .key = "style", .value = .{ .string = "foo:bar;" } }, + }; + const b = [_]MergedAttrEntry{ + .{ .key = "style", .value = .none }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const style_val = result.get("style").?; + try std.testing.expectEqualStrings("foo:bar;", style_val.string); +} + +test "merge - style null + style string" { + const allocator = std.testing.allocator; + const a = [_]MergedAttrEntry{ + .{ .key = "style", .value = .none }, + }; + const b = [_]MergedAttrEntry{ + .{ .key = "style", .value = .{ .string = "baz:bash" } }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const style_val = result.get("style").?; + try std.testing.expectEqualStrings("baz:bash;", style_val.string); +} + +test "merge - empty + style string" { + const allocator = std.testing.allocator; + const a = [_]MergedAttrEntry{}; + const b = [_]MergedAttrEntry{ + .{ .key = "style", .value = .{ .string = "baz:bash" } }, + }; + var result = try merge(allocator, &a, &b); + defer result.deinit(); + + const style_val = result.get("style").?; + try std.testing.expectEqualStrings("baz:bash;", style_val.string); +} + +// ============================================================================ +// Rethrow tests +// ============================================================================ + +test "rethrow - basic error without src" { + const allocator = std.testing.allocator; + var pug_err = try rethrow(allocator, "test error", "foo.pug", 3, null); + defer pug_err.deinit(); + + try std.testing.expectEqualStrings("test error", pug_err.getMessage()); + try std.testing.expectEqualStrings("foo.pug", pug_err.filename.?); + try std.testing.expectEqual(@as(usize, 3), pug_err.line); +} + +test "rethrow - error with src shows context" { + const allocator = std.testing.allocator; + var pug_err = try rethrow(allocator, "test error", "foo.pug", 1, "hello world"); + defer pug_err.deinit(); + + const msg = pug_err.getMessage(); + // Should contain filename:line, source line, and error message + try std.testing.expect(mem.indexOf(u8, msg, "foo.pug:1") != null); + try std.testing.expect(mem.indexOf(u8, msg, "hello world") != null); + try std.testing.expect(mem.indexOf(u8, msg, "test error") != null); } diff --git a/src/strip_comments.zig b/src/strip_comments.zig new file mode 100644 index 0000000..2254ae7 --- /dev/null +++ b/src/strip_comments.zig @@ -0,0 +1,353 @@ +// strip_comments.zig - Zig port of pug-strip-comments +// +// Filters out comment tokens from a token stream. +// Handles both buffered and unbuffered comments with pipeless text support. + +const std = @import("std"); +const Allocator = std.mem.Allocator; + +// Import token types from lexer +const lexer = @import("lexer.zig"); +pub const Token = lexer.Token; +pub const TokenType = lexer.TokenType; + +// Import error types +const pug_error = @import("error.zig"); +pub const PugError = pug_error.PugError; + +// ============================================================================ +// Strip Comments Options +// ============================================================================ + +pub const StripCommentsOptions = struct { + /// Strip unbuffered comments (default: true) + strip_unbuffered: bool = true, + /// Strip buffered comments (default: false) + strip_buffered: bool = false, + /// Source filename for error messages + filename: ?[]const u8 = null, +}; + +// ============================================================================ +// Errors +// ============================================================================ + +pub const StripCommentsError = error{ + OutOfMemory, + UnexpectedToken, +}; + +// ============================================================================ +// Strip Comments Result +// ============================================================================ + +pub const StripCommentsResult = struct { + tokens: std.ArrayListUnmanaged(Token), + err: ?PugError = null, + + pub fn deinit(self: *StripCommentsResult, allocator: Allocator) void { + self.tokens.deinit(allocator); + } +}; + +// ============================================================================ +// Strip Comments Implementation +// ============================================================================ + +/// Strip comments from a token stream +/// Returns filtered tokens with comments removed based on options +pub fn stripComments( + allocator: Allocator, + input: []const Token, + options: StripCommentsOptions, +) StripCommentsError!StripCommentsResult { + var result = StripCommentsResult{ + .tokens = .{}, + }; + + // State tracking + var in_comment = false; + var in_pipeless_text = false; + var comment_is_buffered = false; + + for (input) |tok| { + const should_include = switch (tok.type) { + .comment => blk: { + if (in_comment) { + // Unexpected comment while already in comment + result.err = pug_error.makeError( + allocator, + "UNEXPECTED_TOKEN", + "`comment` encountered when already in a comment", + .{ + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = options.filename, + .src = null, + }, + ) catch null; + return error.UnexpectedToken; + } + // Check if this is a buffered comment + comment_is_buffered = tok.isBuffered(); + + // Determine if we should strip this comment + if (comment_is_buffered) { + in_comment = options.strip_buffered; + } else { + in_comment = options.strip_unbuffered; + } + break :blk !in_comment; + }, + + .start_pipeless_text => blk: { + if (!in_comment) { + break :blk true; + } + if (in_pipeless_text) { + // Unexpected start_pipeless_text + result.err = pug_error.makeError( + allocator, + "UNEXPECTED_TOKEN", + "`start-pipeless-text` encountered when already in pipeless text mode", + .{ + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = options.filename, + .src = null, + }, + ) catch null; + return error.UnexpectedToken; + } + in_pipeless_text = true; + break :blk false; + }, + + .end_pipeless_text => blk: { + if (!in_comment) { + break :blk true; + } + if (!in_pipeless_text) { + // Unexpected end_pipeless_text + result.err = pug_error.makeError( + allocator, + "UNEXPECTED_TOKEN", + "`end-pipeless-text` encountered when not in pipeless text mode", + .{ + .line = tok.loc.start.line, + .column = tok.loc.start.column, + .filename = options.filename, + .src = null, + }, + ) catch null; + return error.UnexpectedToken; + } + in_pipeless_text = false; + in_comment = false; + break :blk false; + }, + + // Text tokens right after comment but before pipeless text + .text, .text_html => !in_comment, + + // All other tokens + else => blk: { + if (in_pipeless_text) { + break :blk false; + } + in_comment = false; + break :blk true; + }, + }; + + if (should_include) { + try result.tokens.append(allocator, tok); + } + } + + return result; +} + +/// Convenience function - strip with default options (unbuffered only) +pub fn stripUnbufferedComments( + allocator: Allocator, + input: []const Token, +) StripCommentsError!StripCommentsResult { + return stripComments(allocator, input, .{}); +} + +/// Convenience function - strip all comments +pub fn stripAllComments( + allocator: Allocator, + input: []const Token, +) StripCommentsError!StripCommentsResult { + return stripComments(allocator, input, .{ + .strip_unbuffered = true, + .strip_buffered = true, + }); +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "stripComments - no comments" { + const allocator = std.testing.allocator; + + const tokens = [_]Token{ + .{ .type = .tag, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .val = .{ .string = "div" } }, + .{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 4 } } }, + .{ .type = .eos, .loc = .{ .start = .{ .line = 2, .column = 1 } } }, + }; + + var result = try stripComments(allocator, &tokens, .{}); + defer result.deinit(allocator); + + try std.testing.expectEqual(@as(usize, 3), result.tokens.items.len); +} + +test "stripComments - strip unbuffered comment" { + const allocator = std.testing.allocator; + + const tokens = [_]Token{ + .{ .type = .tag, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .val = .{ .string = "div" } }, + .{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 4 } } }, + .{ .type = .comment, .loc = .{ .start = .{ .line = 2, .column = 1 } }, .buffer = .{ .boolean = false } }, + .{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 4 } }, .val = .{ .string = "comment text" } }, + .{ .type = .newline, .loc = .{ .start = .{ .line = 2, .column = 16 } } }, + .{ .type = .tag, .loc = .{ .start = .{ .line = 3, .column = 1 } }, .val = .{ .string = "span" } }, + .{ .type = .eos, .loc = .{ .start = .{ .line = 4, .column = 1 } } }, + }; + + var result = try stripComments(allocator, &tokens, .{}); + defer result.deinit(allocator); + + // Should strip comment and its text, keep tags and structure + try std.testing.expectEqual(@as(usize, 5), result.tokens.items.len); + try std.testing.expectEqual(TokenType.tag, result.tokens.items[0].type); + try std.testing.expectEqual(TokenType.newline, result.tokens.items[1].type); + try std.testing.expectEqual(TokenType.newline, result.tokens.items[2].type); + try std.testing.expectEqual(TokenType.tag, result.tokens.items[3].type); + try std.testing.expectEqual(TokenType.eos, result.tokens.items[4].type); +} + +test "stripComments - keep buffered comment by default" { + const allocator = std.testing.allocator; + + const tokens = [_]Token{ + .{ .type = .tag, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .val = .{ .string = "div" } }, + .{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 4 } } }, + .{ .type = .comment, .loc = .{ .start = .{ .line = 2, .column = 1 } }, .buffer = .{ .boolean = true } }, + .{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 4 } }, .val = .{ .string = "buffered comment" } }, + .{ .type = .newline, .loc = .{ .start = .{ .line = 2, .column = 20 } } }, + .{ .type = .eos, .loc = .{ .start = .{ .line = 3, .column = 1 } } }, + }; + + var result = try stripComments(allocator, &tokens, .{}); + defer result.deinit(allocator); + + // Should keep buffered comment + try std.testing.expectEqual(@as(usize, 6), result.tokens.items.len); +} + +test "stripComments - strip buffered when option set" { + const allocator = std.testing.allocator; + + const tokens = [_]Token{ + .{ .type = .tag, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .val = .{ .string = "div" } }, + .{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 4 } } }, + .{ .type = .comment, .loc = .{ .start = .{ .line = 2, .column = 1 } }, .buffer = .{ .boolean = true } }, + .{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 4 } }, .val = .{ .string = "buffered comment" } }, + .{ .type = .newline, .loc = .{ .start = .{ .line = 2, .column = 20 } } }, + .{ .type = .eos, .loc = .{ .start = .{ .line = 3, .column = 1 } } }, + }; + + var result = try stripComments(allocator, &tokens, .{ .strip_buffered = true }); + defer result.deinit(allocator); + + // Should strip buffered comment + try std.testing.expectEqual(@as(usize, 4), result.tokens.items.len); +} + +test "stripComments - pipeless text in comment" { + const allocator = std.testing.allocator; + + const tokens = [_]Token{ + .{ .type = .comment, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .buffer = .{ .boolean = false } }, + .{ .type = .start_pipeless_text, .loc = .{ .start = .{ .line = 1, .column = 1 } } }, + .{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 3 } }, .val = .{ .string = "line 1" } }, + .{ .type = .text, .loc = .{ .start = .{ .line = 3, .column = 3 } }, .val = .{ .string = "line 2" } }, + .{ .type = .end_pipeless_text, .loc = .{ .start = .{ .line = 4, .column = 1 } } }, + .{ .type = .tag, .loc = .{ .start = .{ .line = 5, .column = 1 } }, .val = .{ .string = "div" } }, + .{ .type = .eos, .loc = .{ .start = .{ .line = 6, .column = 1 } } }, + }; + + var result = try stripComments(allocator, &tokens, .{}); + defer result.deinit(allocator); + + // Should strip everything in the comment including pipeless text + try std.testing.expectEqual(@as(usize, 2), result.tokens.items.len); + try std.testing.expectEqual(TokenType.tag, result.tokens.items[0].type); + try std.testing.expectEqual(TokenType.eos, result.tokens.items[1].type); +} + +test "stripComments - pipeless text outside comment" { + const allocator = std.testing.allocator; + + const tokens = [_]Token{ + .{ .type = .tag, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .val = .{ .string = "script" } }, + .{ .type = .dot, .loc = .{ .start = .{ .line = 1, .column = 7 } } }, + .{ .type = .start_pipeless_text, .loc = .{ .start = .{ .line = 1, .column = 8 } } }, + .{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 3 } }, .val = .{ .string = "var x = 1;" } }, + .{ .type = .end_pipeless_text, .loc = .{ .start = .{ .line = 3, .column = 1 } } }, + .{ .type = .eos, .loc = .{ .start = .{ .line = 4, .column = 1 } } }, + }; + + var result = try stripComments(allocator, &tokens, .{}); + defer result.deinit(allocator); + + // Should keep all tokens - no comments + try std.testing.expectEqual(@as(usize, 6), result.tokens.items.len); +} + +test "stripComments - keep unbuffered when option disabled" { + const allocator = std.testing.allocator; + + const tokens = [_]Token{ + .{ .type = .comment, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .buffer = .{ .boolean = false } }, + .{ .type = .text, .loc = .{ .start = .{ .line = 1, .column = 4 } }, .val = .{ .string = "keep me" } }, + .{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 11 } } }, + .{ .type = .eos, .loc = .{ .start = .{ .line = 2, .column = 1 } } }, + }; + + var result = try stripComments(allocator, &tokens, .{ .strip_unbuffered = false }); + defer result.deinit(allocator); + + // Should keep unbuffered comment + try std.testing.expectEqual(@as(usize, 4), result.tokens.items.len); +} + +test "stripAllComments - strips both types" { + const allocator = std.testing.allocator; + + const tokens = [_]Token{ + .{ .type = .comment, .loc = .{ .start = .{ .line = 1, .column = 1 } }, .buffer = .{ .boolean = false } }, + .{ .type = .text, .loc = .{ .start = .{ .line = 1, .column = 4 } }, .val = .{ .string = "unbuffered" } }, + .{ .type = .newline, .loc = .{ .start = .{ .line = 1, .column = 14 } } }, + .{ .type = .comment, .loc = .{ .start = .{ .line = 2, .column = 1 } }, .buffer = .{ .boolean = true } }, + .{ .type = .text, .loc = .{ .start = .{ .line = 2, .column = 4 } }, .val = .{ .string = "buffered" } }, + .{ .type = .newline, .loc = .{ .start = .{ .line = 2, .column = 12 } } }, + .{ .type = .tag, .loc = .{ .start = .{ .line = 3, .column = 1 } }, .val = .{ .string = "div" } }, + .{ .type = .eos, .loc = .{ .start = .{ .line = 4, .column = 1 } } }, + }; + + var result = try stripAllComments(allocator, &tokens); + defer result.deinit(allocator); + + // Should strip both comments, keep tag and structure + try std.testing.expectEqual(@as(usize, 4), result.tokens.items.len); + try std.testing.expectEqual(TokenType.newline, result.tokens.items[0].type); + try std.testing.expectEqual(TokenType.newline, result.tokens.items[1].type); + try std.testing.expectEqual(TokenType.tag, result.tokens.items[2].type); + try std.testing.expectEqual(TokenType.eos, result.tokens.items[3].type); +} diff --git a/src/template.zig b/src/template.zig new file mode 100644 index 0000000..a9d2983 --- /dev/null +++ b/src/template.zig @@ -0,0 +1,683 @@ +// template.zig - Runtime template rendering with data binding +// +// This module provides runtime data binding for Pug templates. +// It allows passing a Zig struct and rendering dynamic content. +// Reuses utilities from runtime.zig for escaping and attribute rendering. + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const pug = @import("pug.zig"); +const parser = @import("parser.zig"); +const Node = parser.Node; +const runtime = @import("runtime.zig"); + +pub const TemplateError = error{ + OutOfMemory, + LexerError, + ParserError, +}; + +/// Render context tracks state like doctype mode +pub const RenderContext = struct { + /// true = HTML5 terse mode (default), false = XHTML mode + terse: bool = true, +}; + +/// Render a template with data +pub fn renderWithData(allocator: Allocator, source: []const u8, data: anytype) ![]const u8 { + // Lex + var lex = pug.lexer.Lexer.init(allocator, source, .{}) catch return error.OutOfMemory; + defer lex.deinit(); + + const tokens = lex.getTokens() catch return error.LexerError; + + // Strip comments + var stripped = pug.strip_comments.stripComments(allocator, tokens, .{}) catch return error.OutOfMemory; + defer stripped.deinit(allocator); + + // Parse + var parse = pug.parser.Parser.init(allocator, stripped.tokens.items, null, source); + defer parse.deinit(); + + const ast = parse.parse() catch { + return error.ParserError; + }; + defer { + ast.deinit(allocator); + allocator.destroy(ast); + } + + // Render with data + var output = std.ArrayListUnmanaged(u8){}; + errdefer output.deinit(allocator); + + // Detect doctype to set terse mode + var ctx = RenderContext{}; + detectDoctype(ast, &ctx); + + try renderNode(allocator, &output, ast, data, &ctx); + + return output.toOwnedSlice(allocator); +} + +/// Scan AST for doctype and set terse mode accordingly +fn detectDoctype(node: *Node, ctx: *RenderContext) void { + if (node.type == .Doctype) { + if (node.val) |val| { + // XHTML doctypes use non-terse mode + if (std.mem.eql(u8, val, "xml") or + std.mem.eql(u8, val, "strict") or + std.mem.eql(u8, val, "transitional") or + std.mem.eql(u8, val, "frameset") or + std.mem.eql(u8, val, "1.1") or + std.mem.eql(u8, val, "basic") or + std.mem.eql(u8, val, "mobile")) + { + ctx.terse = false; + } + } + return; + } + + // Check children + for (node.nodes.items) |child| { + detectDoctype(child, ctx); + if (!ctx.terse) return; + } +} + +fn renderNode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void { + switch (node.type) { + .Block, .NamedBlock => { + for (node.nodes.items) |child| { + try renderNode(allocator, output, child, data, ctx); + } + }, + .Tag, .InterpolatedTag => try renderTag(allocator, output, node, data, ctx), + .Text => try renderText(allocator, output, node, data), + .Code => try renderCode(allocator, output, node, data, ctx), + .Comment => try renderComment(allocator, output, node), + .BlockComment => try renderBlockComment(allocator, output, node, data, ctx), + .Doctype => try renderDoctype(allocator, output, node), + .Each => try renderEach(allocator, output, node, data, ctx), + .Mixin => { + // Mixin definitions are skipped (only mixin calls render) + if (!node.call) return; + for (node.nodes.items) |child| { + try renderNode(allocator, output, child, data, ctx); + } + }, + else => { + for (node.nodes.items) |child| { + try renderNode(allocator, output, child, data, ctx); + } + }, + } +} + +fn renderTag(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void { + const name = tag.name orelse "div"; + + try output.appendSlice(allocator, "<"); + try output.appendSlice(allocator, name); + + // Render attributes using runtime.attr() + for (tag.attrs.items) |attr| { + const attr_val = try evaluateAttrValue(allocator, attr.val, data); + const attr_str = runtime.attr(allocator, attr.name, attr_val, true, ctx.terse) catch |err| switch (err) { + error.FormatError => return error.OutOfMemory, + error.OutOfMemory => return error.OutOfMemory, + }; + defer allocator.free(attr_str); + try output.appendSlice(allocator, attr_str); + } + + // Self-closing logic differs by mode: + // - HTML5 terse: void elements are self-closing without /> + // - XHTML/XML: only explicit / makes tags self-closing + const is_void = isSelfClosing(name); + const is_self_closing = if (ctx.terse) + tag.self_closing or is_void + else + tag.self_closing; + + if (is_self_closing and tag.nodes.items.len == 0 and tag.val == null) { + if (ctx.terse and !tag.self_closing) { + try output.appendSlice(allocator, ">"); + } else { + try output.appendSlice(allocator, "/>"); + } + return; + } + + try output.appendSlice(allocator, ">"); + + // Render text content + if (tag.val) |val| { + try processInterpolation(allocator, output, val, false, data); + } + + // Render children + for (tag.nodes.items) |child| { + try renderNode(allocator, output, child, data, ctx); + } + + // Close tag + if (!is_self_closing) { + try output.appendSlice(allocator, ""); + } +} + +/// Evaluate attribute value from AST to runtime.AttrValue +fn evaluateAttrValue(allocator: Allocator, val: ?[]const u8, data: anytype) !runtime.AttrValue { + _ = allocator; + const v = val orelse return .{ .boolean = true }; // No value = boolean attribute + + // Handle boolean literals + if (std.mem.eql(u8, v, "true")) return .{ .boolean = true }; + if (std.mem.eql(u8, v, "false")) return .{ .boolean = false }; + if (std.mem.eql(u8, v, "null") or std.mem.eql(u8, v, "undefined")) return .none; + + // Quoted string - extract inner value + if (v.len >= 2 and (v[0] == '"' or v[0] == '\'')) { + return .{ .string = v[1 .. v.len - 1] }; + } + + // Expression - try to look up in data + if (getFieldValue(data, v)) |value| { + return .{ .string = value }; + } + + // Unknown expression - return as string literal + return .{ .string = v }; +} + +fn renderText(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), text: *Node, data: anytype) Allocator.Error!void { + if (text.val) |val| { + try processInterpolation(allocator, output, val, false, data); + } +} + +fn renderCode(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), code: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void { + if (code.buffer) { + if (code.val) |val| { + // Check if it's a string literal (quoted) + if (val.len >= 2 and (val[0] == '"' or val[0] == '\'')) { + const inner = val[1 .. val.len - 1]; + if (code.must_escape) { + try runtime.appendEscaped(allocator, output, inner); + } else { + try output.appendSlice(allocator, inner); + } + } else if (getFieldValue(data, val)) |value| { + if (code.must_escape) { + try runtime.appendEscaped(allocator, output, value); + } else { + try output.appendSlice(allocator, value); + } + } + } + } + + for (code.nodes.items) |child| { + try renderNode(allocator, output, child, data, ctx); + } +} + +fn renderEach(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), each: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void { + const collection_name = each.obj orelse return; + const item_name = each.val orelse "item"; + _ = item_name; + + const T = @TypeOf(data); + const info = @typeInfo(T); + + if (info != .@"struct") return; + + inline for (info.@"struct".fields) |field| { + if (std.mem.eql(u8, field.name, collection_name)) { + const collection = @field(data, field.name); + const CollType = @TypeOf(collection); + const coll_info = @typeInfo(CollType); + + if (coll_info == .pointer and coll_info.pointer.size == .slice) { + for (collection) |item| { + const ItemType = @TypeOf(item); + if (ItemType == []const u8) { + for (each.nodes.items) |child| { + try renderNodeWithItem(allocator, output, child, data, item, ctx); + } + } else { + for (each.nodes.items) |child| { + try renderNode(allocator, output, child, data, ctx); + } + } + } + return; + } + } + } + + if (each.alternate) |alt| { + try renderNode(allocator, output, alt, data, ctx); + } +} + +fn renderNodeWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), node: *Node, data: anytype, item: []const u8, ctx: *const RenderContext) Allocator.Error!void { + switch (node.type) { + .Block, .NamedBlock => { + for (node.nodes.items) |child| { + try renderNodeWithItem(allocator, output, child, data, item, ctx); + } + }, + .Tag, .InterpolatedTag => try renderTagWithItem(allocator, output, node, data, item, ctx), + .Text => try renderTextWithItem(allocator, output, node, item), + .Code => { + if (node.buffer) { + if (node.must_escape) { + try runtime.appendEscaped(allocator, output, item); + } else { + try output.appendSlice(allocator, item); + } + } + }, + else => { + for (node.nodes.items) |child| { + try renderNodeWithItem(allocator, output, child, data, item, ctx); + } + }, + } +} + +fn renderTagWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), tag: *Node, data: anytype, item: []const u8, ctx: *const RenderContext) Allocator.Error!void { + const name = tag.name orelse "div"; + + try output.appendSlice(allocator, "<"); + try output.appendSlice(allocator, name); + + // Render attributes using runtime.attr() + for (tag.attrs.items) |attr| { + const attr_val = try evaluateAttrValue(allocator, attr.val, data); + const attr_str = runtime.attr(allocator, attr.name, attr_val, true, ctx.terse) catch |err| switch (err) { + error.FormatError => return error.OutOfMemory, + error.OutOfMemory => return error.OutOfMemory, + }; + defer allocator.free(attr_str); + try output.appendSlice(allocator, attr_str); + } + + const is_void = isSelfClosing(name); + const is_self_closing = if (ctx.terse) + tag.self_closing or is_void + else + tag.self_closing; + + if (is_self_closing and tag.nodes.items.len == 0 and tag.val == null) { + if (ctx.terse and !tag.self_closing) { + try output.appendSlice(allocator, ">"); + } else { + try output.appendSlice(allocator, "/>"); + } + return; + } + + try output.appendSlice(allocator, ">"); + + if (tag.val) |val| { + try processInterpolationWithItem(allocator, output, val, true, data, item); + } + + for (tag.nodes.items) |child| { + try renderNodeWithItem(allocator, output, child, data, item, ctx); + } + + if (!is_self_closing) { + try output.appendSlice(allocator, ""); + } +} + +fn renderTextWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), text: *Node, item: []const u8) Allocator.Error!void { + if (text.val) |val| { + try runtime.appendEscaped(allocator, output, val); + _ = item; + } +} + +fn processInterpolationWithItem(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), text: []const u8, escape: bool, data: anytype, item: []const u8) Allocator.Error!void { + _ = data; + var i: usize = 0; + while (i < text.len) { + if (i + 1 < text.len and text[i] == '#' and text[i + 1] == '{') { + var j = i + 2; + var brace_count: usize = 1; + while (j < text.len and brace_count > 0) { + if (text[j] == '{') brace_count += 1; + if (text[j] == '}') brace_count -= 1; + j += 1; + } + if (brace_count == 0) { + if (escape) { + try runtime.appendEscaped(allocator, output, item); + } else { + try output.appendSlice(allocator, item); + } + i = j; + continue; + } + } + if (escape) { + if (runtime.escapeChar(text[i])) |esc| { + try output.appendSlice(allocator, esc); + } else { + try output.append(allocator, text[i]); + } + } else { + try output.append(allocator, text[i]); + } + i += 1; + } +} + +fn renderComment(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), comment: *Node) Allocator.Error!void { + if (!comment.buffer) return; + try output.appendSlice(allocator, ""); +} + +fn renderBlockComment(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), comment: *Node, data: anytype, ctx: *const RenderContext) Allocator.Error!void { + if (!comment.buffer) return; + try output.appendSlice(allocator, ""); +} + +// Doctype mappings +const doctypes = std.StaticStringMap([]const u8).initComptime(.{ + .{ "html", "" }, + .{ "xml", "" }, + .{ "transitional", "" }, + .{ "strict", "" }, + .{ "frameset", "" }, + .{ "1.1", "" }, + .{ "basic", "" }, + .{ "mobile", "" }, + .{ "plist", "" }, +}); + +fn renderDoctype(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), doctype: *Node) Allocator.Error!void { + if (doctype.val) |val| { + if (doctypes.get(val)) |dt| { + try output.appendSlice(allocator, dt); + } else { + try output.appendSlice(allocator, ""); + } + } else { + try output.appendSlice(allocator, ""); + } +} + +/// Process interpolation #{expr} in text +/// escape_quotes: true for attribute values (escape "), false for text content +fn processInterpolation(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), text: []const u8, escape_quotes: bool, data: anytype) Allocator.Error!void { + var i: usize = 0; + while (i < text.len) { + if (i + 1 < text.len and text[i] == '#' and text[i + 1] == '{') { + var j = i + 2; + var brace_count: usize = 1; + while (j < text.len and brace_count > 0) { + if (text[j] == '{') brace_count += 1; + if (text[j] == '}') brace_count -= 1; + j += 1; + } + if (brace_count == 0) { + const expr = std.mem.trim(u8, text[i + 2 .. j - 1], " \t"); + if (getFieldValue(data, expr)) |value| { + if (escape_quotes) { + try runtime.appendEscaped(allocator, output, value); + } else { + // Text content: escape < > & but not quotes + try appendTextEscaped(allocator, output, value); + } + } + i = j; + continue; + } + } + // Regular character - use appropriate escaping + const c = text[i]; + if (escape_quotes) { + if (runtime.escapeChar(c)) |esc| { + try output.appendSlice(allocator, esc); + } else { + try output.append(allocator, c); + } + } else { + // Text content: escape < > & but not quotes, preserve HTML entities + switch (c) { + '<' => try output.appendSlice(allocator, "<"), + '>' => try output.appendSlice(allocator, ">"), + '&' => { + if (isHtmlEntity(text[i..])) { + try output.append(allocator, c); + } else { + try output.appendSlice(allocator, "&"); + } + }, + else => try output.append(allocator, c), + } + } + i += 1; + } +} + +/// Get a field value from the data struct by name +fn getFieldValue(data: anytype, name: []const u8) ?[]const u8 { + const T = @TypeOf(data); + const info = @typeInfo(T); + + if (info != .@"struct") return null; + + inline for (info.@"struct".fields) |field| { + if (std.mem.eql(u8, field.name, name)) { + const value = @field(data, field.name); + const ValueType = @TypeOf(value); + + if (ValueType == []const u8) { + return value; + } + + const value_info = @typeInfo(ValueType); + if (value_info == .pointer) { + const ptr = value_info.pointer; + if (ptr.size == .one) { + const child_info = @typeInfo(ptr.child); + if (child_info == .array and child_info.array.child == u8) { + return value; + } + } + } + } + } + return null; +} + +/// Escape for text content - escapes < > & (NOT quotes) +/// Preserves existing HTML entities like ’ +fn appendTextEscaped(allocator: Allocator, output: *std.ArrayListUnmanaged(u8), str: []const u8) Allocator.Error!void { + var i: usize = 0; + while (i < str.len) { + const c = str[i]; + switch (c) { + '<' => try output.appendSlice(allocator, "<"), + '>' => try output.appendSlice(allocator, ">"), + '&' => { + if (isHtmlEntity(str[i..])) { + try output.append(allocator, c); + } else { + try output.appendSlice(allocator, "&"); + } + }, + else => try output.append(allocator, c), + } + i += 1; + } +} + +/// Check if string starts with a valid HTML entity +fn isHtmlEntity(str: []const u8) bool { + if (str.len < 3 or str[0] != '&') return false; + + var i: usize = 1; + + // Numeric entity: &#digits; or &#xhex; + if (str[i] == '#') { + i += 1; + if (i >= str.len) return false; + + if (str[i] == 'x' or str[i] == 'X') { + i += 1; + if (i >= str.len) return false; + var has_hex = false; + while (i < str.len and i < 10) : (i += 1) { + const ch = str[i]; + if (ch == ';') return has_hex; + if ((ch >= '0' and ch <= '9') or + (ch >= 'a' and ch <= 'f') or + (ch >= 'A' and ch <= 'F')) + { + has_hex = true; + } else { + return false; + } + } + return false; + } + + var has_digit = false; + while (i < str.len and i < 10) : (i += 1) { + const ch = str[i]; + if (ch == ';') return has_digit; + if (ch >= '0' and ch <= '9') { + has_digit = true; + } else { + return false; + } + } + return false; + } + + // Named entity: &name; + var has_alpha = false; + while (i < str.len and i < 32) : (i += 1) { + const ch = str[i]; + if (ch == ';') return has_alpha; + if ((ch >= 'a' and ch <= 'z') or (ch >= 'A' and ch <= 'Z') or (ch >= '0' and ch <= '9')) { + has_alpha = true; + } else { + return false; + } + } + return false; +} + +fn isSelfClosing(name: []const u8) bool { + const self_closing_tags = [_][]const u8{ + "area", "base", "br", "col", "embed", "hr", "img", "input", + "link", "meta", "param", "source", "track", "wbr", + }; + for (self_closing_tags) |tag| { + if (std.mem.eql(u8, name, tag)) return true; + } + return false; +} + +// ============================================================================ +// Tests +// ============================================================================ + +test "simple interpolation" { + const allocator = std.testing.allocator; + + const html = try renderWithData(allocator, "p Hello, #{name}!", .{ .name = "World" }); + defer allocator.free(html); + + try std.testing.expectEqualStrings("

Hello, World!

", html); +} + +test "multiple interpolations" { + const allocator = std.testing.allocator; + + const html = try renderWithData(allocator, "p #{greeting}, #{name}!", .{ + .greeting = "Hello", + .name = "World", + }); + defer allocator.free(html); + + try std.testing.expectEqualStrings("

Hello, World!

", html); +} + +test "attribute with data" { + const allocator = std.testing.allocator; + + const html = try renderWithData(allocator, "a(href=url) Click", .{ .url = "/home" }); + defer allocator.free(html); + + try std.testing.expectEqualStrings("Click", html); +} + +test "buffered code" { + const allocator = std.testing.allocator; + + const html = try renderWithData(allocator, "p= message", .{ .message = "Hello" }); + defer allocator.free(html); + + try std.testing.expectEqualStrings("

Hello

", html); +} + +test "escape html" { + const allocator = std.testing.allocator; + + const html = try renderWithData(allocator, "p #{content}", .{ .content = "bold" }); + defer allocator.free(html); + + try std.testing.expectEqualStrings("

<b>bold</b>

", html); +} + +test "no data - static template" { + const allocator = std.testing.allocator; + + const html = try renderWithData(allocator, "p Hello, World!", .{}); + defer allocator.free(html); + + try std.testing.expectEqualStrings("

Hello, World!

", html); +} + +test "nested tags with data" { + const allocator = std.testing.allocator; + + const html = try renderWithData(allocator, + \\div + \\ h1 #{title} + \\ p #{body} + , .{ + .title = "Welcome", + .body = "Hello there!", + }); + defer allocator.free(html); + + try std.testing.expectEqualStrings("

Welcome

Hello there!

", html); +} diff --git a/src/test-data/pug-attrs/index.test.js b/src/test-data/pug-attrs/index.test.js new file mode 100644 index 0000000..7bb8854 --- /dev/null +++ b/src/test-data/pug-attrs/index.test.js @@ -0,0 +1,301 @@ +'use strict'; + +var assert = require('assert'); +var utils = require('util'); +var attrs = require('../'); + +var options; +function test(input, expected, locals) { + var opts = options; + locals = locals || {}; + locals.pug = locals.pug || require('pug-runtime'); + it( + utils.inspect(input).replace(/\n/g, '') + ' => ' + utils.inspect(expected), + function() { + var src = attrs(input, opts); + var localKeys = Object.keys(locals).sort(); + var output = Function( + localKeys.join(', '), + 'return (' + src + ');' + ).apply( + null, + localKeys.map(function(key) { + return locals[key]; + }) + ); + if (opts.format === 'html') { + expect(output).toBe(expected); + } else { + expect(output).toEqual(expected); + } + } + ); +} +function withOptions(opts, fn) { + describe('options: ' + utils.inspect(opts), function() { + options = opts; + fn(); + }); +} + +withOptions( + { + terse: true, + format: 'html', + runtime: function(name) { + return 'pug.' + name; + }, + }, + function() { + test([], ''); + test([{name: 'foo', val: 'false', mustEscape: true}], ''); + test([{name: 'foo', val: 'true', mustEscape: true}], ' foo'); + test([{name: 'foo', val: false, mustEscape: true}], ''); + test([{name: 'foo', val: true, mustEscape: true}], ' foo'); + test([{name: 'foo', val: 'foo', mustEscape: true}], '', {foo: false}); + test([{name: 'foo', val: 'foo', mustEscape: true}], ' foo', {foo: true}); + test([{name: 'foo', val: '"foo"', mustEscape: true}], ' foo="foo"'); + test( + [ + {name: 'foo', val: '"foo"', mustEscape: true}, + {name: 'bar', val: '"bar"', mustEscape: true}, + ], + ' foo="foo" bar="bar"' + ); + test([{name: 'foo', val: 'foo', mustEscape: true}], ' foo="fooo"', { + foo: 'fooo', + }); + test( + [ + {name: 'foo', val: 'foo', mustEscape: true}, + {name: 'bar', val: 'bar', mustEscape: true}, + ], + ' foo="fooo" bar="baro"', + {foo: 'fooo', bar: 'baro'} + ); + test( + [{name: 'style', val: '{color: "red"}', mustEscape: true}], + ' style="color:red;"' + ); + test( + [{name: 'style', val: '{color: color}', mustEscape: true}], + ' style="color:red;"', + {color: 'red'} + ); + test( + [ + {name: 'class', val: '"foo"', mustEscape: true}, + {name: 'class', val: '["bar", "baz"]', mustEscape: true}, + ], + ' class="foo bar baz"' + ); + test( + [ + {name: 'class', val: '{foo: foo}', mustEscape: true}, + {name: 'class', val: '["bar", "baz"]', mustEscape: true}, + ], + ' class="foo bar baz"', + {foo: true} + ); + test( + [ + {name: 'class', val: '{foo: foo}', mustEscape: true}, + {name: 'class', val: '["bar", "baz"]', mustEscape: true}, + ], + ' class="bar baz"', + {foo: false} + ); + test( + [ + {name: 'class', val: 'foo', mustEscape: true}, + {name: 'class', val: '""', mustEscape: true}, + ], + ' class="<foo> <str>"', + {foo: ''} + ); + test( + [ + {name: 'foo', val: '"foo"', mustEscape: true}, + {name: 'class', val: '["bar", "baz"]', mustEscape: true}, + ], + ' class="bar baz" foo="foo"' + ); + test( + [ + {name: 'class', val: '["bar", "baz"]', mustEscape: true}, + {name: 'foo', val: '"foo"', mustEscape: true}, + ], + ' class="bar baz" foo="foo"' + ); + test([{name: 'foo', val: '""', mustEscape: false}], ' foo=""'); + test( + [{name: 'foo', val: '""', mustEscape: true}], + ' foo="<foo>"' + ); + test([{name: 'foo', val: 'foo', mustEscape: false}], ' foo=""', { + foo: '', + }); + test([{name: 'foo', val: 'foo', mustEscape: true}], ' foo="<foo>"', { + foo: '', + }); + } +); +withOptions( + { + terse: false, + format: 'html', + runtime: function(name) { + return 'pug.' + name; + }, + }, + function() { + test([{name: 'foo', val: 'false', mustEscape: true}], ''); + test([{name: 'foo', val: 'true', mustEscape: true}], ' foo="foo"'); + test([{name: 'foo', val: false, mustEscape: true}], ''); + test([{name: 'foo', val: true, mustEscape: true}], ' foo="foo"'); + test([{name: 'foo', val: 'foo', mustEscape: true}], '', {foo: false}); + test([{name: 'foo', val: 'foo', mustEscape: true}], ' foo="foo"', { + foo: true, + }); + } +); + +withOptions( + { + terse: true, + format: 'object', + runtime: function(name) { + return 'pug.' + name; + }, + }, + function() { + test([], {}); + test([{name: 'foo', val: 'false', mustEscape: true}], {foo: false}); + test([{name: 'foo', val: 'true', mustEscape: true}], {foo: true}); + test([{name: 'foo', val: false, mustEscape: true}], {foo: false}); + test([{name: 'foo', val: true, mustEscape: true}], {foo: true}); + test( + [{name: 'foo', val: 'foo', mustEscape: true}], + {foo: false}, + {foo: false} + ); + test( + [{name: 'foo', val: 'foo', mustEscape: true}], + {foo: true}, + {foo: true} + ); + test([{name: 'foo', val: '"foo"', mustEscape: true}], {foo: 'foo'}); + test( + [ + {name: 'foo', val: '"foo"', mustEscape: true}, + {name: 'bar', val: '"bar"', mustEscape: true}, + ], + {foo: 'foo', bar: 'bar'} + ); + test( + [{name: 'foo', val: 'foo', mustEscape: true}], + {foo: 'fooo'}, + {foo: 'fooo'} + ); + test( + [ + {name: 'foo', val: 'foo', mustEscape: true}, + {name: 'bar', val: 'bar', mustEscape: true}, + ], + {foo: 'fooo', bar: 'baro'}, + {foo: 'fooo', bar: 'baro'} + ); + test([{name: 'style', val: '{color: "red"}', mustEscape: true}], { + style: 'color:red;', + }); + test( + [{name: 'style', val: '{color: color}', mustEscape: true}], + {style: 'color:red;'}, + {color: 'red'} + ); + test( + [ + {name: 'class', val: '"foo"', mustEscape: true}, + {name: 'class', val: '["bar", "baz"]', mustEscape: true}, + ], + {class: 'foo bar baz'} + ); + test( + [ + {name: 'class', val: '{foo: foo}', mustEscape: true}, + {name: 'class', val: '["bar", "baz"]', mustEscape: true}, + ], + {class: 'foo bar baz'}, + {foo: true} + ); + test( + [ + {name: 'class', val: '{foo: foo}', mustEscape: true}, + {name: 'class', val: '["bar", "baz"]', mustEscape: true}, + ], + {class: 'bar baz'}, + {foo: false} + ); + test( + [ + {name: 'class', val: 'foo', mustEscape: true}, + {name: 'class', val: '""', mustEscape: true}, + ], + {class: '<foo> <str>'}, + {foo: ''} + ); + test( + [ + {name: 'foo', val: '"foo"', mustEscape: true}, + {name: 'class', val: '["bar", "baz"]', mustEscape: true}, + ], + {class: 'bar baz', foo: 'foo'} + ); + test( + [ + {name: 'class', val: '["bar", "baz"]', mustEscape: true}, + {name: 'foo', val: '"foo"', mustEscape: true}, + ], + {class: 'bar baz', foo: 'foo'} + ); + test([{name: 'foo', val: '""', mustEscape: false}], {foo: ''}); + test([{name: 'foo', val: '""', mustEscape: true}], { + foo: '<foo>', + }); + test( + [{name: 'foo', val: 'foo', mustEscape: false}], + {foo: ''}, + {foo: ''} + ); + test( + [{name: 'foo', val: 'foo', mustEscape: true}], + {foo: '<foo>'}, + {foo: ''} + ); + } +); +withOptions( + { + terse: false, + format: 'object', + runtime: function(name) { + return 'pug.' + name; + }, + }, + function() { + test([{name: 'foo', val: 'false', mustEscape: true}], {foo: false}); + test([{name: 'foo', val: 'true', mustEscape: true}], {foo: true}); + test([{name: 'foo', val: false, mustEscape: true}], {foo: false}); + test([{name: 'foo', val: true, mustEscape: true}], {foo: true}); + test( + [{name: 'foo', val: 'foo', mustEscape: true}], + {foo: false}, + {foo: false} + ); + test( + [{name: 'foo', val: 'foo', mustEscape: true}], + {foo: true}, + {foo: true} + ); + } +); diff --git a/src/test-data/pug-filters/test/__snapshots__/filter-aliases.test.js.snap b/src/test-data/pug-filters/test/__snapshots__/filter-aliases.test.js.snap new file mode 100644 index 0000000..a4c74bc --- /dev/null +++ b/src/test-data/pug-filters/test/__snapshots__/filter-aliases.test.js.snap @@ -0,0 +1,284 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`filters can be aliased 1`] = ` +Object { + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 0, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 2, + "nodes": Array [ + Object { + "attrs": Array [], + "block": Object { + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 3, + "nodes": Array [ + Object { + "attrs": Array [], + "block": Object { + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 3, + "nodes": Array [ + Object { + "column": 5, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 4, + "type": "Text", + "val": "function myFunc(foo) {", + }, + Object { + "column": 1, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 5, + "type": "Text", + "val": " +", + }, + Object { + "column": 5, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 5, + "type": "Text", + "val": " return foo;", + }, + Object { + "column": 1, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 6, + "type": "Text", + "val": " +", + }, + Object { + "column": 5, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 6, + "type": "Text", + "val": "}", + }, + ], + "type": "Block", + }, + "column": 9, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 3, + "name": "minify", + "type": "Text", + "val": "function myFunc(n) { + return n; +} +", + }, + ], + "type": "Block", + }, + "column": 3, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 3, + "name": "cdata", + "type": "Text", + "val": "", + }, + ], + "type": "Block", + }, + "column": 1, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "isInline": false, + "line": 2, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", +} +`; + +exports[`options are applied before aliases 1`] = ` +Object { + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 0, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 2, + "nodes": Array [ + Object { + "attrs": Array [], + "block": Object { + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 3, + "nodes": Array [ + Object { + "attrs": Array [], + "block": Object { + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 3, + "nodes": Array [ + Object { + "column": 5, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 4, + "type": "Text", + "val": "function myFunc(foo) {", + }, + Object { + "column": 1, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 5, + "type": "Text", + "val": " +", + }, + Object { + "column": 5, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 5, + "type": "Text", + "val": " return foo;", + }, + Object { + "column": 1, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 6, + "type": "Text", + "val": " +", + }, + Object { + "column": 5, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 6, + "type": "Text", + "val": "}", + }, + ], + "type": "Block", + }, + "column": 9, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 3, + "name": "minify", + "type": "Text", + "val": "function myFunc(n) { + return n; +} +", + }, + ], + "type": "Block", + }, + "column": 3, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 3, + "name": "cdata", + "type": "Text", + "val": "", + }, + Object { + "attrs": Array [], + "block": Object { + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 7, + "nodes": Array [ + Object { + "attrs": Array [], + "block": Object { + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 7, + "nodes": Array [ + Object { + "column": 5, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 8, + "type": "Text", + "val": "function myFunc(foo) {", + }, + Object { + "column": 1, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 9, + "type": "Text", + "val": " +", + }, + Object { + "column": 5, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 9, + "type": "Text", + "val": " return foo;", + }, + Object { + "column": 1, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 10, + "type": "Text", + "val": " +", + }, + Object { + "column": 5, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 10, + "type": "Text", + "val": "}", + }, + ], + "type": "Block", + }, + "column": 9, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 7, + "name": "uglify-js", + "type": "Text", + "val": "function myFunc(n) { + return n; +} +", + }, + ], + "type": "Block", + }, + "column": 3, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "line": 7, + "name": "cdata", + "type": "Text", + "val": "", + }, + ], + "type": "Block", + }, + "column": 1, + "filename": "/packages/pug-filters/test/filter-aliases.test.js", + "isInline": false, + "line": 2, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", +} +`; + +exports[`we do not support chains of aliases 1`] = ` +Object { + "code": "PUG:FILTER_ALISE_CHAIN", + "message": "/packages/pug-filters/test/filter-aliases.test.js:3:9 + +The filter \\"minify-js\\" is an alias for \\"minify\\", which is an alias for \\"uglify-js\\". Pug does not support chains of filter aliases.", +} +`; diff --git a/src/test-data/pug-filters/test/__snapshots__/index.test.js.snap b/src/test-data/pug-filters/test/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..74a2f45 --- /dev/null +++ b/src/test-data/pug-filters/test/__snapshots__/index.test.js.snap @@ -0,0 +1,1074 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`cases/filters.cdata.input.json 1`] = ` +"{ + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Code\\", + \\"val\\": \\"users = [{ name: 'tobi', age: 2 }]\\", + \\"buffer\\": false, + \\"mustEscape\\": false, + \\"isInline\\": false, + \\"line\\": 2, + \\"filename\\": \\"filters.cdata.tokens.json\\" + }, + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"fb:users\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Each\\", + \\"obj\\": \\"users\\", + \\"val\\": \\"user\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"fb:user\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"cdata\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"val\\": \\"#{user.name}\\", + \\"line\\": 8 + } + ] + }, + \\"attrs\\": [], + \\"line\\": 7, + \\"filename\\": \\"filters.cdata.tokens.json\\", + \\"val\\": \\"\\" + } + ] + }, + \\"attrs\\": [ + { + \\"name\\": \\"age\\", + \\"val\\": \\"user.age\\", + \\"mustEscape\\": true + } + ], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 6, + \\"filename\\": \\"filters.cdata.tokens.json\\" + } + ], + \\"line\\": 6, + \\"filename\\": \\"filters.cdata.tokens.json\\" + }, + \\"line\\": 5, + \\"filename\\": \\"filters.cdata.tokens.json\\" + } + ] + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 4, + \\"filename\\": \\"filters.cdata.tokens.json\\" + } + ], + \\"line\\": 0, + \\"filename\\": \\"filters.cdata.tokens.json\\" +}" +`; + +exports[`cases/filters.coffeescript.input.json 1`] = ` +"{ + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"script\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"coffee-script\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"val\\": \\"regexp = /\\\\\\\\n/\\", + \\"line\\": 3 + } + ], + \\"line\\": 2, + \\"filename\\": \\"filters.coffeescript.tokens.json\\" + }, + \\"attrs\\": [], + \\"line\\": 2, + \\"filename\\": \\"filters.coffeescript.tokens.json\\", + \\"val\\": \\"(function() {\\\\n var regexp;\\\\n\\\\n regexp = /\\\\\\\\n/;\\\\n\\\\n}).call(this);\\\\n\\" + }, + { + \\"type\\": \\"Text\\", + \\"name\\": \\"coffee-script\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"val\\": \\"math =\\", + \\"line\\": 5 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 6 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\" square: (value) -> value * value\\", + \\"line\\": 6 + } + ], + \\"line\\": 4, + \\"filename\\": \\"filters.coffeescript.tokens.json\\" + }, + \\"attrs\\": [ + { + \\"name\\": \\"minify\\", + \\"val\\": \\"true\\", + \\"mustEscape\\": true + } + ], + \\"line\\": 4, + \\"filename\\": \\"filters.coffeescript.tokens.json\\", + \\"val\\": \\"(function(){}).call(this);\\" + } + ], + \\"line\\": 1, + \\"filename\\": \\"filters.coffeescript.tokens.json\\" + }, + \\"attrs\\": [ + { + \\"name\\": \\"type\\", + \\"val\\": \\"'text/javascript'\\", + \\"mustEscape\\": true + } + ], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 1, + \\"filename\\": \\"filters.coffeescript.tokens.json\\" + } + ], + \\"line\\": 0, + \\"filename\\": \\"filters.coffeescript.tokens.json\\" +}" +`; + +exports[`cases/filters.custom.input.json 1`] = ` +"{ + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"html\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"body\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"custom\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"val\\": \\"Line 1\\", + \\"line\\": 4 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 5 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"Line 2\\", + \\"line\\": 5 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 6 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\", + \\"line\\": 6 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 7 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"Line 4\\", + \\"line\\": 7 + } + ], + \\"line\\": 3, + \\"filename\\": \\"filters.custom.tokens.json\\" + }, + \\"attrs\\": [ + { + \\"name\\": \\"opt\\", + \\"val\\": \\"'val'\\", + \\"mustEscape\\": true + }, + { + \\"name\\": \\"num\\", + \\"val\\": \\"2\\", + \\"mustEscape\\": true + } + ], + \\"line\\": 3, + \\"filename\\": \\"filters.custom.tokens.json\\", + \\"val\\": \\"BEGINLine 1\\\\nLine 2\\\\n\\\\nLine 4END\\" + } + ], + \\"line\\": 2, + \\"filename\\": \\"filters.custom.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 2, + \\"filename\\": \\"filters.custom.tokens.json\\" + } + ], + \\"line\\": 1, + \\"filename\\": \\"filters.custom.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 1, + \\"filename\\": \\"filters.custom.tokens.json\\" + } + ], + \\"line\\": 0, + \\"filename\\": \\"filters.custom.tokens.json\\" +}" +`; + +exports[`cases/filters.include.custom.input.json 1`] = ` +"{ + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"html\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"body\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"pre\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"line\\": 4, + \\"filename\\": \\"filters.include.custom.tokens.json\\", + \\"val\\": \\"BEGINhtml\\\\n body\\\\n pre\\\\n include:custom(opt='val' num=2) filters.include.custom.pug\\\\nEND\\" + } + ], + \\"line\\": 3, + \\"filename\\": \\"filters.include.custom.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 3, + \\"filename\\": \\"filters.include.custom.tokens.json\\" + } + ], + \\"line\\": 2, + \\"filename\\": \\"filters.include.custom.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 2, + \\"filename\\": \\"filters.include.custom.tokens.json\\" + } + ], + \\"line\\": 1, + \\"filename\\": \\"filters.include.custom.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 1, + \\"filename\\": \\"filters.include.custom.tokens.json\\" + } + ], + \\"line\\": 0, + \\"filename\\": \\"filters.include.custom.tokens.json\\" +}" +`; + +exports[`cases/filters.include.input.json 1`] = ` +"{ + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"html\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"body\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"line\\": 3, + \\"filename\\": \\"filters.include.tokens.json\\", + \\"val\\": \\"

Just some markdown tests.

\\\\n

With new line.

\\\\n\\" + }, + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"script\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"line\\": 5, + \\"filename\\": \\"filters.include.tokens.json\\", + \\"val\\": \\"(function(){}).call(this);\\" + } + ], + \\"line\\": 4, + \\"filename\\": \\"filters.include.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 4, + \\"filename\\": \\"filters.include.tokens.json\\" + }, + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"script\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"line\\": 7, + \\"filename\\": \\"filters.include.tokens.json\\", + \\"val\\": \\"\\" + } + ], + \\"line\\": 6, + \\"filename\\": \\"filters.include.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 6, + \\"filename\\": \\"filters.include.tokens.json\\" + } + ], + \\"line\\": 2, + \\"filename\\": \\"filters.include.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 2, + \\"filename\\": \\"filters.include.tokens.json\\" + } + ], + \\"line\\": 1, + \\"filename\\": \\"filters.include.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 1, + \\"filename\\": \\"filters.include.tokens.json\\" + } + ], + \\"line\\": 0, + \\"filename\\": \\"filters.include.tokens.json\\" +}" +`; + +exports[`cases/filters.inline.input.json 1`] = ` +"{ + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"p\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"val\\": \\"before \\", + \\"line\\": 1, + \\"filename\\": \\"filters.inline.tokens.json\\" + }, + { + \\"type\\": \\"Text\\", + \\"name\\": \\"cdata\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"val\\": \\"inside\\", + \\"line\\": 1, + \\"filename\\": \\"filters.inline.tokens.json\\" + } + ], + \\"line\\": 1, + \\"filename\\": \\"filters.inline.tokens.json\\" + }, + \\"attrs\\": [], + \\"line\\": 1, + \\"filename\\": \\"filters.inline.tokens.json\\", + \\"val\\": \\"\\" + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\" after\\", + \\"line\\": 1, + \\"filename\\": \\"filters.inline.tokens.json\\" + } + ], + \\"line\\": 1, + \\"filename\\": \\"filters.inline.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 1, + \\"filename\\": \\"filters.inline.tokens.json\\" + } + ], + \\"line\\": 0, + \\"filename\\": \\"filters.inline.tokens.json\\" +}" +`; + +exports[`cases/filters.less.input.json 1`] = ` +"{ + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"html\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"head\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"style\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"less\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"val\\": \\"@pad: 15px;\\", + \\"line\\": 5 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 6 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"body {\\", + \\"line\\": 6 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 7 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\" padding: @pad;\\", + \\"line\\": 7 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 8 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"}\\", + \\"line\\": 8 + } + ], + \\"line\\": 4, + \\"filename\\": \\"filters.less.tokens.json\\" + }, + \\"attrs\\": [], + \\"line\\": 4, + \\"filename\\": \\"filters.less.tokens.json\\", + \\"val\\": \\"body {\\\\n padding: 15px;\\\\n}\\\\n\\" + } + ], + \\"line\\": 3, + \\"filename\\": \\"filters.less.tokens.json\\" + }, + \\"attrs\\": [ + { + \\"name\\": \\"type\\", + \\"val\\": \\"\\\\\\"text/css\\\\\\"\\", + \\"mustEscape\\": true + } + ], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 3, + \\"filename\\": \\"filters.less.tokens.json\\" + } + ], + \\"line\\": 2, + \\"filename\\": \\"filters.less.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 2, + \\"filename\\": \\"filters.less.tokens.json\\" + } + ], + \\"line\\": 1, + \\"filename\\": \\"filters.less.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 1, + \\"filename\\": \\"filters.less.tokens.json\\" + } + ], + \\"line\\": 0, + \\"filename\\": \\"filters.less.tokens.json\\" +}" +`; + +exports[`cases/filters.markdown.input.json 1`] = ` +"{ + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"html\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"body\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"markdown-it\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"val\\": \\"This is _some_ awesome **markdown**\\", + \\"line\\": 4 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 5 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"whoop.\\", + \\"line\\": 5 + } + ], + \\"line\\": 3, + \\"filename\\": \\"filters.markdown.tokens.json\\" + }, + \\"attrs\\": [], + \\"line\\": 3, + \\"filename\\": \\"filters.markdown.tokens.json\\", + \\"val\\": \\"

This is some awesome markdown\\\\nwhoop.

\\\\n\\" + } + ], + \\"line\\": 2, + \\"filename\\": \\"filters.markdown.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 2, + \\"filename\\": \\"filters.markdown.tokens.json\\" + } + ], + \\"line\\": 1, + \\"filename\\": \\"filters.markdown.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 1, + \\"filename\\": \\"filters.markdown.tokens.json\\" + } + ], + \\"line\\": 0, + \\"filename\\": \\"filters.markdown.tokens.json\\" +}" +`; + +exports[`cases/filters.nested.input.json 1`] = ` +"{ + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"script\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"cdata\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"uglify-js\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"val\\": \\"(function() {\\", + \\"line\\": 3 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 4 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\" console.log('test')\\", + \\"line\\": 4 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 5 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"})()\\", + \\"line\\": 5 + } + ], + \\"line\\": 2, + \\"filename\\": \\"filters.nested.tokens.json\\" + }, + \\"attrs\\": [], + \\"line\\": 2, + \\"filename\\": \\"filters.nested.tokens.json\\", + \\"val\\": \\"!function(){console.log(\\\\\\"test\\\\\\")}();\\" + } + ], + \\"line\\": 2, + \\"filename\\": \\"filters.nested.tokens.json\\" + }, + \\"attrs\\": [], + \\"line\\": 2, + \\"filename\\": \\"filters.nested.tokens.json\\", + \\"val\\": \\"\\" + } + ], + \\"line\\": 1, + \\"filename\\": \\"filters.nested.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 1, + \\"filename\\": \\"filters.nested.tokens.json\\" + }, + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"script\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"cdata\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"uglify-js\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"coffee-script\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"val\\": \\"(->\\", + \\"line\\": 8 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 9 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\" console.log 'test'\\", + \\"line\\": 9 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 10 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\")()\\", + \\"line\\": 10 + } + ], + \\"line\\": 7, + \\"filename\\": \\"filters.nested.tokens.json\\" + }, + \\"attrs\\": [], + \\"line\\": 7, + \\"filename\\": \\"filters.nested.tokens.json\\", + \\"val\\": \\"(function() {\\\\n (function() {\\\\n return console.log('test');\\\\n })();\\\\n\\\\n}).call(this);\\\\n\\" + } + ], + \\"line\\": 7, + \\"filename\\": \\"filters.nested.tokens.json\\" + }, + \\"attrs\\": [], + \\"line\\": 7, + \\"filename\\": \\"filters.nested.tokens.json\\", + \\"val\\": \\"(function(){!function(){console.log(\\\\\\"test\\\\\\")}()}).call(this);\\" + } + ], + \\"line\\": 7, + \\"filename\\": \\"filters.nested.tokens.json\\" + }, + \\"attrs\\": [], + \\"line\\": 7, + \\"filename\\": \\"filters.nested.tokens.json\\", + \\"val\\": \\"\\" + } + ], + \\"line\\": 6, + \\"filename\\": \\"filters.nested.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 6, + \\"filename\\": \\"filters.nested.tokens.json\\" + } + ], + \\"line\\": 0, + \\"filename\\": \\"filters.nested.tokens.json\\" +}" +`; + +exports[`cases/filters.stylus.input.json 1`] = ` +"{ + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"html\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"head\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"style\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"stylus\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"val\\": \\"body\\", + \\"line\\": 5 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\"\\\\n\\", + \\"line\\": 6 + }, + { + \\"type\\": \\"Text\\", + \\"val\\": \\" padding: 50px\\", + \\"line\\": 6 + } + ], + \\"line\\": 4, + \\"filename\\": \\"filters.stylus.tokens.json\\" + }, + \\"attrs\\": [], + \\"line\\": 4, + \\"filename\\": \\"filters.stylus.tokens.json\\", + \\"val\\": \\"body {\\\\n padding: 50px;\\\\n}\\\\n\\" + } + ], + \\"line\\": 3, + \\"filename\\": \\"filters.stylus.tokens.json\\" + }, + \\"attrs\\": [ + { + \\"name\\": \\"type\\", + \\"val\\": \\"\\\\\\"text/css\\\\\\"\\", + \\"mustEscape\\": true + } + ], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 3, + \\"filename\\": \\"filters.stylus.tokens.json\\" + } + ], + \\"line\\": 2, + \\"filename\\": \\"filters.stylus.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 2, + \\"filename\\": \\"filters.stylus.tokens.json\\" + }, + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"body\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [], + \\"line\\": 7, + \\"filename\\": \\"filters.stylus.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 7, + \\"filename\\": \\"filters.stylus.tokens.json\\" + } + ], + \\"line\\": 1, + \\"filename\\": \\"filters.stylus.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 1, + \\"filename\\": \\"filters.stylus.tokens.json\\" + } + ], + \\"line\\": 0, + \\"filename\\": \\"filters.stylus.tokens.json\\" +}" +`; + +exports[`cases/filters-empty.input.json 1`] = ` +"{ + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Code\\", + \\"val\\": \\"var users = [{ name: 'tobi', age: 2 }]\\", + \\"buffer\\": false, + \\"mustEscape\\": false, + \\"isInline\\": false, + \\"line\\": 1, + \\"filename\\": \\"filters-empty.tokens.json\\" + }, + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"fb:users\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Each\\", + \\"obj\\": \\"users\\", + \\"val\\": \\"user\\", + \\"key\\": null, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Tag\\", + \\"name\\": \\"fb:user\\", + \\"selfClosing\\": false, + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [ + { + \\"type\\": \\"Text\\", + \\"name\\": \\"cdata\\", + \\"block\\": { + \\"type\\": \\"Block\\", + \\"nodes\\": [], + \\"line\\": 6, + \\"filename\\": \\"filters-empty.tokens.json\\" + }, + \\"attrs\\": [], + \\"line\\": 6, + \\"filename\\": \\"filters-empty.tokens.json\\", + \\"val\\": \\"\\" + } + ], + \\"line\\": 5, + \\"filename\\": \\"filters-empty.tokens.json\\" + }, + \\"attrs\\": [ + { + \\"name\\": \\"age\\", + \\"val\\": \\"user.age\\", + \\"mustEscape\\": true + } + ], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 5, + \\"filename\\": \\"filters-empty.tokens.json\\" + } + ], + \\"line\\": 5, + \\"filename\\": \\"filters-empty.tokens.json\\" + }, + \\"line\\": 4, + \\"filename\\": \\"filters-empty.tokens.json\\" + } + ], + \\"line\\": 3, + \\"filename\\": \\"filters-empty.tokens.json\\" + }, + \\"attrs\\": [], + \\"attributeBlocks\\": [], + \\"isInline\\": false, + \\"line\\": 3, + \\"filename\\": \\"filters-empty.tokens.json\\" + } + ], + \\"line\\": 0, + \\"filename\\": \\"filters-empty.tokens.json\\" +}" +`; + +exports[`errors/dynamic-option.input.json 1`] = ` +Object { + "code": "PUG:FILTER_OPTION_NOT_CONSTANT", + "line": 2, + "msg": "\\"opt\\" is not constant. All filters are rendered compile-time so filter options must be constants.", +} +`; diff --git a/src/test-data/pug-filters/test/__snapshots__/per-filter-options-applied-to-nested-filters.test.js.snap b/src/test-data/pug-filters/test/__snapshots__/per-filter-options-applied-to-nested-filters.test.js.snap new file mode 100644 index 0000000..b69c7cb --- /dev/null +++ b/src/test-data/pug-filters/test/__snapshots__/per-filter-options-applied-to-nested-filters.test.js.snap @@ -0,0 +1,103 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`per filter options are applied, even to nested filters 1`] = ` +Object { + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "line": 0, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "line": 2, + "nodes": Array [ + Object { + "attrs": Array [], + "block": Object { + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "line": 3, + "nodes": Array [ + Object { + "attrs": Array [], + "block": Object { + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "line": 3, + "nodes": Array [ + Object { + "column": 5, + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "line": 4, + "type": "Text", + "val": "function myFunc(foo) {", + }, + Object { + "column": 1, + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "line": 5, + "type": "Text", + "val": " +", + }, + Object { + "column": 5, + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "line": 5, + "type": "Text", + "val": " return foo;", + }, + Object { + "column": 1, + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "line": 6, + "type": "Text", + "val": " +", + }, + Object { + "column": 5, + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "line": 6, + "type": "Text", + "val": "}", + }, + ], + "type": "Block", + }, + "column": 9, + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "line": 3, + "name": "uglify-js", + "type": "Text", + "val": "function myFunc(n) { + return n; +} +", + }, + ], + "type": "Block", + }, + "column": 3, + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "line": 3, + "name": "cdata", + "type": "Text", + "val": "", + }, + ], + "type": "Block", + }, + "column": 1, + "filename": "/packages/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js", + "isInline": false, + "line": 2, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", +} +`; diff --git a/src/test-data/pug-filters/test/cases/filters-empty.input.json b/src/test-data/pug-filters/test/cases/filters-empty.input.json new file mode 100644 index 0000000..e360d5d --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters-empty.input.json @@ -0,0 +1,84 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Code", + "val": "var users = [{ name: 'tobi', age: 2 }]", + "buffer": false, + "mustEscape": false, + "isInline": false, + "line": 1, + "filename": "filters-empty.tokens.json" + }, + { + "type": "Tag", + "name": "fb:users", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Each", + "obj": "users", + "val": "user", + "key": null, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "fb:user", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "cdata", + "block": { + "type": "Block", + "nodes": [], + "line": 6, + "filename": "filters-empty.tokens.json" + }, + "attrs": [], + "line": 6, + "filename": "filters-empty.tokens.json" + } + ], + "line": 5, + "filename": "filters-empty.tokens.json" + }, + "attrs": [ + { + "name": "age", + "val": "user.age", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "filters-empty.tokens.json" + } + ], + "line": 5, + "filename": "filters-empty.tokens.json" + }, + "line": 4, + "filename": "filters-empty.tokens.json" + } + ], + "line": 3, + "filename": "filters-empty.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "filters-empty.tokens.json" + } + ], + "line": 0, + "filename": "filters-empty.tokens.json" +} \ No newline at end of file diff --git a/src/test-data/pug-filters/test/cases/filters.cdata.input.json b/src/test-data/pug-filters/test/cases/filters.cdata.input.json new file mode 100644 index 0000000..0b6034f --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters.cdata.input.json @@ -0,0 +1,83 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Code", + "val": "users = [{ name: 'tobi', age: 2 }]", + "buffer": false, + "mustEscape": false, + "isInline": false, + "line": 2, + "filename": "filters.cdata.tokens.json" + }, + { + "type": "Tag", + "name": "fb:users", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Each", + "obj": "users", + "val": "user", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "fb:user", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "cdata", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "#{user.name}", + "line": 8 + } + ] + }, + "attrs": [], + "line": 7, + "filename": "filters.cdata.tokens.json" + } + ] + }, + "attrs": [ + { + "name": "age", + "val": "user.age", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "filters.cdata.tokens.json" + } + ], + "line": 6, + "filename": "filters.cdata.tokens.json" + }, + "line": 5, + "filename": "filters.cdata.tokens.json" + } + ] + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "filters.cdata.tokens.json" + } + ], + "line": 0, + "filename": "filters.cdata.tokens.json" +} \ No newline at end of file diff --git a/src/test-data/pug-filters/test/cases/filters.coffeescript.input.json b/src/test-data/pug-filters/test/cases/filters.coffeescript.input.json new file mode 100644 index 0000000..106339c --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters.coffeescript.input.json @@ -0,0 +1,84 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "coffee-script", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "regexp = /\\n/", + "line": 3 + } + ], + "line": 2, + "filename": "filters.coffeescript.tokens.json" + }, + "attrs": [], + "line": 2, + "filename": "filters.coffeescript.tokens.json" + }, + { + "type": "Filter", + "name": "coffee-script", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "math =", + "line": 5 + }, + { + "type": "Text", + "val": "\n", + "line": 6 + }, + { + "type": "Text", + "val": " square: (value) -> value * value", + "line": 6 + } + ], + "line": 4, + "filename": "filters.coffeescript.tokens.json" + }, + "attrs": [ + { + "name": "minify", + "val": "true", + "mustEscape": true + } + ], + "line": 4, + "filename": "filters.coffeescript.tokens.json" + } + ], + "line": 1, + "filename": "filters.coffeescript.tokens.json" + }, + "attrs": [ + { + "name": "type", + "val": "'text/javascript'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "filters.coffeescript.tokens.json" + } + ], + "line": 0, + "filename": "filters.coffeescript.tokens.json" +} \ No newline at end of file diff --git a/src/test-data/pug-filters/test/cases/filters.custom.input.json b/src/test-data/pug-filters/test/cases/filters.custom.input.json new file mode 100644 index 0000000..ae046d6 --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters.custom.input.json @@ -0,0 +1,101 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "custom", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Line 1", + "line": 4 + }, + { + "type": "Text", + "val": "\n", + "line": 5 + }, + { + "type": "Text", + "val": "Line 2", + "line": 5 + }, + { + "type": "Text", + "val": "\n", + "line": 6 + }, + { + "type": "Text", + "val": "", + "line": 6 + }, + { + "type": "Text", + "val": "\n", + "line": 7 + }, + { + "type": "Text", + "val": "Line 4", + "line": 7 + } + ], + "line": 3, + "filename": "filters.custom.tokens.json" + }, + "attrs": [ + { + "name": "opt", + "val": "'val'", + "mustEscape": true + }, + { + "name": "num", + "val": "2", + "mustEscape": true + } + ], + "line": 3, + "filename": "filters.custom.tokens.json" + } + ], + "line": 2, + "filename": "filters.custom.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "filters.custom.tokens.json" + } + ], + "line": 1, + "filename": "filters.custom.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "filters.custom.tokens.json" + } + ], + "line": 0, + "filename": "filters.custom.tokens.json" +} \ No newline at end of file diff --git a/src/test-data/pug-filters/test/cases/filters.include.custom.input.json b/src/test-data/pug-filters/test/cases/filters.include.custom.input.json new file mode 100644 index 0000000..cc99b5a --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters.include.custom.input.json @@ -0,0 +1,91 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "pre", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 4, + "filename": "filters.include.custom.tokens.json", + "path": "filters.include.custom.pug", + "fullPath": "test/cases/filters.include.custom.pug", + "str": "html\n body\n pre\n include:custom(opt='val' num=2) filters.include.custom.pug\n" + }, + "line": 4, + "filename": "filters.include.custom.tokens.json", + "filters": [ + { + "type": "IncludeFilter", + "name": "custom", + "attrs": [ + { + "name": "opt", + "val": "'val'", + "mustEscape": true + }, + { + "name": "num", + "val": "2", + "mustEscape": true + } + ], + "line": 4, + "filename": "filters.include.custom.tokens.json" + } + ] + } + ], + "line": 3, + "filename": "filters.include.custom.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "filters.include.custom.tokens.json" + } + ], + "line": 2, + "filename": "filters.include.custom.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "filters.include.custom.tokens.json" + } + ], + "line": 1, + "filename": "filters.include.custom.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "filters.include.custom.tokens.json" + } + ], + "line": 0, + "filename": "filters.include.custom.tokens.json" +} \ No newline at end of file diff --git a/src/test-data/pug-filters/test/cases/filters.include.custom.pug b/src/test-data/pug-filters/test/cases/filters.include.custom.pug new file mode 100644 index 0000000..5811147 --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters.include.custom.pug @@ -0,0 +1,4 @@ +html + body + pre + include:custom(opt='val' num=2) filters.include.custom.pug diff --git a/src/test-data/pug-filters/test/cases/filters.include.input.json b/src/test-data/pug-filters/test/cases/filters.include.input.json new file mode 100644 index 0000000..c6f57b1 --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters.include.input.json @@ -0,0 +1,160 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 3, + "filename": "filters.include.tokens.json", + "path": "some.md", + "fullPath": "test/cases/some.md", + "str": "Just _some_ markdown **tests**.\n\nWith new line.\n" + }, + "line": 3, + "filename": "filters.include.tokens.json", + "filters": [ + { + "type": "IncludeFilter", + "name": "markdown-it", + "attrs": [], + "line": 3, + "filename": "filters.include.tokens.json" + } + ] + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 5, + "filename": "filters.include.tokens.json", + "path": "include-filter-coffee.coffee", + "fullPath": "test/cases/include-filter-coffee.coffee", + "str": "math =\n square: (value) -> value * value\n" + }, + "line": 5, + "filename": "filters.include.tokens.json", + "filters": [ + { + "type": "IncludeFilter", + "name": "coffee-script", + "attrs": [ + { + "name": "minify", + "val": "true", + "mustEscape": true + } + ], + "line": 5, + "filename": "filters.include.tokens.json" + } + ] + } + ], + "line": 4, + "filename": "filters.include.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "filters.include.tokens.json" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 7, + "filename": "filters.include.tokens.json", + "path": "include-filter-coffee.coffee", + "fullPath": "test/cases/include-filter-coffee.coffee", + "str": "math =\n square: (value) -> value * value\n" + }, + "line": 7, + "filename": "filters.include.tokens.json", + "filters": [ + { + "type": "IncludeFilter", + "name": "cdata", + "attrs": [], + "line": 7, + "filename": "filters.include.tokens.json" + }, + { + "type": "IncludeFilter", + "name": "coffee-script", + "attrs": [ + { + "name": "minify", + "val": "false", + "mustEscape": true + } + ], + "line": 7, + "filename": "filters.include.tokens.json" + } + ] + } + ], + "line": 6, + "filename": "filters.include.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "filters.include.tokens.json" + } + ], + "line": 2, + "filename": "filters.include.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "filters.include.tokens.json" + } + ], + "line": 1, + "filename": "filters.include.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "filters.include.tokens.json" + } + ], + "line": 0, + "filename": "filters.include.tokens.json" +} diff --git a/src/test-data/pug-filters/test/cases/filters.inline.input.json b/src/test-data/pug-filters/test/cases/filters.inline.input.json new file mode 100644 index 0000000..5899faa --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters.inline.input.json @@ -0,0 +1,56 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "before ", + "line": 1, + "filename": "filters.inline.tokens.json" + }, + { + "type": "Filter", + "name": "cdata", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "inside", + "line": 1, + "filename": "filters.inline.tokens.json" + } + ], + "line": 1, + "filename": "filters.inline.tokens.json" + }, + "attrs": [], + "line": 1, + "filename": "filters.inline.tokens.json" + }, + { + "type": "Text", + "val": " after", + "line": 1, + "filename": "filters.inline.tokens.json" + } + ], + "line": 1, + "filename": "filters.inline.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "filters.inline.tokens.json" + } + ], + "line": 0, + "filename": "filters.inline.tokens.json" +} \ No newline at end of file diff --git a/src/test-data/pug-filters/test/cases/filters.less.input.json b/src/test-data/pug-filters/test/cases/filters.less.input.json new file mode 100644 index 0000000..f13a33a --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters.less.input.json @@ -0,0 +1,113 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "head", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "style", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "less", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "@pad: 15px;", + "line": 5 + }, + { + "type": "Text", + "val": "\n", + "line": 6 + }, + { + "type": "Text", + "val": "body {", + "line": 6 + }, + { + "type": "Text", + "val": "\n", + "line": 7 + }, + { + "type": "Text", + "val": " padding: @pad;", + "line": 7 + }, + { + "type": "Text", + "val": "\n", + "line": 8 + }, + { + "type": "Text", + "val": "}", + "line": 8 + } + ], + "line": 4, + "filename": "filters.less.tokens.json" + }, + "attrs": [], + "line": 4, + "filename": "filters.less.tokens.json" + } + ], + "line": 3, + "filename": "filters.less.tokens.json" + }, + "attrs": [ + { + "name": "type", + "val": "\"text/css\"", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "filters.less.tokens.json" + } + ], + "line": 2, + "filename": "filters.less.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "filters.less.tokens.json" + } + ], + "line": 1, + "filename": "filters.less.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "filters.less.tokens.json" + } + ], + "line": 0, + "filename": "filters.less.tokens.json" +} \ No newline at end of file diff --git a/src/test-data/pug-filters/test/cases/filters.markdown.input.json b/src/test-data/pug-filters/test/cases/filters.markdown.input.json new file mode 100644 index 0000000..4dbf95e --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters.markdown.input.json @@ -0,0 +1,70 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "markdown-it", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "This is _some_ awesome **markdown**", + "line": 4 + }, + { + "type": "Text", + "val": "\n", + "line": 5 + }, + { + "type": "Text", + "val": "whoop.", + "line": 5 + } + ], + "line": 3, + "filename": "filters.markdown.tokens.json" + }, + "attrs": [], + "line": 3, + "filename": "filters.markdown.tokens.json" + } + ], + "line": 2, + "filename": "filters.markdown.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "filters.markdown.tokens.json" + } + ], + "line": 1, + "filename": "filters.markdown.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "filters.markdown.tokens.json" + } + ], + "line": 0, + "filename": "filters.markdown.tokens.json" +} \ No newline at end of file diff --git a/src/test-data/pug-filters/test/cases/filters.nested.input.json b/src/test-data/pug-filters/test/cases/filters.nested.input.json new file mode 100644 index 0000000..8b0354a --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters.nested.input.json @@ -0,0 +1,161 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "cdata", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "uglify-js", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "(function() {", + "line": 3 + }, + { + "type": "Text", + "val": "\n", + "line": 4 + }, + { + "type": "Text", + "val": " console.log('test')", + "line": 4 + }, + { + "type": "Text", + "val": "\n", + "line": 5 + }, + { + "type": "Text", + "val": "})()", + "line": 5 + } + ], + "line": 2, + "filename": "filters.nested.tokens.json" + }, + "attrs": [], + "line": 2, + "filename": "filters.nested.tokens.json" + } + ], + "line": 2, + "filename": "filters.nested.tokens.json" + }, + "attrs": [], + "line": 2, + "filename": "filters.nested.tokens.json" + } + ], + "line": 1, + "filename": "filters.nested.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "filters.nested.tokens.json" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "cdata", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "uglify-js", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "coffee-script", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "(->", + "line": 8 + }, + { + "type": "Text", + "val": "\n", + "line": 9 + }, + { + "type": "Text", + "val": " console.log 'test'", + "line": 9 + }, + { + "type": "Text", + "val": "\n", + "line": 10 + }, + { + "type": "Text", + "val": ")()", + "line": 10 + } + ], + "line": 7, + "filename": "filters.nested.tokens.json" + }, + "attrs": [], + "line": 7, + "filename": "filters.nested.tokens.json" + } + ], + "line": 7, + "filename": "filters.nested.tokens.json" + }, + "attrs": [], + "line": 7, + "filename": "filters.nested.tokens.json" + } + ], + "line": 7, + "filename": "filters.nested.tokens.json" + }, + "attrs": [], + "line": 7, + "filename": "filters.nested.tokens.json" + } + ], + "line": 6, + "filename": "filters.nested.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "filters.nested.tokens.json" + } + ], + "line": 0, + "filename": "filters.nested.tokens.json" +} \ No newline at end of file diff --git a/src/test-data/pug-filters/test/cases/filters.stylus.input.json b/src/test-data/pug-filters/test/cases/filters.stylus.input.json new file mode 100644 index 0000000..8fec328 --- /dev/null +++ b/src/test-data/pug-filters/test/cases/filters.stylus.input.json @@ -0,0 +1,109 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "head", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "style", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Filter", + "name": "stylus", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "body", + "line": 5 + }, + { + "type": "Text", + "val": "\n", + "line": 6 + }, + { + "type": "Text", + "val": " padding: 50px", + "line": 6 + } + ], + "line": 4, + "filename": "filters.stylus.tokens.json" + }, + "attrs": [], + "line": 4, + "filename": "filters.stylus.tokens.json" + } + ], + "line": 3, + "filename": "filters.stylus.tokens.json" + }, + "attrs": [ + { + "name": "type", + "val": "\"text/css\"", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "filters.stylus.tokens.json" + } + ], + "line": 2, + "filename": "filters.stylus.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "filters.stylus.tokens.json" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 7, + "filename": "filters.stylus.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 7, + "filename": "filters.stylus.tokens.json" + } + ], + "line": 1, + "filename": "filters.stylus.tokens.json" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "filters.stylus.tokens.json" + } + ], + "line": 0, + "filename": "filters.stylus.tokens.json" +} \ No newline at end of file diff --git a/src/test-data/pug-filters/test/cases/include-filter-coffee.coffee b/src/test-data/pug-filters/test/cases/include-filter-coffee.coffee new file mode 100644 index 0000000..9723cd7 --- /dev/null +++ b/src/test-data/pug-filters/test/cases/include-filter-coffee.coffee @@ -0,0 +1,2 @@ +math = + square: (value) -> value * value diff --git a/src/test-data/pug-filters/test/cases/some.md b/src/test-data/pug-filters/test/cases/some.md new file mode 100644 index 0000000..8ea3e54 --- /dev/null +++ b/src/test-data/pug-filters/test/cases/some.md @@ -0,0 +1,3 @@ +Just _some_ markdown **tests**. + +With new line. diff --git a/src/test-data/pug-filters/test/custom-filters.js b/src/test-data/pug-filters/test/custom-filters.js new file mode 100644 index 0000000..74755e5 --- /dev/null +++ b/src/test-data/pug-filters/test/custom-filters.js @@ -0,0 +1,9 @@ +var assert = require('assert'); + +module.exports = { + custom: function(str, options) { + expect(options.opt).toBe('val'); + expect(options.num).toBe(2); + return 'BEGIN' + str + 'END'; + }, +}; diff --git a/src/test-data/pug-filters/test/errors-src/dynamic-option.jade b/src/test-data/pug-filters/test/errors-src/dynamic-option.jade new file mode 100644 index 0000000..f79dd94 --- /dev/null +++ b/src/test-data/pug-filters/test/errors-src/dynamic-option.jade @@ -0,0 +1,3 @@ +- var opt = 'a' +:cdata(option=opt) + hey diff --git a/src/test-data/pug-filters/test/errors/dynamic-option.input.json b/src/test-data/pug-filters/test/errors/dynamic-option.input.json new file mode 100644 index 0000000..3728761 --- /dev/null +++ b/src/test-data/pug-filters/test/errors/dynamic-option.input.json @@ -0,0 +1,37 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Code", + "val": "var opt = 'a'", + "buffer": false, + "escape": false, + "isInline": false, + "line": 1 + }, + { + "type": "Filter", + "name": "cdata", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "hey", + "line": 3 + } + ], + "line": 2 + }, + "attrs": [ + { + "name": "option", + "val": "opt", + "escaped": true + } + ], + "line": 2 + } + ], + "line": 0 +} \ No newline at end of file diff --git a/src/test-data/pug-filters/test/filter-aliases.test.js b/src/test-data/pug-filters/test/filter-aliases.test.js new file mode 100644 index 0000000..3a4bf3b --- /dev/null +++ b/src/test-data/pug-filters/test/filter-aliases.test.js @@ -0,0 +1,88 @@ +const lex = require('pug-lexer'); +const parse = require('pug-parser'); +const handleFilters = require('../').handleFilters; + +const customFilters = {}; +test('filters can be aliased', () => { + const source = ` +script + :cdata:minify + function myFunc(foo) { + return foo; + } + `; + + const ast = parse(lex(source, {filename: __filename}), { + filename: __filename, + src: source, + }); + + const options = {}; + const aliases = { + minify: 'uglify-js', + }; + + const output = handleFilters(ast, customFilters, options, aliases); + expect(output).toMatchSnapshot(); +}); + +test('we do not support chains of aliases', () => { + const source = ` +script + :cdata:minify-js + function myFunc(foo) { + return foo; + } + `; + + const ast = parse(lex(source, {filename: __filename}), { + filename: __filename, + src: source, + }); + + const options = {}; + const aliases = { + 'minify-js': 'minify', + minify: 'uglify-js', + }; + + try { + const output = handleFilters(ast, customFilters, options, aliases); + } catch (ex) { + expect({ + code: ex.code, + message: ex.message, + }).toMatchSnapshot(); + return; + } + throw new Error('Expected an exception'); +}); + +test('options are applied before aliases', () => { + const source = ` +script + :cdata:minify + function myFunc(foo) { + return foo; + } + :cdata:uglify-js + function myFunc(foo) { + return foo; + } + `; + + const ast = parse(lex(source, {filename: __filename}), { + filename: __filename, + src: source, + }); + + const options = { + minify: {output: {beautify: true}}, + }; + const aliases = { + minify: 'uglify-js', + }; + + const output = handleFilters(ast, customFilters, options, aliases); + expect(output).toMatchSnapshot(); +}); diff --git a/src/test-data/pug-filters/test/index.test.js b/src/test-data/pug-filters/test/index.test.js new file mode 100644 index 0000000..d1369b5 --- /dev/null +++ b/src/test-data/pug-filters/test/index.test.js @@ -0,0 +1,55 @@ +'use strict'; + +var fs = require('fs'); +var assert = require('assert'); +var handleFilters = require('../').handleFilters; +var customFilters = require('./custom-filters.js'); + +process.chdir(__dirname + '/../'); + +var testCases; + +testCases = fs.readdirSync(__dirname + '/cases').filter(function(name) { + return /\.input\.json$/.test(name); +}); +// +testCases.forEach(function(filename) { + function read(path) { + return fs.readFileSync(__dirname + '/cases/' + path, 'utf8'); + } + + test('cases/' + filename, function() { + var actualAst = JSON.stringify( + handleFilters(JSON.parse(read(filename)), customFilters), + null, + ' ' + ); + expect(actualAst).toMatchSnapshot(); + }); +}); + +testCases = fs.readdirSync(__dirname + '/errors').filter(function(name) { + return /\.input\.json$/.test(name); +}); + +testCases.forEach(function(filename) { + function read(path) { + return fs.readFileSync(__dirname + '/errors/' + path, 'utf8'); + } + + test('errors/' + filename, function() { + var actual; + try { + handleFilters(JSON.parse(read(filename)), customFilters); + throw new Error('Expected ' + filename + ' to throw an exception.'); + } catch (ex) { + if (!ex || !ex.code || ex.code.indexOf('PUG:') !== 0) throw ex; + actual = { + msg: ex.msg, + code: ex.code, + line: ex.line, + }; + } + expect(actual).toMatchSnapshot(); + }); +}); diff --git a/src/test-data/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js b/src/test-data/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js new file mode 100644 index 0000000..30593b5 --- /dev/null +++ b/src/test-data/pug-filters/test/per-filter-options-applied-to-nested-filters.test.js @@ -0,0 +1,28 @@ +const lex = require('pug-lexer'); +const parse = require('pug-parser'); +const handleFilters = require('../').handleFilters; + +const customFilters = {}; +test('per filter options are applied, even to nested filters', () => { + const source = ` +script + :cdata:uglify-js + function myFunc(foo) { + return foo; + } + `; + + const ast = parse(lex(source, {filename: __filename}), { + filename: __filename, + src: source, + }); + + const options = { + 'uglify-js': {output: {beautify: true}}, + }; + + const output = handleFilters(ast, customFilters, options); + expect(output).toMatchSnapshot(); + + // TODO: render with `options.filterOptions['uglify-js']` +}); diff --git a/src/test-data/pug-lexer/cases/attr-es2015.pug b/src/test-data/pug-lexer/cases/attr-es2015.pug new file mode 100644 index 0000000..d19080f --- /dev/null +++ b/src/test-data/pug-lexer/cases/attr-es2015.pug @@ -0,0 +1,3 @@ +- var avatar = '219b77f9d21de75e81851b6b886057c7' + +div.avatar-div(style=`background-image: url(https://www.gravatar.com/avatar/${avatar})`) diff --git a/src/test-data/pug-lexer/cases/attrs-data.pug b/src/test-data/pug-lexer/cases/attrs-data.pug new file mode 100644 index 0000000..9e5b4b6 --- /dev/null +++ b/src/test-data/pug-lexer/cases/attrs-data.pug @@ -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)) diff --git a/src/test-data/pug-lexer/cases/attrs.js.pug b/src/test-data/pug-lexer/cases/attrs.js.pug new file mode 100644 index 0000000..d989be8 --- /dev/null +++ b/src/test-data/pug-lexer/cases/attrs.js.pug @@ -0,0 +1,22 @@ +- var id = 5 +- function answer() { return 42; } +a(href='/user/' + id, class='button') +a(href = '/user/' + id, class = 'button') +meta(key='answer', value=answer()) +a(class = ['class1', 'class2']) +a.tag-class(class = ['class1', 'class2']) + +a(href='/user/' + id class='button') +a(href = '/user/' + id class = 'button') +meta(key='answer' value=answer()) +a(class = ['class1', 'class2']) +a.tag-class(class = ['class1', 'class2']) + +div(id=id)&attributes({foo: 'bar'}) +- var bar = null +div(foo=null bar=bar)&attributes({baz: 'baz'}) + +div(...object) +div(...object after="after") +div(before="before" ...object) +div(before="before" ...object after="after") diff --git a/src/test-data/pug-lexer/cases/attrs.pug b/src/test-data/pug-lexer/cases/attrs.pug new file mode 100644 index 0000000..d4420e3 --- /dev/null +++ b/src/test-data/pug-lexer/cases/attrs.pug @@ -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: ''} + +div&attributes(attrs) + +a(foo='foo' "bar"="bar") +a(foo='foo' 'bar'='bar') diff --git a/src/test-data/pug-lexer/cases/attrs.unescaped.pug b/src/test-data/pug-lexer/cases/attrs.unescaped.pug new file mode 100644 index 0000000..36a4e10 --- /dev/null +++ b/src/test-data/pug-lexer/cases/attrs.unescaped.pug @@ -0,0 +1,3 @@ +script(type='text/x-template') + div(id!='user-<%= user.id %>') + h1 <%= user.title %> \ No newline at end of file diff --git a/src/test-data/pug-lexer/cases/basic.pug b/src/test-data/pug-lexer/cases/basic.pug new file mode 100644 index 0000000..77066d1 --- /dev/null +++ b/src/test-data/pug-lexer/cases/basic.pug @@ -0,0 +1,3 @@ +html + body + h1 Title \ No newline at end of file diff --git a/src/test-data/pug-lexer/cases/blanks.pug b/src/test-data/pug-lexer/cases/blanks.pug new file mode 100644 index 0000000..67b0697 --- /dev/null +++ b/src/test-data/pug-lexer/cases/blanks.pug @@ -0,0 +1,8 @@ + + +ul + li foo + + li bar + + li baz diff --git a/src/test-data/pug-lexer/cases/block-code.pug b/src/test-data/pug-lexer/cases/block-code.pug new file mode 100644 index 0000000..9ab6854 --- /dev/null +++ b/src/test-data/pug-lexer/cases/block-code.pug @@ -0,0 +1,12 @@ +- + list = ["uno", "dos", "tres", + "cuatro", "cinco", "seis"]; +//- Without a block, the element is accepted and no code is generated +- +each item in list + - + string = item.charAt(0) + + .toUpperCase() + + item.slice(1); + li= string diff --git a/src/test-data/pug-lexer/cases/block-expansion.pug b/src/test-data/pug-lexer/cases/block-expansion.pug new file mode 100644 index 0000000..fb40f9a --- /dev/null +++ b/src/test-data/pug-lexer/cases/block-expansion.pug @@ -0,0 +1,5 @@ +ul + li: a(href='#') foo + li: a(href='#') bar + +p baz \ No newline at end of file diff --git a/src/test-data/pug-lexer/cases/block-expansion.shorthands.pug b/src/test-data/pug-lexer/cases/block-expansion.shorthands.pug new file mode 100644 index 0000000..c52a126 --- /dev/null +++ b/src/test-data/pug-lexer/cases/block-expansion.shorthands.pug @@ -0,0 +1,2 @@ +ul + li.list-item: .foo: #bar baz \ No newline at end of file diff --git a/src/test-data/pug-lexer/cases/blockquote.pug b/src/test-data/pug-lexer/cases/blockquote.pug new file mode 100644 index 0000000..a23b70f --- /dev/null +++ b/src/test-data/pug-lexer/cases/blockquote.pug @@ -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 \ No newline at end of file diff --git a/src/test-data/pug-lexer/cases/blocks-in-blocks.pug b/src/test-data/pug-lexer/cases/blocks-in-blocks.pug new file mode 100644 index 0000000..13077d9 --- /dev/null +++ b/src/test-data/pug-lexer/cases/blocks-in-blocks.pug @@ -0,0 +1,4 @@ +extends ./auxiliary/blocks-in-blocks-layout.pug + +block body + h1 Page 2 diff --git a/src/test-data/pug-lexer/cases/blocks-in-if.pug b/src/test-data/pug-lexer/cases/blocks-in-if.pug new file mode 100644 index 0000000..e0c6361 --- /dev/null +++ b/src/test-data/pug-lexer/cases/blocks-in-if.pug @@ -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 diff --git a/src/test-data/pug-lexer/cases/case-blocks.pug b/src/test-data/pug-lexer/cases/case-blocks.pug new file mode 100644 index 0000000..345cd41 --- /dev/null +++ b/src/test-data/pug-lexer/cases/case-blocks.pug @@ -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 \ No newline at end of file diff --git a/src/test-data/pug-lexer/cases/case.pug b/src/test-data/pug-lexer/cases/case.pug new file mode 100644 index 0000000..0fbe2ef --- /dev/null +++ b/src/test-data/pug-lexer/cases/case.pug @@ -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 diff --git a/src/test-data/pug-lexer/cases/classes-empty.pug b/src/test-data/pug-lexer/cases/classes-empty.pug new file mode 100644 index 0000000..5e66d84 --- /dev/null +++ b/src/test-data/pug-lexer/cases/classes-empty.pug @@ -0,0 +1,3 @@ +a(class='') +a(class=null) +a(class=undefined) \ No newline at end of file diff --git a/src/test-data/pug-lexer/cases/classes.pug b/src/test-data/pug-lexer/cases/classes.pug new file mode 100644 index 0000000..699a075 --- /dev/null +++ b/src/test-data/pug-lexer/cases/classes.pug @@ -0,0 +1,14 @@ +a(class=['foo', 'bar', 'baz']) + + + +a.foo(class='bar').baz + + + +a.foo-bar_baz + +a(class={foo: true, bar: false, baz: true}) + +a.-foo +a.3foo diff --git a/src/test-data/pug-lexer/cases/code.conditionals.pug b/src/test-data/pug-lexer/cases/code.conditionals.pug new file mode 100644 index 0000000..aa4c715 --- /dev/null +++ b/src/test-data/pug-lexer/cases/code.conditionals.pug @@ -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 \ No newline at end of file diff --git a/src/test-data/pug-lexer/cases/code.escape.pug b/src/test-data/pug-lexer/cases/code.escape.pug new file mode 100644 index 0000000..762c089 --- /dev/null +++ b/src/test-data/pug-lexer/cases/code.escape.pug @@ -0,0 +1,2 @@ +p= ' +", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "type", + "val": "\\"text/javascript\\"", + }, + ], + "block": Object { + "filename": "includes.pug", + "line": 9, + "nodes": Array [ + Object { + "type": "Text", + "val": "var STRING_SUBSTITUTIONS = { // table of character substitutions + '\\\\t': '\\\\\\\\t', + '\\\\r': '\\\\\\\\r', + '\\\\n': '\\\\\\\\n', + '\\"' : '\\\\\\\\\\"', + '\\\\\\\\': '\\\\\\\\\\\\\\\\' +};", + }, + ], + "type": "Block", + }, + "filename": "includes.pug", + "isInline": false, + "line": 9, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", + }, + "filename": "includes.pug", + "isInline": false, + "line": 6, + "name": "body", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", +} +`; + +exports[`cases from pug includes-with-ext-js.input.json 1`] = ` +Object { + "declaredBlocks": Object {}, + "filename": "includes-with-ext-js.pug", + "line": 0, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "includes-with-ext-js.pug", + "line": 1, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "includes-with-ext-js.pug", + "line": 2, + "nodes": Array [ + Object { + "type": "Text", + "val": "var x = \\"\\\\n here is some \\\\n new lined text\\"; +", + }, + ], + "type": "Block", + }, + "filename": "includes-with-ext-js.pug", + "isInline": true, + "line": 2, + "name": "code", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", + }, + "filename": "includes-with-ext-js.pug", + "isInline": false, + "line": 1, + "name": "pre", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", +} +`; + +exports[`cases from pug layout.append.input.json 1`] = ` +Object { + "declaredBlocks": Object { + "body": Array [ + Object { + "filename": "../fixtures/append/layout.pug", + "line": 7, + "mode": "replace", + "name": "body", + "nodes": Array [], + "type": "NamedBlock", + }, + ], + "head": Array [ + Object { + "filename": "../fixtures/append/layout.pug", + "line": 3, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/jquery.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append/layout.pug", + "line": 4, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append/layout.pug", + "isInline": false, + "line": 4, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/caustic.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append/layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append/layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'app.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append/app-layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append/app-layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'foo.js'", + }, + ], + "block": Object { + "filename": "layout.append.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.append.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'bar.js'", + }, + ], + "block": Object { + "filename": "layout.append.pug", + "line": 6, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.append.pug", + "isInline": false, + "line": 6, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + ], + }, + "filename": "../fixtures/append/layout.pug", + "hasExtends": true, + "line": 0, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/append/layout.pug", + "line": 2, + "nodes": Array [ + Object { + "filename": "../fixtures/append/layout.pug", + "line": 3, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/jquery.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append/layout.pug", + "line": 4, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append/layout.pug", + "isInline": false, + "line": 4, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/caustic.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append/layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append/layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'app.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append/app-layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append/app-layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'foo.js'", + }, + ], + "block": Object { + "filename": "layout.append.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.append.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'bar.js'", + }, + ], + "block": Object { + "filename": "layout.append.pug", + "line": 6, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.append.pug", + "isInline": false, + "line": 6, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/append/layout.pug", + "line": 6, + "nodes": Array [ + Object { + "filename": "../fixtures/append/layout.pug", + "line": 7, + "mode": "replace", + "name": "body", + "nodes": Array [], + "type": "NamedBlock", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/append/layout.pug", + "isInline": false, + "line": 6, + "name": "body", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/append/layout.pug", + "isInline": false, + "line": 2, + "name": "html", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", +} +`; + +exports[`cases from pug layout.append.without-block.input.json 1`] = ` +Object { + "declaredBlocks": Object { + "body": Array [ + Object { + "filename": "../fixtures/append-without-block/layout.pug", + "line": 7, + "mode": "replace", + "name": "body", + "nodes": Array [], + "type": "NamedBlock", + }, + ], + "head": Array [ + Object { + "filename": "../fixtures/append-without-block/layout.pug", + "line": 3, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/jquery.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append-without-block/layout.pug", + "line": 4, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append-without-block/layout.pug", + "isInline": false, + "line": 4, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/caustic.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append-without-block/layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append-without-block/layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'app.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append-without-block/app-layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append-without-block/app-layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'foo.js'", + }, + ], + "block": Object { + "filename": "layout.append.without-block.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.append.without-block.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'bar.js'", + }, + ], + "block": Object { + "filename": "layout.append.without-block.pug", + "line": 6, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.append.without-block.pug", + "isInline": false, + "line": 6, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + ], + }, + "filename": "../fixtures/append-without-block/layout.pug", + "hasExtends": true, + "line": 0, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/append-without-block/layout.pug", + "line": 2, + "nodes": Array [ + Object { + "filename": "../fixtures/append-without-block/layout.pug", + "line": 3, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/jquery.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append-without-block/layout.pug", + "line": 4, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append-without-block/layout.pug", + "isInline": false, + "line": 4, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/caustic.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append-without-block/layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append-without-block/layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'app.js'", + }, + ], + "block": Object { + "filename": "../fixtures/append-without-block/app-layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/append-without-block/app-layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'foo.js'", + }, + ], + "block": Object { + "filename": "layout.append.without-block.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.append.without-block.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'bar.js'", + }, + ], + "block": Object { + "filename": "layout.append.without-block.pug", + "line": 6, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.append.without-block.pug", + "isInline": false, + "line": 6, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/append-without-block/layout.pug", + "line": 6, + "nodes": Array [ + Object { + "filename": "../fixtures/append-without-block/layout.pug", + "line": 7, + "mode": "replace", + "name": "body", + "nodes": Array [], + "type": "NamedBlock", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/append-without-block/layout.pug", + "isInline": false, + "line": 6, + "name": "body", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/append-without-block/layout.pug", + "isInline": false, + "line": 2, + "name": "html", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", +} +`; + +exports[`cases from pug layout.multi.append.prepend.block.input.json 1`] = ` +Object { + "declaredBlocks": Object { + "content": Array [ + Object { + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "line": 3, + "mode": "replace", + "name": "content", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'last'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'prepend'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 13, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 13, + "type": "Text", + "val": "Last prepend must appear at top", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 13, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'first'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'prepend'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 7, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 7, + "type": "Text", + "val": "Something prepended to content", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 7, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'content'", + }, + ], + "block": Object { + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "line": 4, + "nodes": Array [ + Object { + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "line": 5, + "type": "Text", + "val": "Defined content", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "isInline": false, + "line": 4, + "name": "div", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'first'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'append'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 4, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 4, + "type": "Text", + "val": "Something appended to content", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 4, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'last'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'append'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 10, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 10, + "type": "Text", + "val": "Last append must be most last", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 10, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + ], + "parents": Array [ + Object { + "filename": "../fixtures/multi-append-prepend-block/root.pug", + "line": 1, + "mode": "replace", + "name": "content", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'last'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'prepend'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 13, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 13, + "type": "Text", + "val": "Last prepend must appear at top", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 13, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'first'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'prepend'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 7, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 7, + "type": "Text", + "val": "Something prepended to content", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 7, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'content'", + }, + ], + "block": Object { + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "line": 4, + "nodes": Array [ + Object { + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "line": 5, + "type": "Text", + "val": "Defined content", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "isInline": false, + "line": 4, + "name": "div", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'first'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'append'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 4, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 4, + "type": "Text", + "val": "Something appended to content", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 4, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'last'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'append'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 10, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 10, + "type": "Text", + "val": "Last append must be most last", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 10, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + ], + "type": "NamedBlock", + }, + ], + "head": Array [ + Object { + "filename": "../fixtures/multi-append-prepend-block/root.pug", + "line": 4, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'foo.js'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 19, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 19, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'/app.js'", + }, + ], + "block": Object { + "filename": "../fixtures/multi-append-prepend-block/root.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/multi-append-prepend-block/root.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'jquery.js'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 16, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 16, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + ], + }, + "filename": "../fixtures/multi-append-prepend-block/root.pug", + "hasExtends": true, + "line": 0, + "nodes": Array [ + Object { + "filename": "../fixtures/multi-append-prepend-block/root.pug", + "line": 1, + "mode": "replace", + "name": "content", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'last'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'prepend'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 13, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 13, + "type": "Text", + "val": "Last prepend must appear at top", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 13, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'first'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'prepend'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 7, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 7, + "type": "Text", + "val": "Something prepended to content", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 7, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'content'", + }, + ], + "block": Object { + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "line": 4, + "nodes": Array [ + Object { + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "line": 5, + "type": "Text", + "val": "Defined content", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "isInline": false, + "line": 4, + "name": "div", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'first'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'append'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 4, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 4, + "type": "Text", + "val": "Something appended to content", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 4, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'last'", + }, + Object { + "mustEscape": false, + "name": "class", + "val": "'append'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 10, + "nodes": Array [ + Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 10, + "type": "Text", + "val": "Last append must be most last", + }, + ], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 10, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + Object { + "filename": "../fixtures/multi-append-prepend-block/root.pug", + "line": 4, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'foo.js'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 19, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 19, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'/app.js'", + }, + ], + "block": Object { + "filename": "../fixtures/multi-append-prepend-block/root.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/multi-append-prepend-block/root.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'jquery.js'", + }, + ], + "block": Object { + "filename": "layout.multi.append.prepend.block.pug", + "line": 16, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.multi.append.prepend.block.pug", + "isInline": false, + "line": 16, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + ], + "type": "Block", +} +`; + +exports[`cases from pug layout.prepend.input.json 1`] = ` +Object { + "declaredBlocks": Object { + "body": Array [ + Object { + "filename": "../fixtures/prepend/layout.pug", + "line": 7, + "mode": "replace", + "name": "body", + "nodes": Array [], + "type": "NamedBlock", + }, + ], + "head": Array [ + Object { + "filename": "../fixtures/prepend/layout.pug", + "line": 3, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'foo.js'", + }, + ], + "block": Object { + "filename": "layout.prepend.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.prepend.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'bar.js'", + }, + ], + "block": Object { + "filename": "layout.prepend.pug", + "line": 6, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.prepend.pug", + "isInline": false, + "line": 6, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'app.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend/app-layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend/app-layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/jquery.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend/layout.pug", + "line": 4, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend/layout.pug", + "isInline": false, + "line": 4, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/caustic.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend/layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend/layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + ], + }, + "filename": "../fixtures/prepend/layout.pug", + "hasExtends": true, + "line": 0, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/prepend/layout.pug", + "line": 2, + "nodes": Array [ + Object { + "filename": "../fixtures/prepend/layout.pug", + "line": 3, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'foo.js'", + }, + ], + "block": Object { + "filename": "layout.prepend.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.prepend.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'bar.js'", + }, + ], + "block": Object { + "filename": "layout.prepend.pug", + "line": 6, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.prepend.pug", + "isInline": false, + "line": 6, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'app.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend/app-layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend/app-layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/jquery.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend/layout.pug", + "line": 4, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend/layout.pug", + "isInline": false, + "line": 4, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/caustic.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend/layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend/layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/prepend/layout.pug", + "line": 6, + "nodes": Array [ + Object { + "filename": "../fixtures/prepend/layout.pug", + "line": 7, + "mode": "replace", + "name": "body", + "nodes": Array [], + "type": "NamedBlock", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/prepend/layout.pug", + "isInline": false, + "line": 6, + "name": "body", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/prepend/layout.pug", + "isInline": false, + "line": 2, + "name": "html", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", +} +`; + +exports[`cases from pug layout.prepend.without-block.input.json 1`] = ` +Object { + "declaredBlocks": Object { + "body": Array [ + Object { + "filename": "../fixtures/prepend-without-block/layout.pug", + "line": 7, + "mode": "replace", + "name": "body", + "nodes": Array [], + "type": "NamedBlock", + }, + ], + "head": Array [ + Object { + "filename": "../fixtures/prepend-without-block/layout.pug", + "line": 3, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'foo.js'", + }, + ], + "block": Object { + "filename": "layout.prepend.without-block.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.prepend.without-block.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'bar.js'", + }, + ], + "block": Object { + "filename": "layout.prepend.without-block.pug", + "line": 6, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.prepend.without-block.pug", + "isInline": false, + "line": 6, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'app.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend-without-block/app-layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend-without-block/app-layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/jquery.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend-without-block/layout.pug", + "line": 4, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend-without-block/layout.pug", + "isInline": false, + "line": 4, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/caustic.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend-without-block/layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend-without-block/layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + ], + }, + "filename": "../fixtures/prepend-without-block/layout.pug", + "hasExtends": true, + "line": 0, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/prepend-without-block/layout.pug", + "line": 2, + "nodes": Array [ + Object { + "filename": "../fixtures/prepend-without-block/layout.pug", + "line": 3, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'foo.js'", + }, + ], + "block": Object { + "filename": "layout.prepend.without-block.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.prepend.without-block.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'bar.js'", + }, + ], + "block": Object { + "filename": "layout.prepend.without-block.pug", + "line": 6, + "nodes": Array [], + "type": "Block", + }, + "filename": "layout.prepend.without-block.pug", + "isInline": false, + "line": 6, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'app.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend-without-block/app-layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend-without-block/app-layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/jquery.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend-without-block/layout.pug", + "line": 4, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend-without-block/layout.pug", + "isInline": false, + "line": 4, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "src", + "val": "'vendor/caustic.js'", + }, + ], + "block": Object { + "filename": "../fixtures/prepend-without-block/layout.pug", + "line": 5, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/prepend-without-block/layout.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/prepend-without-block/layout.pug", + "line": 6, + "nodes": Array [ + Object { + "filename": "../fixtures/prepend-without-block/layout.pug", + "line": 7, + "mode": "replace", + "name": "body", + "nodes": Array [], + "type": "NamedBlock", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/prepend-without-block/layout.pug", + "isInline": false, + "line": 6, + "name": "body", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/prepend-without-block/layout.pug", + "isInline": false, + "line": 2, + "name": "html", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", +} +`; + +exports[`error handling child-with-tags.input.json 1`] = ` +Object { + "code": "PUG:UNEXPECTED_NODES_IN_EXTENDING_ROOT", + "line": 6, + "msg": "Only named blocks and mixins can appear at the top level of an extending template", +} +`; + +exports[`error handling extends-not-first.input.json 1`] = ` +Object { + "code": "PUG:EXTENDS_NOT_FIRST", + "line": 4, + "msg": "Declaration of template inheritance (\\"extends\\") should be the first thing in the file. There can only be one extends statement per file.", +} +`; + +exports[`error handling unexpected-block.input.json 1`] = ` +Object { + "code": "PUG:UNEXPECTED_BLOCK", + "line": 3, + "msg": "Unexpected block foo", +} +`; + +exports[`special cases extending-empty.input.json 1`] = ` +Object { + "declaredBlocks": Object {}, + "filename": "../fixtures/empty.pug", + "hasExtends": true, + "line": 0, + "nodes": Array [], + "type": "Block", +} +`; + +exports[`special cases extending-include.input.json 1`] = ` +Object { + "declaredBlocks": Object { + "body": Array [ + Object { + "filename": "extending-include.pug", + "line": 4, + "mode": "replace", + "name": "body", + "nodes": Array [ + Object { + "args": "'myimg.png'", + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'with-border'", + }, + Object { + "mustEscape": true, + "name": "alt", + "val": "\\"My image\\"", + }, + ], + "block": null, + "call": true, + "filename": "extending-include.pug", + "line": 5, + "name": "image", + "type": "Mixin", + }, + ], + "parents": Array [ + Object { + "filename": "../fixtures/layout.pug", + "line": 8, + "mode": "replace", + "name": "body", + "nodes": Array [ + Object { + "args": "'myimg.png'", + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'with-border'", + }, + Object { + "mustEscape": true, + "name": "alt", + "val": "\\"My image\\"", + }, + ], + "block": null, + "call": true, + "filename": "extending-include.pug", + "line": 5, + "name": "image", + "type": "Mixin", + }, + ], + "type": "NamedBlock", + }, + ], + "type": "NamedBlock", + }, + ], + "head": Array [ + Object { + "filename": "../fixtures/layout.pug", + "line": 5, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "filename": "../fixtures/layout.pug", + "isHtml": true, + "line": 6, + "type": "Text", + "val": "Hello world!", + }, + ], + "type": "NamedBlock", + }, + ], + }, + "filename": "../fixtures/layout.pug", + "hasExtends": true, + "line": 0, + "nodes": Array [ + Object { + "args": "src", + "block": Object { + "filename": "../fixtures/mixins.pug", + "line": 2, + "nodes": Array [ + Object { + "attributeBlocks": Array [ + "attributes", + ], + "attrs": Array [ + Object { + "mustEscape": true, + "name": "cl-src", + "val": "src", + }, + ], + "block": Object { + "filename": "../fixtures/mixins.pug", + "line": 2, + "nodes": Array [], + "type": "Block", + }, + "filename": "../fixtures/mixins.pug", + "isInline": true, + "line": 2, + "name": "img", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", + }, + "call": false, + "filename": "../fixtures/mixins.pug", + "line": 1, + "name": "image", + "type": "Mixin", + }, + Object { + "filename": "../fixtures/layout.pug", + "line": 1, + "type": "Doctype", + "val": "", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/layout.pug", + "line": 3, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/layout.pug", + "line": 4, + "nodes": Array [ + Object { + "filename": "../fixtures/layout.pug", + "line": 5, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "filename": "../fixtures/layout.pug", + "isHtml": true, + "line": 6, + "type": "Text", + "val": "Hello world!", + }, + ], + "type": "NamedBlock", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/layout.pug", + "isInline": false, + "line": 4, + "name": "head", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/layout.pug", + "line": 7, + "nodes": Array [ + Object { + "filename": "../fixtures/layout.pug", + "line": 8, + "mode": "replace", + "name": "body", + "nodes": Array [ + Object { + "args": "'myimg.png'", + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "mustEscape": false, + "name": "class", + "val": "'with-border'", + }, + Object { + "mustEscape": true, + "name": "alt", + "val": "\\"My image\\"", + }, + ], + "block": null, + "call": true, + "filename": "extending-include.pug", + "line": 5, + "name": "image", + "type": "Mixin", + }, + ], + "type": "NamedBlock", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/layout.pug", + "isInline": false, + "line": 7, + "name": "body", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/layout.pug", + "isInline": false, + "line": 3, + "name": "html", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", +} +`; + +exports[`special cases root-mixin.input.json 1`] = ` +Object { + "declaredBlocks": Object { + "body": Array [ + Object { + "filename": "root-mixin.pug", + "line": 6, + "mode": "replace", + "name": "body", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "root-mixin.pug", + "line": 7, + "nodes": Array [ + Object { + "filename": "root-mixin.pug", + "line": 7, + "type": "Text", + "val": "Before", + }, + ], + "type": "Block", + }, + "filename": "root-mixin.pug", + "isInline": false, + "line": 7, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "args": null, + "attributeBlocks": Array [], + "attrs": Array [], + "block": null, + "call": true, + "filename": "root-mixin.pug", + "line": 8, + "name": "myMixin", + "type": "Mixin", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "root-mixin.pug", + "line": 9, + "nodes": Array [ + Object { + "filename": "root-mixin.pug", + "line": 9, + "type": "Text", + "val": "After", + }, + ], + "type": "Block", + }, + "filename": "root-mixin.pug", + "isInline": false, + "line": 9, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + ], + "parents": Array [ + Object { + "filename": "../fixtures/layout.pug", + "line": 8, + "mode": "replace", + "name": "body", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "root-mixin.pug", + "line": 7, + "nodes": Array [ + Object { + "filename": "root-mixin.pug", + "line": 7, + "type": "Text", + "val": "Before", + }, + ], + "type": "Block", + }, + "filename": "root-mixin.pug", + "isInline": false, + "line": 7, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "args": null, + "attributeBlocks": Array [], + "attrs": Array [], + "block": null, + "call": true, + "filename": "root-mixin.pug", + "line": 8, + "name": "myMixin", + "type": "Mixin", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "root-mixin.pug", + "line": 9, + "nodes": Array [ + Object { + "filename": "root-mixin.pug", + "line": 9, + "type": "Text", + "val": "After", + }, + ], + "type": "Block", + }, + "filename": "root-mixin.pug", + "isInline": false, + "line": 9, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + ], + "type": "NamedBlock", + }, + ], + "head": Array [ + Object { + "filename": "../fixtures/layout.pug", + "line": 5, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "filename": "../fixtures/layout.pug", + "isHtml": true, + "line": 6, + "type": "Text", + "val": "Hello world!", + }, + ], + "type": "NamedBlock", + }, + ], + }, + "filename": "../fixtures/layout.pug", + "hasExtends": true, + "line": 0, + "nodes": Array [ + Object { + "args": null, + "block": Object { + "filename": "root-mixin.pug", + "line": 4, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "root-mixin.pug", + "line": 4, + "nodes": Array [ + Object { + "filename": "root-mixin.pug", + "line": 4, + "type": "Text", + "val": "Hello world", + }, + ], + "type": "Block", + }, + "filename": "root-mixin.pug", + "isInline": false, + "line": 4, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", + }, + "call": false, + "filename": "root-mixin.pug", + "line": 3, + "name": "myMixin", + "type": "Mixin", + }, + Object { + "filename": "../fixtures/layout.pug", + "line": 1, + "type": "Doctype", + "val": "", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/layout.pug", + "line": 3, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/layout.pug", + "line": 4, + "nodes": Array [ + Object { + "filename": "../fixtures/layout.pug", + "line": 5, + "mode": "replace", + "name": "head", + "nodes": Array [ + Object { + "filename": "../fixtures/layout.pug", + "isHtml": true, + "line": 6, + "type": "Text", + "val": "Hello world!", + }, + ], + "type": "NamedBlock", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/layout.pug", + "isInline": false, + "line": 4, + "name": "head", + "selfClosing": false, + "type": "Tag", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "../fixtures/layout.pug", + "line": 7, + "nodes": Array [ + Object { + "filename": "../fixtures/layout.pug", + "line": 8, + "mode": "replace", + "name": "body", + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "root-mixin.pug", + "line": 7, + "nodes": Array [ + Object { + "filename": "root-mixin.pug", + "line": 7, + "type": "Text", + "val": "Before", + }, + ], + "type": "Block", + }, + "filename": "root-mixin.pug", + "isInline": false, + "line": 7, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + Object { + "args": null, + "attributeBlocks": Array [], + "attrs": Array [], + "block": null, + "call": true, + "filename": "root-mixin.pug", + "line": 8, + "name": "myMixin", + "type": "Mixin", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "root-mixin.pug", + "line": 9, + "nodes": Array [ + Object { + "filename": "root-mixin.pug", + "line": 9, + "type": "Text", + "val": "After", + }, + ], + "type": "Block", + }, + "filename": "root-mixin.pug", + "isInline": false, + "line": 9, + "name": "p", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/layout.pug", + "isInline": false, + "line": 7, + "name": "body", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", + }, + "filename": "../fixtures/layout.pug", + "isInline": false, + "line": 3, + "name": "html", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", +} +`; diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/1794-extends.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/1794-extends.pug new file mode 100644 index 0000000..99649d6 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/1794-extends.pug @@ -0,0 +1 @@ +block content \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/1794-include.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/1794-include.pug new file mode 100644 index 0000000..b9c03b4 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/1794-include.pug @@ -0,0 +1,4 @@ +mixin test() + .test&attributes(attributes) + ++test() \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/blocks-in-blocks-layout.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/blocks-in-blocks-layout.pug new file mode 100644 index 0000000..17ca8a0 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/blocks-in-blocks-layout.pug @@ -0,0 +1,8 @@ +doctype html +html + head + title Default title + body + block body + .container + block content diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/dialog.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/dialog.pug new file mode 100644 index 0000000..607bdec --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/dialog.pug @@ -0,0 +1,6 @@ + +extends window.pug + +block window-content + .dialog + block content diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/empty-block.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/empty-block.pug new file mode 100644 index 0000000..776e5fe --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/empty-block.pug @@ -0,0 +1,2 @@ +block test + diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/escapes.html b/src/test-data/pug-linker/test/cases-src/auxiliary/escapes.html new file mode 100644 index 0000000..69e3701 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/escapes.html @@ -0,0 +1,3 @@ + diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/extends-empty-block-1.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/extends-empty-block-1.pug new file mode 100644 index 0000000..2729803 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/extends-empty-block-1.pug @@ -0,0 +1,5 @@ +extends empty-block.pug + +block test + div test1 + diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/extends-empty-block-2.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/extends-empty-block-2.pug new file mode 100644 index 0000000..beb2e83 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/extends-empty-block-2.pug @@ -0,0 +1,5 @@ +extends empty-block.pug + +block test + div test2 + diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/extends-from-root.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/extends-from-root.pug new file mode 100644 index 0000000..da52beb --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/extends-from-root.pug @@ -0,0 +1,4 @@ +extends /auxiliary/layout.pug + +block content + include /auxiliary/include-from-root.pug diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/extends-relative.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/extends-relative.pug new file mode 100644 index 0000000..7a2ecc4 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/extends-relative.pug @@ -0,0 +1,4 @@ +extends ../../cases-src/auxiliary/layout + +block content + include ../../cases-src/auxiliary/include-from-root diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/filter-in-include.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/filter-in-include.pug new file mode 100644 index 0000000..a3df945 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/filter-in-include.pug @@ -0,0 +1,8 @@ +html + head + style(type="text/css") + :less + @pad: 15px; + body { + padding: @pad; + } diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/includable.js b/src/test-data/pug-linker/test/cases-src/auxiliary/includable.js new file mode 100644 index 0000000..38c071e --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/includable.js @@ -0,0 +1,8 @@ +var STRING_SUBSTITUTIONS = { + // table of character substitutions + '\t': '\\t', + '\r': '\\r', + '\n': '\\n', + '"': '\\"', + '\\': '\\\\', +}; diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/include-from-root.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/include-from-root.pug new file mode 100644 index 0000000..93c364b --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/include-from-root.pug @@ -0,0 +1 @@ +h1 hello \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.mixin.block.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.mixin.block.pug new file mode 100644 index 0000000..890febc --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.mixin.block.pug @@ -0,0 +1,11 @@ +mixin article() + article + block + +html + head + title My Application + block head + body + +article + block content diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.recursive-grand-grandparent.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.recursive-grand-grandparent.pug new file mode 100644 index 0000000..61033fa --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.recursive-grand-grandparent.pug @@ -0,0 +1,2 @@ +h1 grand-grandparent +block grand-grandparent \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.recursive-grandparent.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.recursive-grandparent.pug new file mode 100644 index 0000000..f8ad4b8 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.recursive-grandparent.pug @@ -0,0 +1,6 @@ +extends inheritance.extend.recursive-grand-grandparent.pug + +block grand-grandparent + h2 grandparent + block grandparent + diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.recursive-parent.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.recursive-parent.pug new file mode 100644 index 0000000..72d7230 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/inheritance.extend.recursive-parent.pug @@ -0,0 +1,5 @@ +extends inheritance.extend.recursive-grandparent.pug + +block grandparent + h3 parent + block parent \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/layout.include.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/layout.include.pug new file mode 100644 index 0000000..96734bf --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/layout.include.pug @@ -0,0 +1,7 @@ +html + head + title My Application + block head + body + block content + include window.pug diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/layout.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/layout.pug new file mode 100644 index 0000000..7d183b3 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/layout.pug @@ -0,0 +1,6 @@ +html + head + title My Application + block head + body + block content \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/mixin-at-end-of-file.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/mixin-at-end-of-file.pug new file mode 100644 index 0000000..e51eb01 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/mixin-at-end-of-file.pug @@ -0,0 +1,3 @@ +mixin slide + section.slide + block \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/mixins.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/mixins.pug new file mode 100644 index 0000000..0c14c1d --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/mixins.pug @@ -0,0 +1,3 @@ + +mixin foo() + p bar \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/pet.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/pet.pug new file mode 100644 index 0000000..ebee3a8 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/pet.pug @@ -0,0 +1,3 @@ +.pet + h1 {{name}} + p {{name}} is a {{species}} that is {{age}} old \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/smile.html b/src/test-data/pug-linker/test/cases-src/auxiliary/smile.html new file mode 100644 index 0000000..3eadc80 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/smile.html @@ -0,0 +1 @@ +

:)

diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/window.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/window.pug new file mode 100644 index 0000000..7ab7132 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/window.pug @@ -0,0 +1,4 @@ + +.window + a(href='#').close Close + block window-content \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases-src/auxiliary/yield-nested.pug b/src/test-data/pug-linker/test/cases-src/auxiliary/yield-nested.pug new file mode 100644 index 0000000..0771c0a --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/auxiliary/yield-nested.pug @@ -0,0 +1,10 @@ +html + head + title + body + h1 Page + #content + #content-wrapper + yield + #footer + stuff \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases-src/include-extends-from-root.pug b/src/test-data/pug-linker/test/cases-src/include-extends-from-root.pug new file mode 100644 index 0000000..a79a57d --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include-extends-from-root.pug @@ -0,0 +1 @@ +include /auxiliary/extends-from-root.pug diff --git a/src/test-data/pug-linker/test/cases-src/include-extends-of-common-template.pug b/src/test-data/pug-linker/test/cases-src/include-extends-of-common-template.pug new file mode 100644 index 0000000..2511f52 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include-extends-of-common-template.pug @@ -0,0 +1,2 @@ +include auxiliary/extends-empty-block-1.pug +include auxiliary/extends-empty-block-2.pug diff --git a/src/test-data/pug-linker/test/cases-src/include-extends-relative.pug b/src/test-data/pug-linker/test/cases-src/include-extends-relative.pug new file mode 100644 index 0000000..f1648ff --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include-extends-relative.pug @@ -0,0 +1 @@ +include ../cases-src/auxiliary/extends-relative.pug diff --git a/src/test-data/pug-linker/test/cases-src/include-filter-coffee.coffee b/src/test-data/pug-linker/test/cases-src/include-filter-coffee.coffee new file mode 100644 index 0000000..9723cd7 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include-filter-coffee.coffee @@ -0,0 +1,2 @@ +math = + square: (value) -> value * value diff --git a/src/test-data/pug-linker/test/cases-src/include-filter-stylus.pug b/src/test-data/pug-linker/test/cases-src/include-filter-stylus.pug new file mode 100644 index 0000000..eefd3c1 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include-filter-stylus.pug @@ -0,0 +1,2 @@ +style(type="text/css") + include:stylus some.styl diff --git a/src/test-data/pug-linker/test/cases-src/include-filter.pug b/src/test-data/pug-linker/test/cases-src/include-filter.pug new file mode 100644 index 0000000..e7ea3db --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include-filter.pug @@ -0,0 +1,7 @@ +html + body + include:markdown-it some.md + script + include:coffee-script(minify=true) include-filter-coffee.coffee + script + include:coffee-script(minify=false) include-filter-coffee.coffee diff --git a/src/test-data/pug-linker/test/cases-src/include-only-text-body.pug b/src/test-data/pug-linker/test/cases-src/include-only-text-body.pug new file mode 100644 index 0000000..fdb080c --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include-only-text-body.pug @@ -0,0 +1,3 @@ +| The message is " +yield +| " diff --git a/src/test-data/pug-linker/test/cases-src/include-only-text.pug b/src/test-data/pug-linker/test/cases-src/include-only-text.pug new file mode 100644 index 0000000..ede4f0f --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include-only-text.pug @@ -0,0 +1,5 @@ +html + body + p + include include-only-text-body.pug + em hello world diff --git a/src/test-data/pug-linker/test/cases-src/include-with-text-head.pug b/src/test-data/pug-linker/test/cases-src/include-with-text-head.pug new file mode 100644 index 0000000..4e670c0 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include-with-text-head.pug @@ -0,0 +1,3 @@ +head + script(type='text/javascript'). + alert('hello world'); diff --git a/src/test-data/pug-linker/test/cases-src/include-with-text.pug b/src/test-data/pug-linker/test/cases-src/include-with-text.pug new file mode 100644 index 0000000..bc83ea5 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include-with-text.pug @@ -0,0 +1,4 @@ +html + include include-with-text-head.pug + script(src='/caustic.js') + script(src='/app.js') diff --git a/src/test-data/pug-linker/test/cases-src/include.script.pug b/src/test-data/pug-linker/test/cases-src/include.script.pug new file mode 100644 index 0000000..f449144 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include.script.pug @@ -0,0 +1,2 @@ +script#pet-template(type='text/x-template') + include auxiliary/pet.pug diff --git a/src/test-data/pug-linker/test/cases-src/include.yield.nested.pug b/src/test-data/pug-linker/test/cases-src/include.yield.nested.pug new file mode 100644 index 0000000..f4a7d69 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/include.yield.nested.pug @@ -0,0 +1,4 @@ + +include auxiliary/yield-nested.pug + p some content + p and some more diff --git a/src/test-data/pug-linker/test/cases-src/includes-with-ext-js.pug b/src/test-data/pug-linker/test/cases-src/includes-with-ext-js.pug new file mode 100644 index 0000000..65bfa8a --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/includes-with-ext-js.pug @@ -0,0 +1,3 @@ +pre + code + include javascript-new-lines.js diff --git a/src/test-data/pug-linker/test/cases-src/includes.pug b/src/test-data/pug-linker/test/cases-src/includes.pug new file mode 100644 index 0000000..7761ce2 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/includes.pug @@ -0,0 +1,10 @@ + +include auxiliary/mixins.pug + ++foo + +body + include auxiliary/smile.html + include auxiliary/escapes.html + script(type="text/javascript") + include:verbatim auxiliary/includable.js diff --git a/src/test-data/pug-linker/test/cases-src/javascript-new-lines.js b/src/test-data/pug-linker/test/cases-src/javascript-new-lines.js new file mode 100644 index 0000000..bb0c26f --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/javascript-new-lines.js @@ -0,0 +1 @@ +var x = '\n here is some \n new lined text'; diff --git a/src/test-data/pug-linker/test/cases-src/layout.append.pug b/src/test-data/pug-linker/test/cases-src/layout.append.pug new file mode 100644 index 0000000..d771bc9 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/layout.append.pug @@ -0,0 +1,6 @@ + +extends ../fixtures/append/app-layout.pug + +block append head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug-linker/test/cases-src/layout.append.without-block.pug b/src/test-data/pug-linker/test/cases-src/layout.append.without-block.pug new file mode 100644 index 0000000..19842fc --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/layout.append.without-block.pug @@ -0,0 +1,6 @@ + +extends ../fixtures/append-without-block/app-layout.pug + +append head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug-linker/test/cases-src/layout.multi.append.prepend.block.pug b/src/test-data/pug-linker/test/cases-src/layout.multi.append.prepend.block.pug new file mode 100644 index 0000000..79d15b1 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/layout.multi.append.prepend.block.pug @@ -0,0 +1,19 @@ +extends ../fixtures/multi-append-prepend-block/redefine.pug + +append content + p.first.append Something appended to content + +prepend content + p.first.prepend Something prepended to content + +append content + p.last.append Last append must be most last + +prepend content + p.last.prepend Last prepend must appear at top + +append head + script(src='jquery.js') + +prepend head + script(src='foo.js') diff --git a/src/test-data/pug-linker/test/cases-src/layout.prepend.pug b/src/test-data/pug-linker/test/cases-src/layout.prepend.pug new file mode 100644 index 0000000..4659a11 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/layout.prepend.pug @@ -0,0 +1,6 @@ + +extends ../fixtures/prepend/app-layout.pug + +block prepend head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug-linker/test/cases-src/layout.prepend.without-block.pug b/src/test-data/pug-linker/test/cases-src/layout.prepend.without-block.pug new file mode 100644 index 0000000..516d01b --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/layout.prepend.without-block.pug @@ -0,0 +1,6 @@ + +extends ../fixtures/prepend-without-block/app-layout.pug + +prepend head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug-linker/test/cases-src/some-included.styl b/src/test-data/pug-linker/test/cases-src/some-included.styl new file mode 100644 index 0000000..7458543 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/some-included.styl @@ -0,0 +1,2 @@ +body + padding 10px diff --git a/src/test-data/pug-linker/test/cases-src/some.md b/src/test-data/pug-linker/test/cases-src/some.md new file mode 100644 index 0000000..8ea3e54 --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/some.md @@ -0,0 +1,3 @@ +Just _some_ markdown **tests**. + +With new line. diff --git a/src/test-data/pug-linker/test/cases-src/some.styl b/src/test-data/pug-linker/test/cases-src/some.styl new file mode 100644 index 0000000..f77222d --- /dev/null +++ b/src/test-data/pug-linker/test/cases-src/some.styl @@ -0,0 +1 @@ +@import "some-included" diff --git a/src/test-data/pug-linker/test/cases/include-extends-from-root.input.json b/src/test-data/pug-linker/test/cases/include-extends-from-root.input.json new file mode 100644 index 0000000..dc58773 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/include-extends-from-root.input.json @@ -0,0 +1,201 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Include", + "file": { + "type": "FileReference", + "line": 1, + "filename": "include-extends-from-root.pug", + "path": "/auxiliary/extends-from-root.pug", + "fullPath": "auxiliary/extends-from-root.pug", + "str": "extends /auxiliary/layout.pug\n\nblock content\n include /auxiliary/include-from-root.pug\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "/auxiliary/layout.pug", + "line": 1, + "filename": "auxiliary/extends-from-root.pug", + "fullPath": "auxiliary/layout.pug", + "str": "html\n head\n title My Application\n block head\n body\n block content", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "head", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "title", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "My Application", + "line": 3, + "filename": "auxiliary/layout.pug" + } + ], + "line": 3, + "filename": "auxiliary/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "auxiliary/layout.pug" + }, + { + "type": "NamedBlock", + "nodes": [], + "line": 4, + "filename": "auxiliary/layout.pug", + "name": "head", + "mode": "replace" + } + ], + "line": 2, + "filename": "auxiliary/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "auxiliary/layout.pug" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 6, + "filename": "auxiliary/layout.pug", + "name": "content", + "mode": "replace" + } + ], + "line": 5, + "filename": "auxiliary/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "auxiliary/layout.pug" + } + ], + "line": 1, + "filename": "auxiliary/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "auxiliary/layout.pug" + } + ], + "line": 0, + "filename": "auxiliary/layout.pug" + } + }, + "line": 1, + "filename": "auxiliary/extends-from-root.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Include", + "file": { + "type": "FileReference", + "line": 4, + "filename": "auxiliary/extends-from-root.pug", + "path": "/auxiliary/include-from-root.pug", + "fullPath": "auxiliary/include-from-root.pug", + "str": "h1 hello", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "h1", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "hello", + "line": 1, + "filename": "auxiliary/include-from-root.pug" + } + ], + "line": 1, + "filename": "auxiliary/include-from-root.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "auxiliary/include-from-root.pug" + } + ], + "line": 0, + "filename": "auxiliary/include-from-root.pug" + } + }, + "line": 4, + "filename": "auxiliary/extends-from-root.pug", + "block": { + "type": "Block", + "nodes": [], + "line": 4, + "filename": "auxiliary/extends-from-root.pug" + } + } + ], + "line": 3, + "filename": "auxiliary/extends-from-root.pug", + "name": "content", + "mode": "replace" + } + ], + "line": 0, + "filename": "auxiliary/extends-from-root.pug" + } + }, + "line": 1, + "filename": "include-extends-from-root.pug", + "block": { + "type": "Block", + "nodes": [], + "line": 1, + "filename": "include-extends-from-root.pug" + } + } + ], + "line": 0, + "filename": "include-extends-from-root.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/include-extends-of-common-template.input.json b/src/test-data/pug-linker/test/cases/include-extends-of-common-template.input.json new file mode 100644 index 0000000..a20f7f2 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/include-extends-of-common-template.input.json @@ -0,0 +1,179 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Include", + "file": { + "type": "FileReference", + "line": 1, + "filename": "include-extends-of-common-template.pug", + "path": "auxiliary/extends-empty-block-1.pug", + "fullPath": "auxiliary/extends-empty-block-1.pug", + "str": "extends empty-block.pug\n\nblock test\n div test1\n\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "empty-block.pug", + "line": 1, + "filename": "auxiliary/extends-empty-block-1.pug", + "fullPath": "auxiliary/empty-block.pug", + "str": "block test\n\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 1, + "filename": "auxiliary/empty-block.pug", + "name": "test", + "mode": "replace" + } + ], + "line": 0, + "filename": "auxiliary/empty-block.pug" + } + }, + "line": 1, + "filename": "auxiliary/extends-empty-block-1.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "div", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "test1", + "line": 4, + "filename": "auxiliary/extends-empty-block-1.pug" + } + ], + "line": 4, + "filename": "auxiliary/extends-empty-block-1.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "auxiliary/extends-empty-block-1.pug" + } + ], + "line": 3, + "filename": "auxiliary/extends-empty-block-1.pug", + "name": "test", + "mode": "replace" + } + ], + "line": 0, + "filename": "auxiliary/extends-empty-block-1.pug" + } + }, + "line": 1, + "filename": "include-extends-of-common-template.pug", + "block": { + "type": "Block", + "nodes": [], + "line": 1, + "filename": "include-extends-of-common-template.pug" + } + }, + { + "type": "Include", + "file": { + "type": "FileReference", + "line": 2, + "filename": "include-extends-of-common-template.pug", + "path": "auxiliary/extends-empty-block-2.pug", + "fullPath": "auxiliary/extends-empty-block-2.pug", + "str": "extends empty-block.pug\n\nblock test\n div test2\n\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "empty-block.pug", + "line": 1, + "filename": "auxiliary/extends-empty-block-2.pug", + "fullPath": "auxiliary/empty-block.pug", + "str": "block test\n\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 1, + "filename": "auxiliary/empty-block.pug", + "name": "test", + "mode": "replace" + } + ], + "line": 0, + "filename": "auxiliary/empty-block.pug" + } + }, + "line": 1, + "filename": "auxiliary/extends-empty-block-2.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "div", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "test2", + "line": 4, + "filename": "auxiliary/extends-empty-block-2.pug" + } + ], + "line": 4, + "filename": "auxiliary/extends-empty-block-2.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "auxiliary/extends-empty-block-2.pug" + } + ], + "line": 3, + "filename": "auxiliary/extends-empty-block-2.pug", + "name": "test", + "mode": "replace" + } + ], + "line": 0, + "filename": "auxiliary/extends-empty-block-2.pug" + } + }, + "line": 2, + "filename": "include-extends-of-common-template.pug", + "block": { + "type": "Block", + "nodes": [], + "line": 2, + "filename": "include-extends-of-common-template.pug" + } + } + ], + "line": 0, + "filename": "include-extends-of-common-template.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/include-extends-relative.input.json b/src/test-data/pug-linker/test/cases/include-extends-relative.input.json new file mode 100644 index 0000000..7f3d560 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/include-extends-relative.input.json @@ -0,0 +1,166 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Include", + "file": { + "type": "FileReference", + "line": 1, + "filename": "include-extends-relative.pug", + "path": "../cases-src/auxiliary/extends-relative.pug", + "fullPath": "../cases-src/auxiliary/extends-relative.pug", + "str": "extends ../../cases-src/auxiliary/layout\n\nblock content\n include ../../cases-src/auxiliary/include-from-root\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../../cases-src/auxiliary/layout", + "line": 1, + "filename": "../cases-src/auxiliary/extends-relative.pug", + "fullPath": "../cases-src/auxiliary/layout.pug", + "str": "html\n head\n title My Application\n block head\n body\n block content", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "head", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "title", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "My Application", + "line": 3, + "filename": "../cases-src/auxiliary/layout.pug" + } + ], + "line": 3, + "filename": "../cases-src/auxiliary/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "../cases-src/auxiliary/layout.pug" + }, + { + "type": "NamedBlock", + "nodes": [], + "line": 4, + "filename": "../cases-src/auxiliary/layout.pug", + "name": "head", + "mode": "replace" + } + ], + "line": 2, + "filename": "../cases-src/auxiliary/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "../cases-src/auxiliary/layout.pug" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 6, + "filename": "../cases-src/auxiliary/layout.pug", + "name": "content", + "mode": "replace" + } + ], + "line": 5, + "filename": "../cases-src/auxiliary/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "../cases-src/auxiliary/layout.pug" + } + ], + "line": 1, + "filename": "../cases-src/auxiliary/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "../cases-src/auxiliary/layout.pug" + } + ], + "line": 0, + "filename": "../cases-src/auxiliary/layout.pug" + } + }, + "line": 1, + "filename": "../cases-src/auxiliary/extends-relative.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 4, + "filename": "../cases-src/auxiliary/extends-relative.pug", + "path": "../../cases-src/auxiliary/include-from-root", + "fullPath": "../cases-src/auxiliary/include-from-root.pug", + "str": "h1 hello" + }, + "line": 4, + "filename": "../cases-src/auxiliary/extends-relative.pug", + "filters": [] + } + ], + "line": 3, + "filename": "../cases-src/auxiliary/extends-relative.pug", + "name": "content", + "mode": "replace" + } + ], + "line": 0, + "filename": "../cases-src/auxiliary/extends-relative.pug" + } + }, + "line": 1, + "filename": "include-extends-relative.pug", + "block": { + "type": "Block", + "nodes": [], + "line": 1, + "filename": "include-extends-relative.pug" + } + } + ], + "line": 0, + "filename": "include-extends-relative.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/include-filter-stylus.input.json b/src/test-data/pug-linker/test/cases/include-filter-stylus.input.json new file mode 100644 index 0000000..3145a07 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/include-filter-stylus.input.json @@ -0,0 +1,52 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "style", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 2, + "filename": "include-filter-stylus.pug", + "path": "some.styl", + "fullPath": "some.styl", + "str": "@import \"some-included\"\n" + }, + "line": 2, + "filename": "include-filter-stylus.pug", + "filters": [ + { + "type": "IncludeFilter", + "name": "stylus", + "attrs": [], + "line": 2, + "filename": "include-filter-stylus.pug" + } + ] + } + ], + "line": 1, + "filename": "include-filter-stylus.pug" + }, + "attrs": [ + { + "name": "type", + "val": "\"text/css\"", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "include-filter-stylus.pug" + } + ], + "line": 0, + "filename": "include-filter-stylus.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/include-filter.input.json b/src/test-data/pug-linker/test/cases/include-filter.input.json new file mode 100644 index 0000000..1f5d0c4 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/include-filter.input.json @@ -0,0 +1,153 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 3, + "filename": "include-filter.pug", + "path": "some.md", + "fullPath": "some.md", + "str": "Just _some_ markdown **tests**.\n\nWith new line.\n" + }, + "line": 3, + "filename": "include-filter.pug", + "filters": [ + { + "type": "IncludeFilter", + "name": "markdown-it", + "attrs": [], + "line": 3, + "filename": "include-filter.pug" + } + ] + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 5, + "filename": "include-filter.pug", + "path": "include-filter-coffee.coffee", + "fullPath": "include-filter-coffee.coffee", + "str": "math =\n square: (value) -> value * value\n" + }, + "line": 5, + "filename": "include-filter.pug", + "filters": [ + { + "type": "IncludeFilter", + "name": "coffee-script", + "attrs": [ + { + "name": "minify", + "val": "true", + "mustEscape": true + } + ], + "line": 5, + "filename": "include-filter.pug" + } + ] + } + ], + "line": 4, + "filename": "include-filter.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "include-filter.pug" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 7, + "filename": "include-filter.pug", + "path": "include-filter-coffee.coffee", + "fullPath": "include-filter-coffee.coffee", + "str": "math =\n square: (value) -> value * value\n" + }, + "line": 7, + "filename": "include-filter.pug", + "filters": [ + { + "type": "IncludeFilter", + "name": "coffee-script", + "attrs": [ + { + "name": "minify", + "val": "false", + "mustEscape": true + } + ], + "line": 7, + "filename": "include-filter.pug" + } + ] + } + ], + "line": 6, + "filename": "include-filter.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "include-filter.pug" + } + ], + "line": 2, + "filename": "include-filter.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "include-filter.pug" + } + ], + "line": 1, + "filename": "include-filter.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "include-filter.pug" + } + ], + "line": 0, + "filename": "include-filter.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/include-only-text-body.input.json b/src/test-data/pug-linker/test/cases/include-only-text-body.input.json new file mode 100644 index 0000000..82d7656 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/include-only-text-body.input.json @@ -0,0 +1,24 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "The message is \"", + "line": 1, + "filename": "include-only-text-body.pug" + }, + { + "type": "YieldBlock", + "line": 2, + "filename": "include-only-text-body.pug" + }, + { + "type": "Text", + "val": "\"", + "line": 3, + "filename": "include-only-text-body.pug" + } + ], + "line": 0, + "filename": "include-only-text-body.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/include-only-text.input.json b/src/test-data/pug-linker/test/cases/include-only-text.input.json new file mode 100644 index 0000000..84d010a --- /dev/null +++ b/src/test-data/pug-linker/test/cases/include-only-text.input.json @@ -0,0 +1,125 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Include", + "file": { + "type": "FileReference", + "line": 4, + "filename": "include-only-text.pug", + "path": "include-only-text-body.pug", + "fullPath": "include-only-text-body.pug", + "str": "| The message is \"\nyield\n| \"\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "The message is \"", + "line": 1, + "filename": "include-only-text-body.pug" + }, + { + "type": "YieldBlock", + "line": 2, + "filename": "include-only-text-body.pug" + }, + { + "type": "Text", + "val": "\"", + "line": 3, + "filename": "include-only-text-body.pug" + } + ], + "line": 0, + "filename": "include-only-text-body.pug" + } + }, + "line": 4, + "filename": "include-only-text.pug", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "em", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "hello world", + "line": 5, + "filename": "include-only-text.pug" + } + ], + "line": 5, + "filename": "include-only-text.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": true, + "line": 5, + "filename": "include-only-text.pug" + } + ], + "line": 5, + "filename": "include-only-text.pug" + } + } + ], + "line": 3, + "filename": "include-only-text.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "include-only-text.pug" + } + ], + "line": 2, + "filename": "include-only-text.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "include-only-text.pug" + } + ], + "line": 1, + "filename": "include-only-text.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "include-only-text.pug" + } + ], + "line": 0, + "filename": "include-only-text.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/include-with-text-head.input.json b/src/test-data/pug-linker/test/cases/include-with-text-head.input.json new file mode 100644 index 0000000..03d7151 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/include-with-text-head.input.json @@ -0,0 +1,53 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "head", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "alert('hello world');", + "line": 3 + } + ], + "line": 2, + "filename": "include-with-text-head.pug" + }, + "attrs": [ + { + "name": "type", + "val": "'text/javascript'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "include-with-text-head.pug", + "textOnly": true + } + ], + "line": 1, + "filename": "include-with-text-head.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "include-with-text-head.pug" + } + ], + "line": 0, + "filename": "include-with-text-head.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/include-with-text.input.json b/src/test-data/pug-linker/test/cases/include-with-text.input.json new file mode 100644 index 0000000..128c078 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/include-with-text.input.json @@ -0,0 +1,141 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Include", + "file": { + "type": "FileReference", + "line": 2, + "filename": "include-with-text.pug", + "path": "include-with-text-head.pug", + "fullPath": "include-with-text-head.pug", + "str": "head\n script(type='text/javascript').\n alert('hello world');\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "head", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "alert('hello world');", + "line": 3 + } + ], + "line": 2, + "filename": "include-with-text-head.pug" + }, + "attrs": [ + { + "name": "type", + "val": "'text/javascript'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "include-with-text-head.pug", + "textOnly": true + } + ], + "line": 1, + "filename": "include-with-text-head.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "include-with-text-head.pug" + } + ], + "line": 0, + "filename": "include-with-text-head.pug" + } + }, + "line": 2, + "filename": "include-with-text.pug", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 3, + "filename": "include-with-text.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'/caustic.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "include-with-text.pug" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 4, + "filename": "include-with-text.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'/app.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "include-with-text.pug" + } + ], + "line": 3, + "filename": "include-with-text.pug" + } + } + ], + "line": 1, + "filename": "include-with-text.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "include-with-text.pug" + } + ], + "line": 0, + "filename": "include-with-text.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/include.script.input.json b/src/test-data/pug-linker/test/cases/include.script.input.json new file mode 100644 index 0000000..315b120 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/include.script.input.json @@ -0,0 +1,130 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Include", + "file": { + "type": "FileReference", + "line": 2, + "filename": "include.script.pug", + "path": "auxiliary/pet.pug", + "fullPath": "auxiliary/pet.pug", + "str": ".pet\n h1 {{name}}\n p {{name}} is a {{species}} that is {{age}} old", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "div", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "h1", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "{{name}}", + "line": 2, + "filename": "auxiliary/pet.pug" + } + ], + "line": 2, + "filename": "auxiliary/pet.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "auxiliary/pet.pug" + }, + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "{{name}} is a {{species}} that is {{age}} old", + "line": 3, + "filename": "auxiliary/pet.pug" + } + ], + "line": 3, + "filename": "auxiliary/pet.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "auxiliary/pet.pug" + } + ], + "line": 1, + "filename": "auxiliary/pet.pug" + }, + "attrs": [ + { + "name": "class", + "val": "'pet'", + "mustEscape": false + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "auxiliary/pet.pug" + } + ], + "line": 0, + "filename": "auxiliary/pet.pug" + } + }, + "line": 2, + "filename": "include.script.pug", + "block": { + "type": "Block", + "nodes": [], + "line": 2, + "filename": "include.script.pug" + } + } + ], + "line": 1, + "filename": "include.script.pug" + }, + "attrs": [ + { + "name": "id", + "val": "'pet-template'", + "mustEscape": false + }, + { + "name": "type", + "val": "'text/x-template'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "include.script.pug" + } + ], + "line": 0, + "filename": "include.script.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/include.yield.nested.input.json b/src/test-data/pug-linker/test/cases/include.yield.nested.input.json new file mode 100644 index 0000000..3c69e4c --- /dev/null +++ b/src/test-data/pug-linker/test/cases/include.yield.nested.input.json @@ -0,0 +1,260 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Include", + "file": { + "type": "FileReference", + "line": 2, + "filename": "include.yield.nested.pug", + "path": "auxiliary/yield-nested.pug", + "fullPath": "auxiliary/yield-nested.pug", + "str": "html\n head\n title\n body\n h1 Page\n #content\n #content-wrapper\n yield\n #footer\n stuff", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "head", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "title", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 3, + "filename": "auxiliary/yield-nested.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "auxiliary/yield-nested.pug" + } + ], + "line": 2, + "filename": "auxiliary/yield-nested.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "auxiliary/yield-nested.pug" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "h1", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Page", + "line": 5, + "filename": "auxiliary/yield-nested.pug" + } + ], + "line": 5, + "filename": "auxiliary/yield-nested.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "auxiliary/yield-nested.pug" + }, + { + "type": "Tag", + "name": "div", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "div", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "YieldBlock", + "line": 8, + "filename": "auxiliary/yield-nested.pug" + } + ], + "line": 7, + "filename": "auxiliary/yield-nested.pug" + }, + "attrs": [ + { + "name": "id", + "val": "'content-wrapper'", + "mustEscape": false + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 7, + "filename": "auxiliary/yield-nested.pug" + } + ], + "line": 6, + "filename": "auxiliary/yield-nested.pug" + }, + "attrs": [ + { + "name": "id", + "val": "'content'", + "mustEscape": false + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "auxiliary/yield-nested.pug" + }, + { + "type": "Tag", + "name": "div", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "stuff", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 10, + "filename": "auxiliary/yield-nested.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 10, + "filename": "auxiliary/yield-nested.pug" + } + ], + "line": 9, + "filename": "auxiliary/yield-nested.pug" + }, + "attrs": [ + { + "name": "id", + "val": "'footer'", + "mustEscape": false + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 9, + "filename": "auxiliary/yield-nested.pug" + } + ], + "line": 4, + "filename": "auxiliary/yield-nested.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "auxiliary/yield-nested.pug" + } + ], + "line": 1, + "filename": "auxiliary/yield-nested.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "auxiliary/yield-nested.pug" + } + ], + "line": 0, + "filename": "auxiliary/yield-nested.pug" + } + }, + "line": 2, + "filename": "include.yield.nested.pug", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "some content", + "line": 3, + "filename": "include.yield.nested.pug" + } + ], + "line": 3, + "filename": "include.yield.nested.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "include.yield.nested.pug" + }, + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "and some more", + "line": 4, + "filename": "include.yield.nested.pug" + } + ], + "line": 4, + "filename": "include.yield.nested.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "include.yield.nested.pug" + } + ], + "line": 3, + "filename": "include.yield.nested.pug" + } + } + ], + "line": 0, + "filename": "include.yield.nested.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/includes-with-ext-js.input.json b/src/test-data/pug-linker/test/cases/includes-with-ext-js.input.json new file mode 100644 index 0000000..cd88d12 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/includes-with-ext-js.input.json @@ -0,0 +1,55 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "pre", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "code", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 3, + "filename": "includes-with-ext-js.pug", + "path": "javascript-new-lines.js", + "fullPath": "javascript-new-lines.js", + "str": "var x = \"\\n here is some \\n new lined text\";\n" + }, + "line": 3, + "filename": "includes-with-ext-js.pug", + "filters": [] + } + ], + "line": 2, + "filename": "includes-with-ext-js.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": true, + "line": 2, + "filename": "includes-with-ext-js.pug" + } + ], + "line": 1, + "filename": "includes-with-ext-js.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 1, + "filename": "includes-with-ext-js.pug" + } + ], + "line": 0, + "filename": "includes-with-ext-js.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/includes.input.json b/src/test-data/pug-linker/test/cases/includes.input.json new file mode 100644 index 0000000..79d5f89 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/includes.input.json @@ -0,0 +1,172 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Include", + "file": { + "type": "FileReference", + "line": 2, + "filename": "includes.pug", + "path": "auxiliary/mixins.pug", + "fullPath": "auxiliary/mixins.pug", + "str": "\nmixin foo()\n p bar", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Mixin", + "name": "foo", + "args": null, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "bar", + "line": 3, + "filename": "auxiliary/mixins.pug" + } + ], + "line": 3, + "filename": "auxiliary/mixins.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "auxiliary/mixins.pug" + } + ], + "line": 3, + "filename": "auxiliary/mixins.pug" + }, + "call": false, + "line": 2, + "filename": "auxiliary/mixins.pug" + } + ], + "line": 0, + "filename": "auxiliary/mixins.pug" + } + }, + "line": 2, + "filename": "includes.pug", + "block": { + "type": "Block", + "nodes": [], + "line": 2, + "filename": "includes.pug" + } + }, + { + "type": "Mixin", + "name": "foo", + "args": null, + "block": null, + "call": true, + "attrs": [], + "attributeBlocks": [], + "line": 4, + "filename": "includes.pug" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 7, + "filename": "includes.pug", + "path": "auxiliary/smile.html", + "fullPath": "auxiliary/smile.html", + "str": "

:)

\n" + }, + "line": 7, + "filename": "includes.pug", + "filters": [] + }, + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 8, + "filename": "includes.pug", + "path": "auxiliary/escapes.html", + "fullPath": "auxiliary/escapes.html", + "str": "\n" + }, + "line": 8, + "filename": "includes.pug", + "filters": [] + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "RawInclude", + "file": { + "type": "FileReference", + "line": 10, + "filename": "includes.pug", + "path": "auxiliary/includable.js", + "fullPath": "auxiliary/includable.js", + "str": "var STRING_SUBSTITUTIONS = { // table of character substitutions\n '\\t': '\\\\t',\n '\\r': '\\\\r',\n '\\n': '\\\\n',\n '\"' : '\\\\\"',\n '\\\\': '\\\\\\\\'\n};" + }, + "line": 10, + "filename": "includes.pug", + "filters": [ + { + "type": "IncludeFilter", + "name": "verbatim", + "attrs": [], + "line": 10, + "filename": "includes.pug" + } + ] + } + ], + "line": 9, + "filename": "includes.pug" + }, + "attrs": [ + { + "name": "type", + "val": "\"text/javascript\"", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 9, + "filename": "includes.pug" + } + ], + "line": 6, + "filename": "includes.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "includes.pug" + } + ], + "line": 0, + "filename": "includes.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/layout.append.input.json b/src/test-data/pug-linker/test/cases/layout.append.input.json new file mode 100644 index 0000000..2b8879a --- /dev/null +++ b/src/test-data/pug-linker/test/cases/layout.append.input.json @@ -0,0 +1,226 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../fixtures/append/app-layout.pug", + "line": 2, + "filename": "layout.append.pug", + "fullPath": "../fixtures/append/app-layout.pug", + "str": "\nextends layout\n\nblock append head\n script(src='app.js')", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "layout", + "line": 2, + "filename": "../fixtures/append/app-layout.pug", + "fullPath": "../fixtures/append/layout.pug", + "str": "\nhtml\n block head\n script(src='vendor/jquery.js')\n script(src='vendor/caustic.js')\n body\n block body", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 4, + "filename": "../fixtures/append/layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'vendor/jquery.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "../fixtures/append/layout.pug" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "../fixtures/append/layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'vendor/caustic.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "../fixtures/append/layout.pug" + } + ], + "line": 3, + "filename": "../fixtures/append/layout.pug", + "name": "head", + "mode": "replace" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 7, + "filename": "../fixtures/append/layout.pug", + "name": "body", + "mode": "replace" + } + ], + "line": 6, + "filename": "../fixtures/append/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "../fixtures/append/layout.pug" + } + ], + "line": 2, + "filename": "../fixtures/append/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "../fixtures/append/layout.pug" + } + ], + "line": 0, + "filename": "../fixtures/append/layout.pug" + } + }, + "line": 2, + "filename": "../fixtures/append/app-layout.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "../fixtures/append/app-layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'app.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "../fixtures/append/app-layout.pug" + } + ], + "line": 4, + "filename": "../fixtures/append/app-layout.pug", + "name": "head", + "mode": "append" + } + ], + "line": 0, + "filename": "../fixtures/append/app-layout.pug" + } + }, + "line": 2, + "filename": "layout.append.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "layout.append.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'foo.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "layout.append.pug" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 6, + "filename": "layout.append.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'bar.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "layout.append.pug" + } + ], + "line": 4, + "filename": "layout.append.pug", + "name": "head", + "mode": "append" + } + ], + "line": 0, + "filename": "layout.append.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/layout.append.without-block.input.json b/src/test-data/pug-linker/test/cases/layout.append.without-block.input.json new file mode 100644 index 0000000..4fc3d87 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/layout.append.without-block.input.json @@ -0,0 +1,226 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../fixtures/append-without-block/app-layout.pug", + "line": 2, + "filename": "layout.append.without-block.pug", + "fullPath": "../fixtures/append-without-block/app-layout.pug", + "str": "\nextends layout.pug\n\nappend head\n script(src='app.js')\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "layout.pug", + "line": 2, + "filename": "../fixtures/append-without-block/app-layout.pug", + "fullPath": "../fixtures/append-without-block/layout.pug", + "str": "\nhtml\n block head\n script(src='vendor/jquery.js')\n script(src='vendor/caustic.js')\n body\n block body", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 4, + "filename": "../fixtures/append-without-block/layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'vendor/jquery.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "../fixtures/append-without-block/layout.pug" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "../fixtures/append-without-block/layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'vendor/caustic.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "../fixtures/append-without-block/layout.pug" + } + ], + "line": 3, + "filename": "../fixtures/append-without-block/layout.pug", + "name": "head", + "mode": "replace" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 7, + "filename": "../fixtures/append-without-block/layout.pug", + "name": "body", + "mode": "replace" + } + ], + "line": 6, + "filename": "../fixtures/append-without-block/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "../fixtures/append-without-block/layout.pug" + } + ], + "line": 2, + "filename": "../fixtures/append-without-block/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "../fixtures/append-without-block/layout.pug" + } + ], + "line": 0, + "filename": "../fixtures/append-without-block/layout.pug" + } + }, + "line": 2, + "filename": "../fixtures/append-without-block/app-layout.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "../fixtures/append-without-block/app-layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'app.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "../fixtures/append-without-block/app-layout.pug" + } + ], + "line": 4, + "filename": "../fixtures/append-without-block/app-layout.pug", + "name": "head", + "mode": "append" + } + ], + "line": 0, + "filename": "../fixtures/append-without-block/app-layout.pug" + } + }, + "line": 2, + "filename": "layout.append.without-block.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "layout.append.without-block.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'foo.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "layout.append.without-block.pug" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 6, + "filename": "layout.append.without-block.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'bar.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "layout.append.without-block.pug" + } + ], + "line": 4, + "filename": "layout.append.without-block.pug", + "name": "head", + "mode": "append" + } + ], + "line": 0, + "filename": "layout.append.without-block.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/layout.multi.append.prepend.block.input.json b/src/test-data/pug-linker/test/cases/layout.multi.append.prepend.block.input.json new file mode 100644 index 0000000..cdd8d01 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/layout.multi.append.prepend.block.input.json @@ -0,0 +1,365 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../fixtures/multi-append-prepend-block/redefine.pug", + "line": 1, + "filename": "layout.multi.append.prepend.block.pug", + "fullPath": "../fixtures/multi-append-prepend-block/redefine.pug", + "str": "extends root.pug\n\nblock content\n\t.content\n\t\t| Defined content\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "root.pug", + "line": 1, + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "fullPath": "../fixtures/multi-append-prepend-block/root.pug", + "str": "block content\n\t| default content\n\nblock head\n\tscript(src='/app.js')", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Text", + "val": "default content", + "line": 2, + "filename": "../fixtures/multi-append-prepend-block/root.pug" + } + ], + "line": 1, + "filename": "../fixtures/multi-append-prepend-block/root.pug", + "name": "content", + "mode": "replace" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "../fixtures/multi-append-prepend-block/root.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'/app.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "../fixtures/multi-append-prepend-block/root.pug" + } + ], + "line": 4, + "filename": "../fixtures/multi-append-prepend-block/root.pug", + "name": "head", + "mode": "replace" + } + ], + "line": 0, + "filename": "../fixtures/multi-append-prepend-block/root.pug" + } + }, + "line": 1, + "filename": "../fixtures/multi-append-prepend-block/redefine.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "div", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Defined content", + "line": 5, + "filename": "../fixtures/multi-append-prepend-block/redefine.pug" + } + ], + "line": 4, + "filename": "../fixtures/multi-append-prepend-block/redefine.pug" + }, + "attrs": [ + { + "name": "class", + "val": "'content'", + "mustEscape": false + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "../fixtures/multi-append-prepend-block/redefine.pug" + } + ], + "line": 3, + "filename": "../fixtures/multi-append-prepend-block/redefine.pug", + "name": "content", + "mode": "replace" + } + ], + "line": 0, + "filename": "../fixtures/multi-append-prepend-block/redefine.pug" + } + }, + "line": 1, + "filename": "layout.multi.append.prepend.block.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Something appended to content", + "line": 4, + "filename": "layout.multi.append.prepend.block.pug" + } + ], + "line": 4, + "filename": "layout.multi.append.prepend.block.pug" + }, + "attrs": [ + { + "name": "class", + "val": "'first'", + "mustEscape": false + }, + { + "name": "class", + "val": "'append'", + "mustEscape": false + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "layout.multi.append.prepend.block.pug" + } + ], + "line": 3, + "filename": "layout.multi.append.prepend.block.pug", + "name": "content", + "mode": "append" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Something prepended to content", + "line": 7, + "filename": "layout.multi.append.prepend.block.pug" + } + ], + "line": 7, + "filename": "layout.multi.append.prepend.block.pug" + }, + "attrs": [ + { + "name": "class", + "val": "'first'", + "mustEscape": false + }, + { + "name": "class", + "val": "'prepend'", + "mustEscape": false + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 7, + "filename": "layout.multi.append.prepend.block.pug" + } + ], + "line": 6, + "filename": "layout.multi.append.prepend.block.pug", + "name": "content", + "mode": "prepend" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Last append must be most last", + "line": 10, + "filename": "layout.multi.append.prepend.block.pug" + } + ], + "line": 10, + "filename": "layout.multi.append.prepend.block.pug" + }, + "attrs": [ + { + "name": "class", + "val": "'last'", + "mustEscape": false + }, + { + "name": "class", + "val": "'append'", + "mustEscape": false + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 10, + "filename": "layout.multi.append.prepend.block.pug" + } + ], + "line": 9, + "filename": "layout.multi.append.prepend.block.pug", + "name": "content", + "mode": "append" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Last prepend must appear at top", + "line": 13, + "filename": "layout.multi.append.prepend.block.pug" + } + ], + "line": 13, + "filename": "layout.multi.append.prepend.block.pug" + }, + "attrs": [ + { + "name": "class", + "val": "'last'", + "mustEscape": false + }, + { + "name": "class", + "val": "'prepend'", + "mustEscape": false + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 13, + "filename": "layout.multi.append.prepend.block.pug" + } + ], + "line": 12, + "filename": "layout.multi.append.prepend.block.pug", + "name": "content", + "mode": "prepend" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 16, + "filename": "layout.multi.append.prepend.block.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'jquery.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 16, + "filename": "layout.multi.append.prepend.block.pug" + } + ], + "line": 15, + "filename": "layout.multi.append.prepend.block.pug", + "name": "head", + "mode": "append" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 19, + "filename": "layout.multi.append.prepend.block.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'foo.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 19, + "filename": "layout.multi.append.prepend.block.pug" + } + ], + "line": 18, + "filename": "layout.multi.append.prepend.block.pug", + "name": "head", + "mode": "prepend" + } + ], + "line": 0, + "filename": "layout.multi.append.prepend.block.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/layout.prepend.input.json b/src/test-data/pug-linker/test/cases/layout.prepend.input.json new file mode 100644 index 0000000..44a5cc0 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/layout.prepend.input.json @@ -0,0 +1,226 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../fixtures/prepend/app-layout.pug", + "line": 2, + "filename": "layout.prepend.pug", + "fullPath": "../fixtures/prepend/app-layout.pug", + "str": "\nextends layout.pug\n\nblock prepend head\n script(src='app.js')\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "layout.pug", + "line": 2, + "filename": "../fixtures/prepend/app-layout.pug", + "fullPath": "../fixtures/prepend/layout.pug", + "str": "\nhtml\n block head\n script(src='vendor/jquery.js')\n script(src='vendor/caustic.js')\n body\n block body", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 4, + "filename": "../fixtures/prepend/layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'vendor/jquery.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "../fixtures/prepend/layout.pug" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "../fixtures/prepend/layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'vendor/caustic.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "../fixtures/prepend/layout.pug" + } + ], + "line": 3, + "filename": "../fixtures/prepend/layout.pug", + "name": "head", + "mode": "replace" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 7, + "filename": "../fixtures/prepend/layout.pug", + "name": "body", + "mode": "replace" + } + ], + "line": 6, + "filename": "../fixtures/prepend/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "../fixtures/prepend/layout.pug" + } + ], + "line": 2, + "filename": "../fixtures/prepend/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "../fixtures/prepend/layout.pug" + } + ], + "line": 0, + "filename": "../fixtures/prepend/layout.pug" + } + }, + "line": 2, + "filename": "../fixtures/prepend/app-layout.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "../fixtures/prepend/app-layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'app.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "../fixtures/prepend/app-layout.pug" + } + ], + "line": 4, + "filename": "../fixtures/prepend/app-layout.pug", + "name": "head", + "mode": "prepend" + } + ], + "line": 0, + "filename": "../fixtures/prepend/app-layout.pug" + } + }, + "line": 2, + "filename": "layout.prepend.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "layout.prepend.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'foo.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "layout.prepend.pug" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 6, + "filename": "layout.prepend.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'bar.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "layout.prepend.pug" + } + ], + "line": 4, + "filename": "layout.prepend.pug", + "name": "head", + "mode": "prepend" + } + ], + "line": 0, + "filename": "layout.prepend.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/cases/layout.prepend.without-block.input.json b/src/test-data/pug-linker/test/cases/layout.prepend.without-block.input.json new file mode 100644 index 0000000..d94e193 --- /dev/null +++ b/src/test-data/pug-linker/test/cases/layout.prepend.without-block.input.json @@ -0,0 +1,226 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../fixtures/prepend-without-block/app-layout.pug", + "line": 2, + "filename": "layout.prepend.without-block.pug", + "fullPath": "../fixtures/prepend-without-block/app-layout.pug", + "str": "\nextends layout.pug\n\nprepend head\n script(src='app.js')\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "layout.pug", + "line": 2, + "filename": "../fixtures/prepend-without-block/app-layout.pug", + "fullPath": "../fixtures/prepend-without-block/layout.pug", + "str": "\nhtml\n block head\n script(src='vendor/jquery.js')\n script(src='vendor/caustic.js')\n body\n block body", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 4, + "filename": "../fixtures/prepend-without-block/layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'vendor/jquery.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "../fixtures/prepend-without-block/layout.pug" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "../fixtures/prepend-without-block/layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'vendor/caustic.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "../fixtures/prepend-without-block/layout.pug" + } + ], + "line": 3, + "filename": "../fixtures/prepend-without-block/layout.pug", + "name": "head", + "mode": "replace" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 7, + "filename": "../fixtures/prepend-without-block/layout.pug", + "name": "body", + "mode": "replace" + } + ], + "line": 6, + "filename": "../fixtures/prepend-without-block/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "../fixtures/prepend-without-block/layout.pug" + } + ], + "line": 2, + "filename": "../fixtures/prepend-without-block/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "../fixtures/prepend-without-block/layout.pug" + } + ], + "line": 0, + "filename": "../fixtures/prepend-without-block/layout.pug" + } + }, + "line": 2, + "filename": "../fixtures/prepend-without-block/app-layout.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "../fixtures/prepend-without-block/app-layout.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'app.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "../fixtures/prepend-without-block/app-layout.pug" + } + ], + "line": 4, + "filename": "../fixtures/prepend-without-block/app-layout.pug", + "name": "head", + "mode": "prepend" + } + ], + "line": 0, + "filename": "../fixtures/prepend-without-block/app-layout.pug" + } + }, + "line": 2, + "filename": "layout.prepend.without-block.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 5, + "filename": "layout.prepend.without-block.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'foo.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 5, + "filename": "layout.prepend.without-block.pug" + }, + { + "type": "Tag", + "name": "script", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 6, + "filename": "layout.prepend.without-block.pug" + }, + "attrs": [ + { + "name": "src", + "val": "'bar.js'", + "mustEscape": true + } + ], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "layout.prepend.without-block.pug" + } + ], + "line": 4, + "filename": "layout.prepend.without-block.pug", + "name": "head", + "mode": "prepend" + } + ], + "line": 0, + "filename": "layout.prepend.without-block.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/errors-src/child-with-tags.pug b/src/test-data/pug-linker/test/errors-src/child-with-tags.pug new file mode 100644 index 0000000..fb439dd --- /dev/null +++ b/src/test-data/pug-linker/test/errors-src/child-with-tags.pug @@ -0,0 +1,6 @@ +extend ../fixtures/layout + +block body + p Hello world! + +p BAD!!! diff --git a/src/test-data/pug-linker/test/errors-src/extends-not-first.pug b/src/test-data/pug-linker/test/errors-src/extends-not-first.pug new file mode 100644 index 0000000..47249bc --- /dev/null +++ b/src/test-data/pug-linker/test/errors-src/extends-not-first.pug @@ -0,0 +1,4 @@ +block body + p Hey + +extends ../fixtures/layout diff --git a/src/test-data/pug-linker/test/errors-src/unexpected-block.pug b/src/test-data/pug-linker/test/errors-src/unexpected-block.pug new file mode 100644 index 0000000..5d56192 --- /dev/null +++ b/src/test-data/pug-linker/test/errors-src/unexpected-block.pug @@ -0,0 +1,4 @@ +extends ../fixtures/empty.pug + +block foo + div Hello World diff --git a/src/test-data/pug-linker/test/errors/child-with-tags.input.json b/src/test-data/pug-linker/test/errors/child-with-tags.input.json new file mode 100644 index 0000000..8875e2e --- /dev/null +++ b/src/test-data/pug-linker/test/errors/child-with-tags.input.json @@ -0,0 +1,163 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../fixtures/layout", + "line": 1, + "filename": "child-with-tags.pug", + "fullPath": "../fixtures/layout.pug", + "str": "doctype\n\nhtml\n head\n block head\n Hello world!\n body\n block body\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Doctype", + "val": "", + "line": 1, + "filename": "../fixtures/layout.pug" + }, + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "head", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Text", + "val": "Hello world!", + "filename": "../fixtures/layout.pug", + "line": 6, + "isHtml": true + } + ], + "line": 5, + "filename": "../fixtures/layout.pug", + "name": "head", + "mode": "replace" + } + ], + "line": 4, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "../fixtures/layout.pug" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 8, + "filename": "../fixtures/layout.pug", + "name": "body", + "mode": "replace" + } + ], + "line": 7, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 7, + "filename": "../fixtures/layout.pug" + } + ], + "line": 3, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "../fixtures/layout.pug" + } + ], + "line": 0, + "filename": "../fixtures/layout.pug" + } + }, + "line": 1, + "filename": "child-with-tags.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Hello world!", + "line": 4, + "filename": "child-with-tags.pug" + } + ], + "line": 4, + "filename": "child-with-tags.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "child-with-tags.pug" + } + ], + "line": 3, + "filename": "child-with-tags.pug", + "name": "body", + "mode": "replace" + }, + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "BAD!!!", + "line": 6, + "filename": "child-with-tags.pug" + } + ], + "line": 6, + "filename": "child-with-tags.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 6, + "filename": "child-with-tags.pug" + } + ], + "line": 0, + "filename": "child-with-tags.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/errors/extends-not-first.input.json b/src/test-data/pug-linker/test/errors/extends-not-first.input.json new file mode 100644 index 0000000..44f8b47 --- /dev/null +++ b/src/test-data/pug-linker/test/errors/extends-not-first.input.json @@ -0,0 +1,140 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Hey", + "line": 2, + "filename": "extends-not-first.pug" + } + ], + "line": 2, + "filename": "extends-not-first.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 2, + "filename": "extends-not-first.pug" + } + ], + "line": 1, + "filename": "extends-not-first.pug", + "name": "body", + "mode": "replace" + }, + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../fixtures/layout", + "line": 4, + "filename": "extends-not-first.pug", + "fullPath": "../fixtures/layout.pug", + "str": "doctype\n\nhtml\n head\n block head\n Hello world!\n body\n block body\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Doctype", + "val": "", + "line": 1, + "filename": "../fixtures/layout.pug" + }, + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "head", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Text", + "val": "Hello world!", + "filename": "../fixtures/layout.pug", + "line": 6, + "isHtml": true + } + ], + "line": 5, + "filename": "../fixtures/layout.pug", + "name": "head", + "mode": "replace" + } + ], + "line": 4, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "../fixtures/layout.pug" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 8, + "filename": "../fixtures/layout.pug", + "name": "body", + "mode": "replace" + } + ], + "line": 7, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 7, + "filename": "../fixtures/layout.pug" + } + ], + "line": 3, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "../fixtures/layout.pug" + } + ], + "line": 0, + "filename": "../fixtures/layout.pug" + } + }, + "line": 4, + "filename": "extends-not-first.pug" + } + ], + "line": 0, + "filename": "extends-not-first.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/errors/unexpected-block.input.json b/src/test-data/pug-linker/test/errors/unexpected-block.input.json new file mode 100644 index 0000000..4433046 --- /dev/null +++ b/src/test-data/pug-linker/test/errors/unexpected-block.input.json @@ -0,0 +1,58 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../fixtures/empty.pug", + "line": 1, + "filename": "unexpected-block.pug", + "fullPath": "../fixtures/empty.pug", + "str": "", + "ast": { + "type": "Block", + "nodes": [], + "line": 0, + "filename": "../fixtures/empty.pug" + } + }, + "line": 1, + "filename": "unexpected-block.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "div", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Hello World", + "line": 4, + "filename": "unexpected-block.pug" + } + ], + "line": 4, + "filename": "unexpected-block.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "unexpected-block.pug" + } + ], + "line": 3, + "filename": "unexpected-block.pug", + "name": "foo", + "mode": "replace" + } + ], + "line": 0, + "filename": "unexpected-block.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/fixtures/append-without-block/app-layout.pug b/src/test-data/pug-linker/test/fixtures/append-without-block/app-layout.pug new file mode 100644 index 0000000..1b55872 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/append-without-block/app-layout.pug @@ -0,0 +1,5 @@ + +extends layout.pug + +append head + script(src='app.js') diff --git a/src/test-data/pug-linker/test/fixtures/append-without-block/layout.pug b/src/test-data/pug-linker/test/fixtures/append-without-block/layout.pug new file mode 100644 index 0000000..671b3c9 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/append-without-block/layout.pug @@ -0,0 +1,7 @@ + +html + block head + script(src='vendor/jquery.js') + script(src='vendor/caustic.js') + body + block body \ No newline at end of file diff --git a/src/test-data/pug-linker/test/fixtures/append-without-block/page.pug b/src/test-data/pug-linker/test/fixtures/append-without-block/page.pug new file mode 100644 index 0000000..e607ae7 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/append-without-block/page.pug @@ -0,0 +1,6 @@ + +extends app-layout.pug + +append head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug-linker/test/fixtures/append/app-layout.pug b/src/test-data/pug-linker/test/fixtures/append/app-layout.pug new file mode 100644 index 0000000..48bf886 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/append/app-layout.pug @@ -0,0 +1,5 @@ + +extends layout + +block append head + script(src='app.js') \ No newline at end of file diff --git a/src/test-data/pug-linker/test/fixtures/append/layout.pug b/src/test-data/pug-linker/test/fixtures/append/layout.pug new file mode 100644 index 0000000..671b3c9 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/append/layout.pug @@ -0,0 +1,7 @@ + +html + block head + script(src='vendor/jquery.js') + script(src='vendor/caustic.js') + body + block body \ No newline at end of file diff --git a/src/test-data/pug-linker/test/fixtures/append/page.html b/src/test-data/pug-linker/test/fixtures/append/page.html new file mode 100644 index 0000000..bc5e126 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/append/page.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test-data/pug-linker/test/fixtures/append/page.pug b/src/test-data/pug-linker/test/fixtures/append/page.pug new file mode 100644 index 0000000..1ae9909 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/append/page.pug @@ -0,0 +1,6 @@ + +extends app-layout + +block append head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug-linker/test/fixtures/empty.pug b/src/test-data/pug-linker/test/fixtures/empty.pug new file mode 100644 index 0000000..e69de29 diff --git a/src/test-data/pug-linker/test/fixtures/layout.pug b/src/test-data/pug-linker/test/fixtures/layout.pug new file mode 100644 index 0000000..aaa3c63 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/layout.pug @@ -0,0 +1,8 @@ +doctype + +html + head + block head + Hello world! + body + block body diff --git a/src/test-data/pug-linker/test/fixtures/mixins.pug b/src/test-data/pug-linker/test/fixtures/mixins.pug new file mode 100644 index 0000000..e20550a --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/mixins.pug @@ -0,0 +1,2 @@ +mixin image(src) + img(cl-src=src)&attributes(attributes) diff --git a/src/test-data/pug-linker/test/fixtures/multi-append-prepend-block/redefine.pug b/src/test-data/pug-linker/test/fixtures/multi-append-prepend-block/redefine.pug new file mode 100644 index 0000000..abc178e --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/multi-append-prepend-block/redefine.pug @@ -0,0 +1,5 @@ +extends root.pug + +block content + .content + | Defined content diff --git a/src/test-data/pug-linker/test/fixtures/multi-append-prepend-block/root.pug b/src/test-data/pug-linker/test/fixtures/multi-append-prepend-block/root.pug new file mode 100644 index 0000000..8e3334a --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/multi-append-prepend-block/root.pug @@ -0,0 +1,5 @@ +block content + | default content + +block head + script(src='/app.js') \ No newline at end of file diff --git a/src/test-data/pug-linker/test/fixtures/prepend-without-block/app-layout.pug b/src/test-data/pug-linker/test/fixtures/prepend-without-block/app-layout.pug new file mode 100644 index 0000000..53f89ba --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/prepend-without-block/app-layout.pug @@ -0,0 +1,5 @@ + +extends layout.pug + +prepend head + script(src='app.js') diff --git a/src/test-data/pug-linker/test/fixtures/prepend-without-block/layout.pug b/src/test-data/pug-linker/test/fixtures/prepend-without-block/layout.pug new file mode 100644 index 0000000..671b3c9 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/prepend-without-block/layout.pug @@ -0,0 +1,7 @@ + +html + block head + script(src='vendor/jquery.js') + script(src='vendor/caustic.js') + body + block body \ No newline at end of file diff --git a/src/test-data/pug-linker/test/fixtures/prepend-without-block/page.html b/src/test-data/pug-linker/test/fixtures/prepend-without-block/page.html new file mode 100644 index 0000000..8753a42 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/prepend-without-block/page.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test-data/pug-linker/test/fixtures/prepend-without-block/page.pug b/src/test-data/pug-linker/test/fixtures/prepend-without-block/page.pug new file mode 100644 index 0000000..6b9bb01 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/prepend-without-block/page.pug @@ -0,0 +1,6 @@ + +extends app-layout.pug + +prepend head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug-linker/test/fixtures/prepend/app-layout.pug b/src/test-data/pug-linker/test/fixtures/prepend/app-layout.pug new file mode 100644 index 0000000..7040eec --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/prepend/app-layout.pug @@ -0,0 +1,5 @@ + +extends layout.pug + +block prepend head + script(src='app.js') diff --git a/src/test-data/pug-linker/test/fixtures/prepend/layout.pug b/src/test-data/pug-linker/test/fixtures/prepend/layout.pug new file mode 100644 index 0000000..671b3c9 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/prepend/layout.pug @@ -0,0 +1,7 @@ + +html + block head + script(src='vendor/jquery.js') + script(src='vendor/caustic.js') + body + block body \ No newline at end of file diff --git a/src/test-data/pug-linker/test/fixtures/prepend/page.html b/src/test-data/pug-linker/test/fixtures/prepend/page.html new file mode 100644 index 0000000..8753a42 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/prepend/page.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test-data/pug-linker/test/fixtures/prepend/page.pug b/src/test-data/pug-linker/test/fixtures/prepend/page.pug new file mode 100644 index 0000000..c2a91c9 --- /dev/null +++ b/src/test-data/pug-linker/test/fixtures/prepend/page.pug @@ -0,0 +1,6 @@ + +extends app-layout.pug + +block prepend head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug-linker/test/index.test.js b/src/test-data/pug-linker/test/index.test.js new file mode 100644 index 0000000..862629d --- /dev/null +++ b/src/test-data/pug-linker/test/index.test.js @@ -0,0 +1,46 @@ +var assert = require('assert'); +var fs = require('fs'); +var link = require('../'); + +function testDir(dir) { + fs.readdirSync(dir).forEach(function(name) { + if (!/\.input\.json$/.test(name)) return; + test(name, function() { + var actual = link(JSON.parse(fs.readFileSync(dir + '/' + name, 'utf8'))); + expect(actual).toMatchSnapshot(); + }); + }); +} + +function testDirError(dir) { + fs.readdirSync(dir).forEach(function(name) { + if (!/\.input\.json$/.test(name)) return; + test(name, function() { + var input = JSON.parse(fs.readFileSync(dir + '/' + name, 'utf8')); + var err; + try { + link(input); + } catch (ex) { + err = { + msg: ex.msg, + code: ex.code, + line: ex.line, + }; + } + if (!err) throw new Error('Expected error'); + expect(err).toMatchSnapshot(); + }); + }); +} + +describe('cases from pug', function() { + testDir(__dirname + '/cases'); +}); + +describe('special cases', function() { + testDir(__dirname + '/special-cases'); +}); + +describe('error handling', function() { + testDirError(__dirname + '/errors'); +}); diff --git a/src/test-data/pug-linker/test/special-cases-src/extending-empty.pug b/src/test-data/pug-linker/test/special-cases-src/extending-empty.pug new file mode 100644 index 0000000..0b87566 --- /dev/null +++ b/src/test-data/pug-linker/test/special-cases-src/extending-empty.pug @@ -0,0 +1 @@ +extend ../fixtures/empty.pug diff --git a/src/test-data/pug-linker/test/special-cases-src/extending-include.pug b/src/test-data/pug-linker/test/special-cases-src/extending-include.pug new file mode 100644 index 0000000..5dd10ec --- /dev/null +++ b/src/test-data/pug-linker/test/special-cases-src/extending-include.pug @@ -0,0 +1,5 @@ +extend ../fixtures/layout.pug +include ../fixtures/mixins.pug + +block body + +image('myimg.png').with-border(alt="My image") diff --git a/src/test-data/pug-linker/test/special-cases-src/root-mixin.pug b/src/test-data/pug-linker/test/special-cases-src/root-mixin.pug new file mode 100644 index 0000000..c81b2b5 --- /dev/null +++ b/src/test-data/pug-linker/test/special-cases-src/root-mixin.pug @@ -0,0 +1,9 @@ +extend ../fixtures/layout.pug + +mixin myMixin + p Hello world + +block body + p Before + +myMixin + p After diff --git a/src/test-data/pug-linker/test/special-cases/extending-empty.input.json b/src/test-data/pug-linker/test/special-cases/extending-empty.input.json new file mode 100644 index 0000000..532adb5 --- /dev/null +++ b/src/test-data/pug-linker/test/special-cases/extending-empty.input.json @@ -0,0 +1,26 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../fixtures/empty.pug", + "line": 1, + "filename": "extending-empty.pug", + "fullPath": "../fixtures/empty.pug", + "str": "", + "ast": { + "type": "Block", + "nodes": [], + "line": 0, + "filename": "../fixtures/empty.pug" + } + }, + "line": 1, + "filename": "extending-empty.pug" + } + ], + "line": 0, + "filename": "extending-empty.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/special-cases/extending-include.input.json b/src/test-data/pug-linker/test/special-cases/extending-include.input.json new file mode 100644 index 0000000..495311e --- /dev/null +++ b/src/test-data/pug-linker/test/special-cases/extending-include.input.json @@ -0,0 +1,204 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../fixtures/layout.pug", + "line": 1, + "filename": "extending-include.pug", + "fullPath": "../fixtures/layout.pug", + "str": "doctype\n\nhtml\n head\n block head\n Hello world!\n body\n block body\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Doctype", + "val": "", + "line": 1, + "filename": "../fixtures/layout.pug" + }, + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "head", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Text", + "val": "Hello world!", + "filename": "../fixtures/layout.pug", + "line": 6, + "isHtml": true + } + ], + "line": 5, + "filename": "../fixtures/layout.pug", + "name": "head", + "mode": "replace" + } + ], + "line": 4, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "../fixtures/layout.pug" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 8, + "filename": "../fixtures/layout.pug", + "name": "body", + "mode": "replace" + } + ], + "line": 7, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 7, + "filename": "../fixtures/layout.pug" + } + ], + "line": 3, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "../fixtures/layout.pug" + } + ], + "line": 0, + "filename": "../fixtures/layout.pug" + } + }, + "line": 1, + "filename": "extending-include.pug" + }, + { + "type": "Include", + "file": { + "type": "FileReference", + "line": 2, + "filename": "extending-include.pug", + "path": "../fixtures/mixins.pug", + "fullPath": "../fixtures/mixins.pug", + "str": "mixin image(src)\n img(cl-src=src)&attributes(attributes)\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Mixin", + "name": "image", + "args": "src", + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "img", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [], + "line": 2, + "filename": "../fixtures/mixins.pug" + }, + "attrs": [ + { + "name": "cl-src", + "val": "src", + "mustEscape": true + } + ], + "attributeBlocks": [ + "attributes" + ], + "isInline": true, + "line": 2, + "filename": "../fixtures/mixins.pug" + } + ], + "line": 2, + "filename": "../fixtures/mixins.pug" + }, + "call": false, + "line": 1, + "filename": "../fixtures/mixins.pug" + } + ], + "line": 0, + "filename": "../fixtures/mixins.pug" + } + }, + "line": 2, + "filename": "extending-include.pug", + "block": { + "type": "Block", + "nodes": [], + "line": 2, + "filename": "extending-include.pug" + } + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Mixin", + "name": "image", + "args": "'myimg.png'", + "block": null, + "call": true, + "attrs": [ + { + "name": "class", + "val": "'with-border'", + "mustEscape": false + }, + { + "name": "alt", + "val": "\"My image\"", + "mustEscape": true + } + ], + "attributeBlocks": [], + "line": 5, + "filename": "extending-include.pug" + } + ], + "line": 4, + "filename": "extending-include.pug", + "name": "body", + "mode": "replace" + } + ], + "line": 0, + "filename": "extending-include.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-linker/test/special-cases/root-mixin.input.json b/src/test-data/pug-linker/test/special-cases/root-mixin.input.json new file mode 100644 index 0000000..743250e --- /dev/null +++ b/src/test-data/pug-linker/test/special-cases/root-mixin.input.json @@ -0,0 +1,212 @@ +{ + "type": "Block", + "nodes": [ + { + "type": "Extends", + "file": { + "type": "FileReference", + "path": "../fixtures/layout.pug", + "line": 1, + "filename": "root-mixin.pug", + "fullPath": "../fixtures/layout.pug", + "str": "doctype\n\nhtml\n head\n block head\n Hello world!\n body\n block body\n", + "ast": { + "type": "Block", + "nodes": [ + { + "type": "Doctype", + "val": "", + "line": 1, + "filename": "../fixtures/layout.pug" + }, + { + "type": "Tag", + "name": "html", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "head", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Text", + "val": "Hello world!", + "filename": "../fixtures/layout.pug", + "line": 6, + "isHtml": true + } + ], + "line": 5, + "filename": "../fixtures/layout.pug", + "name": "head", + "mode": "replace" + } + ], + "line": 4, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "../fixtures/layout.pug" + }, + { + "type": "Tag", + "name": "body", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "NamedBlock", + "nodes": [], + "line": 8, + "filename": "../fixtures/layout.pug", + "name": "body", + "mode": "replace" + } + ], + "line": 7, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 7, + "filename": "../fixtures/layout.pug" + } + ], + "line": 3, + "filename": "../fixtures/layout.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 3, + "filename": "../fixtures/layout.pug" + } + ], + "line": 0, + "filename": "../fixtures/layout.pug" + } + }, + "line": 1, + "filename": "root-mixin.pug" + }, + { + "type": "Mixin", + "name": "myMixin", + "args": null, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Hello world", + "line": 4, + "filename": "root-mixin.pug" + } + ], + "line": 4, + "filename": "root-mixin.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 4, + "filename": "root-mixin.pug" + } + ], + "line": 4, + "filename": "root-mixin.pug" + }, + "call": false, + "line": 3, + "filename": "root-mixin.pug" + }, + { + "type": "NamedBlock", + "nodes": [ + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "Before", + "line": 7, + "filename": "root-mixin.pug" + } + ], + "line": 7, + "filename": "root-mixin.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 7, + "filename": "root-mixin.pug" + }, + { + "type": "Mixin", + "name": "myMixin", + "args": null, + "block": null, + "call": true, + "attrs": [], + "attributeBlocks": [], + "line": 8, + "filename": "root-mixin.pug" + }, + { + "type": "Tag", + "name": "p", + "selfClosing": false, + "block": { + "type": "Block", + "nodes": [ + { + "type": "Text", + "val": "After", + "line": 9, + "filename": "root-mixin.pug" + } + ], + "line": 9, + "filename": "root-mixin.pug" + }, + "attrs": [], + "attributeBlocks": [], + "isInline": false, + "line": 9, + "filename": "root-mixin.pug" + } + ], + "line": 6, + "filename": "root-mixin.pug", + "name": "body", + "mode": "replace" + } + ], + "line": 0, + "filename": "root-mixin.pug" +} \ No newline at end of file diff --git a/src/test-data/pug-load/test/__snapshots__/index.test.js.snap b/src/test-data/pug-load/test/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..6ad59e8 --- /dev/null +++ b/src/test-data/pug-load/test/__snapshots__/index.test.js.snap @@ -0,0 +1,166 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`pug-load 1`] = ` +Object { + "filename": "/foo.pug", + "line": 0, + "nodes": Array [ + Object { + "column": 1, + "file": Object { + "ast": Object { + "filename": "/bar.pug", + "line": 0, + "nodes": Array [ + Object { + "column": 1, + "filename": "/bar.pug", + "line": 1, + "mode": "replace", + "name": "bing", + "nodes": Array [], + "type": "NamedBlock", + }, + ], + "type": "Block", + }, + "column": 9, + "filename": "/foo.pug", + "fullPath": "/bar.pug", + "line": 1, + "path": "bar.pug", + "raw": Object { + "hash": "538bf7d4b81ef364b1f2e9d42c11f156", + "size": 11, + "type": "Buffer", + }, + "str": "block bing +", + "type": "FileReference", + }, + "filename": "/foo.pug", + "line": 1, + "type": "Extends", + }, + Object { + "column": 1, + "filename": "/foo.pug", + "line": 3, + "mode": "replace", + "name": "bing", + "nodes": Array [ + Object { + "block": Object { + "filename": "/foo.pug", + "line": 4, + "nodes": Array [], + "type": "Block", + }, + "column": 3, + "file": Object { + "ast": Object { + "filename": "/bing.pug", + "line": 0, + "nodes": Array [ + Object { + "attributeBlocks": Array [], + "attrs": Array [ + Object { + "column": 1, + "filename": "/packages/pug-load/test/bing.pug", + "line": 1, + "mustEscape": false, + "name": "class", + "val": "'bing'", + }, + ], + "block": Object { + "filename": "/bing.pug", + "line": 1, + "nodes": Array [ + Object { + "column": 7, + "filename": "/bing.pug", + "line": 1, + "type": "Text", + "val": "bong", + }, + ], + "type": "Block", + }, + "column": 1, + "filename": "/bing.pug", + "isInline": false, + "line": 1, + "name": "div", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "Block", + }, + "column": 11, + "filename": "/foo.pug", + "fullPath": "/bing.pug", + "line": 4, + "path": "bing.pug", + "raw": Object { + "hash": "58ecbe086e7a045084cbddac849a2563", + "size": 11, + "type": "Buffer", + }, + "str": ".bing bong +", + "type": "FileReference", + }, + "filename": "/foo.pug", + "line": 4, + "type": "Include", + }, + Object { + "attributeBlocks": Array [], + "attrs": Array [], + "block": Object { + "filename": "/foo.pug", + "line": 5, + "nodes": Array [ + Object { + "column": 5, + "file": Object { + "column": 13, + "filename": "/foo.pug", + "fullPath": "/script.js", + "line": 6, + "path": "script.js", + "raw": Object { + "hash": "86d4f8e34165faeb09f10255121078f8", + "size": 32, + "type": "Buffer", + }, + "str": "document.write('hello world!'); +", + "type": "FileReference", + }, + "filename": "/foo.pug", + "filters": Array [], + "line": 6, + "type": "RawInclude", + }, + ], + "type": "Block", + }, + "column": 3, + "filename": "/foo.pug", + "isInline": false, + "line": 5, + "name": "script", + "selfClosing": false, + "type": "Tag", + }, + ], + "type": "NamedBlock", + }, + ], + "type": "Block", +} +`; diff --git a/src/test-data/pug-load/test/bar.pug b/src/test-data/pug-load/test/bar.pug new file mode 100644 index 0000000..24e3cee --- /dev/null +++ b/src/test-data/pug-load/test/bar.pug @@ -0,0 +1 @@ +block bing diff --git a/src/test-data/pug-load/test/bing.pug b/src/test-data/pug-load/test/bing.pug new file mode 100644 index 0000000..9013b36 --- /dev/null +++ b/src/test-data/pug-load/test/bing.pug @@ -0,0 +1 @@ +.bing bong diff --git a/src/test-data/pug-load/test/foo.pug b/src/test-data/pug-load/test/foo.pug new file mode 100644 index 0000000..d30a98f --- /dev/null +++ b/src/test-data/pug-load/test/foo.pug @@ -0,0 +1,6 @@ +extends bar.pug + +block bing + include bing.pug + script + include script.js diff --git a/src/test-data/pug-load/test/index.test.js b/src/test-data/pug-load/test/index.test.js new file mode 100644 index 0000000..f8b21fb --- /dev/null +++ b/src/test-data/pug-load/test/index.test.js @@ -0,0 +1,30 @@ +'use strict'; + +var fs = require('fs'); +var path = require('path'); +var assert = require('assert'); +var walk = require('pug-walk'); +var lex = require('pug-lexer'); +var parse = require('pug-parser'); +var load = require('../'); + +test('pug-load', () => { + var filename = __dirname + '/foo.pug'; + var ast = load.file(filename, { + lex: lex, + parse: parse, + }); + + ast = walk( + ast, + function(node) { + if (node.filename) + node.filename = '/' + path.basename(node.filename); + if (node.fullPath) + node.fullPath = '/' + path.basename(node.fullPath); + }, + {includeDependencies: true} + ); + + expect(ast).toMatchSnapshot(); +}); diff --git a/src/test-data/pug-load/test/script.js b/src/test-data/pug-load/test/script.js new file mode 100644 index 0000000..1e07719 --- /dev/null +++ b/src/test-data/pug-load/test/script.js @@ -0,0 +1 @@ +document.write('hello world!'); diff --git a/src/test-data/pug-parser/cases/attr-es2015.tokens.json b/src/test-data/pug-parser/cases/attr-es2015.tokens.json new file mode 100644 index 0000000..8616bc6 --- /dev/null +++ b/src/test-data/pug-parser/cases/attr-es2015.tokens.json @@ -0,0 +1,9 @@ +{"type":"code","loc":{"start":{"line":1,"column":1},"filename":"/cases/attr-es2015.pug","end":{"line":1,"column":50}},"val":"var avatar = '219b77f9d21de75e81851b6b886057c7'","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":3,"column":1},"filename":"/cases/attr-es2015.pug","end":{"line":3,"column":1}}} +{"type":"tag","loc":{"start":{"line":3,"column":1},"filename":"/cases/attr-es2015.pug","end":{"line":3,"column":4}},"val":"div"} +{"type":"class","loc":{"start":{"line":3,"column":4},"filename":"/cases/attr-es2015.pug","end":{"line":3,"column":15}},"val":"avatar-div"} +{"type":"start-attributes","loc":{"start":{"line":3,"column":15},"filename":"/cases/attr-es2015.pug","end":{"line":3,"column":16}}} +{"type":"attribute","loc":{"start":{"line":3,"column":16},"filename":"/cases/attr-es2015.pug","end":{"line":3,"column":88}},"name":"style","mustEscape":true,"val":"`background-image: url(https://www.gravatar.com/avatar/${avatar})`"} +{"type":"end-attributes","loc":{"start":{"line":3,"column":88},"filename":"/cases/attr-es2015.pug","end":{"line":3,"column":89}}} +{"type":"newline","loc":{"start":{"line":4,"column":1},"filename":"/cases/attr-es2015.pug","end":{"line":4,"column":1}}} +{"type":"eos","loc":{"start":{"line":4,"column":1},"filename":"/cases/attr-es2015.pug","end":{"line":4,"column":1}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/attrs-data.tokens.json b/src/test-data/pug-parser/cases/attrs-data.tokens.json new file mode 100644 index 0000000..8f4ee02 --- /dev/null +++ b/src/test-data/pug-parser/cases/attrs-data.tokens.json @@ -0,0 +1,33 @@ +{"type":"code","loc":{"start":{"line":1,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":1,"column":30}},"val":"var user = { name: 'tobi' }","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":2,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":2,"column":1}}} +{"type":"tag","loc":{"start":{"line":2,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":2,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":2,"column":4},"filename":"/cases/attrs-data.pug","end":{"line":2,"column":5}}} +{"type":"attribute","loc":{"start":{"line":2,"column":5},"filename":"/cases/attrs-data.pug","end":{"line":2,"column":19}},"name":"data-user","mustEscape":true,"val":"user"} +{"type":"end-attributes","loc":{"start":{"line":2,"column":19},"filename":"/cases/attrs-data.pug","end":{"line":2,"column":20}}} +{"type":"newline","loc":{"start":{"line":3,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":3,"column":1}}} +{"type":"tag","loc":{"start":{"line":3,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":3,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":3,"column":4},"filename":"/cases/attrs-data.pug","end":{"line":3,"column":5}}} +{"type":"attribute","loc":{"start":{"line":3,"column":5},"filename":"/cases/attrs-data.pug","end":{"line":3,"column":23}},"name":"data-items","mustEscape":true,"val":"[1,2,3]"} +{"type":"end-attributes","loc":{"start":{"line":3,"column":23},"filename":"/cases/attrs-data.pug","end":{"line":3,"column":24}}} +{"type":"newline","loc":{"start":{"line":4,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":4,"column":1}}} +{"type":"tag","loc":{"start":{"line":4,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":4,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":4,"column":4},"filename":"/cases/attrs-data.pug","end":{"line":4,"column":5}}} +{"type":"attribute","loc":{"start":{"line":4,"column":5},"filename":"/cases/attrs-data.pug","end":{"line":4,"column":25}},"name":"data-username","mustEscape":true,"val":"'tobi'"} +{"type":"end-attributes","loc":{"start":{"line":4,"column":25},"filename":"/cases/attrs-data.pug","end":{"line":4,"column":26}}} +{"type":"newline","loc":{"start":{"line":5,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":5,"column":1}}} +{"type":"tag","loc":{"start":{"line":5,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":5,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":5,"column":4},"filename":"/cases/attrs-data.pug","end":{"line":5,"column":5}}} +{"type":"attribute","loc":{"start":{"line":5,"column":5},"filename":"/cases/attrs-data.pug","end":{"line":5,"column":42}},"name":"data-escaped","mustEscape":true,"val":"{message: \"Let's rock!\"}"} +{"type":"end-attributes","loc":{"start":{"line":5,"column":42},"filename":"/cases/attrs-data.pug","end":{"line":5,"column":43}}} +{"type":"newline","loc":{"start":{"line":6,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":6,"column":1}}} +{"type":"tag","loc":{"start":{"line":6,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":6,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":6,"column":4},"filename":"/cases/attrs-data.pug","end":{"line":6,"column":5}}} +{"type":"attribute","loc":{"start":{"line":6,"column":5},"filename":"/cases/attrs-data.pug","end":{"line":6,"column":60}},"name":"data-ampersand","mustEscape":true,"val":"{message: \"a quote: " this & that\"}"} +{"type":"end-attributes","loc":{"start":{"line":6,"column":60},"filename":"/cases/attrs-data.pug","end":{"line":6,"column":61}}} +{"type":"newline","loc":{"start":{"line":7,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":7,"column":1}}} +{"type":"tag","loc":{"start":{"line":7,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":7,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":7,"column":4},"filename":"/cases/attrs-data.pug","end":{"line":7,"column":5}}} +{"type":"attribute","loc":{"start":{"line":7,"column":5},"filename":"/cases/attrs-data.pug","end":{"line":7,"column":26}},"name":"data-epoc","mustEscape":true,"val":"new Date(0)"} +{"type":"end-attributes","loc":{"start":{"line":7,"column":26},"filename":"/cases/attrs-data.pug","end":{"line":7,"column":27}}} +{"type":"newline","loc":{"start":{"line":8,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":8,"column":1}}} +{"type":"eos","loc":{"start":{"line":8,"column":1},"filename":"/cases/attrs-data.pug","end":{"line":8,"column":1}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/attrs.js.tokens.json b/src/test-data/pug-parser/cases/attrs.js.tokens.json new file mode 100644 index 0000000..7da64d9 --- /dev/null +++ b/src/test-data/pug-parser/cases/attrs.js.tokens.json @@ -0,0 +1,78 @@ +{"type":"code","loc":{"start":{"line":1,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":1,"column":13}},"val":"var id = 5","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":2,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":2,"column":1}}} +{"type":"code","loc":{"start":{"line":2,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":2,"column":35}},"val":"function answer() { return 42; }","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":3,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":3,"column":1}}} +{"type":"tag","loc":{"start":{"line":3,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":3,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":3,"column":2},"filename":"/cases/attrs.js.pug","end":{"line":3,"column":3}}} +{"type":"attribute","loc":{"start":{"line":3,"column":3},"filename":"/cases/attrs.js.pug","end":{"line":3,"column":21}},"name":"href","mustEscape":true,"val":"'/user/' + id"} +{"type":"attribute","loc":{"start":{"line":3,"column":23},"filename":"/cases/attrs.js.pug","end":{"line":3,"column":37}},"name":"class","mustEscape":true,"val":"'button'"} +{"type":"end-attributes","loc":{"start":{"line":3,"column":37},"filename":"/cases/attrs.js.pug","end":{"line":3,"column":38}}} +{"type":"newline","loc":{"start":{"line":4,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":4,"column":1}}} +{"type":"tag","loc":{"start":{"line":4,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":4,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":4,"column":2},"filename":"/cases/attrs.js.pug","end":{"line":4,"column":3}}} +{"type":"attribute","loc":{"start":{"line":4,"column":3},"filename":"/cases/attrs.js.pug","end":{"line":4,"column":25}},"name":"href","mustEscape":true,"val":"'/user/' + id"} +{"type":"attribute","loc":{"start":{"line":4,"column":27},"filename":"/cases/attrs.js.pug","end":{"line":4,"column":45}},"name":"class","mustEscape":true,"val":"'button'"} +{"type":"end-attributes","loc":{"start":{"line":4,"column":45},"filename":"/cases/attrs.js.pug","end":{"line":4,"column":46}}} +{"type":"newline","loc":{"start":{"line":5,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":5,"column":1}}} +{"type":"tag","loc":{"start":{"line":5,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":5,"column":5}},"val":"meta"} +{"type":"start-attributes","loc":{"start":{"line":5,"column":5},"filename":"/cases/attrs.js.pug","end":{"line":5,"column":6}}} +{"type":"attribute","loc":{"start":{"line":5,"column":6},"filename":"/cases/attrs.js.pug","end":{"line":5,"column":18}},"name":"key","mustEscape":true,"val":"'answer'"} +{"type":"attribute","loc":{"start":{"line":5,"column":20},"filename":"/cases/attrs.js.pug","end":{"line":5,"column":34}},"name":"value","mustEscape":true,"val":"answer()"} +{"type":"end-attributes","loc":{"start":{"line":5,"column":34},"filename":"/cases/attrs.js.pug","end":{"line":5,"column":35}}} +{"type":"newline","loc":{"start":{"line":6,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":6,"column":1}}} +{"type":"tag","loc":{"start":{"line":6,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":6,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":6,"column":2},"filename":"/cases/attrs.js.pug","end":{"line":6,"column":3}}} +{"type":"attribute","loc":{"start":{"line":6,"column":3},"filename":"/cases/attrs.js.pug","end":{"line":6,"column":31}},"name":"class","mustEscape":true,"val":"['class1', 'class2']"} +{"type":"end-attributes","loc":{"start":{"line":6,"column":31},"filename":"/cases/attrs.js.pug","end":{"line":6,"column":32}}} +{"type":"newline","loc":{"start":{"line":7,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":7,"column":1}}} +{"type":"tag","loc":{"start":{"line":7,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":7,"column":2}},"val":"a"} +{"type":"class","loc":{"start":{"line":7,"column":2},"filename":"/cases/attrs.js.pug","end":{"line":7,"column":12}},"val":"tag-class"} +{"type":"start-attributes","loc":{"start":{"line":7,"column":12},"filename":"/cases/attrs.js.pug","end":{"line":7,"column":13}}} +{"type":"attribute","loc":{"start":{"line":7,"column":13},"filename":"/cases/attrs.js.pug","end":{"line":7,"column":41}},"name":"class","mustEscape":true,"val":"['class1', 'class2']"} +{"type":"end-attributes","loc":{"start":{"line":7,"column":41},"filename":"/cases/attrs.js.pug","end":{"line":7,"column":42}}} +{"type":"newline","loc":{"start":{"line":9,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":9,"column":1}}} +{"type":"tag","loc":{"start":{"line":9,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":9,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":9,"column":2},"filename":"/cases/attrs.js.pug","end":{"line":9,"column":3}}} +{"type":"attribute","loc":{"start":{"line":9,"column":3},"filename":"/cases/attrs.js.pug","end":{"line":9,"column":21}},"name":"href","mustEscape":true,"val":"'/user/' + id"} +{"type":"attribute","loc":{"start":{"line":9,"column":22},"filename":"/cases/attrs.js.pug","end":{"line":9,"column":36}},"name":"class","mustEscape":true,"val":"'button'"} +{"type":"end-attributes","loc":{"start":{"line":9,"column":36},"filename":"/cases/attrs.js.pug","end":{"line":9,"column":37}}} +{"type":"newline","loc":{"start":{"line":10,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":10,"column":1}}} +{"type":"tag","loc":{"start":{"line":10,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":10,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":10,"column":2},"filename":"/cases/attrs.js.pug","end":{"line":10,"column":3}}} +{"type":"attribute","loc":{"start":{"line":10,"column":3},"filename":"/cases/attrs.js.pug","end":{"line":10,"column":25}},"name":"href","mustEscape":true,"val":"'/user/' + id"} +{"type":"attribute","loc":{"start":{"line":10,"column":26},"filename":"/cases/attrs.js.pug","end":{"line":10,"column":44}},"name":"class","mustEscape":true,"val":"'button'"} +{"type":"end-attributes","loc":{"start":{"line":10,"column":44},"filename":"/cases/attrs.js.pug","end":{"line":10,"column":45}}} +{"type":"newline","loc":{"start":{"line":11,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":11,"column":1}}} +{"type":"tag","loc":{"start":{"line":11,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":11,"column":5}},"val":"meta"} +{"type":"start-attributes","loc":{"start":{"line":11,"column":5},"filename":"/cases/attrs.js.pug","end":{"line":11,"column":6}}} +{"type":"attribute","loc":{"start":{"line":11,"column":6},"filename":"/cases/attrs.js.pug","end":{"line":11,"column":18}},"name":"key","mustEscape":true,"val":"'answer'"} +{"type":"attribute","loc":{"start":{"line":11,"column":19},"filename":"/cases/attrs.js.pug","end":{"line":11,"column":33}},"name":"value","mustEscape":true,"val":"answer()"} +{"type":"end-attributes","loc":{"start":{"line":11,"column":33},"filename":"/cases/attrs.js.pug","end":{"line":11,"column":34}}} +{"type":"newline","loc":{"start":{"line":12,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":12,"column":1}}} +{"type":"tag","loc":{"start":{"line":12,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":12,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":12,"column":2},"filename":"/cases/attrs.js.pug","end":{"line":12,"column":3}}} +{"type":"attribute","loc":{"start":{"line":12,"column":3},"filename":"/cases/attrs.js.pug","end":{"line":12,"column":31}},"name":"class","mustEscape":true,"val":"['class1', 'class2']"} +{"type":"end-attributes","loc":{"start":{"line":12,"column":31},"filename":"/cases/attrs.js.pug","end":{"line":12,"column":32}}} +{"type":"newline","loc":{"start":{"line":13,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":13,"column":1}}} +{"type":"tag","loc":{"start":{"line":13,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":13,"column":2}},"val":"a"} +{"type":"class","loc":{"start":{"line":13,"column":2},"filename":"/cases/attrs.js.pug","end":{"line":13,"column":12}},"val":"tag-class"} +{"type":"start-attributes","loc":{"start":{"line":13,"column":12},"filename":"/cases/attrs.js.pug","end":{"line":13,"column":13}}} +{"type":"attribute","loc":{"start":{"line":13,"column":13},"filename":"/cases/attrs.js.pug","end":{"line":13,"column":41}},"name":"class","mustEscape":true,"val":"['class1', 'class2']"} +{"type":"end-attributes","loc":{"start":{"line":13,"column":41},"filename":"/cases/attrs.js.pug","end":{"line":13,"column":42}}} +{"type":"newline","loc":{"start":{"line":15,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":15,"column":1}}} +{"type":"tag","loc":{"start":{"line":15,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":15,"column":4}},"val":"div"} +{"type":"start-attributes","loc":{"start":{"line":15,"column":4},"filename":"/cases/attrs.js.pug","end":{"line":15,"column":5}}} +{"type":"attribute","loc":{"start":{"line":15,"column":5},"filename":"/cases/attrs.js.pug","end":{"line":15,"column":10}},"name":"id","mustEscape":true,"val":"id"} +{"type":"end-attributes","loc":{"start":{"line":15,"column":10},"filename":"/cases/attrs.js.pug","end":{"line":15,"column":11}}} +{"type":"&attributes","loc":{"start":{"line":15,"column":11},"filename":"/cases/attrs.js.pug","end":{"line":15,"column":36}},"val":"{foo: 'bar'}"} +{"type":"newline","loc":{"start":{"line":16,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":16,"column":1}}} +{"type":"code","loc":{"start":{"line":16,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":16,"column":17}},"val":"var bar = null","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":17,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":17,"column":1}}} +{"type":"tag","loc":{"start":{"line":17,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":17,"column":4}},"val":"div"} +{"type":"start-attributes","loc":{"start":{"line":17,"column":4},"filename":"/cases/attrs.js.pug","end":{"line":17,"column":5}}} +{"type":"attribute","loc":{"start":{"line":17,"column":5},"filename":"/cases/attrs.js.pug","end":{"line":17,"column":13}},"name":"foo","mustEscape":true,"val":"null"} +{"type":"attribute","loc":{"start":{"line":17,"column":14},"filename":"/cases/attrs.js.pug","end":{"line":17,"column":21}},"name":"bar","mustEscape":true,"val":"bar"} +{"type":"end-attributes","loc":{"start":{"line":17,"column":21},"filename":"/cases/attrs.js.pug","end":{"line":17,"column":22}}} +{"type":"&attributes","loc":{"start":{"line":17,"column":22},"filename":"/cases/attrs.js.pug","end":{"line":17,"column":47}},"val":"{baz: 'baz'}"} +{"type":"newline","loc":{"start":{"line":18,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":18,"column":1}}} +{"type":"eos","loc":{"start":{"line":18,"column":1},"filename":"/cases/attrs.js.pug","end":{"line":18,"column":1}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/attrs.tokens.json b/src/test-data/pug-parser/cases/attrs.tokens.json new file mode 100644 index 0000000..d15ce77 --- /dev/null +++ b/src/test-data/pug-parser/cases/attrs.tokens.json @@ -0,0 +1,180 @@ +{"type":"tag","loc":{"start":{"line":1,"column":1},"filename":"/cases/attrs.pug","end":{"line":1,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":1,"column":2},"filename":"/cases/attrs.pug","end":{"line":1,"column":3}}} +{"type":"attribute","loc":{"start":{"line":1,"column":3},"filename":"/cases/attrs.pug","end":{"line":1,"column":18}},"name":"href","mustEscape":true,"val":"'/contact'"} +{"type":"end-attributes","loc":{"start":{"line":1,"column":18},"filename":"/cases/attrs.pug","end":{"line":1,"column":19}}} +{"type":"text","loc":{"start":{"line":1,"column":20},"filename":"/cases/attrs.pug","end":{"line":1,"column":27}},"val":"contact"} +{"type":"newline","loc":{"start":{"line":2,"column":1},"filename":"/cases/attrs.pug","end":{"line":2,"column":1}}} +{"type":"tag","loc":{"start":{"line":2,"column":1},"filename":"/cases/attrs.pug","end":{"line":2,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":2,"column":2},"filename":"/cases/attrs.pug","end":{"line":2,"column":3}}} +{"type":"attribute","loc":{"start":{"line":2,"column":3},"filename":"/cases/attrs.pug","end":{"line":2,"column":15}},"name":"href","mustEscape":true,"val":"'/save'"} +{"type":"end-attributes","loc":{"start":{"line":2,"column":15},"filename":"/cases/attrs.pug","end":{"line":2,"column":16}}} +{"type":"class","loc":{"start":{"line":2,"column":16},"filename":"/cases/attrs.pug","end":{"line":2,"column":23}},"val":"button"} +{"type":"text","loc":{"start":{"line":2,"column":24},"filename":"/cases/attrs.pug","end":{"line":2,"column":28}},"val":"save"} +{"type":"newline","loc":{"start":{"line":3,"column":1},"filename":"/cases/attrs.pug","end":{"line":3,"column":1}}} +{"type":"tag","loc":{"start":{"line":3,"column":1},"filename":"/cases/attrs.pug","end":{"line":3,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":3,"column":2},"filename":"/cases/attrs.pug","end":{"line":3,"column":3}}} +{"type":"attribute","loc":{"start":{"line":3,"column":3},"filename":"/cases/attrs.pug","end":{"line":3,"column":6}},"name":"foo","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":3,"column":8},"filename":"/cases/attrs.pug","end":{"line":3,"column":11}},"name":"bar","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":3,"column":13},"filename":"/cases/attrs.pug","end":{"line":3,"column":16}},"name":"baz","mustEscape":true,"val":true} +{"type":"end-attributes","loc":{"start":{"line":3,"column":16},"filename":"/cases/attrs.pug","end":{"line":3,"column":17}}} +{"type":"newline","loc":{"start":{"line":4,"column":1},"filename":"/cases/attrs.pug","end":{"line":4,"column":1}}} +{"type":"tag","loc":{"start":{"line":4,"column":1},"filename":"/cases/attrs.pug","end":{"line":4,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":4,"column":2},"filename":"/cases/attrs.pug","end":{"line":4,"column":3}}} +{"type":"attribute","loc":{"start":{"line":4,"column":3},"filename":"/cases/attrs.pug","end":{"line":4,"column":22}},"name":"foo","mustEscape":true,"val":"'foo, bar, baz'"} +{"type":"attribute","loc":{"start":{"line":4,"column":24},"filename":"/cases/attrs.pug","end":{"line":4,"column":29}},"name":"bar","mustEscape":true,"val":"1"} +{"type":"end-attributes","loc":{"start":{"line":4,"column":29},"filename":"/cases/attrs.pug","end":{"line":4,"column":30}}} +{"type":"newline","loc":{"start":{"line":5,"column":1},"filename":"/cases/attrs.pug","end":{"line":5,"column":1}}} +{"type":"tag","loc":{"start":{"line":5,"column":1},"filename":"/cases/attrs.pug","end":{"line":5,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":5,"column":2},"filename":"/cases/attrs.pug","end":{"line":5,"column":3}}} +{"type":"attribute","loc":{"start":{"line":5,"column":3},"filename":"/cases/attrs.pug","end":{"line":5,"column":16}},"name":"foo","mustEscape":true,"val":"'((foo))'"} +{"type":"attribute","loc":{"start":{"line":5,"column":18},"filename":"/cases/attrs.pug","end":{"line":5,"column":34}},"name":"bar","mustEscape":true,"val":"(1) ? 1 : 0"} +{"type":"end-attributes","loc":{"start":{"line":5,"column":35},"filename":"/cases/attrs.pug","end":{"line":5,"column":36}}} +{"type":"newline","loc":{"start":{"line":6,"column":1},"filename":"/cases/attrs.pug","end":{"line":6,"column":1}}} +{"type":"tag","loc":{"start":{"line":6,"column":1},"filename":"/cases/attrs.pug","end":{"line":6,"column":7}},"val":"select"} +{"type":"indent","loc":{"start":{"line":7,"column":1},"filename":"/cases/attrs.pug","end":{"line":7,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":7,"column":3},"filename":"/cases/attrs.pug","end":{"line":7,"column":9}},"val":"option"} +{"type":"start-attributes","loc":{"start":{"line":7,"column":9},"filename":"/cases/attrs.pug","end":{"line":7,"column":10}}} +{"type":"attribute","loc":{"start":{"line":7,"column":10},"filename":"/cases/attrs.pug","end":{"line":7,"column":21}},"name":"value","mustEscape":true,"val":"'foo'"} +{"type":"attribute","loc":{"start":{"line":7,"column":23},"filename":"/cases/attrs.pug","end":{"line":7,"column":31}},"name":"selected","mustEscape":true,"val":true} +{"type":"end-attributes","loc":{"start":{"line":7,"column":31},"filename":"/cases/attrs.pug","end":{"line":7,"column":32}}} +{"type":"text","loc":{"start":{"line":7,"column":33},"filename":"/cases/attrs.pug","end":{"line":7,"column":36}},"val":"Foo"} +{"type":"newline","loc":{"start":{"line":8,"column":1},"filename":"/cases/attrs.pug","end":{"line":8,"column":3}}} +{"type":"tag","loc":{"start":{"line":8,"column":3},"filename":"/cases/attrs.pug","end":{"line":8,"column":9}},"val":"option"} +{"type":"start-attributes","loc":{"start":{"line":8,"column":9},"filename":"/cases/attrs.pug","end":{"line":8,"column":10}}} +{"type":"attribute","loc":{"start":{"line":8,"column":10},"filename":"/cases/attrs.pug","end":{"line":8,"column":18}},"name":"selected","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":8,"column":20},"filename":"/cases/attrs.pug","end":{"line":8,"column":31}},"name":"value","mustEscape":true,"val":"'bar'"} +{"type":"end-attributes","loc":{"start":{"line":8,"column":31},"filename":"/cases/attrs.pug","end":{"line":8,"column":32}}} +{"type":"text","loc":{"start":{"line":8,"column":33},"filename":"/cases/attrs.pug","end":{"line":8,"column":36}},"val":"Bar"} +{"type":"outdent","loc":{"start":{"line":9,"column":1},"filename":"/cases/attrs.pug","end":{"line":9,"column":1}}} +{"type":"tag","loc":{"start":{"line":9,"column":1},"filename":"/cases/attrs.pug","end":{"line":9,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":9,"column":2},"filename":"/cases/attrs.pug","end":{"line":9,"column":3}}} +{"type":"attribute","loc":{"start":{"line":9,"column":3},"filename":"/cases/attrs.pug","end":{"line":9,"column":15}},"name":"foo","mustEscape":true,"val":"\"class:\""} +{"type":"end-attributes","loc":{"start":{"line":9,"column":15},"filename":"/cases/attrs.pug","end":{"line":9,"column":16}}} +{"type":"newline","loc":{"start":{"line":10,"column":1},"filename":"/cases/attrs.pug","end":{"line":10,"column":1}}} +{"type":"tag","loc":{"start":{"line":10,"column":1},"filename":"/cases/attrs.pug","end":{"line":10,"column":6}},"val":"input"} +{"type":"start-attributes","loc":{"start":{"line":10,"column":6},"filename":"/cases/attrs.pug","end":{"line":10,"column":7}}} +{"type":"attribute","loc":{"start":{"line":10,"column":7},"filename":"/cases/attrs.pug","end":{"line":10,"column":21}},"name":"pattern","mustEscape":true,"val":"'\\\\S+'"} +{"type":"end-attributes","loc":{"start":{"line":10,"column":21},"filename":"/cases/attrs.pug","end":{"line":10,"column":22}}} +{"type":"newline","loc":{"start":{"line":12,"column":1},"filename":"/cases/attrs.pug","end":{"line":12,"column":1}}} +{"type":"tag","loc":{"start":{"line":12,"column":1},"filename":"/cases/attrs.pug","end":{"line":12,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":12,"column":2},"filename":"/cases/attrs.pug","end":{"line":12,"column":3}}} +{"type":"attribute","loc":{"start":{"line":12,"column":3},"filename":"/cases/attrs.pug","end":{"line":12,"column":18}},"name":"href","mustEscape":true,"val":"'/contact'"} +{"type":"end-attributes","loc":{"start":{"line":12,"column":18},"filename":"/cases/attrs.pug","end":{"line":12,"column":19}}} +{"type":"text","loc":{"start":{"line":12,"column":20},"filename":"/cases/attrs.pug","end":{"line":12,"column":27}},"val":"contact"} +{"type":"newline","loc":{"start":{"line":13,"column":1},"filename":"/cases/attrs.pug","end":{"line":13,"column":1}}} +{"type":"tag","loc":{"start":{"line":13,"column":1},"filename":"/cases/attrs.pug","end":{"line":13,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":13,"column":2},"filename":"/cases/attrs.pug","end":{"line":13,"column":3}}} +{"type":"attribute","loc":{"start":{"line":13,"column":3},"filename":"/cases/attrs.pug","end":{"line":13,"column":15}},"name":"href","mustEscape":true,"val":"'/save'"} +{"type":"end-attributes","loc":{"start":{"line":13,"column":15},"filename":"/cases/attrs.pug","end":{"line":13,"column":16}}} +{"type":"class","loc":{"start":{"line":13,"column":16},"filename":"/cases/attrs.pug","end":{"line":13,"column":23}},"val":"button"} +{"type":"text","loc":{"start":{"line":13,"column":24},"filename":"/cases/attrs.pug","end":{"line":13,"column":28}},"val":"save"} +{"type":"newline","loc":{"start":{"line":14,"column":1},"filename":"/cases/attrs.pug","end":{"line":14,"column":1}}} +{"type":"tag","loc":{"start":{"line":14,"column":1},"filename":"/cases/attrs.pug","end":{"line":14,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":14,"column":2},"filename":"/cases/attrs.pug","end":{"line":14,"column":3}}} +{"type":"attribute","loc":{"start":{"line":14,"column":3},"filename":"/cases/attrs.pug","end":{"line":14,"column":6}},"name":"foo","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":14,"column":7},"filename":"/cases/attrs.pug","end":{"line":14,"column":10}},"name":"bar","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":14,"column":11},"filename":"/cases/attrs.pug","end":{"line":14,"column":14}},"name":"baz","mustEscape":true,"val":true} +{"type":"end-attributes","loc":{"start":{"line":14,"column":14},"filename":"/cases/attrs.pug","end":{"line":14,"column":15}}} +{"type":"newline","loc":{"start":{"line":15,"column":1},"filename":"/cases/attrs.pug","end":{"line":15,"column":1}}} +{"type":"tag","loc":{"start":{"line":15,"column":1},"filename":"/cases/attrs.pug","end":{"line":15,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":15,"column":2},"filename":"/cases/attrs.pug","end":{"line":15,"column":3}}} +{"type":"attribute","loc":{"start":{"line":15,"column":3},"filename":"/cases/attrs.pug","end":{"line":15,"column":22}},"name":"foo","mustEscape":true,"val":"'foo, bar, baz'"} +{"type":"attribute","loc":{"start":{"line":15,"column":23},"filename":"/cases/attrs.pug","end":{"line":15,"column":28}},"name":"bar","mustEscape":true,"val":"1"} +{"type":"end-attributes","loc":{"start":{"line":15,"column":28},"filename":"/cases/attrs.pug","end":{"line":15,"column":29}}} +{"type":"newline","loc":{"start":{"line":16,"column":1},"filename":"/cases/attrs.pug","end":{"line":16,"column":1}}} +{"type":"tag","loc":{"start":{"line":16,"column":1},"filename":"/cases/attrs.pug","end":{"line":16,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":16,"column":2},"filename":"/cases/attrs.pug","end":{"line":16,"column":3}}} +{"type":"attribute","loc":{"start":{"line":16,"column":3},"filename":"/cases/attrs.pug","end":{"line":16,"column":16}},"name":"foo","mustEscape":true,"val":"'((foo))'"} +{"type":"attribute","loc":{"start":{"line":16,"column":17},"filename":"/cases/attrs.pug","end":{"line":16,"column":33}},"name":"bar","mustEscape":true,"val":"(1) ? 1 : 0"} +{"type":"end-attributes","loc":{"start":{"line":16,"column":34},"filename":"/cases/attrs.pug","end":{"line":16,"column":35}}} +{"type":"newline","loc":{"start":{"line":17,"column":1},"filename":"/cases/attrs.pug","end":{"line":17,"column":1}}} +{"type":"tag","loc":{"start":{"line":17,"column":1},"filename":"/cases/attrs.pug","end":{"line":17,"column":7}},"val":"select"} +{"type":"indent","loc":{"start":{"line":18,"column":1},"filename":"/cases/attrs.pug","end":{"line":18,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":18,"column":3},"filename":"/cases/attrs.pug","end":{"line":18,"column":9}},"val":"option"} +{"type":"start-attributes","loc":{"start":{"line":18,"column":9},"filename":"/cases/attrs.pug","end":{"line":18,"column":10}}} +{"type":"attribute","loc":{"start":{"line":18,"column":10},"filename":"/cases/attrs.pug","end":{"line":18,"column":21}},"name":"value","mustEscape":true,"val":"'foo'"} +{"type":"attribute","loc":{"start":{"line":18,"column":22},"filename":"/cases/attrs.pug","end":{"line":18,"column":30}},"name":"selected","mustEscape":true,"val":true} +{"type":"end-attributes","loc":{"start":{"line":18,"column":30},"filename":"/cases/attrs.pug","end":{"line":18,"column":31}}} +{"type":"text","loc":{"start":{"line":18,"column":32},"filename":"/cases/attrs.pug","end":{"line":18,"column":35}},"val":"Foo"} +{"type":"newline","loc":{"start":{"line":19,"column":1},"filename":"/cases/attrs.pug","end":{"line":19,"column":3}}} +{"type":"tag","loc":{"start":{"line":19,"column":3},"filename":"/cases/attrs.pug","end":{"line":19,"column":9}},"val":"option"} +{"type":"start-attributes","loc":{"start":{"line":19,"column":9},"filename":"/cases/attrs.pug","end":{"line":19,"column":10}}} +{"type":"attribute","loc":{"start":{"line":19,"column":10},"filename":"/cases/attrs.pug","end":{"line":19,"column":18}},"name":"selected","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":19,"column":19},"filename":"/cases/attrs.pug","end":{"line":19,"column":30}},"name":"value","mustEscape":true,"val":"'bar'"} +{"type":"end-attributes","loc":{"start":{"line":19,"column":30},"filename":"/cases/attrs.pug","end":{"line":19,"column":31}}} +{"type":"text","loc":{"start":{"line":19,"column":32},"filename":"/cases/attrs.pug","end":{"line":19,"column":35}},"val":"Bar"} +{"type":"outdent","loc":{"start":{"line":20,"column":1},"filename":"/cases/attrs.pug","end":{"line":20,"column":1}}} +{"type":"tag","loc":{"start":{"line":20,"column":1},"filename":"/cases/attrs.pug","end":{"line":20,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":20,"column":2},"filename":"/cases/attrs.pug","end":{"line":20,"column":3}}} +{"type":"attribute","loc":{"start":{"line":20,"column":3},"filename":"/cases/attrs.pug","end":{"line":20,"column":15}},"name":"foo","mustEscape":true,"val":"\"class:\""} +{"type":"end-attributes","loc":{"start":{"line":20,"column":15},"filename":"/cases/attrs.pug","end":{"line":20,"column":16}}} +{"type":"newline","loc":{"start":{"line":21,"column":1},"filename":"/cases/attrs.pug","end":{"line":21,"column":1}}} +{"type":"tag","loc":{"start":{"line":21,"column":1},"filename":"/cases/attrs.pug","end":{"line":21,"column":6}},"val":"input"} +{"type":"start-attributes","loc":{"start":{"line":21,"column":6},"filename":"/cases/attrs.pug","end":{"line":21,"column":7}}} +{"type":"attribute","loc":{"start":{"line":21,"column":7},"filename":"/cases/attrs.pug","end":{"line":21,"column":21}},"name":"pattern","mustEscape":true,"val":"'\\\\S+'"} +{"type":"end-attributes","loc":{"start":{"line":21,"column":21},"filename":"/cases/attrs.pug","end":{"line":21,"column":22}}} +{"type":"newline","loc":{"start":{"line":22,"column":1},"filename":"/cases/attrs.pug","end":{"line":22,"column":1}}} +{"type":"tag","loc":{"start":{"line":22,"column":1},"filename":"/cases/attrs.pug","end":{"line":22,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":22,"column":4},"filename":"/cases/attrs.pug","end":{"line":22,"column":5}}} +{"type":"attribute","loc":{"start":{"line":22,"column":5},"filename":"/cases/attrs.pug","end":{"line":22,"column":17}},"name":"terse","mustEscape":true,"val":"\"true\""} +{"type":"end-attributes","loc":{"start":{"line":22,"column":17},"filename":"/cases/attrs.pug","end":{"line":22,"column":18}}} +{"type":"newline","loc":{"start":{"line":23,"column":1},"filename":"/cases/attrs.pug","end":{"line":23,"column":1}}} +{"type":"tag","loc":{"start":{"line":23,"column":1},"filename":"/cases/attrs.pug","end":{"line":23,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":23,"column":4},"filename":"/cases/attrs.pug","end":{"line":23,"column":5}}} +{"type":"attribute","loc":{"start":{"line":23,"column":5},"filename":"/cases/attrs.pug","end":{"line":23,"column":21}},"name":"date","mustEscape":true,"val":"new Date(0)"} +{"type":"end-attributes","loc":{"start":{"line":23,"column":21},"filename":"/cases/attrs.pug","end":{"line":23,"column":22}}} +{"type":"newline","loc":{"start":{"line":25,"column":1},"filename":"/cases/attrs.pug","end":{"line":25,"column":1}}} +{"type":"tag","loc":{"start":{"line":25,"column":1},"filename":"/cases/attrs.pug","end":{"line":25,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":25,"column":4},"filename":"/cases/attrs.pug","end":{"line":25,"column":5}}} +{"type":"attribute","loc":{"start":{"line":25,"column":5},"filename":"/cases/attrs.pug","end":{"line":25,"column":8}},"name":"abc","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":26,"column":5},"filename":"/cases/attrs.pug","end":{"line":26,"column":8}},"name":"def","mustEscape":true,"val":true} +{"type":"end-attributes","loc":{"start":{"line":26,"column":8},"filename":"/cases/attrs.pug","end":{"line":26,"column":9}}} +{"type":"newline","loc":{"start":{"line":27,"column":1},"filename":"/cases/attrs.pug","end":{"line":27,"column":1}}} +{"type":"tag","loc":{"start":{"line":27,"column":1},"filename":"/cases/attrs.pug","end":{"line":27,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":27,"column":4},"filename":"/cases/attrs.pug","end":{"line":27,"column":5}}} +{"type":"attribute","loc":{"start":{"line":27,"column":5},"filename":"/cases/attrs.pug","end":{"line":27,"column":8}},"name":"abc","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":28,"column":5},"filename":"/cases/attrs.pug","end":{"line":28,"column":8}},"name":"def","mustEscape":true,"val":true} +{"type":"end-attributes","loc":{"start":{"line":28,"column":8},"filename":"/cases/attrs.pug","end":{"line":28,"column":9}}} +{"type":"newline","loc":{"start":{"line":29,"column":1},"filename":"/cases/attrs.pug","end":{"line":29,"column":1}}} +{"type":"tag","loc":{"start":{"line":29,"column":1},"filename":"/cases/attrs.pug","end":{"line":29,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":29,"column":4},"filename":"/cases/attrs.pug","end":{"line":29,"column":5}}} +{"type":"attribute","loc":{"start":{"line":29,"column":5},"filename":"/cases/attrs.pug","end":{"line":29,"column":8}},"name":"abc","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":30,"column":3},"filename":"/cases/attrs.pug","end":{"line":30,"column":6}},"name":"def","mustEscape":true,"val":true} +{"type":"end-attributes","loc":{"start":{"line":30,"column":6},"filename":"/cases/attrs.pug","end":{"line":30,"column":7}}} +{"type":"newline","loc":{"start":{"line":31,"column":1},"filename":"/cases/attrs.pug","end":{"line":31,"column":1}}} +{"type":"tag","loc":{"start":{"line":31,"column":1},"filename":"/cases/attrs.pug","end":{"line":31,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":31,"column":4},"filename":"/cases/attrs.pug","end":{"line":31,"column":5}}} +{"type":"attribute","loc":{"start":{"line":31,"column":5},"filename":"/cases/attrs.pug","end":{"line":31,"column":8}},"name":"abc","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":32,"column":4},"filename":"/cases/attrs.pug","end":{"line":32,"column":7}},"name":"def","mustEscape":true,"val":true} +{"type":"end-attributes","loc":{"start":{"line":32,"column":7},"filename":"/cases/attrs.pug","end":{"line":32,"column":8}}} +{"type":"newline","loc":{"start":{"line":33,"column":1},"filename":"/cases/attrs.pug","end":{"line":33,"column":1}}} +{"type":"tag","loc":{"start":{"line":33,"column":1},"filename":"/cases/attrs.pug","end":{"line":33,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":33,"column":4},"filename":"/cases/attrs.pug","end":{"line":33,"column":5}}} +{"type":"attribute","loc":{"start":{"line":33,"column":5},"filename":"/cases/attrs.pug","end":{"line":33,"column":8}},"name":"abc","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":34,"column":3},"filename":"/cases/attrs.pug","end":{"line":34,"column":6}},"name":"def","mustEscape":true,"val":true} +{"type":"end-attributes","loc":{"start":{"line":34,"column":6},"filename":"/cases/attrs.pug","end":{"line":34,"column":7}}} +{"type":"newline","loc":{"start":{"line":35,"column":1},"filename":"/cases/attrs.pug","end":{"line":35,"column":1}}} +{"type":"tag","loc":{"start":{"line":35,"column":1},"filename":"/cases/attrs.pug","end":{"line":35,"column":4}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":35,"column":4},"filename":"/cases/attrs.pug","end":{"line":35,"column":5}}} +{"type":"attribute","loc":{"start":{"line":35,"column":5},"filename":"/cases/attrs.pug","end":{"line":35,"column":8}},"name":"abc","mustEscape":true,"val":true} +{"type":"attribute","loc":{"start":{"line":36,"column":5},"filename":"/cases/attrs.pug","end":{"line":36,"column":8}},"name":"def","mustEscape":true,"val":true} +{"type":"end-attributes","loc":{"start":{"line":36,"column":8},"filename":"/cases/attrs.pug","end":{"line":36,"column":9}}} +{"type":"newline","loc":{"start":{"line":38,"column":1},"filename":"/cases/attrs.pug","end":{"line":38,"column":1}}} +{"type":"code","loc":{"start":{"line":38,"column":1},"filename":"/cases/attrs.pug","end":{"line":38,"column":41}},"val":"var attrs = {foo: 'bar', bar: ''}","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":40,"column":1},"filename":"/cases/attrs.pug","end":{"line":40,"column":1}}} +{"type":"tag","loc":{"start":{"line":40,"column":1},"filename":"/cases/attrs.pug","end":{"line":40,"column":4}},"val":"div"} +{"type":"&attributes","loc":{"start":{"line":40,"column":4},"filename":"/cases/attrs.pug","end":{"line":40,"column":22}},"val":"attrs"} +{"type":"newline","loc":{"start":{"line":42,"column":1},"filename":"/cases/attrs.pug","end":{"line":42,"column":1}}} +{"type":"tag","loc":{"start":{"line":42,"column":1},"filename":"/cases/attrs.pug","end":{"line":42,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":42,"column":2},"filename":"/cases/attrs.pug","end":{"line":42,"column":3}}} +{"type":"attribute","loc":{"start":{"line":42,"column":3},"filename":"/cases/attrs.pug","end":{"line":42,"column":12}},"name":"foo","mustEscape":true,"val":"'foo'"} +{"type":"attribute","loc":{"start":{"line":42,"column":13},"filename":"/cases/attrs.pug","end":{"line":42,"column":24}},"name":"bar","mustEscape":true,"val":"\"bar\""} +{"type":"end-attributes","loc":{"start":{"line":42,"column":24},"filename":"/cases/attrs.pug","end":{"line":42,"column":25}}} +{"type":"newline","loc":{"start":{"line":43,"column":1},"filename":"/cases/attrs.pug","end":{"line":43,"column":1}}} +{"type":"tag","loc":{"start":{"line":43,"column":1},"filename":"/cases/attrs.pug","end":{"line":43,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":43,"column":2},"filename":"/cases/attrs.pug","end":{"line":43,"column":3}}} +{"type":"attribute","loc":{"start":{"line":43,"column":3},"filename":"/cases/attrs.pug","end":{"line":43,"column":12}},"name":"foo","mustEscape":true,"val":"'foo'"} +{"type":"attribute","loc":{"start":{"line":43,"column":13},"filename":"/cases/attrs.pug","end":{"line":43,"column":24}},"name":"bar","mustEscape":true,"val":"'bar'"} +{"type":"end-attributes","loc":{"start":{"line":43,"column":24},"filename":"/cases/attrs.pug","end":{"line":43,"column":25}}} +{"type":"newline","loc":{"start":{"line":44,"column":1},"filename":"/cases/attrs.pug","end":{"line":44,"column":1}}} +{"type":"eos","loc":{"start":{"line":44,"column":1},"filename":"/cases/attrs.pug","end":{"line":44,"column":1}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/attrs.unescaped.tokens.json b/src/test-data/pug-parser/cases/attrs.unescaped.tokens.json new file mode 100644 index 0000000..9cce521 --- /dev/null +++ b/src/test-data/pug-parser/cases/attrs.unescaped.tokens.json @@ -0,0 +1,15 @@ +{"type":"tag","loc":{"start":{"line":1,"column":1},"filename":"/cases/attrs.unescaped.pug","end":{"line":1,"column":7}},"val":"script"} +{"type":"start-attributes","loc":{"start":{"line":1,"column":7},"filename":"/cases/attrs.unescaped.pug","end":{"line":1,"column":8}}} +{"type":"attribute","loc":{"start":{"line":1,"column":8},"filename":"/cases/attrs.unescaped.pug","end":{"line":1,"column":30}},"name":"type","mustEscape":true,"val":"'text/x-template'"} +{"type":"end-attributes","loc":{"start":{"line":1,"column":30},"filename":"/cases/attrs.unescaped.pug","end":{"line":1,"column":31}}} +{"type":"indent","loc":{"start":{"line":2,"column":1},"filename":"/cases/attrs.unescaped.pug","end":{"line":2,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":2,"column":3},"filename":"/cases/attrs.unescaped.pug","end":{"line":2,"column":6}},"val":"div"} +{"type":"start-attributes","loc":{"start":{"line":2,"column":6},"filename":"/cases/attrs.unescaped.pug","end":{"line":2,"column":7}}} +{"type":"attribute","loc":{"start":{"line":2,"column":7},"filename":"/cases/attrs.unescaped.pug","end":{"line":2,"column":32}},"name":"id","mustEscape":false,"val":"'user-<%= user.id %>'"} +{"type":"end-attributes","loc":{"start":{"line":2,"column":32},"filename":"/cases/attrs.unescaped.pug","end":{"line":2,"column":33}}} +{"type":"indent","loc":{"start":{"line":3,"column":1},"filename":"/cases/attrs.unescaped.pug","end":{"line":3,"column":5}},"val":4} +{"type":"tag","loc":{"start":{"line":3,"column":5},"filename":"/cases/attrs.unescaped.pug","end":{"line":3,"column":7}},"val":"h1"} +{"type":"text","loc":{"start":{"line":3,"column":8},"filename":"/cases/attrs.unescaped.pug","end":{"line":3,"column":25}},"val":"<%= user.title %>"} +{"type":"outdent","loc":{"start":{"line":3,"column":25},"filename":"/cases/attrs.unescaped.pug","end":{"line":3,"column":25}}} +{"type":"outdent","loc":{"start":{"line":3,"column":25},"filename":"/cases/attrs.unescaped.pug","end":{"line":3,"column":25}}} +{"type":"eos","loc":{"start":{"line":3,"column":25},"filename":"/cases/attrs.unescaped.pug","end":{"line":3,"column":25}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/basic.tokens.json b/src/test-data/pug-parser/cases/basic.tokens.json new file mode 100644 index 0000000..0d38aae --- /dev/null +++ b/src/test-data/pug-parser/cases/basic.tokens.json @@ -0,0 +1,9 @@ +{"type":"tag","loc":{"start":{"line":1,"column":1},"filename":"/cases/basic.pug","end":{"line":1,"column":5}},"val":"html"} +{"type":"indent","loc":{"start":{"line":2,"column":1},"filename":"/cases/basic.pug","end":{"line":2,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":2,"column":3},"filename":"/cases/basic.pug","end":{"line":2,"column":7}},"val":"body"} +{"type":"indent","loc":{"start":{"line":3,"column":1},"filename":"/cases/basic.pug","end":{"line":3,"column":5}},"val":4} +{"type":"tag","loc":{"start":{"line":3,"column":5},"filename":"/cases/basic.pug","end":{"line":3,"column":7}},"val":"h1"} +{"type":"text","loc":{"start":{"line":3,"column":8},"filename":"/cases/basic.pug","end":{"line":3,"column":13}},"val":"Title"} +{"type":"outdent","loc":{"start":{"line":3,"column":13},"filename":"/cases/basic.pug","end":{"line":3,"column":13}}} +{"type":"outdent","loc":{"start":{"line":3,"column":13},"filename":"/cases/basic.pug","end":{"line":3,"column":13}}} +{"type":"eos","loc":{"start":{"line":3,"column":13},"filename":"/cases/basic.pug","end":{"line":3,"column":13}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/blanks.tokens.json b/src/test-data/pug-parser/cases/blanks.tokens.json new file mode 100644 index 0000000..1529f5c --- /dev/null +++ b/src/test-data/pug-parser/cases/blanks.tokens.json @@ -0,0 +1,13 @@ +{"type":"newline","loc":{"start":{"line":3,"column":1},"filename":"/cases/blanks.pug","end":{"line":3,"column":1}}} +{"type":"tag","loc":{"start":{"line":3,"column":1},"filename":"/cases/blanks.pug","end":{"line":3,"column":3}},"val":"ul"} +{"type":"indent","loc":{"start":{"line":4,"column":1},"filename":"/cases/blanks.pug","end":{"line":4,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":4,"column":3},"filename":"/cases/blanks.pug","end":{"line":4,"column":5}},"val":"li"} +{"type":"text","loc":{"start":{"line":4,"column":6},"filename":"/cases/blanks.pug","end":{"line":4,"column":9}},"val":"foo"} +{"type":"newline","loc":{"start":{"line":6,"column":1},"filename":"/cases/blanks.pug","end":{"line":6,"column":3}}} +{"type":"tag","loc":{"start":{"line":6,"column":3},"filename":"/cases/blanks.pug","end":{"line":6,"column":5}},"val":"li"} +{"type":"text","loc":{"start":{"line":6,"column":6},"filename":"/cases/blanks.pug","end":{"line":6,"column":9}},"val":"bar"} +{"type":"newline","loc":{"start":{"line":8,"column":1},"filename":"/cases/blanks.pug","end":{"line":8,"column":3}}} +{"type":"tag","loc":{"start":{"line":8,"column":3},"filename":"/cases/blanks.pug","end":{"line":8,"column":5}},"val":"li"} +{"type":"text","loc":{"start":{"line":8,"column":6},"filename":"/cases/blanks.pug","end":{"line":8,"column":9}},"val":"baz"} +{"type":"outdent","loc":{"start":{"line":9,"column":1},"filename":"/cases/blanks.pug","end":{"line":9,"column":1}}} +{"type":"eos","loc":{"start":{"line":9,"column":1},"filename":"/cases/blanks.pug","end":{"line":9,"column":1}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/block-code.tokens.json b/src/test-data/pug-parser/cases/block-code.tokens.json new file mode 100644 index 0000000..bff1694 --- /dev/null +++ b/src/test-data/pug-parser/cases/block-code.tokens.json @@ -0,0 +1,28 @@ +{"type":"blockcode","loc":{"start":{"line":1,"column":1},"filename":"/cases/block-code.pug","end":{"line":1,"column":2}}} +{"type":"start-pipeless-text","loc":{"start":{"line":1,"column":2},"filename":"/cases/block-code.pug","end":{"line":1,"column":2}}} +{"type":"text","loc":{"start":{"line":2,"column":3},"filename":"/cases/block-code.pug","end":{"line":2,"column":32}},"val":"list = [\"uno\", \"dos\", \"tres\","} +{"type":"newline","loc":{"start":{"line":3,"column":1},"filename":"/cases/block-code.pug","end":{"line":3,"column":3}}} +{"type":"text","loc":{"start":{"line":3,"column":3},"filename":"/cases/block-code.pug","end":{"line":3,"column":38}},"val":" \"cuatro\", \"cinco\", \"seis\"];"} +{"type":"end-pipeless-text","loc":{"start":{"line":3,"column":38},"filename":"/cases/block-code.pug","end":{"line":3,"column":38}}} +{"type":"newline","loc":{"start":{"line":4,"column":1},"filename":"/cases/block-code.pug","end":{"line":4,"column":1}}} +{"type":"comment","loc":{"start":{"line":4,"column":1},"filename":"/cases/block-code.pug","end":{"line":4,"column":70}},"val":" Without a block, the element is accepted and no code is generated","buffer":false} +{"type":"newline","loc":{"start":{"line":5,"column":1},"filename":"/cases/block-code.pug","end":{"line":5,"column":1}}} +{"type":"blockcode","loc":{"start":{"line":5,"column":1},"filename":"/cases/block-code.pug","end":{"line":5,"column":2}}} +{"type":"newline","loc":{"start":{"line":6,"column":1},"filename":"/cases/block-code.pug","end":{"line":6,"column":1}}} +{"type":"each","loc":{"start":{"line":6,"column":1},"filename":"/cases/block-code.pug","end":{"line":6,"column":18}},"val":"item","key":null,"code":"list"} +{"type":"indent","loc":{"start":{"line":7,"column":1},"filename":"/cases/block-code.pug","end":{"line":7,"column":3}},"val":2} +{"type":"blockcode","loc":{"start":{"line":7,"column":3},"filename":"/cases/block-code.pug","end":{"line":7,"column":4}}} +{"type":"start-pipeless-text","loc":{"start":{"line":7,"column":4},"filename":"/cases/block-code.pug","end":{"line":7,"column":4}}} +{"type":"text","loc":{"start":{"line":8,"column":5},"filename":"/cases/block-code.pug","end":{"line":8,"column":28}},"val":"string = item.charAt(0)"} +{"type":"newline","loc":{"start":{"line":9,"column":1},"filename":"/cases/block-code.pug","end":{"line":9,"column":5}}} +{"type":"text","loc":{"start":{"line":9,"column":5},"filename":"/cases/block-code.pug","end":{"line":9,"column":5}},"val":""} +{"type":"newline","loc":{"start":{"line":10,"column":1},"filename":"/cases/block-code.pug","end":{"line":10,"column":5}}} +{"type":"text","loc":{"start":{"line":10,"column":5},"filename":"/cases/block-code.pug","end":{"line":10,"column":23}},"val":" .toUpperCase() +"} +{"type":"newline","loc":{"start":{"line":11,"column":1},"filename":"/cases/block-code.pug","end":{"line":11,"column":5}}} +{"type":"text","loc":{"start":{"line":11,"column":5},"filename":"/cases/block-code.pug","end":{"line":11,"column":19}},"val":"item.slice(1);"} +{"type":"end-pipeless-text","loc":{"start":{"line":11,"column":19},"filename":"/cases/block-code.pug","end":{"line":11,"column":19}}} +{"type":"newline","loc":{"start":{"line":12,"column":1},"filename":"/cases/block-code.pug","end":{"line":12,"column":3}}} +{"type":"tag","loc":{"start":{"line":12,"column":3},"filename":"/cases/block-code.pug","end":{"line":12,"column":5}},"val":"li"} +{"type":"code","loc":{"start":{"line":12,"column":5},"filename":"/cases/block-code.pug","end":{"line":12,"column":13}},"val":"string","mustEscape":true,"buffer":true} +{"type":"outdent","loc":{"start":{"line":13,"column":1},"filename":"/cases/block-code.pug","end":{"line":13,"column":1}}} +{"type":"eos","loc":{"start":{"line":13,"column":1},"filename":"/cases/block-code.pug","end":{"line":13,"column":1}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/block-expansion.shorthands.tokens.json b/src/test-data/pug-parser/cases/block-expansion.shorthands.tokens.json new file mode 100644 index 0000000..055869e --- /dev/null +++ b/src/test-data/pug-parser/cases/block-expansion.shorthands.tokens.json @@ -0,0 +1,11 @@ +{"type":"tag","loc":{"start":{"line":1,"column":1},"filename":"/cases/block-expansion.shorthands.pug","end":{"line":1,"column":3}},"val":"ul"} +{"type":"indent","loc":{"start":{"line":2,"column":1},"filename":"/cases/block-expansion.shorthands.pug","end":{"line":2,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":2,"column":3},"filename":"/cases/block-expansion.shorthands.pug","end":{"line":2,"column":5}},"val":"li"} +{"type":"class","loc":{"start":{"line":2,"column":5},"filename":"/cases/block-expansion.shorthands.pug","end":{"line":2,"column":15}},"val":"list-item"} +{"type":":","loc":{"start":{"line":2,"column":15},"filename":"/cases/block-expansion.shorthands.pug","end":{"line":2,"column":17}}} +{"type":"class","loc":{"start":{"line":2,"column":17},"filename":"/cases/block-expansion.shorthands.pug","end":{"line":2,"column":21}},"val":"foo"} +{"type":":","loc":{"start":{"line":2,"column":21},"filename":"/cases/block-expansion.shorthands.pug","end":{"line":2,"column":23}}} +{"type":"id","loc":{"start":{"line":2,"column":23},"filename":"/cases/block-expansion.shorthands.pug","end":{"line":2,"column":27}},"val":"bar"} +{"type":"text","loc":{"start":{"line":2,"column":28},"filename":"/cases/block-expansion.shorthands.pug","end":{"line":2,"column":31}},"val":"baz"} +{"type":"outdent","loc":{"start":{"line":2,"column":31},"filename":"/cases/block-expansion.shorthands.pug","end":{"line":2,"column":31}}} +{"type":"eos","loc":{"start":{"line":2,"column":31},"filename":"/cases/block-expansion.shorthands.pug","end":{"line":2,"column":31}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/block-expansion.tokens.json b/src/test-data/pug-parser/cases/block-expansion.tokens.json new file mode 100644 index 0000000..c49b659 --- /dev/null +++ b/src/test-data/pug-parser/cases/block-expansion.tokens.json @@ -0,0 +1,21 @@ +{"type":"tag","loc":{"start":{"line":1,"column":1},"filename":"/cases/block-expansion.pug","end":{"line":1,"column":3}},"val":"ul"} +{"type":"indent","loc":{"start":{"line":2,"column":1},"filename":"/cases/block-expansion.pug","end":{"line":2,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":2,"column":3},"filename":"/cases/block-expansion.pug","end":{"line":2,"column":5}},"val":"li"} +{"type":":","loc":{"start":{"line":2,"column":5},"filename":"/cases/block-expansion.pug","end":{"line":2,"column":7}}} +{"type":"tag","loc":{"start":{"line":2,"column":7},"filename":"/cases/block-expansion.pug","end":{"line":2,"column":8}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":2,"column":8},"filename":"/cases/block-expansion.pug","end":{"line":2,"column":9}}} +{"type":"attribute","loc":{"start":{"line":2,"column":9},"filename":"/cases/block-expansion.pug","end":{"line":2,"column":17}},"name":"href","mustEscape":true,"val":"'#'"} +{"type":"end-attributes","loc":{"start":{"line":2,"column":17},"filename":"/cases/block-expansion.pug","end":{"line":2,"column":18}}} +{"type":"text","loc":{"start":{"line":2,"column":19},"filename":"/cases/block-expansion.pug","end":{"line":2,"column":22}},"val":"foo"} +{"type":"newline","loc":{"start":{"line":3,"column":1},"filename":"/cases/block-expansion.pug","end":{"line":3,"column":3}}} +{"type":"tag","loc":{"start":{"line":3,"column":3},"filename":"/cases/block-expansion.pug","end":{"line":3,"column":5}},"val":"li"} +{"type":":","loc":{"start":{"line":3,"column":5},"filename":"/cases/block-expansion.pug","end":{"line":3,"column":7}}} +{"type":"tag","loc":{"start":{"line":3,"column":7},"filename":"/cases/block-expansion.pug","end":{"line":3,"column":8}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":3,"column":8},"filename":"/cases/block-expansion.pug","end":{"line":3,"column":9}}} +{"type":"attribute","loc":{"start":{"line":3,"column":9},"filename":"/cases/block-expansion.pug","end":{"line":3,"column":17}},"name":"href","mustEscape":true,"val":"'#'"} +{"type":"end-attributes","loc":{"start":{"line":3,"column":17},"filename":"/cases/block-expansion.pug","end":{"line":3,"column":18}}} +{"type":"text","loc":{"start":{"line":3,"column":19},"filename":"/cases/block-expansion.pug","end":{"line":3,"column":22}},"val":"bar"} +{"type":"outdent","loc":{"start":{"line":5,"column":1},"filename":"/cases/block-expansion.pug","end":{"line":5,"column":1}}} +{"type":"tag","loc":{"start":{"line":5,"column":1},"filename":"/cases/block-expansion.pug","end":{"line":5,"column":2}},"val":"p"} +{"type":"text","loc":{"start":{"line":5,"column":3},"filename":"/cases/block-expansion.pug","end":{"line":5,"column":6}},"val":"baz"} +{"type":"eos","loc":{"start":{"line":5,"column":6},"filename":"/cases/block-expansion.pug","end":{"line":5,"column":6}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/blockquote.tokens.json b/src/test-data/pug-parser/cases/blockquote.tokens.json new file mode 100644 index 0000000..cb0b8f0 --- /dev/null +++ b/src/test-data/pug-parser/cases/blockquote.tokens.json @@ -0,0 +1,10 @@ +{"type":"tag","loc":{"start":{"line":1,"column":1},"filename":"/cases/blockquote.pug","end":{"line":1,"column":7}},"val":"figure"} +{"type":"indent","loc":{"start":{"line":2,"column":1},"filename":"/cases/blockquote.pug","end":{"line":2,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":2,"column":3},"filename":"/cases/blockquote.pug","end":{"line":2,"column":13}},"val":"blockquote"} +{"type":"indent","loc":{"start":{"line":3,"column":1},"filename":"/cases/blockquote.pug","end":{"line":3,"column":5}},"val":4} +{"type":"text","loc":{"start":{"line":3,"column":7},"filename":"/cases/blockquote.pug","end":{"line":3,"column":123}},"val":"Try to define yourself by what you do, and you’ll burnout every time. You are. That is enough. I rest in that."} +{"type":"outdent","loc":{"start":{"line":4,"column":1},"filename":"/cases/blockquote.pug","end":{"line":4,"column":3}}} +{"type":"tag","loc":{"start":{"line":4,"column":3},"filename":"/cases/blockquote.pug","end":{"line":4,"column":13}},"val":"figcaption"} +{"type":"text","loc":{"start":{"line":4,"column":14},"filename":"/cases/blockquote.pug","end":{"line":4,"column":47}},"val":"from @thefray at 1:43pm on May 10"} +{"type":"outdent","loc":{"start":{"line":4,"column":47},"filename":"/cases/blockquote.pug","end":{"line":4,"column":47}}} +{"type":"eos","loc":{"start":{"line":4,"column":47},"filename":"/cases/blockquote.pug","end":{"line":4,"column":47}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/blocks-in-blocks.tokens.json b/src/test-data/pug-parser/cases/blocks-in-blocks.tokens.json new file mode 100644 index 0000000..c3cb941 --- /dev/null +++ b/src/test-data/pug-parser/cases/blocks-in-blocks.tokens.json @@ -0,0 +1,9 @@ +{"type":"extends","loc":{"start":{"line":1,"column":1},"filename":"/cases/blocks-in-blocks.pug","end":{"line":1,"column":8}}} +{"type":"path","loc":{"start":{"line":1,"column":9},"filename":"/cases/blocks-in-blocks.pug","end":{"line":1,"column":48}},"val":"./auxiliary/blocks-in-blocks-layout.pug"} +{"type":"newline","loc":{"start":{"line":3,"column":1},"filename":"/cases/blocks-in-blocks.pug","end":{"line":3,"column":1}}} +{"type":"block","loc":{"start":{"line":3,"column":1},"filename":"/cases/blocks-in-blocks.pug","end":{"line":3,"column":11}},"val":"body","mode":"replace"} +{"type":"indent","loc":{"start":{"line":4,"column":1},"filename":"/cases/blocks-in-blocks.pug","end":{"line":4,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":4,"column":3},"filename":"/cases/blocks-in-blocks.pug","end":{"line":4,"column":5}},"val":"h1"} +{"type":"text","loc":{"start":{"line":4,"column":6},"filename":"/cases/blocks-in-blocks.pug","end":{"line":4,"column":12}},"val":"Page 2"} +{"type":"outdent","loc":{"start":{"line":5,"column":1},"filename":"/cases/blocks-in-blocks.pug","end":{"line":5,"column":1}}} +{"type":"eos","loc":{"start":{"line":5,"column":1},"filename":"/cases/blocks-in-blocks.pug","end":{"line":5,"column":1}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/blocks-in-if.tokens.json b/src/test-data/pug-parser/cases/blocks-in-if.tokens.json new file mode 100644 index 0000000..3f30fc3 --- /dev/null +++ b/src/test-data/pug-parser/cases/blocks-in-if.tokens.json @@ -0,0 +1,44 @@ +{"type":"comment","loc":{"start":{"line":1,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":1,"column":49}},"val":" see https://github.com/pugjs/pug/issues/1589","buffer":false} +{"type":"newline","loc":{"start":{"line":3,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":3,"column":1}}} +{"type":"code","loc":{"start":{"line":3,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":3,"column":17}},"val":"var ajax = true","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":5,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":5,"column":1}}} +{"type":"code","loc":{"start":{"line":5,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":5,"column":12}},"val":"if( ajax )","mustEscape":false,"buffer":false} +{"type":"indent","loc":{"start":{"line":6,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":6,"column":5}},"val":4} +{"type":"comment","loc":{"start":{"line":6,"column":5},"filename":"/cases/blocks-in-if.pug","end":{"line":6,"column":46}},"val":" return only contents if ajax requests","buffer":false} +{"type":"newline","loc":{"start":{"line":7,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":7,"column":5}}} +{"type":"block","loc":{"start":{"line":7,"column":5},"filename":"/cases/blocks-in-if.pug","end":{"line":7,"column":19}},"val":"contents","mode":"replace"} +{"type":"indent","loc":{"start":{"line":8,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":8,"column":9}},"val":8} +{"type":"tag","loc":{"start":{"line":8,"column":9},"filename":"/cases/blocks-in-if.pug","end":{"line":8,"column":10}},"val":"p"} +{"type":"text","loc":{"start":{"line":8,"column":11},"filename":"/cases/blocks-in-if.pug","end":{"line":8,"column":24}},"val":"ajax contents"} +{"type":"outdent","loc":{"start":{"line":10,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":10,"column":1}}} +{"type":"outdent","loc":{"start":{"line":10,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":10,"column":1}}} +{"type":"code","loc":{"start":{"line":10,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":10,"column":6}},"val":"else","mustEscape":false,"buffer":false} +{"type":"indent","loc":{"start":{"line":11,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":11,"column":5}},"val":4} +{"type":"comment","loc":{"start":{"line":11,"column":5},"filename":"/cases/blocks-in-if.pug","end":{"line":11,"column":24}},"val":" return all html","buffer":false} +{"type":"newline","loc":{"start":{"line":12,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":12,"column":5}}} +{"type":"doctype","loc":{"start":{"line":12,"column":5},"filename":"/cases/blocks-in-if.pug","end":{"line":12,"column":17}},"val":"html"} +{"type":"newline","loc":{"start":{"line":13,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":13,"column":5}}} +{"type":"tag","loc":{"start":{"line":13,"column":5},"filename":"/cases/blocks-in-if.pug","end":{"line":13,"column":9}},"val":"html"} +{"type":"indent","loc":{"start":{"line":14,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":14,"column":9}},"val":8} +{"type":"tag","loc":{"start":{"line":14,"column":9},"filename":"/cases/blocks-in-if.pug","end":{"line":14,"column":13}},"val":"head"} +{"type":"indent","loc":{"start":{"line":15,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":15,"column":13}},"val":12} +{"type":"tag","loc":{"start":{"line":15,"column":13},"filename":"/cases/blocks-in-if.pug","end":{"line":15,"column":17}},"val":"meta"} +{"type":"start-attributes","loc":{"start":{"line":15,"column":17},"filename":"/cases/blocks-in-if.pug","end":{"line":15,"column":18}}} +{"type":"attribute","loc":{"start":{"line":15,"column":19},"filename":"/cases/blocks-in-if.pug","end":{"line":15,"column":33}},"name":"charset","mustEscape":true,"val":"'utf8'"} +{"type":"end-attributes","loc":{"start":{"line":15,"column":34},"filename":"/cases/blocks-in-if.pug","end":{"line":15,"column":35}}} +{"type":"newline","loc":{"start":{"line":16,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":16,"column":13}}} +{"type":"tag","loc":{"start":{"line":16,"column":13},"filename":"/cases/blocks-in-if.pug","end":{"line":16,"column":18}},"val":"title"} +{"type":"text","loc":{"start":{"line":16,"column":19},"filename":"/cases/blocks-in-if.pug","end":{"line":16,"column":25}},"val":"sample"} +{"type":"newline","loc":{"start":{"line":17,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":17,"column":13}}} +{"type":"tag","loc":{"start":{"line":17,"column":13},"filename":"/cases/blocks-in-if.pug","end":{"line":17,"column":17}},"val":"body"} +{"type":"indent","loc":{"start":{"line":18,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":18,"column":17}},"val":16} +{"type":"block","loc":{"start":{"line":18,"column":17},"filename":"/cases/blocks-in-if.pug","end":{"line":18,"column":31}},"val":"contents","mode":"replace"} +{"type":"indent","loc":{"start":{"line":19,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":19,"column":21}},"val":20} +{"type":"tag","loc":{"start":{"line":19,"column":21},"filename":"/cases/blocks-in-if.pug","end":{"line":19,"column":22}},"val":"p"} +{"type":"text","loc":{"start":{"line":19,"column":23},"filename":"/cases/blocks-in-if.pug","end":{"line":19,"column":35}},"val":"all contetns"} +{"type":"outdent","loc":{"start":{"line":20,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":20,"column":1}}} +{"type":"outdent","loc":{"start":{"line":20,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":20,"column":1}}} +{"type":"outdent","loc":{"start":{"line":20,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":20,"column":1}}} +{"type":"outdent","loc":{"start":{"line":20,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":20,"column":1}}} +{"type":"outdent","loc":{"start":{"line":20,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":20,"column":1}}} +{"type":"eos","loc":{"start":{"line":20,"column":1},"filename":"/cases/blocks-in-if.pug","end":{"line":20,"column":1}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/case-blocks.tokens.json b/src/test-data/pug-parser/cases/case-blocks.tokens.json new file mode 100644 index 0000000..165b477 --- /dev/null +++ b/src/test-data/pug-parser/cases/case-blocks.tokens.json @@ -0,0 +1,29 @@ +{"type":"tag","loc":{"start":{"line":1,"column":1},"filename":"/cases/case-blocks.pug","end":{"line":1,"column":5}},"val":"html"} +{"type":"indent","loc":{"start":{"line":2,"column":1},"filename":"/cases/case-blocks.pug","end":{"line":2,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":2,"column":3},"filename":"/cases/case-blocks.pug","end":{"line":2,"column":7}},"val":"body"} +{"type":"indent","loc":{"start":{"line":3,"column":1},"filename":"/cases/case-blocks.pug","end":{"line":3,"column":5}},"val":4} +{"type":"code","loc":{"start":{"line":3,"column":5},"filename":"/cases/case-blocks.pug","end":{"line":3,"column":22}},"val":"var friends = 1","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":4,"column":1},"filename":"/cases/case-blocks.pug","end":{"line":4,"column":5}}} +{"type":"case","loc":{"start":{"line":4,"column":5},"filename":"/cases/case-blocks.pug","end":{"line":4,"column":17}},"val":"friends"} +{"type":"indent","loc":{"start":{"line":5,"column":1},"filename":"/cases/case-blocks.pug","end":{"line":5,"column":7}},"val":6} +{"type":"when","loc":{"start":{"line":5,"column":7},"filename":"/cases/case-blocks.pug","end":{"line":5,"column":13}},"val":"0"} +{"type":"indent","loc":{"start":{"line":6,"column":1},"filename":"/cases/case-blocks.pug","end":{"line":6,"column":9}},"val":8} +{"type":"tag","loc":{"start":{"line":6,"column":9},"filename":"/cases/case-blocks.pug","end":{"line":6,"column":10}},"val":"p"} +{"type":"text","loc":{"start":{"line":6,"column":11},"filename":"/cases/case-blocks.pug","end":{"line":6,"column":30}},"val":"you have no friends"} +{"type":"outdent","loc":{"start":{"line":7,"column":1},"filename":"/cases/case-blocks.pug","end":{"line":7,"column":7}}} +{"type":"when","loc":{"start":{"line":7,"column":7},"filename":"/cases/case-blocks.pug","end":{"line":7,"column":13}},"val":"1"} +{"type":"indent","loc":{"start":{"line":8,"column":1},"filename":"/cases/case-blocks.pug","end":{"line":8,"column":9}},"val":8} +{"type":"tag","loc":{"start":{"line":8,"column":9},"filename":"/cases/case-blocks.pug","end":{"line":8,"column":10}},"val":"p"} +{"type":"text","loc":{"start":{"line":8,"column":11},"filename":"/cases/case-blocks.pug","end":{"line":8,"column":28}},"val":"you have a friend"} +{"type":"outdent","loc":{"start":{"line":9,"column":1},"filename":"/cases/case-blocks.pug","end":{"line":9,"column":7}}} +{"type":"default","loc":{"start":{"line":9,"column":7},"filename":"/cases/case-blocks.pug","end":{"line":9,"column":14}}} +{"type":"indent","loc":{"start":{"line":10,"column":1},"filename":"/cases/case-blocks.pug","end":{"line":10,"column":9}},"val":8} +{"type":"tag","loc":{"start":{"line":10,"column":9},"filename":"/cases/case-blocks.pug","end":{"line":10,"column":10}},"val":"p"} +{"type":"text","loc":{"start":{"line":10,"column":11},"filename":"/cases/case-blocks.pug","end":{"line":10,"column":20}},"val":"you have "} +{"type":"interpolated-code","loc":{"start":{"line":10,"column":20},"filename":"/cases/case-blocks.pug","end":{"line":10,"column":30}},"mustEscape":true,"buffer":true,"val":"friends"} +{"type":"text","loc":{"start":{"line":10,"column":30},"filename":"/cases/case-blocks.pug","end":{"line":10,"column":38}},"val":" friends"} +{"type":"outdent","loc":{"start":{"line":10,"column":38},"filename":"/cases/case-blocks.pug","end":{"line":10,"column":38}}} +{"type":"outdent","loc":{"start":{"line":10,"column":38},"filename":"/cases/case-blocks.pug","end":{"line":10,"column":38}}} +{"type":"outdent","loc":{"start":{"line":10,"column":38},"filename":"/cases/case-blocks.pug","end":{"line":10,"column":38}}} +{"type":"outdent","loc":{"start":{"line":10,"column":38},"filename":"/cases/case-blocks.pug","end":{"line":10,"column":38}}} +{"type":"eos","loc":{"start":{"line":10,"column":38},"filename":"/cases/case-blocks.pug","end":{"line":10,"column":38}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/case.tokens.json b/src/test-data/pug-parser/cases/case.tokens.json new file mode 100644 index 0000000..da7847e --- /dev/null +++ b/src/test-data/pug-parser/cases/case.tokens.json @@ -0,0 +1,61 @@ +{"type":"tag","loc":{"start":{"line":1,"column":1},"filename":"/cases/case.pug","end":{"line":1,"column":5}},"val":"html"} +{"type":"indent","loc":{"start":{"line":2,"column":1},"filename":"/cases/case.pug","end":{"line":2,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":2,"column":3},"filename":"/cases/case.pug","end":{"line":2,"column":7}},"val":"body"} +{"type":"indent","loc":{"start":{"line":3,"column":1},"filename":"/cases/case.pug","end":{"line":3,"column":5}},"val":4} +{"type":"code","loc":{"start":{"line":3,"column":5},"filename":"/cases/case.pug","end":{"line":3,"column":22}},"val":"var friends = 1","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":4,"column":1},"filename":"/cases/case.pug","end":{"line":4,"column":5}}} +{"type":"case","loc":{"start":{"line":4,"column":5},"filename":"/cases/case.pug","end":{"line":4,"column":17}},"val":"friends"} +{"type":"indent","loc":{"start":{"line":5,"column":1},"filename":"/cases/case.pug","end":{"line":5,"column":7}},"val":6} +{"type":"when","loc":{"start":{"line":5,"column":7},"filename":"/cases/case.pug","end":{"line":5,"column":13}},"val":"0"} +{"type":":","loc":{"start":{"line":5,"column":13},"filename":"/cases/case.pug","end":{"line":5,"column":15}}} +{"type":"tag","loc":{"start":{"line":5,"column":15},"filename":"/cases/case.pug","end":{"line":5,"column":16}},"val":"p"} +{"type":"text","loc":{"start":{"line":5,"column":17},"filename":"/cases/case.pug","end":{"line":5,"column":36}},"val":"you have no friends"} +{"type":"newline","loc":{"start":{"line":6,"column":1},"filename":"/cases/case.pug","end":{"line":6,"column":7}}} +{"type":"when","loc":{"start":{"line":6,"column":7},"filename":"/cases/case.pug","end":{"line":6,"column":13}},"val":"1"} +{"type":":","loc":{"start":{"line":6,"column":13},"filename":"/cases/case.pug","end":{"line":6,"column":15}}} +{"type":"tag","loc":{"start":{"line":6,"column":15},"filename":"/cases/case.pug","end":{"line":6,"column":16}},"val":"p"} +{"type":"text","loc":{"start":{"line":6,"column":17},"filename":"/cases/case.pug","end":{"line":6,"column":34}},"val":"you have a friend"} +{"type":"newline","loc":{"start":{"line":7,"column":1},"filename":"/cases/case.pug","end":{"line":7,"column":7}}} +{"type":"default","loc":{"start":{"line":7,"column":7},"filename":"/cases/case.pug","end":{"line":7,"column":14}}} +{"type":":","loc":{"start":{"line":7,"column":14},"filename":"/cases/case.pug","end":{"line":7,"column":16}}} +{"type":"tag","loc":{"start":{"line":7,"column":16},"filename":"/cases/case.pug","end":{"line":7,"column":17}},"val":"p"} +{"type":"text","loc":{"start":{"line":7,"column":18},"filename":"/cases/case.pug","end":{"line":7,"column":27}},"val":"you have "} +{"type":"interpolated-code","loc":{"start":{"line":7,"column":27},"filename":"/cases/case.pug","end":{"line":7,"column":37}},"mustEscape":true,"buffer":true,"val":"friends"} +{"type":"text","loc":{"start":{"line":7,"column":37},"filename":"/cases/case.pug","end":{"line":7,"column":45}},"val":" friends"} +{"type":"outdent","loc":{"start":{"line":8,"column":1},"filename":"/cases/case.pug","end":{"line":8,"column":5}}} +{"type":"code","loc":{"start":{"line":8,"column":5},"filename":"/cases/case.pug","end":{"line":8,"column":22}},"val":"var friends = 0","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":9,"column":1},"filename":"/cases/case.pug","end":{"line":9,"column":5}}} +{"type":"case","loc":{"start":{"line":9,"column":5},"filename":"/cases/case.pug","end":{"line":9,"column":17}},"val":"friends"} +{"type":"indent","loc":{"start":{"line":10,"column":1},"filename":"/cases/case.pug","end":{"line":10,"column":7}},"val":6} +{"type":"when","loc":{"start":{"line":10,"column":7},"filename":"/cases/case.pug","end":{"line":10,"column":13}},"val":"0"} +{"type":"newline","loc":{"start":{"line":11,"column":1},"filename":"/cases/case.pug","end":{"line":11,"column":7}}} +{"type":"when","loc":{"start":{"line":11,"column":7},"filename":"/cases/case.pug","end":{"line":11,"column":13}},"val":"1"} +{"type":"indent","loc":{"start":{"line":12,"column":1},"filename":"/cases/case.pug","end":{"line":12,"column":9}},"val":8} +{"type":"tag","loc":{"start":{"line":12,"column":9},"filename":"/cases/case.pug","end":{"line":12,"column":10}},"val":"p"} +{"type":"text","loc":{"start":{"line":12,"column":11},"filename":"/cases/case.pug","end":{"line":12,"column":36}},"val":"you have very few friends"} +{"type":"outdent","loc":{"start":{"line":13,"column":1},"filename":"/cases/case.pug","end":{"line":13,"column":7}}} +{"type":"default","loc":{"start":{"line":13,"column":7},"filename":"/cases/case.pug","end":{"line":13,"column":14}}} +{"type":"indent","loc":{"start":{"line":14,"column":1},"filename":"/cases/case.pug","end":{"line":14,"column":9}},"val":8} +{"type":"tag","loc":{"start":{"line":14,"column":9},"filename":"/cases/case.pug","end":{"line":14,"column":10}},"val":"p"} +{"type":"text","loc":{"start":{"line":14,"column":11},"filename":"/cases/case.pug","end":{"line":14,"column":20}},"val":"you have "} +{"type":"interpolated-code","loc":{"start":{"line":14,"column":20},"filename":"/cases/case.pug","end":{"line":14,"column":30}},"mustEscape":true,"buffer":true,"val":"friends"} +{"type":"text","loc":{"start":{"line":14,"column":30},"filename":"/cases/case.pug","end":{"line":14,"column":38}},"val":" friends"} +{"type":"outdent","loc":{"start":{"line":16,"column":1},"filename":"/cases/case.pug","end":{"line":16,"column":5}}} +{"type":"outdent","loc":{"start":{"line":16,"column":1},"filename":"/cases/case.pug","end":{"line":16,"column":5}}} +{"type":"code","loc":{"start":{"line":16,"column":5},"filename":"/cases/case.pug","end":{"line":16,"column":27}},"val":"var friend = 'Tim:G'","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":17,"column":1},"filename":"/cases/case.pug","end":{"line":17,"column":5}}} +{"type":"case","loc":{"start":{"line":17,"column":5},"filename":"/cases/case.pug","end":{"line":17,"column":16}},"val":"friend"} +{"type":"indent","loc":{"start":{"line":18,"column":1},"filename":"/cases/case.pug","end":{"line":18,"column":7}},"val":6} +{"type":"when","loc":{"start":{"line":18,"column":7},"filename":"/cases/case.pug","end":{"line":18,"column":19}},"val":"'Tim:G'"} +{"type":":","loc":{"start":{"line":18,"column":19},"filename":"/cases/case.pug","end":{"line":18,"column":24}}} +{"type":"tag","loc":{"start":{"line":18,"column":24},"filename":"/cases/case.pug","end":{"line":18,"column":25}},"val":"p"} +{"type":"text","loc":{"start":{"line":18,"column":26},"filename":"/cases/case.pug","end":{"line":18,"column":44}},"val":"Friend is a string"} +{"type":"newline","loc":{"start":{"line":19,"column":1},"filename":"/cases/case.pug","end":{"line":19,"column":7}}} +{"type":"when","loc":{"start":{"line":19,"column":7},"filename":"/cases/case.pug","end":{"line":19,"column":22}},"val":"{tim: 'g'}"} +{"type":":","loc":{"start":{"line":19,"column":22},"filename":"/cases/case.pug","end":{"line":19,"column":24}}} +{"type":"tag","loc":{"start":{"line":19,"column":24},"filename":"/cases/case.pug","end":{"line":19,"column":25}},"val":"p"} +{"type":"text","loc":{"start":{"line":19,"column":26},"filename":"/cases/case.pug","end":{"line":19,"column":45}},"val":"Friend is an object"} +{"type":"outdent","loc":{"start":{"line":20,"column":1},"filename":"/cases/case.pug","end":{"line":20,"column":1}}} +{"type":"outdent","loc":{"start":{"line":20,"column":1},"filename":"/cases/case.pug","end":{"line":20,"column":1}}} +{"type":"outdent","loc":{"start":{"line":20,"column":1},"filename":"/cases/case.pug","end":{"line":20,"column":1}}} +{"type":"eos","loc":{"start":{"line":20,"column":1},"filename":"/cases/case.pug","end":{"line":20,"column":1}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/classes-empty.tokens.json b/src/test-data/pug-parser/cases/classes-empty.tokens.json new file mode 100644 index 0000000..1e9fe57 --- /dev/null +++ b/src/test-data/pug-parser/cases/classes-empty.tokens.json @@ -0,0 +1,15 @@ +{"type":"tag","loc":{"start":{"line":1,"column":1},"filename":"/cases/classes-empty.pug","end":{"line":1,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":1,"column":2},"filename":"/cases/classes-empty.pug","end":{"line":1,"column":3}}} +{"type":"attribute","loc":{"start":{"line":1,"column":3},"filename":"/cases/classes-empty.pug","end":{"line":1,"column":11}},"name":"class","mustEscape":true,"val":"''"} +{"type":"end-attributes","loc":{"start":{"line":1,"column":11},"filename":"/cases/classes-empty.pug","end":{"line":1,"column":12}}} +{"type":"newline","loc":{"start":{"line":2,"column":1},"filename":"/cases/classes-empty.pug","end":{"line":2,"column":1}}} +{"type":"tag","loc":{"start":{"line":2,"column":1},"filename":"/cases/classes-empty.pug","end":{"line":2,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":2,"column":2},"filename":"/cases/classes-empty.pug","end":{"line":2,"column":3}}} +{"type":"attribute","loc":{"start":{"line":2,"column":3},"filename":"/cases/classes-empty.pug","end":{"line":2,"column":13}},"name":"class","mustEscape":true,"val":"null"} +{"type":"end-attributes","loc":{"start":{"line":2,"column":13},"filename":"/cases/classes-empty.pug","end":{"line":2,"column":14}}} +{"type":"newline","loc":{"start":{"line":3,"column":1},"filename":"/cases/classes-empty.pug","end":{"line":3,"column":1}}} +{"type":"tag","loc":{"start":{"line":3,"column":1},"filename":"/cases/classes-empty.pug","end":{"line":3,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":3,"column":2},"filename":"/cases/classes-empty.pug","end":{"line":3,"column":3}}} +{"type":"attribute","loc":{"start":{"line":3,"column":3},"filename":"/cases/classes-empty.pug","end":{"line":3,"column":18}},"name":"class","mustEscape":true,"val":"undefined"} +{"type":"end-attributes","loc":{"start":{"line":3,"column":18},"filename":"/cases/classes-empty.pug","end":{"line":3,"column":19}}} +{"type":"eos","loc":{"start":{"line":3,"column":19},"filename":"/cases/classes-empty.pug","end":{"line":3,"column":19}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/classes.tokens.json b/src/test-data/pug-parser/cases/classes.tokens.json new file mode 100644 index 0000000..e5fc7ee --- /dev/null +++ b/src/test-data/pug-parser/cases/classes.tokens.json @@ -0,0 +1,27 @@ +{"type":"tag","loc":{"start":{"line":1,"column":1},"filename":"/cases/classes.pug","end":{"line":1,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":1,"column":2},"filename":"/cases/classes.pug","end":{"line":1,"column":3}}} +{"type":"attribute","loc":{"start":{"line":1,"column":3},"filename":"/cases/classes.pug","end":{"line":1,"column":30}},"name":"class","mustEscape":true,"val":"['foo', 'bar', 'baz']"} +{"type":"end-attributes","loc":{"start":{"line":1,"column":30},"filename":"/cases/classes.pug","end":{"line":1,"column":31}}} +{"type":"newline","loc":{"start":{"line":5,"column":1},"filename":"/cases/classes.pug","end":{"line":5,"column":1}}} +{"type":"tag","loc":{"start":{"line":5,"column":1},"filename":"/cases/classes.pug","end":{"line":5,"column":2}},"val":"a"} +{"type":"class","loc":{"start":{"line":5,"column":2},"filename":"/cases/classes.pug","end":{"line":5,"column":6}},"val":"foo"} +{"type":"start-attributes","loc":{"start":{"line":5,"column":6},"filename":"/cases/classes.pug","end":{"line":5,"column":7}}} +{"type":"attribute","loc":{"start":{"line":5,"column":7},"filename":"/cases/classes.pug","end":{"line":5,"column":18}},"name":"class","mustEscape":true,"val":"'bar'"} +{"type":"end-attributes","loc":{"start":{"line":5,"column":18},"filename":"/cases/classes.pug","end":{"line":5,"column":19}}} +{"type":"class","loc":{"start":{"line":5,"column":19},"filename":"/cases/classes.pug","end":{"line":5,"column":23}},"val":"baz"} +{"type":"newline","loc":{"start":{"line":9,"column":1},"filename":"/cases/classes.pug","end":{"line":9,"column":1}}} +{"type":"tag","loc":{"start":{"line":9,"column":1},"filename":"/cases/classes.pug","end":{"line":9,"column":2}},"val":"a"} +{"type":"class","loc":{"start":{"line":9,"column":2},"filename":"/cases/classes.pug","end":{"line":9,"column":14}},"val":"foo-bar_baz"} +{"type":"newline","loc":{"start":{"line":11,"column":1},"filename":"/cases/classes.pug","end":{"line":11,"column":1}}} +{"type":"tag","loc":{"start":{"line":11,"column":1},"filename":"/cases/classes.pug","end":{"line":11,"column":2}},"val":"a"} +{"type":"start-attributes","loc":{"start":{"line":11,"column":2},"filename":"/cases/classes.pug","end":{"line":11,"column":3}}} +{"type":"attribute","loc":{"start":{"line":11,"column":3},"filename":"/cases/classes.pug","end":{"line":11,"column":43}},"name":"class","mustEscape":true,"val":"{foo: true, bar: false, baz: true}"} +{"type":"end-attributes","loc":{"start":{"line":11,"column":43},"filename":"/cases/classes.pug","end":{"line":11,"column":44}}} +{"type":"newline","loc":{"start":{"line":13,"column":1},"filename":"/cases/classes.pug","end":{"line":13,"column":1}}} +{"type":"tag","loc":{"start":{"line":13,"column":1},"filename":"/cases/classes.pug","end":{"line":13,"column":2}},"val":"a"} +{"type":"class","loc":{"start":{"line":13,"column":2},"filename":"/cases/classes.pug","end":{"line":13,"column":7}},"val":"-foo"} +{"type":"newline","loc":{"start":{"line":14,"column":1},"filename":"/cases/classes.pug","end":{"line":14,"column":1}}} +{"type":"tag","loc":{"start":{"line":14,"column":1},"filename":"/cases/classes.pug","end":{"line":14,"column":2}},"val":"a"} +{"type":"class","loc":{"start":{"line":14,"column":2},"filename":"/cases/classes.pug","end":{"line":14,"column":7}},"val":"3foo"} +{"type":"newline","loc":{"start":{"line":15,"column":1},"filename":"/cases/classes.pug","end":{"line":15,"column":1}}} +{"type":"eos","loc":{"start":{"line":15,"column":1},"filename":"/cases/classes.pug","end":{"line":15,"column":1}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/code.conditionals.tokens.json b/src/test-data/pug-parser/cases/code.conditionals.tokens.json new file mode 100644 index 0000000..9f9d21b --- /dev/null +++ b/src/test-data/pug-parser/cases/code.conditionals.tokens.json @@ -0,0 +1,86 @@ +{"type":"newline","loc":{"start":{"line":2,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":2,"column":1}}} +{"type":"code","loc":{"start":{"line":2,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":2,"column":12}},"val":"if (true)","mustEscape":false,"buffer":false} +{"type":"indent","loc":{"start":{"line":3,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":3,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":3,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":3,"column":4}},"val":"p"} +{"type":"text","loc":{"start":{"line":3,"column":5},"filename":"/cases/code.conditionals.pug","end":{"line":3,"column":8}},"val":"foo"} +{"type":"outdent","loc":{"start":{"line":4,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":4,"column":1}}} +{"type":"code","loc":{"start":{"line":4,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":4,"column":7}},"val":"else","mustEscape":false,"buffer":false} +{"type":"indent","loc":{"start":{"line":5,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":5,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":5,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":5,"column":4}},"val":"p"} +{"type":"text","loc":{"start":{"line":5,"column":5},"filename":"/cases/code.conditionals.pug","end":{"line":5,"column":8}},"val":"bar"} +{"type":"outdent","loc":{"start":{"line":7,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":7,"column":1}}} +{"type":"code","loc":{"start":{"line":7,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":7,"column":14}},"val":"if (true) {","mustEscape":false,"buffer":false} +{"type":"indent","loc":{"start":{"line":8,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":8,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":8,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":8,"column":4}},"val":"p"} +{"type":"text","loc":{"start":{"line":8,"column":5},"filename":"/cases/code.conditionals.pug","end":{"line":8,"column":8}},"val":"foo"} +{"type":"outdent","loc":{"start":{"line":9,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":9,"column":1}}} +{"type":"code","loc":{"start":{"line":9,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":9,"column":11}},"val":"} else {","mustEscape":false,"buffer":false} +{"type":"indent","loc":{"start":{"line":10,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":10,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":10,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":10,"column":4}},"val":"p"} +{"type":"text","loc":{"start":{"line":10,"column":5},"filename":"/cases/code.conditionals.pug","end":{"line":10,"column":8}},"val":"bar"} +{"type":"outdent","loc":{"start":{"line":11,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":11,"column":1}}} +{"type":"code","loc":{"start":{"line":11,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":11,"column":4}},"val":"}","mustEscape":false,"buffer":false} +{"type":"newline","loc":{"start":{"line":13,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":13,"column":1}}} +{"type":"if","loc":{"start":{"line":13,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":13,"column":8}},"val":"true"} +{"type":"indent","loc":{"start":{"line":14,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":14,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":14,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":14,"column":4}},"val":"p"} +{"type":"text","loc":{"start":{"line":14,"column":5},"filename":"/cases/code.conditionals.pug","end":{"line":14,"column":8}},"val":"foo"} +{"type":"newline","loc":{"start":{"line":15,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":15,"column":3}}} +{"type":"tag","loc":{"start":{"line":15,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":15,"column":4}},"val":"p"} +{"type":"text","loc":{"start":{"line":15,"column":5},"filename":"/cases/code.conditionals.pug","end":{"line":15,"column":8}},"val":"bar"} +{"type":"newline","loc":{"start":{"line":16,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":16,"column":3}}} +{"type":"tag","loc":{"start":{"line":16,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":16,"column":4}},"val":"p"} +{"type":"text","loc":{"start":{"line":16,"column":5},"filename":"/cases/code.conditionals.pug","end":{"line":16,"column":8}},"val":"baz"} +{"type":"outdent","loc":{"start":{"line":17,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":17,"column":1}}} +{"type":"else","loc":{"start":{"line":17,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":17,"column":5}},"val":""} +{"type":"indent","loc":{"start":{"line":18,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":18,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":18,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":18,"column":4}},"val":"p"} +{"type":"text","loc":{"start":{"line":18,"column":5},"filename":"/cases/code.conditionals.pug","end":{"line":18,"column":8}},"val":"bar"} +{"type":"outdent","loc":{"start":{"line":20,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":20,"column":1}}} +{"type":"if","loc":{"start":{"line":20,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":20,"column":12}},"val":"!(true)"} +{"type":"indent","loc":{"start":{"line":21,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":21,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":21,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":21,"column":4}},"val":"p"} +{"type":"text","loc":{"start":{"line":21,"column":5},"filename":"/cases/code.conditionals.pug","end":{"line":21,"column":8}},"val":"foo"} +{"type":"outdent","loc":{"start":{"line":22,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":22,"column":1}}} +{"type":"else","loc":{"start":{"line":22,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":22,"column":5}},"val":""} +{"type":"indent","loc":{"start":{"line":23,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":23,"column":3}},"val":2} +{"type":"tag","loc":{"start":{"line":23,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":23,"column":4}},"val":"p"} +{"type":"text","loc":{"start":{"line":23,"column":5},"filename":"/cases/code.conditionals.pug","end":{"line":23,"column":8}},"val":"bar"} +{"type":"outdent","loc":{"start":{"line":25,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":25,"column":1}}} +{"type":"if","loc":{"start":{"line":25,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":25,"column":12}},"val":"'nested'"} +{"type":"indent","loc":{"start":{"line":26,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":26,"column":3}},"val":2} +{"type":"if","loc":{"start":{"line":26,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":26,"column":13}},"val":"'works'"} +{"type":"indent","loc":{"start":{"line":27,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":27,"column":5}},"val":4} +{"type":"tag","loc":{"start":{"line":27,"column":5},"filename":"/cases/code.conditionals.pug","end":{"line":27,"column":6}},"val":"p"} +{"type":"text","loc":{"start":{"line":27,"column":7},"filename":"/cases/code.conditionals.pug","end":{"line":27,"column":10}},"val":"yay"} +{"type":"outdent","loc":{"start":{"line":29,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":29,"column":1}}} +{"type":"outdent","loc":{"start":{"line":29,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":29,"column":1}}} +{"type":"comment","loc":{"start":{"line":29,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":29,"column":23}},"val":" allow empty blocks","buffer":false} +{"type":"newline","loc":{"start":{"line":30,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":30,"column":1}}} +{"type":"if","loc":{"start":{"line":30,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":30,"column":9}},"val":"false"} +{"type":"newline","loc":{"start":{"line":31,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":31,"column":1}}} +{"type":"else","loc":{"start":{"line":31,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":31,"column":5}},"val":""} +{"type":"indent","loc":{"start":{"line":32,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":32,"column":3}},"val":2} +{"type":"class","loc":{"start":{"line":32,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":32,"column":7}},"val":"bar"} +{"type":"outdent","loc":{"start":{"line":33,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":33,"column":1}}} +{"type":"if","loc":{"start":{"line":33,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":33,"column":8}},"val":"true"} +{"type":"indent","loc":{"start":{"line":34,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":34,"column":3}},"val":2} +{"type":"class","loc":{"start":{"line":34,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":34,"column":7}},"val":"bar"} +{"type":"outdent","loc":{"start":{"line":35,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":35,"column":1}}} +{"type":"else","loc":{"start":{"line":35,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":35,"column":5}},"val":""} +{"type":"newline","loc":{"start":{"line":36,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":36,"column":1}}} +{"type":"class","loc":{"start":{"line":36,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":36,"column":6}},"val":"bing"} +{"type":"newline","loc":{"start":{"line":38,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":38,"column":1}}} +{"type":"if","loc":{"start":{"line":38,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":38,"column":9}},"val":"false"} +{"type":"indent","loc":{"start":{"line":39,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":39,"column":3}},"val":2} +{"type":"class","loc":{"start":{"line":39,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":39,"column":8}},"val":"bing"} +{"type":"outdent","loc":{"start":{"line":40,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":40,"column":1}}} +{"type":"else-if","loc":{"start":{"line":40,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":40,"column":14}},"val":"false"} +{"type":"indent","loc":{"start":{"line":41,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":41,"column":3}},"val":2} +{"type":"class","loc":{"start":{"line":41,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":41,"column":7}},"val":"bar"} +{"type":"outdent","loc":{"start":{"line":42,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":42,"column":1}}} +{"type":"else","loc":{"start":{"line":42,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":42,"column":5}},"val":""} +{"type":"indent","loc":{"start":{"line":43,"column":1},"filename":"/cases/code.conditionals.pug","end":{"line":43,"column":3}},"val":2} +{"type":"class","loc":{"start":{"line":43,"column":3},"filename":"/cases/code.conditionals.pug","end":{"line":43,"column":7}},"val":"foo"} +{"type":"outdent","loc":{"start":{"line":43,"column":7},"filename":"/cases/code.conditionals.pug","end":{"line":43,"column":7}}} +{"type":"eos","loc":{"start":{"line":43,"column":7},"filename":"/cases/code.conditionals.pug","end":{"line":43,"column":7}}} \ No newline at end of file diff --git a/src/test-data/pug-parser/cases/code.escape.tokens.json b/src/test-data/pug-parser/cases/code.escape.tokens.json new file mode 100644 index 0000000..45fee43 --- /dev/null +++ b/src/test-data/pug-parser/cases/code.escape.tokens.json @@ -0,0 +1,6 @@ +{"type":"tag","loc":{"start":{"line":1,"column":1},"filename":"/cases/code.escape.pug","end":{"line":1,"column":2}},"val":"p"} +{"type":"code","loc":{"start":{"line":1,"column":2},"filename":"/cases/code.escape.pug","end":{"line":1,"column":14}},"val":"'' +doctype html +html + + head + title= "Some " + "JavaScript" + != js + + + + body \ No newline at end of file diff --git a/src/test-data/pug/test/README.md b/src/test-data/pug/test/README.md new file mode 100644 index 0000000..0989992 --- /dev/null +++ b/src/test-data/pug/test/README.md @@ -0,0 +1,15 @@ +# Running Tests + +To run tests (with node.js installed) you must complete 2 steps. + +## 1 Install dependencies + +``` +npm install +``` + +## 2 Run tests + +``` +npm test +``` diff --git a/src/test-data/pug/test/__snapshots__/pug.test.js.snap b/src/test-data/pug/test/__snapshots__/pug.test.js.snap new file mode 100644 index 0000000..8e3600c --- /dev/null +++ b/src/test-data/pug/test/__snapshots__/pug.test.js.snap @@ -0,0 +1,110 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`pug .compileClient() should support module syntax in pug.compileClient(str, options) when inlineRuntimeFunctions it false 1`] = ` +"var pug = require(\\"pug-runtime\\"); +function template(locals) { + var pug_html = \\"\\", + pug_mixins = {}, + pug_interp; + var pug_debug_filename, pug_debug_line; + try { + var self = locals || {}; + pug_debug_line = 1; + pug_html = pug_html + '\\\\u003Cdiv class=\\"bar\\"\\\\u003E'; + pug_debug_line = 1; + pug_html = + pug_html + + pug.escape(null == (pug_interp = self.foo) ? \\"\\" : pug_interp) + + \\"\\\\u003C\\\\u002Fdiv\\\\u003E\\"; + } catch (err) { + pug.rethrow(err, pug_debug_filename, pug_debug_line); + } + return pug_html; +} +module.exports = template; +" +`; + +exports[`pug .compileClient() should support module syntax in pug.compileClient(str, options) when inlineRuntimeFunctions it true 1`] = ` +"function pug_escape(e) { + var a = \\"\\" + e, + t = pug_match_html.exec(a); + if (!t) return e; + var r, + c, + n, + s = \\"\\"; + for (r = t.index, c = 0; r < a.length; r++) { + switch (a.charCodeAt(r)) { + case 34: + n = \\""\\"; + break; + case 38: + n = \\"&\\"; + break; + case 60: + n = \\"<\\"; + break; + case 62: + n = \\">\\"; + break; + default: + continue; + } + c !== r && (s += a.substring(c, r)), (c = r + 1), (s += n); + } + return c !== r ? s + a.substring(c, r) : s; +} +var pug_match_html = /[\\"&<>]/; +function pug_rethrow(e, n, r, t) { + if (!(e instanceof Error)) throw e; + if (!((\\"undefined\\" == typeof window && n) || t)) + throw ((e.message += \\" on line \\" + r), e); + var o, a, i, s; + try { + (t = t || require(\\"fs\\").readFileSync(n, { encoding: \\"utf8\\" })), + (o = 3), + (a = t.split(\\"\\\\n\\")), + (i = Math.max(r - o, 0)), + (s = Math.min(a.length, r + o)); + } catch (t) { + return ( + (e.message += \\" - could not read from \\" + n + \\" (\\" + t.message + \\")\\"), + void pug_rethrow(e, null, r) + ); + } + (o = a + .slice(i, s) + .map(function(e, n) { + var t = n + i + 1; + return (t == r ? \\" > \\" : \\" \\") + t + \\"| \\" + e; + }) + .join(\\"\\\\n\\")), + (e.path = n); + try { + e.message = (n || \\"Pug\\") + \\":\\" + r + \\"\\\\n\\" + o + \\"\\\\n\\\\n\\" + e.message; + } catch (e) {} + throw e; +} +function template(locals) { + var pug_html = \\"\\", + pug_mixins = {}, + pug_interp; + var pug_debug_filename, pug_debug_line; + try { + var self = locals || {}; + pug_debug_line = 1; + pug_html = pug_html + '\\\\u003Cdiv class=\\"bar\\"\\\\u003E'; + pug_debug_line = 1; + pug_html = + pug_html + + pug_escape(null == (pug_interp = self.foo) ? \\"\\" : pug_interp) + + \\"\\\\u003C\\\\u002Fdiv\\\\u003E\\"; + } catch (err) { + pug_rethrow(err, pug_debug_filename, pug_debug_line); + } + return pug_html; +} +module.exports = template; +" +`; diff --git a/src/test-data/pug/test/anti-cases/attrs.unescaped.pug b/src/test-data/pug/test/anti-cases/attrs.unescaped.pug new file mode 100644 index 0000000..ab47e09 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/attrs.unescaped.pug @@ -0,0 +1,3 @@ +script(type='text/x-template') + #user(id!='user-<%= user.id %>') + h1 <%= user.title %> \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/case-when.pug b/src/test-data/pug/test/anti-cases/case-when.pug new file mode 100644 index 0000000..74977d1 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/case-when.pug @@ -0,0 +1,4 @@ +when 5 + .foo +when 6 + .bar \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/case-without-with.pug b/src/test-data/pug/test/anti-cases/case-without-with.pug new file mode 100644 index 0000000..3cbf016 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/case-without-with.pug @@ -0,0 +1,2 @@ +case foo + .div \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/else-condition.pug b/src/test-data/pug/test/anti-cases/else-condition.pug new file mode 100644 index 0000000..93ff87e --- /dev/null +++ b/src/test-data/pug/test/anti-cases/else-condition.pug @@ -0,0 +1,4 @@ +if foo + div +else bar + article \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/else-without-if.pug b/src/test-data/pug/test/anti-cases/else-without-if.pug new file mode 100644 index 0000000..3062364 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/else-without-if.pug @@ -0,0 +1,2 @@ +else + .foo \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/inlining-a-mixin-after-a-tag.pug b/src/test-data/pug/test/anti-cases/inlining-a-mixin-after-a-tag.pug new file mode 100644 index 0000000..3d01493 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/inlining-a-mixin-after-a-tag.pug @@ -0,0 +1 @@ +foo()+bar() \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/key-char-ending-badly.pug b/src/test-data/pug/test/anti-cases/key-char-ending-badly.pug new file mode 100644 index 0000000..45ca24a --- /dev/null +++ b/src/test-data/pug/test/anti-cases/key-char-ending-badly.pug @@ -0,0 +1 @@ +div("foo"abc) diff --git a/src/test-data/pug/test/anti-cases/key-ending-badly.pug b/src/test-data/pug/test/anti-cases/key-ending-badly.pug new file mode 100644 index 0000000..8e3c305 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/key-ending-badly.pug @@ -0,0 +1 @@ +div(foo!~abc) diff --git a/src/test-data/pug/test/anti-cases/mismatched-inline-tag.pug b/src/test-data/pug/test/anti-cases/mismatched-inline-tag.pug new file mode 100644 index 0000000..35fa2aa --- /dev/null +++ b/src/test-data/pug/test/anti-cases/mismatched-inline-tag.pug @@ -0,0 +1,2 @@ +//- #1871 +p #[strong a} diff --git a/src/test-data/pug/test/anti-cases/mixin-args-syntax-error.pug b/src/test-data/pug/test/anti-cases/mixin-args-syntax-error.pug new file mode 100644 index 0000000..d0b725b --- /dev/null +++ b/src/test-data/pug/test/anti-cases/mixin-args-syntax-error.pug @@ -0,0 +1,2 @@ +mixin foo(a, b) ++foo('a'b'b') diff --git a/src/test-data/pug/test/anti-cases/mixins-blocks-with-bodies.pug b/src/test-data/pug/test/anti-cases/mixins-blocks-with-bodies.pug new file mode 100644 index 0000000..e7e6281 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/mixins-blocks-with-bodies.pug @@ -0,0 +1,3 @@ +mixin foo + block + bar \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/multiple-non-nested-tags-on-a-line.pug b/src/test-data/pug/test/anti-cases/multiple-non-nested-tags-on-a-line.pug new file mode 100644 index 0000000..fc9c884 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/multiple-non-nested-tags-on-a-line.pug @@ -0,0 +1 @@ +foo()bar \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/non-existant-filter.pug b/src/test-data/pug/test/anti-cases/non-existant-filter.pug new file mode 100644 index 0000000..8caa922 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/non-existant-filter.pug @@ -0,0 +1,2 @@ +:not-a-valid-filter + foo bar \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/non-mixin-block.pug b/src/test-data/pug/test/anti-cases/non-mixin-block.pug new file mode 100644 index 0000000..11ff9bc --- /dev/null +++ b/src/test-data/pug/test/anti-cases/non-mixin-block.pug @@ -0,0 +1,2 @@ +div + block \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/open-brace-in-attributes.pug b/src/test-data/pug/test/anti-cases/open-brace-in-attributes.pug new file mode 100644 index 0000000..7b5f21d --- /dev/null +++ b/src/test-data/pug/test/anti-cases/open-brace-in-attributes.pug @@ -0,0 +1 @@ +div(title=[) \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/readme.md b/src/test-data/pug/test/anti-cases/readme.md new file mode 100644 index 0000000..6dae996 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/readme.md @@ -0,0 +1 @@ +This folder collects examples of files that are not valid `pug`, but were at some point accepted by the parser without throwing an error. The tests ensure that all these cases now throw some form of error message (hopefully a helpful one). \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/self-closing-tag-with-block.pug b/src/test-data/pug/test/anti-cases/self-closing-tag-with-block.pug new file mode 100644 index 0000000..b1b0c1d --- /dev/null +++ b/src/test-data/pug/test/anti-cases/self-closing-tag-with-block.pug @@ -0,0 +1,2 @@ +input + | Inputs cannot have content diff --git a/src/test-data/pug/test/anti-cases/self-closing-tag-with-body.pug b/src/test-data/pug/test/anti-cases/self-closing-tag-with-body.pug new file mode 100644 index 0000000..55fbed1 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/self-closing-tag-with-body.pug @@ -0,0 +1 @@ +input Input's can't have content \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/self-closing-tag-with-code.pug b/src/test-data/pug/test/anti-cases/self-closing-tag-with-code.pug new file mode 100644 index 0000000..e836232 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/self-closing-tag-with-code.pug @@ -0,0 +1 @@ +input= 'Inputs cannot have code' diff --git a/src/test-data/pug/test/anti-cases/tabs-and-spaces.pug b/src/test-data/pug/test/anti-cases/tabs-and-spaces.pug new file mode 100644 index 0000000..07868c2 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/tabs-and-spaces.pug @@ -0,0 +1,3 @@ +div + div + article \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/unclosed-interpolated-call.pug b/src/test-data/pug/test/anti-cases/unclosed-interpolated-call.pug new file mode 100644 index 0000000..63d02db --- /dev/null +++ b/src/test-data/pug/test/anti-cases/unclosed-interpolated-call.pug @@ -0,0 +1 @@ ++#{myMixin \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/unclosed-interpolated-tag.pug b/src/test-data/pug/test/anti-cases/unclosed-interpolated-tag.pug new file mode 100644 index 0000000..be66079 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/unclosed-interpolated-tag.pug @@ -0,0 +1,4 @@ +mixin item + block + ++item( Contact \ No newline at end of file diff --git a/src/test-data/pug/test/anti-cases/unclosed-interpolation.pug b/src/test-data/pug/test/anti-cases/unclosed-interpolation.pug new file mode 100644 index 0000000..4698dd9 --- /dev/null +++ b/src/test-data/pug/test/anti-cases/unclosed-interpolation.pug @@ -0,0 +1 @@ +#{myMixin \ No newline at end of file diff --git a/src/test-data/pug/test/browser/index.html b/src/test-data/pug/test/browser/index.html new file mode 100644 index 0000000..d88e5f6 --- /dev/null +++ b/src/test-data/pug/test/browser/index.html @@ -0,0 +1,10 @@ +
\ No newline at end of file diff --git a/src/test-data/pug/test/browser/index.pug b/src/test-data/pug/test/browser/index.pug new file mode 100644 index 0000000..17e7759 --- /dev/null +++ b/src/test-data/pug/test/browser/index.pug @@ -0,0 +1,20 @@ +!!! 5 +html + head + body + textarea#input(placeholder='write pug here', style='width: 100%; min-height: 400px;'). + p + author + != myName + pre(style='background: #ECECEC;width: 100%; min-height: 400px;') + code#output + script(src='../../pug.js') + script. + var input = document.getElementById('input'); + var output = document.getElementById('output'); + setInterval(function () { + pug.render(input.value, {myName: 'Forbes Lindesay', pretty: true}, function (err, res) { + if (err) throw err; + output.textContent = res; + }) + }, 500) \ No newline at end of file diff --git a/src/test-data/pug/test/cases-es2015/attr.html b/src/test-data/pug/test/cases-es2015/attr.html new file mode 100644 index 0000000..fba8cc1 --- /dev/null +++ b/src/test-data/pug/test/cases-es2015/attr.html @@ -0,0 +1 @@ +
diff --git a/src/test-data/pug/test/cases-es2015/attr.pug b/src/test-data/pug/test/cases-es2015/attr.pug new file mode 100644 index 0000000..d19080f --- /dev/null +++ b/src/test-data/pug/test/cases-es2015/attr.pug @@ -0,0 +1,3 @@ +- var avatar = '219b77f9d21de75e81851b6b886057c7' + +div.avatar-div(style=`background-image: url(https://www.gravatar.com/avatar/${avatar})`) diff --git a/src/test-data/pug/test/cases/attrs-data.html b/src/test-data/pug/test/cases/attrs-data.html new file mode 100644 index 0000000..71116d3 --- /dev/null +++ b/src/test-data/pug/test/cases/attrs-data.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/test-data/pug/test/cases/attrs-data.pug b/src/test-data/pug/test/cases/attrs-data.pug new file mode 100644 index 0000000..9e5b4b6 --- /dev/null +++ b/src/test-data/pug/test/cases/attrs-data.pug @@ -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)) diff --git a/src/test-data/pug/test/cases/attrs.colon.html b/src/test-data/pug/test/cases/attrs.colon.html new file mode 100644 index 0000000..f8e02d6 --- /dev/null +++ b/src/test-data/pug/test/cases/attrs.colon.html @@ -0,0 +1 @@ +
Click Me! diff --git a/src/test-data/pug/test/cases/attrs.colon.pug b/src/test-data/pug/test/cases/attrs.colon.pug new file mode 100644 index 0000000..ed7ea7c --- /dev/null +++ b/src/test-data/pug/test/cases/attrs.colon.pug @@ -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! diff --git a/src/test-data/pug/test/cases/attrs.html b/src/test-data/pug/test/cases/attrs.html new file mode 100644 index 0000000..9dcaee5 --- /dev/null +++ b/src/test-data/pug/test/cases/attrs.html @@ -0,0 +1,20 @@ +contactsave + +contactsave + + + + + + + + + + +
diff --git a/src/test-data/pug/test/cases/attrs.js.html b/src/test-data/pug/test/cases/attrs.js.html new file mode 100644 index 0000000..edd3813 --- /dev/null +++ b/src/test-data/pug/test/cases/attrs.js.html @@ -0,0 +1,5 @@ + + + +
+
\ No newline at end of file diff --git a/src/test-data/pug/test/cases/attrs.js.pug b/src/test-data/pug/test/cases/attrs.js.pug new file mode 100644 index 0000000..910c13a --- /dev/null +++ b/src/test-data/pug/test/cases/attrs.js.pug @@ -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'}) diff --git a/src/test-data/pug/test/cases/attrs.pug b/src/test-data/pug/test/cases/attrs.pug new file mode 100644 index 0000000..d4420e3 --- /dev/null +++ b/src/test-data/pug/test/cases/attrs.pug @@ -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: ''} + +div&attributes(attrs) + +a(foo='foo' "bar"="bar") +a(foo='foo' 'bar'='bar') diff --git a/src/test-data/pug/test/cases/attrs.unescaped.html b/src/test-data/pug/test/cases/attrs.unescaped.html new file mode 100644 index 0000000..2c2f3f1 --- /dev/null +++ b/src/test-data/pug/test/cases/attrs.unescaped.html @@ -0,0 +1,5 @@ + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/attrs.unescaped.pug b/src/test-data/pug/test/cases/attrs.unescaped.pug new file mode 100644 index 0000000..36a4e10 --- /dev/null +++ b/src/test-data/pug/test/cases/attrs.unescaped.pug @@ -0,0 +1,3 @@ +script(type='text/x-template') + div(id!='user-<%= user.id %>') + h1 <%= user.title %> \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/1794-extends.pug b/src/test-data/pug/test/cases/auxiliary/1794-extends.pug new file mode 100644 index 0000000..99649d6 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/1794-extends.pug @@ -0,0 +1 @@ +block content \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/1794-include.pug b/src/test-data/pug/test/cases/auxiliary/1794-include.pug new file mode 100644 index 0000000..b9c03b4 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/1794-include.pug @@ -0,0 +1,4 @@ +mixin test() + .test&attributes(attributes) + ++test() \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/blocks-in-blocks-layout.pug b/src/test-data/pug/test/cases/auxiliary/blocks-in-blocks-layout.pug new file mode 100644 index 0000000..17ca8a0 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/blocks-in-blocks-layout.pug @@ -0,0 +1,8 @@ +doctype html +html + head + title Default title + body + block body + .container + block content diff --git a/src/test-data/pug/test/cases/auxiliary/dialog.pug b/src/test-data/pug/test/cases/auxiliary/dialog.pug new file mode 100644 index 0000000..607bdec --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/dialog.pug @@ -0,0 +1,6 @@ + +extends window.pug + +block window-content + .dialog + block content diff --git a/src/test-data/pug/test/cases/auxiliary/empty-block.pug b/src/test-data/pug/test/cases/auxiliary/empty-block.pug new file mode 100644 index 0000000..776e5fe --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/empty-block.pug @@ -0,0 +1,2 @@ +block test + diff --git a/src/test-data/pug/test/cases/auxiliary/escapes.html b/src/test-data/pug/test/cases/auxiliary/escapes.html new file mode 100644 index 0000000..3b414f2 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/escapes.html @@ -0,0 +1,3 @@ + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/extends-empty-block-1.pug b/src/test-data/pug/test/cases/auxiliary/extends-empty-block-1.pug new file mode 100644 index 0000000..2729803 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/extends-empty-block-1.pug @@ -0,0 +1,5 @@ +extends empty-block.pug + +block test + div test1 + diff --git a/src/test-data/pug/test/cases/auxiliary/extends-empty-block-2.pug b/src/test-data/pug/test/cases/auxiliary/extends-empty-block-2.pug new file mode 100644 index 0000000..beb2e83 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/extends-empty-block-2.pug @@ -0,0 +1,5 @@ +extends empty-block.pug + +block test + div test2 + diff --git a/src/test-data/pug/test/cases/auxiliary/extends-from-root.pug b/src/test-data/pug/test/cases/auxiliary/extends-from-root.pug new file mode 100644 index 0000000..da52beb --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/extends-from-root.pug @@ -0,0 +1,4 @@ +extends /auxiliary/layout.pug + +block content + include /auxiliary/include-from-root.pug diff --git a/src/test-data/pug/test/cases/auxiliary/extends-relative.pug b/src/test-data/pug/test/cases/auxiliary/extends-relative.pug new file mode 100644 index 0000000..612879a --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/extends-relative.pug @@ -0,0 +1,4 @@ +extends ../../cases/auxiliary/layout + +block content + include ../../cases/auxiliary/include-from-root diff --git a/src/test-data/pug/test/cases/auxiliary/filter-in-include.pug b/src/test-data/pug/test/cases/auxiliary/filter-in-include.pug new file mode 100644 index 0000000..a3df945 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/filter-in-include.pug @@ -0,0 +1,8 @@ +html + head + style(type="text/css") + :less + @pad: 15px; + body { + padding: @pad; + } diff --git a/src/test-data/pug/test/cases/auxiliary/includable.js b/src/test-data/pug/test/cases/auxiliary/includable.js new file mode 100644 index 0000000..38c071e --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/includable.js @@ -0,0 +1,8 @@ +var STRING_SUBSTITUTIONS = { + // table of character substitutions + '\t': '\\t', + '\r': '\\r', + '\n': '\\n', + '"': '\\"', + '\\': '\\\\', +}; diff --git a/src/test-data/pug/test/cases/auxiliary/include-from-root.pug b/src/test-data/pug/test/cases/auxiliary/include-from-root.pug new file mode 100644 index 0000000..93c364b --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/include-from-root.pug @@ -0,0 +1 @@ +h1 hello \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/inheritance.extend.mixin.block.pug b/src/test-data/pug/test/cases/auxiliary/inheritance.extend.mixin.block.pug new file mode 100644 index 0000000..890febc --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/inheritance.extend.mixin.block.pug @@ -0,0 +1,11 @@ +mixin article() + article + block + +html + head + title My Application + block head + body + +article + block content diff --git a/src/test-data/pug/test/cases/auxiliary/inheritance.extend.recursive-grand-grandparent.pug b/src/test-data/pug/test/cases/auxiliary/inheritance.extend.recursive-grand-grandparent.pug new file mode 100644 index 0000000..61033fa --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/inheritance.extend.recursive-grand-grandparent.pug @@ -0,0 +1,2 @@ +h1 grand-grandparent +block grand-grandparent \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/inheritance.extend.recursive-grandparent.pug b/src/test-data/pug/test/cases/auxiliary/inheritance.extend.recursive-grandparent.pug new file mode 100644 index 0000000..f8ad4b8 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/inheritance.extend.recursive-grandparent.pug @@ -0,0 +1,6 @@ +extends inheritance.extend.recursive-grand-grandparent.pug + +block grand-grandparent + h2 grandparent + block grandparent + diff --git a/src/test-data/pug/test/cases/auxiliary/inheritance.extend.recursive-parent.pug b/src/test-data/pug/test/cases/auxiliary/inheritance.extend.recursive-parent.pug new file mode 100644 index 0000000..72d7230 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/inheritance.extend.recursive-parent.pug @@ -0,0 +1,5 @@ +extends inheritance.extend.recursive-grandparent.pug + +block grandparent + h3 parent + block parent \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/layout.include.pug b/src/test-data/pug/test/cases/auxiliary/layout.include.pug new file mode 100644 index 0000000..96734bf --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/layout.include.pug @@ -0,0 +1,7 @@ +html + head + title My Application + block head + body + block content + include window.pug diff --git a/src/test-data/pug/test/cases/auxiliary/layout.pug b/src/test-data/pug/test/cases/auxiliary/layout.pug new file mode 100644 index 0000000..7d183b3 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/layout.pug @@ -0,0 +1,6 @@ +html + head + title My Application + block head + body + block content \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/mixin-at-end-of-file.pug b/src/test-data/pug/test/cases/auxiliary/mixin-at-end-of-file.pug new file mode 100644 index 0000000..e51eb01 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/mixin-at-end-of-file.pug @@ -0,0 +1,3 @@ +mixin slide + section.slide + block \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/mixins.pug b/src/test-data/pug/test/cases/auxiliary/mixins.pug new file mode 100644 index 0000000..0c14c1d --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/mixins.pug @@ -0,0 +1,3 @@ + +mixin foo() + p bar \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/pet.pug b/src/test-data/pug/test/cases/auxiliary/pet.pug new file mode 100644 index 0000000..ebee3a8 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/pet.pug @@ -0,0 +1,3 @@ +.pet + h1 {{name}} + p {{name}} is a {{species}} that is {{age}} old \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/smile.html b/src/test-data/pug/test/cases/auxiliary/smile.html new file mode 100644 index 0000000..05a0c49 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/smile.html @@ -0,0 +1 @@ +

:)

\ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/window.pug b/src/test-data/pug/test/cases/auxiliary/window.pug new file mode 100644 index 0000000..7ab7132 --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/window.pug @@ -0,0 +1,4 @@ + +.window + a(href='#').close Close + block window-content \ No newline at end of file diff --git a/src/test-data/pug/test/cases/auxiliary/yield-nested.pug b/src/test-data/pug/test/cases/auxiliary/yield-nested.pug new file mode 100644 index 0000000..0771c0a --- /dev/null +++ b/src/test-data/pug/test/cases/auxiliary/yield-nested.pug @@ -0,0 +1,10 @@ +html + head + title + body + h1 Page + #content + #content-wrapper + yield + #footer + stuff \ No newline at end of file diff --git a/src/test-data/pug/test/cases/basic.html b/src/test-data/pug/test/cases/basic.html new file mode 100644 index 0000000..a01532a --- /dev/null +++ b/src/test-data/pug/test/cases/basic.html @@ -0,0 +1,5 @@ + + +

Title

+ + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/basic.pug b/src/test-data/pug/test/cases/basic.pug new file mode 100644 index 0000000..77066d1 --- /dev/null +++ b/src/test-data/pug/test/cases/basic.pug @@ -0,0 +1,3 @@ +html + body + h1 Title \ No newline at end of file diff --git a/src/test-data/pug/test/cases/blanks.html b/src/test-data/pug/test/cases/blanks.html new file mode 100644 index 0000000..d58268c --- /dev/null +++ b/src/test-data/pug/test/cases/blanks.html @@ -0,0 +1,5 @@ +
    +
  • foo
  • +
  • bar
  • +
  • baz
  • +
\ No newline at end of file diff --git a/src/test-data/pug/test/cases/blanks.pug b/src/test-data/pug/test/cases/blanks.pug new file mode 100644 index 0000000..67b0697 --- /dev/null +++ b/src/test-data/pug/test/cases/blanks.pug @@ -0,0 +1,8 @@ + + +ul + li foo + + li bar + + li baz diff --git a/src/test-data/pug/test/cases/block-code.html b/src/test-data/pug/test/cases/block-code.html new file mode 100644 index 0000000..489fe5d --- /dev/null +++ b/src/test-data/pug/test/cases/block-code.html @@ -0,0 +1,7 @@ + +
  • Uno
  • +
  • Dos
  • +
  • Tres
  • +
  • Cuatro
  • +
  • Cinco
  • +
  • Seis
  • diff --git a/src/test-data/pug/test/cases/block-code.pug b/src/test-data/pug/test/cases/block-code.pug new file mode 100644 index 0000000..9ab6854 --- /dev/null +++ b/src/test-data/pug/test/cases/block-code.pug @@ -0,0 +1,12 @@ +- + list = ["uno", "dos", "tres", + "cuatro", "cinco", "seis"]; +//- Without a block, the element is accepted and no code is generated +- +each item in list + - + string = item.charAt(0) + + .toUpperCase() + + item.slice(1); + li= string diff --git a/src/test-data/pug/test/cases/block-expansion.html b/src/test-data/pug/test/cases/block-expansion.html new file mode 100644 index 0000000..3c24259 --- /dev/null +++ b/src/test-data/pug/test/cases/block-expansion.html @@ -0,0 +1,5 @@ + +

    baz

    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/block-expansion.pug b/src/test-data/pug/test/cases/block-expansion.pug new file mode 100644 index 0000000..fb40f9a --- /dev/null +++ b/src/test-data/pug/test/cases/block-expansion.pug @@ -0,0 +1,5 @@ +ul + li: a(href='#') foo + li: a(href='#') bar + +p baz \ No newline at end of file diff --git a/src/test-data/pug/test/cases/block-expansion.shorthands.html b/src/test-data/pug/test/cases/block-expansion.shorthands.html new file mode 100644 index 0000000..96cf0e7 --- /dev/null +++ b/src/test-data/pug/test/cases/block-expansion.shorthands.html @@ -0,0 +1,7 @@ +
      +
    • +
      +
      baz
      +
      +
    • +
    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/block-expansion.shorthands.pug b/src/test-data/pug/test/cases/block-expansion.shorthands.pug new file mode 100644 index 0000000..c52a126 --- /dev/null +++ b/src/test-data/pug/test/cases/block-expansion.shorthands.pug @@ -0,0 +1,2 @@ +ul + li.list-item: .foo: #bar baz \ No newline at end of file diff --git a/src/test-data/pug/test/cases/blockquote.html b/src/test-data/pug/test/cases/blockquote.html new file mode 100644 index 0000000..92b64de --- /dev/null +++ b/src/test-data/pug/test/cases/blockquote.html @@ -0,0 +1,4 @@ +
    +
    Try to define yourself by what you do, and you’ll burnout every time. You are. That is enough. I rest in that.
    +
    from @thefray at 1:43pm on May 10
    +
    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/blockquote.pug b/src/test-data/pug/test/cases/blockquote.pug new file mode 100644 index 0000000..a23b70f --- /dev/null +++ b/src/test-data/pug/test/cases/blockquote.pug @@ -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 \ No newline at end of file diff --git a/src/test-data/pug/test/cases/blocks-in-blocks.html b/src/test-data/pug/test/cases/blocks-in-blocks.html new file mode 100644 index 0000000..d7955ab --- /dev/null +++ b/src/test-data/pug/test/cases/blocks-in-blocks.html @@ -0,0 +1,9 @@ + + + + Default title + + +

    Page 2

    + + diff --git a/src/test-data/pug/test/cases/blocks-in-blocks.pug b/src/test-data/pug/test/cases/blocks-in-blocks.pug new file mode 100644 index 0000000..13077d9 --- /dev/null +++ b/src/test-data/pug/test/cases/blocks-in-blocks.pug @@ -0,0 +1,4 @@ +extends ./auxiliary/blocks-in-blocks-layout.pug + +block body + h1 Page 2 diff --git a/src/test-data/pug/test/cases/blocks-in-if.html b/src/test-data/pug/test/cases/blocks-in-if.html new file mode 100644 index 0000000..c3b9107 --- /dev/null +++ b/src/test-data/pug/test/cases/blocks-in-if.html @@ -0,0 +1 @@ +

    ajax contents

    diff --git a/src/test-data/pug/test/cases/blocks-in-if.pug b/src/test-data/pug/test/cases/blocks-in-if.pug new file mode 100644 index 0000000..e0c6361 --- /dev/null +++ b/src/test-data/pug/test/cases/blocks-in-if.pug @@ -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 diff --git a/src/test-data/pug/test/cases/case-blocks.html b/src/test-data/pug/test/cases/case-blocks.html new file mode 100644 index 0000000..893b07d --- /dev/null +++ b/src/test-data/pug/test/cases/case-blocks.html @@ -0,0 +1,5 @@ + + +

    you have a friend

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/case-blocks.pug b/src/test-data/pug/test/cases/case-blocks.pug new file mode 100644 index 0000000..345cd41 --- /dev/null +++ b/src/test-data/pug/test/cases/case-blocks.pug @@ -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 \ No newline at end of file diff --git a/src/test-data/pug/test/cases/case.html b/src/test-data/pug/test/cases/case.html new file mode 100644 index 0000000..f264fb7 --- /dev/null +++ b/src/test-data/pug/test/cases/case.html @@ -0,0 +1,8 @@ + + + +

    you have a friend

    +

    you have very few friends

    +

    Friend is a string

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/case.pug b/src/test-data/pug/test/cases/case.pug new file mode 100644 index 0000000..0fbe2ef --- /dev/null +++ b/src/test-data/pug/test/cases/case.pug @@ -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 diff --git a/src/test-data/pug/test/cases/classes-empty.html b/src/test-data/pug/test/cases/classes-empty.html new file mode 100644 index 0000000..bcc28a9 --- /dev/null +++ b/src/test-data/pug/test/cases/classes-empty.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/classes-empty.pug b/src/test-data/pug/test/cases/classes-empty.pug new file mode 100644 index 0000000..5e66d84 --- /dev/null +++ b/src/test-data/pug/test/cases/classes-empty.pug @@ -0,0 +1,3 @@ +a(class='') +a(class=null) +a(class=undefined) \ No newline at end of file diff --git a/src/test-data/pug/test/cases/classes.html b/src/test-data/pug/test/cases/classes.html new file mode 100644 index 0000000..07da8c5 --- /dev/null +++ b/src/test-data/pug/test/cases/classes.html @@ -0,0 +1 @@ + diff --git a/src/test-data/pug/test/cases/classes.pug b/src/test-data/pug/test/cases/classes.pug new file mode 100644 index 0000000..67e1a1b --- /dev/null +++ b/src/test-data/pug/test/cases/classes.pug @@ -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}) diff --git a/src/test-data/pug/test/cases/code.conditionals.html b/src/test-data/pug/test/cases/code.conditionals.html new file mode 100644 index 0000000..1370312 --- /dev/null +++ b/src/test-data/pug/test/cases/code.conditionals.html @@ -0,0 +1,11 @@ +

    foo

    +

    foo

    +

    foo

    +

    bar

    +

    baz

    +

    bar

    +

    yay

    +
    +
    +
    +
    diff --git a/src/test-data/pug/test/cases/code.conditionals.pug b/src/test-data/pug/test/cases/code.conditionals.pug new file mode 100644 index 0000000..aa4c715 --- /dev/null +++ b/src/test-data/pug/test/cases/code.conditionals.pug @@ -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 \ No newline at end of file diff --git a/src/test-data/pug/test/cases/code.escape.html b/src/test-data/pug/test/cases/code.escape.html new file mode 100644 index 0000000..c0e1758 --- /dev/null +++ b/src/test-data/pug/test/cases/code.escape.html @@ -0,0 +1,2 @@ +

    <script>

    +

    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/escape-chars.pug b/src/test-data/pug/test/cases/escape-chars.pug new file mode 100644 index 0000000..f7978d6 --- /dev/null +++ b/src/test-data/pug/test/cases/escape-chars.pug @@ -0,0 +1,2 @@ +script. + var re = /\d+/; \ No newline at end of file diff --git a/src/test-data/pug/test/cases/escape-test.html b/src/test-data/pug/test/cases/escape-test.html new file mode 100644 index 0000000..15e72d9 --- /dev/null +++ b/src/test-data/pug/test/cases/escape-test.html @@ -0,0 +1,9 @@ + + + + escape-test + + + + + diff --git a/src/test-data/pug/test/cases/escape-test.pug b/src/test-data/pug/test/cases/escape-test.pug new file mode 100644 index 0000000..168c549 --- /dev/null +++ b/src/test-data/pug/test/cases/escape-test.pug @@ -0,0 +1,8 @@ +doctype html +html + head + title escape-test + body + textarea + - var txt = '' + | #{txt} diff --git a/src/test-data/pug/test/cases/escaping-class-attribute.html b/src/test-data/pug/test/cases/escaping-class-attribute.html new file mode 100644 index 0000000..9563642 --- /dev/null +++ b/src/test-data/pug/test/cases/escaping-class-attribute.html @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/escaping-class-attribute.pug b/src/test-data/pug/test/cases/escaping-class-attribute.pug new file mode 100644 index 0000000..dffbb8b --- /dev/null +++ b/src/test-data/pug/test/cases/escaping-class-attribute.pug @@ -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 %>") diff --git a/src/test-data/pug/test/cases/filter-in-include.html b/src/test-data/pug/test/cases/filter-in-include.html new file mode 100644 index 0000000..b6b5636 --- /dev/null +++ b/src/test-data/pug/test/cases/filter-in-include.html @@ -0,0 +1,7 @@ + + + + diff --git a/src/test-data/pug/test/cases/filter-in-include.pug b/src/test-data/pug/test/cases/filter-in-include.pug new file mode 100644 index 0000000..dce48fa --- /dev/null +++ b/src/test-data/pug/test/cases/filter-in-include.pug @@ -0,0 +1 @@ +include ./auxiliary/filter-in-include.pug diff --git a/src/test-data/pug/test/cases/filters-empty.html b/src/test-data/pug/test/cases/filters-empty.html new file mode 100644 index 0000000..9ad128f --- /dev/null +++ b/src/test-data/pug/test/cases/filters-empty.html @@ -0,0 +1,4 @@ + + + + diff --git a/src/test-data/pug/test/cases/filters-empty.pug b/src/test-data/pug/test/cases/filters-empty.pug new file mode 100644 index 0000000..7aa64de --- /dev/null +++ b/src/test-data/pug/test/cases/filters-empty.pug @@ -0,0 +1,6 @@ +- var users = [{ name: 'tobi', age: 2 }] + +fb:users + for user in users + fb:user(age=user.age) + :cdata diff --git a/src/test-data/pug/test/cases/filters.coffeescript.html b/src/test-data/pug/test/cases/filters.coffeescript.html new file mode 100644 index 0000000..7394061 --- /dev/null +++ b/src/test-data/pug/test/cases/filters.coffeescript.html @@ -0,0 +1,9 @@ + diff --git a/src/test-data/pug/test/cases/filters.coffeescript.pug b/src/test-data/pug/test/cases/filters.coffeescript.pug new file mode 100644 index 0000000..f2be6f8 --- /dev/null +++ b/src/test-data/pug/test/cases/filters.coffeescript.pug @@ -0,0 +1,6 @@ +script(type='text/javascript') + :coffee-script + regexp = /\n/ + :coffee-script(minify=true) + math = + square: (value) -> value * value diff --git a/src/test-data/pug/test/cases/filters.custom.html b/src/test-data/pug/test/cases/filters.custom.html new file mode 100644 index 0000000..811701c --- /dev/null +++ b/src/test-data/pug/test/cases/filters.custom.html @@ -0,0 +1,8 @@ + + + BEGINLine 1 +Line 2 + +Line 4END + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/filters.custom.pug b/src/test-data/pug/test/cases/filters.custom.pug new file mode 100644 index 0000000..16808f6 --- /dev/null +++ b/src/test-data/pug/test/cases/filters.custom.pug @@ -0,0 +1,7 @@ +html + body + :custom(opt='val' num=2) + Line 1 + Line 2 + + Line 4 diff --git a/src/test-data/pug/test/cases/filters.include.custom.html b/src/test-data/pug/test/cases/filters.include.custom.html new file mode 100644 index 0000000..05169e5 --- /dev/null +++ b/src/test-data/pug/test/cases/filters.include.custom.html @@ -0,0 +1,10 @@ + + + +

    BEGINhtml
    +  body
    +    pre
    +      include:custom(opt='val' num=2) filters.include.custom.pug
    +END
    + + diff --git a/src/test-data/pug/test/cases/filters.include.custom.pug b/src/test-data/pug/test/cases/filters.include.custom.pug new file mode 100644 index 0000000..5811147 --- /dev/null +++ b/src/test-data/pug/test/cases/filters.include.custom.pug @@ -0,0 +1,4 @@ +html + body + pre + include:custom(opt='val' num=2) filters.include.custom.pug diff --git a/src/test-data/pug/test/cases/filters.include.html b/src/test-data/pug/test/cases/filters.include.html new file mode 100644 index 0000000..1dc755f --- /dev/null +++ b/src/test-data/pug/test/cases/filters.include.html @@ -0,0 +1,19 @@ + +

    Just some markdown tests.

    +

    With new line.

    + + + + + diff --git a/src/test-data/pug/test/cases/filters.include.pug b/src/test-data/pug/test/cases/filters.include.pug new file mode 100644 index 0000000..e7ea3db --- /dev/null +++ b/src/test-data/pug/test/cases/filters.include.pug @@ -0,0 +1,7 @@ +html + body + include:markdown-it some.md + script + include:coffee-script(minify=true) include-filter-coffee.coffee + script + include:coffee-script(minify=false) include-filter-coffee.coffee diff --git a/src/test-data/pug/test/cases/filters.inline.html b/src/test-data/pug/test/cases/filters.inline.html new file mode 100644 index 0000000..e602ebd --- /dev/null +++ b/src/test-data/pug/test/cases/filters.inline.html @@ -0,0 +1,3 @@ + +

    + before after

    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/filters.inline.pug b/src/test-data/pug/test/cases/filters.inline.pug new file mode 100644 index 0000000..7b57985 --- /dev/null +++ b/src/test-data/pug/test/cases/filters.inline.pug @@ -0,0 +1 @@ +p before #[:cdata inside] after \ No newline at end of file diff --git a/src/test-data/pug/test/cases/filters.less.html b/src/test-data/pug/test/cases/filters.less.html new file mode 100644 index 0000000..5cdb913 --- /dev/null +++ b/src/test-data/pug/test/cases/filters.less.html @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/filters.less.pug b/src/test-data/pug/test/cases/filters.less.pug new file mode 100644 index 0000000..a3df945 --- /dev/null +++ b/src/test-data/pug/test/cases/filters.less.pug @@ -0,0 +1,8 @@ +html + head + style(type="text/css") + :less + @pad: 15px; + body { + padding: @pad; + } diff --git a/src/test-data/pug/test/cases/filters.markdown.html b/src/test-data/pug/test/cases/filters.markdown.html new file mode 100644 index 0000000..aa3d975 --- /dev/null +++ b/src/test-data/pug/test/cases/filters.markdown.html @@ -0,0 +1,5 @@ + +

    This is some awesome markdown +whoop.

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/filters.markdown.pug b/src/test-data/pug/test/cases/filters.markdown.pug new file mode 100644 index 0000000..30b1e4f --- /dev/null +++ b/src/test-data/pug/test/cases/filters.markdown.pug @@ -0,0 +1,5 @@ +html + body + :markdown + This is _some_ awesome **markdown** + whoop. diff --git a/src/test-data/pug/test/cases/filters.nested.html b/src/test-data/pug/test/cases/filters.nested.html new file mode 100644 index 0000000..a5a2af3 --- /dev/null +++ b/src/test-data/pug/test/cases/filters.nested.html @@ -0,0 +1,2 @@ + + diff --git a/src/test-data/pug/test/cases/filters.nested.pug b/src/test-data/pug/test/cases/filters.nested.pug new file mode 100644 index 0000000..c79ccdd --- /dev/null +++ b/src/test-data/pug/test/cases/filters.nested.pug @@ -0,0 +1,10 @@ +script + :cdata:uglify-js + (function() { + console.log('test') + })() +script + :cdata:uglify-js:coffee-script + (-> + console.log 'test' + )() diff --git a/src/test-data/pug/test/cases/filters.stylus.html b/src/test-data/pug/test/cases/filters.stylus.html new file mode 100644 index 0000000..d131a14 --- /dev/null +++ b/src/test-data/pug/test/cases/filters.stylus.html @@ -0,0 +1,8 @@ + + + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/filters.stylus.pug b/src/test-data/pug/test/cases/filters.stylus.pug new file mode 100644 index 0000000..323d29c --- /dev/null +++ b/src/test-data/pug/test/cases/filters.stylus.pug @@ -0,0 +1,7 @@ +html + head + style(type="text/css") + :stylus + body + padding: 50px + body diff --git a/src/test-data/pug/test/cases/html.html b/src/test-data/pug/test/cases/html.html new file mode 100644 index 0000000..a038efd --- /dev/null +++ b/src/test-data/pug/test/cases/html.html @@ -0,0 +1,9 @@ +
      +
    • foo
    • +
    • bar
    • +
    • baz
    • +
    + + +

    You can embed html as well.

    +

    Even as the body of a block expansion.

    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/html.pug b/src/test-data/pug/test/cases/html.pug new file mode 100644 index 0000000..0e5422d --- /dev/null +++ b/src/test-data/pug/test/cases/html.pug @@ -0,0 +1,13 @@ +- var version = 1449104952939 + +
      +
    • foo
    • +
    • bar
    • +
    • baz
    • +
    + + + + +p You can embed html as well. +p: Even as the body of a block expansion. diff --git a/src/test-data/pug/test/cases/html5.html b/src/test-data/pug/test/cases/html5.html new file mode 100644 index 0000000..83a553a --- /dev/null +++ b/src/test-data/pug/test/cases/html5.html @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/html5.pug b/src/test-data/pug/test/cases/html5.pug new file mode 100644 index 0000000..8dc68e2 --- /dev/null +++ b/src/test-data/pug/test/cases/html5.pug @@ -0,0 +1,4 @@ +doctype html +input(type='checkbox', checked) +input(type='checkbox', checked=true) +input(type='checkbox', checked=false) \ No newline at end of file diff --git a/src/test-data/pug/test/cases/include-extends-from-root.html b/src/test-data/pug/test/cases/include-extends-from-root.html new file mode 100644 index 0000000..3916f5d --- /dev/null +++ b/src/test-data/pug/test/cases/include-extends-from-root.html @@ -0,0 +1,8 @@ + + + My Application + + +

    hello

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/include-extends-from-root.pug b/src/test-data/pug/test/cases/include-extends-from-root.pug new file mode 100644 index 0000000..a79a57d --- /dev/null +++ b/src/test-data/pug/test/cases/include-extends-from-root.pug @@ -0,0 +1 @@ +include /auxiliary/extends-from-root.pug diff --git a/src/test-data/pug/test/cases/include-extends-of-common-template.html b/src/test-data/pug/test/cases/include-extends-of-common-template.html new file mode 100644 index 0000000..dd04738 --- /dev/null +++ b/src/test-data/pug/test/cases/include-extends-of-common-template.html @@ -0,0 +1,2 @@ +
    test1
    +
    test2
    diff --git a/src/test-data/pug/test/cases/include-extends-of-common-template.pug b/src/test-data/pug/test/cases/include-extends-of-common-template.pug new file mode 100644 index 0000000..2511f52 --- /dev/null +++ b/src/test-data/pug/test/cases/include-extends-of-common-template.pug @@ -0,0 +1,2 @@ +include auxiliary/extends-empty-block-1.pug +include auxiliary/extends-empty-block-2.pug diff --git a/src/test-data/pug/test/cases/include-extends-relative.html b/src/test-data/pug/test/cases/include-extends-relative.html new file mode 100644 index 0000000..3916f5d --- /dev/null +++ b/src/test-data/pug/test/cases/include-extends-relative.html @@ -0,0 +1,8 @@ + + + My Application + + +

    hello

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/include-extends-relative.pug b/src/test-data/pug/test/cases/include-extends-relative.pug new file mode 100644 index 0000000..1b5238c --- /dev/null +++ b/src/test-data/pug/test/cases/include-extends-relative.pug @@ -0,0 +1 @@ +include ../cases/auxiliary/extends-relative.pug diff --git a/src/test-data/pug/test/cases/include-filter-coffee.coffee b/src/test-data/pug/test/cases/include-filter-coffee.coffee new file mode 100644 index 0000000..9723cd7 --- /dev/null +++ b/src/test-data/pug/test/cases/include-filter-coffee.coffee @@ -0,0 +1,2 @@ +math = + square: (value) -> value * value diff --git a/src/test-data/pug/test/cases/include-only-text-body.html b/src/test-data/pug/test/cases/include-only-text-body.html new file mode 100644 index 0000000..f86b593 --- /dev/null +++ b/src/test-data/pug/test/cases/include-only-text-body.html @@ -0,0 +1 @@ +The message is "" \ No newline at end of file diff --git a/src/test-data/pug/test/cases/include-only-text-body.pug b/src/test-data/pug/test/cases/include-only-text-body.pug new file mode 100644 index 0000000..fdb080c --- /dev/null +++ b/src/test-data/pug/test/cases/include-only-text-body.pug @@ -0,0 +1,3 @@ +| The message is " +yield +| " diff --git a/src/test-data/pug/test/cases/include-only-text.html b/src/test-data/pug/test/cases/include-only-text.html new file mode 100644 index 0000000..6936ae4 --- /dev/null +++ b/src/test-data/pug/test/cases/include-only-text.html @@ -0,0 +1,5 @@ + + +

    The message is "hello world"

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/include-only-text.pug b/src/test-data/pug/test/cases/include-only-text.pug new file mode 100644 index 0000000..ede4f0f --- /dev/null +++ b/src/test-data/pug/test/cases/include-only-text.pug @@ -0,0 +1,5 @@ +html + body + p + include include-only-text-body.pug + em hello world diff --git a/src/test-data/pug/test/cases/include-with-text-head.html b/src/test-data/pug/test/cases/include-with-text-head.html new file mode 100644 index 0000000..716f359 --- /dev/null +++ b/src/test-data/pug/test/cases/include-with-text-head.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/include-with-text-head.pug b/src/test-data/pug/test/cases/include-with-text-head.pug new file mode 100644 index 0000000..4e670c0 --- /dev/null +++ b/src/test-data/pug/test/cases/include-with-text-head.pug @@ -0,0 +1,3 @@ +head + script(type='text/javascript'). + alert('hello world'); diff --git a/src/test-data/pug/test/cases/include-with-text.html b/src/test-data/pug/test/cases/include-with-text.html new file mode 100644 index 0000000..78386f7 --- /dev/null +++ b/src/test-data/pug/test/cases/include-with-text.html @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/include-with-text.pug b/src/test-data/pug/test/cases/include-with-text.pug new file mode 100644 index 0000000..bc83ea5 --- /dev/null +++ b/src/test-data/pug/test/cases/include-with-text.pug @@ -0,0 +1,4 @@ +html + include include-with-text-head.pug + script(src='/caustic.js') + script(src='/app.js') diff --git a/src/test-data/pug/test/cases/include.script.html b/src/test-data/pug/test/cases/include.script.html new file mode 100644 index 0000000..cdd37c2 --- /dev/null +++ b/src/test-data/pug/test/cases/include.script.html @@ -0,0 +1,6 @@ + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/include.script.pug b/src/test-data/pug/test/cases/include.script.pug new file mode 100644 index 0000000..f449144 --- /dev/null +++ b/src/test-data/pug/test/cases/include.script.pug @@ -0,0 +1,2 @@ +script#pet-template(type='text/x-template') + include auxiliary/pet.pug diff --git a/src/test-data/pug/test/cases/include.yield.nested.html b/src/test-data/pug/test/cases/include.yield.nested.html new file mode 100644 index 0000000..947b615 --- /dev/null +++ b/src/test-data/pug/test/cases/include.yield.nested.html @@ -0,0 +1,17 @@ + + + + + +

    Page

    +
    +
    +

    some content

    +

    and some more

    +
    +
    + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/include.yield.nested.pug b/src/test-data/pug/test/cases/include.yield.nested.pug new file mode 100644 index 0000000..f4a7d69 --- /dev/null +++ b/src/test-data/pug/test/cases/include.yield.nested.pug @@ -0,0 +1,4 @@ + +include auxiliary/yield-nested.pug + p some content + p and some more diff --git a/src/test-data/pug/test/cases/includes-with-ext-js.html b/src/test-data/pug/test/cases/includes-with-ext-js.html new file mode 100644 index 0000000..26c9184 --- /dev/null +++ b/src/test-data/pug/test/cases/includes-with-ext-js.html @@ -0,0 +1,2 @@ +
    var x = '\n here is some \n new lined text';
    +
    diff --git a/src/test-data/pug/test/cases/includes-with-ext-js.pug b/src/test-data/pug/test/cases/includes-with-ext-js.pug new file mode 100644 index 0000000..65bfa8a --- /dev/null +++ b/src/test-data/pug/test/cases/includes-with-ext-js.pug @@ -0,0 +1,3 @@ +pre + code + include javascript-new-lines.js diff --git a/src/test-data/pug/test/cases/includes.html b/src/test-data/pug/test/cases/includes.html new file mode 100644 index 0000000..eb61d5c --- /dev/null +++ b/src/test-data/pug/test/cases/includes.html @@ -0,0 +1,18 @@ +

    bar

    + +

    :)

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/includes.pug b/src/test-data/pug/test/cases/includes.pug new file mode 100644 index 0000000..7761ce2 --- /dev/null +++ b/src/test-data/pug/test/cases/includes.pug @@ -0,0 +1,10 @@ + +include auxiliary/mixins.pug + ++foo + +body + include auxiliary/smile.html + include auxiliary/escapes.html + script(type="text/javascript") + include:verbatim auxiliary/includable.js diff --git a/src/test-data/pug/test/cases/inheritance.alert-dialog.html b/src/test-data/pug/test/cases/inheritance.alert-dialog.html new file mode 100644 index 0000000..88a5dc6 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.alert-dialog.html @@ -0,0 +1,6 @@ +
    Close +
    +

    Alert!

    +

    I'm an alert!

    +
    +
    diff --git a/src/test-data/pug/test/cases/inheritance.alert-dialog.pug b/src/test-data/pug/test/cases/inheritance.alert-dialog.pug new file mode 100644 index 0000000..7afcaf0 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.alert-dialog.pug @@ -0,0 +1,6 @@ + +extends auxiliary/dialog.pug + +block content + h1 Alert! + p I'm an alert! diff --git a/src/test-data/pug/test/cases/inheritance.defaults.html b/src/test-data/pug/test/cases/inheritance.defaults.html new file mode 100644 index 0000000..e6878d1 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.defaults.html @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/inheritance.defaults.pug b/src/test-data/pug/test/cases/inheritance.defaults.pug new file mode 100644 index 0000000..aaead83 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.defaults.pug @@ -0,0 +1,6 @@ +html + head + block head + script(src='jquery.js') + script(src='keymaster.js') + script(src='caustic.js') \ No newline at end of file diff --git a/src/test-data/pug/test/cases/inheritance.extend.html b/src/test-data/pug/test/cases/inheritance.extend.html new file mode 100644 index 0000000..1f4eae4 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.html @@ -0,0 +1,10 @@ + + + My Application + + + +

    Page

    +

    Some content

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/inheritance.extend.include.html b/src/test-data/pug/test/cases/inheritance.extend.include.html new file mode 100644 index 0000000..66da1cc --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.include.html @@ -0,0 +1,14 @@ + + + My Application + + + +

    Page

    +

    Some content

    +
    Close +

    Awesome

    +

    Now we can extend included blocks!

    +
    + + diff --git a/src/test-data/pug/test/cases/inheritance.extend.include.pug b/src/test-data/pug/test/cases/inheritance.extend.include.pug new file mode 100644 index 0000000..b67dfc3 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.include.pug @@ -0,0 +1,13 @@ + +extend auxiliary/layout.include.pug + +block head + script(src='jquery.js') + +block content + h2 Page + p Some content + +block window-content + h2 Awesome + p Now we can extend included blocks! diff --git a/src/test-data/pug/test/cases/inheritance.extend.mixins.block.html b/src/test-data/pug/test/cases/inheritance.extend.mixins.block.html new file mode 100644 index 0000000..0ea5d94 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.mixins.block.html @@ -0,0 +1,10 @@ + + + My Application + + +
    +

    Hello World!

    +
    + + diff --git a/src/test-data/pug/test/cases/inheritance.extend.mixins.block.pug b/src/test-data/pug/test/cases/inheritance.extend.mixins.block.pug new file mode 100644 index 0000000..775a5dc --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.mixins.block.pug @@ -0,0 +1,4 @@ +extend auxiliary/inheritance.extend.mixin.block.pug + +block content + p Hello World! diff --git a/src/test-data/pug/test/cases/inheritance.extend.mixins.html b/src/test-data/pug/test/cases/inheritance.extend.mixins.html new file mode 100644 index 0000000..618e2b1 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.mixins.html @@ -0,0 +1,9 @@ + + + My Application + + +

    The meaning of life

    +

    Foo bar baz!

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/inheritance.extend.mixins.pug b/src/test-data/pug/test/cases/inheritance.extend.mixins.pug new file mode 100644 index 0000000..ceaa412 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.mixins.pug @@ -0,0 +1,11 @@ + +extend auxiliary/layout.pug + +mixin article(title) + if title + h1= title + block + +block content + +article("The meaning of life") + p Foo bar baz! diff --git a/src/test-data/pug/test/cases/inheritance.extend.pug b/src/test-data/pug/test/cases/inheritance.extend.pug new file mode 100644 index 0000000..4ce3a6f --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.pug @@ -0,0 +1,9 @@ + +extend auxiliary/layout.pug + +block head + script(src='jquery.js') + +block content + h2 Page + p Some content diff --git a/src/test-data/pug/test/cases/inheritance.extend.recursive.html b/src/test-data/pug/test/cases/inheritance.extend.recursive.html new file mode 100644 index 0000000..d5d0522 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.recursive.html @@ -0,0 +1,4 @@ +

    grand-grandparent

    +

    grandparent

    +

    parent

    +

    child

    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/inheritance.extend.recursive.pug b/src/test-data/pug/test/cases/inheritance.extend.recursive.pug new file mode 100644 index 0000000..5842523 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.recursive.pug @@ -0,0 +1,4 @@ +extends /auxiliary/inheritance.extend.recursive-parent.pug + +block parent + h4 child \ No newline at end of file diff --git a/src/test-data/pug/test/cases/inheritance.extend.whitespace.html b/src/test-data/pug/test/cases/inheritance.extend.whitespace.html new file mode 100644 index 0000000..1f4eae4 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.whitespace.html @@ -0,0 +1,10 @@ + + + My Application + + + +

    Page

    +

    Some content

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/inheritance.extend.whitespace.pug b/src/test-data/pug/test/cases/inheritance.extend.whitespace.pug new file mode 100644 index 0000000..25ee9e0 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.extend.whitespace.pug @@ -0,0 +1,13 @@ + +extend auxiliary/layout.pug + +block head + + script(src='jquery.js') + +block content + + + + h2 Page + p Some content diff --git a/src/test-data/pug/test/cases/inheritance.html b/src/test-data/pug/test/cases/inheritance.html new file mode 100644 index 0000000..1f4eae4 --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.html @@ -0,0 +1,10 @@ + + + My Application + + + +

    Page

    +

    Some content

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/inheritance.pug b/src/test-data/pug/test/cases/inheritance.pug new file mode 100644 index 0000000..dd5415d --- /dev/null +++ b/src/test-data/pug/test/cases/inheritance.pug @@ -0,0 +1,9 @@ + +extends auxiliary/layout.pug + +block head + script(src='jquery.js') + +block content + h2 Page + p Some content diff --git a/src/test-data/pug/test/cases/inline-tag.html b/src/test-data/pug/test/cases/inline-tag.html new file mode 100644 index 0000000..7ea3af7 --- /dev/null +++ b/src/test-data/pug/test/cases/inline-tag.html @@ -0,0 +1,21 @@ + +

    bing foo bong

    +

    + bing + foo + [foo] + + bong + +

    +

    + bing + foo + [foo] + + bong +

    +

    + #[strong escaped] + #[escaped +

    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/inline-tag.pug b/src/test-data/pug/test/cases/inline-tag.pug new file mode 100644 index 0000000..df7b549 --- /dev/null +++ b/src/test-data/pug/test/cases/inline-tag.pug @@ -0,0 +1,19 @@ +p bing #[strong foo] bong + +p. + bing + #[strong foo] + #[strong= '[foo]'] + #[- var foo = 'foo]'] + bong + +p + | bing + | #[strong foo] + | #[strong= '[foo]'] + | #[- var foo = 'foo]'] + | bong + +p. + \#[strong escaped] + \#[#[strong escaped] diff --git a/src/test-data/pug/test/cases/intepolated-elements.html b/src/test-data/pug/test/cases/intepolated-elements.html new file mode 100644 index 0000000..721fa02 --- /dev/null +++ b/src/test-data/pug/test/cases/intepolated-elements.html @@ -0,0 +1,4 @@ + +

    with inline link

    +

    Some text

    +

    Some text with inline link

    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/intepolated-elements.pug b/src/test-data/pug/test/cases/intepolated-elements.pug new file mode 100644 index 0000000..5fe8bcf --- /dev/null +++ b/src/test-data/pug/test/cases/intepolated-elements.pug @@ -0,0 +1,3 @@ +p #[a.rho(href='#', class='rho--modifier') with inline link] +p Some text #[a.rho(href='#', class='rho--modifier')] +p Some text #[a.rho(href='#', class='rho--modifier') with inline link] \ No newline at end of file diff --git a/src/test-data/pug/test/cases/interpolated-mixin.html b/src/test-data/pug/test/cases/interpolated-mixin.html new file mode 100644 index 0000000..101aa95 --- /dev/null +++ b/src/test-data/pug/test/cases/interpolated-mixin.html @@ -0,0 +1,3 @@ + +

    This also works http://www.bing.com so hurrah for Pug +

    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/interpolated-mixin.pug b/src/test-data/pug/test/cases/interpolated-mixin.pug new file mode 100644 index 0000000..ae8fc74 --- /dev/null +++ b/src/test-data/pug/test/cases/interpolated-mixin.pug @@ -0,0 +1,4 @@ +mixin linkit(url) + a(href=url)= url + +p This also works #[+linkit('http://www.bing.com')] so hurrah for Pug \ No newline at end of file diff --git a/src/test-data/pug/test/cases/interpolation.escape.html b/src/test-data/pug/test/cases/interpolation.escape.html new file mode 100644 index 0000000..8dd546b --- /dev/null +++ b/src/test-data/pug/test/cases/interpolation.escape.html @@ -0,0 +1,6 @@ + + some + #{text} + here + My ID is {42} + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/interpolation.escape.pug b/src/test-data/pug/test/cases/interpolation.escape.pug new file mode 100644 index 0000000..cff251b --- /dev/null +++ b/src/test-data/pug/test/cases/interpolation.escape.pug @@ -0,0 +1,7 @@ + +- var id = 42; +foo + | some + | \#{text} + | here + | My ID #{"is {" + id + "}"} \ No newline at end of file diff --git a/src/test-data/pug/test/cases/javascript-new-lines.js b/src/test-data/pug/test/cases/javascript-new-lines.js new file mode 100644 index 0000000..bb0c26f --- /dev/null +++ b/src/test-data/pug/test/cases/javascript-new-lines.js @@ -0,0 +1 @@ +var x = '\n here is some \n new lined text'; diff --git a/src/test-data/pug/test/cases/layout.append.html b/src/test-data/pug/test/cases/layout.append.html new file mode 100644 index 0000000..bc5e126 --- /dev/null +++ b/src/test-data/pug/test/cases/layout.append.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test-data/pug/test/cases/layout.append.pug b/src/test-data/pug/test/cases/layout.append.pug new file mode 100644 index 0000000..d771bc9 --- /dev/null +++ b/src/test-data/pug/test/cases/layout.append.pug @@ -0,0 +1,6 @@ + +extends ../fixtures/append/app-layout.pug + +block append head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug/test/cases/layout.append.without-block.html b/src/test-data/pug/test/cases/layout.append.without-block.html new file mode 100644 index 0000000..bc5e126 --- /dev/null +++ b/src/test-data/pug/test/cases/layout.append.without-block.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test-data/pug/test/cases/layout.append.without-block.pug b/src/test-data/pug/test/cases/layout.append.without-block.pug new file mode 100644 index 0000000..19842fc --- /dev/null +++ b/src/test-data/pug/test/cases/layout.append.without-block.pug @@ -0,0 +1,6 @@ + +extends ../fixtures/append-without-block/app-layout.pug + +append head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug/test/cases/layout.multi.append.prepend.block.html b/src/test-data/pug/test/cases/layout.multi.append.prepend.block.html new file mode 100644 index 0000000..314c2b3 --- /dev/null +++ b/src/test-data/pug/test/cases/layout.multi.append.prepend.block.html @@ -0,0 +1,8 @@ +

    Last prepend must appear at top

    +

    Something prepended to content

    +
    Defined content
    +

    Something appended to content

    +

    Last append must be most last

    + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/layout.multi.append.prepend.block.pug b/src/test-data/pug/test/cases/layout.multi.append.prepend.block.pug new file mode 100644 index 0000000..79d15b1 --- /dev/null +++ b/src/test-data/pug/test/cases/layout.multi.append.prepend.block.pug @@ -0,0 +1,19 @@ +extends ../fixtures/multi-append-prepend-block/redefine.pug + +append content + p.first.append Something appended to content + +prepend content + p.first.prepend Something prepended to content + +append content + p.last.append Last append must be most last + +prepend content + p.last.prepend Last prepend must appear at top + +append head + script(src='jquery.js') + +prepend head + script(src='foo.js') diff --git a/src/test-data/pug/test/cases/layout.prepend.html b/src/test-data/pug/test/cases/layout.prepend.html new file mode 100644 index 0000000..8753a42 --- /dev/null +++ b/src/test-data/pug/test/cases/layout.prepend.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test-data/pug/test/cases/layout.prepend.pug b/src/test-data/pug/test/cases/layout.prepend.pug new file mode 100644 index 0000000..4659a11 --- /dev/null +++ b/src/test-data/pug/test/cases/layout.prepend.pug @@ -0,0 +1,6 @@ + +extends ../fixtures/prepend/app-layout.pug + +block prepend head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug/test/cases/layout.prepend.without-block.html b/src/test-data/pug/test/cases/layout.prepend.without-block.html new file mode 100644 index 0000000..8753a42 --- /dev/null +++ b/src/test-data/pug/test/cases/layout.prepend.without-block.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test-data/pug/test/cases/layout.prepend.without-block.pug b/src/test-data/pug/test/cases/layout.prepend.without-block.pug new file mode 100644 index 0000000..516d01b --- /dev/null +++ b/src/test-data/pug/test/cases/layout.prepend.without-block.pug @@ -0,0 +1,6 @@ + +extends ../fixtures/prepend-without-block/app-layout.pug + +prepend head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug/test/cases/mixin-at-end-of-file.html b/src/test-data/pug/test/cases/mixin-at-end-of-file.html new file mode 100644 index 0000000..495ca32 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin-at-end-of-file.html @@ -0,0 +1,3 @@ +
    +

    some awesome content

    +
    diff --git a/src/test-data/pug/test/cases/mixin-at-end-of-file.pug b/src/test-data/pug/test/cases/mixin-at-end-of-file.pug new file mode 100644 index 0000000..3d2faa1 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin-at-end-of-file.pug @@ -0,0 +1,4 @@ +include ./auxiliary/mixin-at-end-of-file.pug + ++slide() + p some awesome content diff --git a/src/test-data/pug/test/cases/mixin-block-with-space.html b/src/test-data/pug/test/cases/mixin-block-with-space.html new file mode 100644 index 0000000..5f1fc02 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin-block-with-space.html @@ -0,0 +1,3 @@ + +
    This text should appear +
    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixin-block-with-space.pug b/src/test-data/pug/test/cases/mixin-block-with-space.pug new file mode 100644 index 0000000..471aac8 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin-block-with-space.pug @@ -0,0 +1,6 @@ +mixin m(id) + div + block + ++m() + | This text should appear \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixin-hoist.html b/src/test-data/pug/test/cases/mixin-hoist.html new file mode 100644 index 0000000..1755a30 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin-hoist.html @@ -0,0 +1,5 @@ + + +

    Pug

    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixin-hoist.pug b/src/test-data/pug/test/cases/mixin-hoist.pug new file mode 100644 index 0000000..eb2c423 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin-hoist.pug @@ -0,0 +1,7 @@ + +mixin foo() + h1= title + +html + body + +foo diff --git a/src/test-data/pug/test/cases/mixin-via-include.html b/src/test-data/pug/test/cases/mixin-via-include.html new file mode 100644 index 0000000..8124337 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin-via-include.html @@ -0,0 +1 @@ +

    bar

    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixin-via-include.pug b/src/test-data/pug/test/cases/mixin-via-include.pug new file mode 100644 index 0000000..bb7b6d2 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin-via-include.pug @@ -0,0 +1,5 @@ +//- regression test for https://github.com/pugjs/pug/issues/1435 + +include ../fixtures/mixin-include.pug + ++bang \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixin.attrs.html b/src/test-data/pug/test/cases/mixin.attrs.html new file mode 100644 index 0000000..2f2e0ef --- /dev/null +++ b/src/test-data/pug/test/cases/mixin.attrs.html @@ -0,0 +1,32 @@ + +
    Hello World +
    +
    +

    Section 1

    +

    Some important content.

    +
    +
    +

    Section 2

    +

    Even more important content.

    + +
    +
    +
    +

    Section 3

    +

    Last content.

    + +
    +
    +
    +

    Some final words.

    +
    +
    +
    + +
    +
    work
    +
    +

    1

    +

    2

    +

    3

    +

    4

    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixin.attrs.pug b/src/test-data/pug/test/cases/mixin.attrs.pug new file mode 100644 index 0000000..82a46ff --- /dev/null +++ b/src/test-data/pug/test/cases/mixin.attrs.pug @@ -0,0 +1,59 @@ +mixin centered(title) + div.centered(id=attributes.id) + - if (title) + h1(class=attributes.class)= title + block + - if (attributes.href) + .footer + a(href=attributes.href) Back + +mixin main(title) + div.stretch + +centered(title).highlight&attributes(attributes) + block + +mixin bottom + div.bottom&attributes(attributes) + block + +body + +centered#First Hello World + +centered('Section 1')#Second + p Some important content. + +centered('Section 2')#Third.foo(href='menu.html', class='bar') + p Even more important content. + +main('Section 3')(href='#') + p Last content. + +bottom.foo(class='bar', name='end', id='Last', data-attr='baz') + p Some final words. + +bottom(class=['class1', 'class2']) + +mixin foo + div.thing(attr1='foo', attr2='bar')&attributes(attributes) + +- var val = '' +- var classes = ['foo', 'bar'] ++foo(attr3='baz' data-foo=val data-bar!=val class=classes).thunk + +//- Regression test for #1424 +mixin work_filmstrip_item(work) + div&attributes(attributes)= work ++work_filmstrip_item('work')("data-profile"='profile', "data-creator-name"='name') + +mixin my-mixin(arg1, arg2, arg3, arg4) + p= arg1 + p= arg2 + p= arg3 + p= arg4 + ++foo( + attr3="qux" + class="baz" +) + ++my-mixin( +'1', + '2', + '3', + '4' +) diff --git a/src/test-data/pug/test/cases/mixin.block-tag-behaviour.html b/src/test-data/pug/test/cases/mixin.block-tag-behaviour.html new file mode 100644 index 0000000..580dbe0 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin.block-tag-behaviour.html @@ -0,0 +1,22 @@ + + +
    +

    Foo

    +

    I'm article foo

    +
    + + + + +
    +

    Something

    +

    + I'm a much longer + text-only article, + but you can still + inline html tags + in me if you want. +

    +
    + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixin.block-tag-behaviour.pug b/src/test-data/pug/test/cases/mixin.block-tag-behaviour.pug new file mode 100644 index 0000000..1d2d2d3 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin.block-tag-behaviour.pug @@ -0,0 +1,24 @@ + +mixin article(name) + section.article + h1= name + block + +html + body + +article('Foo'): p I'm article foo + +mixin article(name) + section.article + h1= name + p + block + +html + body + +article('Something'). + I'm a much longer + text-only article, + but you can still + inline html tags + in me if you want. \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixin.blocks.html b/src/test-data/pug/test/cases/mixin.blocks.html new file mode 100644 index 0000000..def5c6f --- /dev/null +++ b/src/test-data/pug/test/cases/mixin.blocks.html @@ -0,0 +1,34 @@ + + +
    + + + +
    + + + + +
    + + + +
    + + + + +
    + +
    + + +
    +
    +

    one

    +

    two

    +

    three

    +
    +
    +
    123 +
    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixin.blocks.pug b/src/test-data/pug/test/cases/mixin.blocks.pug new file mode 100644 index 0000000..30c9990 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin.blocks.pug @@ -0,0 +1,44 @@ + + +mixin form(method, action) + form(method=method, action=action) + - var csrf_token_from_somewhere = 'hey' + input(type='hidden', name='_csrf', value=csrf_token_from_somewhere) + block + +html + body + +form('GET', '/search') + input(type='text', name='query', placeholder='Search') + input(type='submit', value='Search') + +html + body + +form('POST', '/search') + input(type='text', name='query', placeholder='Search') + input(type='submit', value='Search') + +html + body + +form('POST', '/search') + +mixin bar() + #bar + block + +mixin foo() + #foo + +bar + block + ++foo + p one + p two + p three + + +mixin baz + #baz + block + ++baz()= '123' \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixin.merge.html b/src/test-data/pug/test/cases/mixin.merge.html new file mode 100644 index 0000000..e513d35 --- /dev/null +++ b/src/test-data/pug/test/cases/mixin.merge.html @@ -0,0 +1,34 @@ + +

    One

    +

    Two

    +

    Three

    +

    Four

    +

    One

    +

    Two

    +

    Three

    +

    Four

    +

    One

    +

    Two

    +

    Three

    +

    Four

    +

    One

    +

    Two

    +

    Three

    +

    Four

    +

    One

    +

    Two

    +

    Three

    +

    Four

    +

    One

    +

    Two

    +

    Three

    +

    Four

    +

    One

    +

    Two

    +

    Three

    +

    Four

    +

    One

    +

    Two

    +

    Three

    +

    Four

    + diff --git a/src/test-data/pug/test/cases/mixin.merge.pug b/src/test-data/pug/test/cases/mixin.merge.pug new file mode 100644 index 0000000..f0d217d --- /dev/null +++ b/src/test-data/pug/test/cases/mixin.merge.pug @@ -0,0 +1,15 @@ +mixin foo + p.bar&attributes(attributes) One + p.baz.quux&attributes(attributes) Two + p&attributes(attributes) Three + p.bar&attributes(attributes)(class="baz") Four + +body + +foo.hello + +foo#world + +foo.hello#world + +foo.hello.world + +foo(class="hello") + +foo.hello(class="world") + +foo + +foo&attributes({class: "hello"}) \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixins-unused.html b/src/test-data/pug/test/cases/mixins-unused.html new file mode 100644 index 0000000..5db7bc1 --- /dev/null +++ b/src/test-data/pug/test/cases/mixins-unused.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixins-unused.pug b/src/test-data/pug/test/cases/mixins-unused.pug new file mode 100644 index 0000000..b0af6cc --- /dev/null +++ b/src/test-data/pug/test/cases/mixins-unused.pug @@ -0,0 +1,3 @@ +mixin never-called + .wtf This isn't something we ever want to output +body \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixins.html b/src/test-data/pug/test/cases/mixins.html new file mode 100644 index 0000000..a75b175 --- /dev/null +++ b/src/test-data/pug/test/cases/mixins.html @@ -0,0 +1,23 @@ + +
    +

    Tobi

    +
    +
    +

    This

    +

    is regular, javascript

    +
    +
    +
    + +
      +
    • foo
    • +
    • bar
    • +
    • baz
    • +
    +
      +
    • foo
    • +
    • bar
    • +
    • baz
    • +
    + +
    This is interpolated
    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/mixins.pug b/src/test-data/pug/test/cases/mixins.pug new file mode 100644 index 0000000..4e45671 --- /dev/null +++ b/src/test-data/pug/test/cases/mixins.pug @@ -0,0 +1,32 @@ +mixin comment(title, str) + .comment + h2= title + p.body= str + + +mixin comment (title, str) + .comment + h2= title + p.body= str + +#user + h1 Tobi + .comments + +comment('This', + (('is regular, javascript'))) + +mixin list + ul + li foo + li bar + li baz + +body + +list() + + list() + +mixin foobar(str) + div#interpolation= str + 'interpolated' + +- var suffix = "bar" ++#{'foo' + suffix}('This is ') diff --git a/src/test-data/pug/test/cases/mixins.rest-args.html b/src/test-data/pug/test/cases/mixins.rest-args.html new file mode 100644 index 0000000..5b37365 --- /dev/null +++ b/src/test-data/pug/test/cases/mixins.rest-args.html @@ -0,0 +1,6 @@ +
      +
    • 1
    • +
    • 2
    • +
    • 3
    • +
    • 4
    • +
    diff --git a/src/test-data/pug/test/cases/mixins.rest-args.pug b/src/test-data/pug/test/cases/mixins.rest-args.pug new file mode 100644 index 0000000..929a927 --- /dev/null +++ b/src/test-data/pug/test/cases/mixins.rest-args.pug @@ -0,0 +1,6 @@ +mixin list(tag, ...items) + #{tag} + each item in items + li= item + ++list('ul', 1, 2, 3, 4) diff --git a/src/test-data/pug/test/cases/namespaces.html b/src/test-data/pug/test/cases/namespaces.html new file mode 100644 index 0000000..90522ac --- /dev/null +++ b/src/test-data/pug/test/cases/namespaces.html @@ -0,0 +1,2 @@ +Something + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/namespaces.pug b/src/test-data/pug/test/cases/namespaces.pug new file mode 100644 index 0000000..0694677 --- /dev/null +++ b/src/test-data/pug/test/cases/namespaces.pug @@ -0,0 +1,2 @@ +fb:user:role Something +foo(fb:foo='bar') \ No newline at end of file diff --git a/src/test-data/pug/test/cases/nesting.html b/src/test-data/pug/test/cases/nesting.html new file mode 100644 index 0000000..56c15cb --- /dev/null +++ b/src/test-data/pug/test/cases/nesting.html @@ -0,0 +1,11 @@ +
      +
    • a
    • +
    • b
    • +
    • +
        +
      • c
      • +
      • d
      • +
      +
    • +
    • e
    • +
    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/nesting.pug b/src/test-data/pug/test/cases/nesting.pug new file mode 100644 index 0000000..f8cab4d --- /dev/null +++ b/src/test-data/pug/test/cases/nesting.pug @@ -0,0 +1,8 @@ +ul + li a + li b + li + ul + li c + li d + li e \ No newline at end of file diff --git a/src/test-data/pug/test/cases/pipeless-comments.html b/src/test-data/pug/test/cases/pipeless-comments.html new file mode 100644 index 0000000..5f9af83 --- /dev/null +++ b/src/test-data/pug/test/cases/pipeless-comments.html @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/pipeless-comments.pug b/src/test-data/pug/test/cases/pipeless-comments.pug new file mode 100644 index 0000000..426e459 --- /dev/null +++ b/src/test-data/pug/test/cases/pipeless-comments.pug @@ -0,0 +1,4 @@ +// + .foo + .bar + .hey diff --git a/src/test-data/pug/test/cases/pipeless-filters.html b/src/test-data/pug/test/cases/pipeless-filters.html new file mode 100644 index 0000000..64e4cb7 --- /dev/null +++ b/src/test-data/pug/test/cases/pipeless-filters.html @@ -0,0 +1,2 @@ +
    code sample
    +

    Heading

    diff --git a/src/test-data/pug/test/cases/pipeless-filters.pug b/src/test-data/pug/test/cases/pipeless-filters.pug new file mode 100644 index 0000000..b24c25a --- /dev/null +++ b/src/test-data/pug/test/cases/pipeless-filters.pug @@ -0,0 +1,4 @@ +:markdown-it + code sample + + # Heading diff --git a/src/test-data/pug/test/cases/pipeless-tag.html b/src/test-data/pug/test/cases/pipeless-tag.html new file mode 100644 index 0000000..f6f8935 --- /dev/null +++ b/src/test-data/pug/test/cases/pipeless-tag.html @@ -0,0 +1,3 @@ + +
      what
    +is going on
    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/pipeless-tag.pug b/src/test-data/pug/test/cases/pipeless-tag.pug new file mode 100644 index 0000000..d521da4 --- /dev/null +++ b/src/test-data/pug/test/cases/pipeless-tag.pug @@ -0,0 +1,3 @@ +pre. + what + is #{'going'} #[| #{'on'}] diff --git a/src/test-data/pug/test/cases/pre.html b/src/test-data/pug/test/cases/pre.html new file mode 100644 index 0000000..33bab4e --- /dev/null +++ b/src/test-data/pug/test/cases/pre.html @@ -0,0 +1,7 @@ +
    foo
    +bar
    +baz
    +
    +
    foo
    +bar
    +baz
    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/pre.pug b/src/test-data/pug/test/cases/pre.pug new file mode 100644 index 0000000..75673c5 --- /dev/null +++ b/src/test-data/pug/test/cases/pre.pug @@ -0,0 +1,10 @@ +pre. + foo + bar + baz + +pre + code. + foo + bar + baz \ No newline at end of file diff --git a/src/test-data/pug/test/cases/quotes.html b/src/test-data/pug/test/cases/quotes.html new file mode 100644 index 0000000..592b136 --- /dev/null +++ b/src/test-data/pug/test/cases/quotes.html @@ -0,0 +1,2 @@ +

    "foo"

    +

    'foo'

    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/quotes.pug b/src/test-data/pug/test/cases/quotes.pug new file mode 100644 index 0000000..499c835 --- /dev/null +++ b/src/test-data/pug/test/cases/quotes.pug @@ -0,0 +1,2 @@ +p "foo" +p 'foo' \ No newline at end of file diff --git a/src/test-data/pug/test/cases/regression.1794.html b/src/test-data/pug/test/cases/regression.1794.html new file mode 100644 index 0000000..b322cca --- /dev/null +++ b/src/test-data/pug/test/cases/regression.1794.html @@ -0,0 +1 @@ +
    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/regression.1794.pug b/src/test-data/pug/test/cases/regression.1794.pug new file mode 100644 index 0000000..fb33c31 --- /dev/null +++ b/src/test-data/pug/test/cases/regression.1794.pug @@ -0,0 +1,4 @@ +extends ./auxiliary/1794-extends.pug + +block content + include ./auxiliary/1794-include.pug \ No newline at end of file diff --git a/src/test-data/pug/test/cases/regression.784.html b/src/test-data/pug/test/cases/regression.784.html new file mode 100644 index 0000000..933e986 --- /dev/null +++ b/src/test-data/pug/test/cases/regression.784.html @@ -0,0 +1 @@ +
    google.com
    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/regression.784.pug b/src/test-data/pug/test/cases/regression.784.pug new file mode 100644 index 0000000..bab7540 --- /dev/null +++ b/src/test-data/pug/test/cases/regression.784.pug @@ -0,0 +1,2 @@ +- var url = 'http://www.google.com' +.url #{url.replace('http://', '').replace(/^www\./, '')} \ No newline at end of file diff --git a/src/test-data/pug/test/cases/script.whitespace.html b/src/test-data/pug/test/cases/script.whitespace.html new file mode 100644 index 0000000..45b7ced --- /dev/null +++ b/src/test-data/pug/test/cases/script.whitespace.html @@ -0,0 +1,7 @@ + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/script.whitespace.pug b/src/test-data/pug/test/cases/script.whitespace.pug new file mode 100644 index 0000000..e0afc3a --- /dev/null +++ b/src/test-data/pug/test/cases/script.whitespace.pug @@ -0,0 +1,6 @@ +script. + if (foo) { + + bar(); + + } \ No newline at end of file diff --git a/src/test-data/pug/test/cases/scripts.html b/src/test-data/pug/test/cases/scripts.html new file mode 100644 index 0000000..e3dc48b --- /dev/null +++ b/src/test-data/pug/test/cases/scripts.html @@ -0,0 +1,9 @@ + + + + +
    \ No newline at end of file diff --git a/src/test-data/pug/test/cases/scripts.non-js.html b/src/test-data/pug/test/cases/scripts.non-js.html new file mode 100644 index 0000000..9daff38 --- /dev/null +++ b/src/test-data/pug/test/cases/scripts.non-js.html @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/scripts.non-js.pug b/src/test-data/pug/test/cases/scripts.non-js.pug new file mode 100644 index 0000000..9f9a408 --- /dev/null +++ b/src/test-data/pug/test/cases/scripts.non-js.pug @@ -0,0 +1,9 @@ +script#user-template(type='text/template') + #user + h1 <%= user.name %> + p <%= user.description %> + +script#user-template(type='text/template'). + if (foo) { + bar(); + } \ No newline at end of file diff --git a/src/test-data/pug/test/cases/scripts.pug b/src/test-data/pug/test/cases/scripts.pug new file mode 100644 index 0000000..d28887f --- /dev/null +++ b/src/test-data/pug/test/cases/scripts.pug @@ -0,0 +1,8 @@ +script. + if (foo) { + bar(); + } +script!= 'foo()' +script foo() +script +div \ No newline at end of file diff --git a/src/test-data/pug/test/cases/self-closing-html.html b/src/test-data/pug/test/cases/self-closing-html.html new file mode 100644 index 0000000..02a38d0 --- /dev/null +++ b/src/test-data/pug/test/cases/self-closing-html.html @@ -0,0 +1,4 @@ + + +
    + diff --git a/src/test-data/pug/test/cases/self-closing-html.pug b/src/test-data/pug/test/cases/self-closing-html.pug new file mode 100644 index 0000000..094e42a --- /dev/null +++ b/src/test-data/pug/test/cases/self-closing-html.pug @@ -0,0 +1,4 @@ +doctype html +html + body + br/ diff --git a/src/test-data/pug/test/cases/single-period.html b/src/test-data/pug/test/cases/single-period.html new file mode 100644 index 0000000..430944c --- /dev/null +++ b/src/test-data/pug/test/cases/single-period.html @@ -0,0 +1 @@ +. \ No newline at end of file diff --git a/src/test-data/pug/test/cases/single-period.pug b/src/test-data/pug/test/cases/single-period.pug new file mode 100644 index 0000000..f3d734c --- /dev/null +++ b/src/test-data/pug/test/cases/single-period.pug @@ -0,0 +1 @@ +span . \ No newline at end of file diff --git a/src/test-data/pug/test/cases/some-included.styl b/src/test-data/pug/test/cases/some-included.styl new file mode 100644 index 0000000..7458543 --- /dev/null +++ b/src/test-data/pug/test/cases/some-included.styl @@ -0,0 +1,2 @@ +body + padding 10px diff --git a/src/test-data/pug/test/cases/some.md b/src/test-data/pug/test/cases/some.md new file mode 100644 index 0000000..8ea3e54 --- /dev/null +++ b/src/test-data/pug/test/cases/some.md @@ -0,0 +1,3 @@ +Just _some_ markdown **tests**. + +With new line. diff --git a/src/test-data/pug/test/cases/some.styl b/src/test-data/pug/test/cases/some.styl new file mode 100644 index 0000000..f77222d --- /dev/null +++ b/src/test-data/pug/test/cases/some.styl @@ -0,0 +1 @@ +@import "some-included" diff --git a/src/test-data/pug/test/cases/source.html b/src/test-data/pug/test/cases/source.html new file mode 100644 index 0000000..1881c0f --- /dev/null +++ b/src/test-data/pug/test/cases/source.html @@ -0,0 +1,6 @@ + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/source.pug b/src/test-data/pug/test/cases/source.pug new file mode 100644 index 0000000..db22b80 --- /dev/null +++ b/src/test-data/pug/test/cases/source.pug @@ -0,0 +1,4 @@ +html + audio(preload='auto', autobuffer, controls) + source(src='foo') + source(src='bar') \ No newline at end of file diff --git a/src/test-data/pug/test/cases/styles.html b/src/test-data/pug/test/cases/styles.html new file mode 100644 index 0000000..251556e --- /dev/null +++ b/src/test-data/pug/test/cases/styles.html @@ -0,0 +1,20 @@ + + + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    + + diff --git a/src/test-data/pug/test/cases/styles.pug b/src/test-data/pug/test/cases/styles.pug new file mode 100644 index 0000000..9618353 --- /dev/null +++ b/src/test-data/pug/test/cases/styles.pug @@ -0,0 +1,19 @@ +html + head + style. + body { + padding: 50px; + } + body + div(style='color:red;background:green') + div(style={color: 'red', background: 'green'}) + div&attributes({style: 'color:red;background:green'}) + div&attributes({style: {color: 'red', background: 'green'}}) + mixin div() + div&attributes(attributes) + +div(style='color:red;background:green') + +div(style={color: 'red', background: 'green'}) + - var bg = 'green'; + div(style={color: 'red', background: bg}) + div&attributes({style: {color: 'red', background: bg}}) + +div(style={color: 'red', background: bg}) diff --git a/src/test-data/pug/test/cases/tag.interpolation.html b/src/test-data/pug/test/cases/tag.interpolation.html new file mode 100644 index 0000000..9f2816c --- /dev/null +++ b/src/test-data/pug/test/cases/tag.interpolation.html @@ -0,0 +1,9 @@ +

    value

    +

    value

    +here + diff --git a/src/test-data/pug/test/cases/tag.interpolation.pug b/src/test-data/pug/test/cases/tag.interpolation.pug new file mode 100644 index 0000000..d923ddb --- /dev/null +++ b/src/test-data/pug/test/cases/tag.interpolation.pug @@ -0,0 +1,22 @@ + +- var tag = 'p' +- var foo = 'bar' + +#{tag} value +#{tag}(foo='bar') value +#{foo ? 'a' : 'li'}(something) here + +mixin item(icon) + li + if attributes.href + a&attributes(attributes) + img.icon(src=icon) + block + else + span&attributes(attributes) + img.icon(src=icon) + block + +ul + +item('contact') Contact + +item(href='/contact') Contact diff --git a/src/test-data/pug/test/cases/tags.self-closing.html b/src/test-data/pug/test/cases/tags.self-closing.html new file mode 100644 index 0000000..4f0bc7b --- /dev/null +++ b/src/test-data/pug/test/cases/tags.self-closing.html @@ -0,0 +1,14 @@ + + + + + + + / + / + + + / + / + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/tags.self-closing.pug b/src/test-data/pug/test/cases/tags.self-closing.pug new file mode 100644 index 0000000..9207c32 --- /dev/null +++ b/src/test-data/pug/test/cases/tags.self-closing.pug @@ -0,0 +1,19 @@ + +body + foo + foo(bar='baz') + foo/ + foo(bar='baz')/ + foo / + foo(bar='baz') / + #{'foo'}/ + #{'foo'}(bar='baz')/ + #{'foo'} / + #{'foo'}(bar='baz') / + //- can have a single space after them + img + //- can have lots of white space after them + img + #{ + 'foo' + }/ diff --git a/src/test-data/pug/test/cases/template.html b/src/test-data/pug/test/cases/template.html new file mode 100644 index 0000000..2054e05 --- /dev/null +++ b/src/test-data/pug/test/cases/template.html @@ -0,0 +1,11 @@ + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/template.pug b/src/test-data/pug/test/cases/template.pug new file mode 100644 index 0000000..20e086b --- /dev/null +++ b/src/test-data/pug/test/cases/template.pug @@ -0,0 +1,9 @@ +script(type='text/x-template') + article + h2 {{title}} + p {{description}} + +script(type='text/x-template'). + article + h2 {{title}} + p {{description}} diff --git a/src/test-data/pug/test/cases/text-block.html b/src/test-data/pug/test/cases/text-block.html new file mode 100644 index 0000000..fae8caa --- /dev/null +++ b/src/test-data/pug/test/cases/text-block.html @@ -0,0 +1,6 @@ + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/text-block.pug b/src/test-data/pug/test/cases/text-block.pug new file mode 100644 index 0000000..a032fa7 --- /dev/null +++ b/src/test-data/pug/test/cases/text-block.pug @@ -0,0 +1,6 @@ + +label Username: + input(type='text', name='user[name]') + +label Password: + input(type='text', name='user[pass]') \ No newline at end of file diff --git a/src/test-data/pug/test/cases/text.html b/src/test-data/pug/test/cases/text.html new file mode 100644 index 0000000..0b0efb0 --- /dev/null +++ b/src/test-data/pug/test/cases/text.html @@ -0,0 +1,36 @@ + + +

    +

    +

    + foo + bar + + + baz +

    +

    + foo + + + bar + baz + +

    foo + + +bar +baz + +
    foo
    +  bar
    +    baz
    +.
    +
    foo
    +  bar
    +    baz
    +.
    +
    foo + bar + baz +. diff --git a/src/test-data/pug/test/cases/text.pug b/src/test-data/pug/test/cases/text.pug new file mode 100644 index 0000000..abb0e0b --- /dev/null +++ b/src/test-data/pug/test/cases/text.pug @@ -0,0 +1,46 @@ +option(value='') -- (selected) -- + +p + +p. + +p + | foo + | bar + | + | + | baz + +p. + foo + + + bar + baz + +. + +. + foo + + + bar + baz + +pre + | foo + | bar + | baz + | . + +pre. + foo + bar + baz + . + +. + foo + bar + baz + . diff --git a/src/test-data/pug/test/cases/utf8bom.html b/src/test-data/pug/test/cases/utf8bom.html new file mode 100644 index 0000000..e3e18f0 --- /dev/null +++ b/src/test-data/pug/test/cases/utf8bom.html @@ -0,0 +1 @@ +

    "foo"

    diff --git a/src/test-data/pug/test/cases/utf8bom.pug b/src/test-data/pug/test/cases/utf8bom.pug new file mode 100644 index 0000000..9a32814 --- /dev/null +++ b/src/test-data/pug/test/cases/utf8bom.pug @@ -0,0 +1 @@ +p "foo" diff --git a/src/test-data/pug/test/cases/vars.html b/src/test-data/pug/test/cases/vars.html new file mode 100644 index 0000000..e9b7590 --- /dev/null +++ b/src/test-data/pug/test/cases/vars.html @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/vars.pug b/src/test-data/pug/test/cases/vars.pug new file mode 100644 index 0000000..46451a9 --- /dev/null +++ b/src/test-data/pug/test/cases/vars.pug @@ -0,0 +1,3 @@ +- var foo = 'bar' +- var list = [1,2,3] +a(class=list, id=foo) \ No newline at end of file diff --git a/src/test-data/pug/test/cases/while.html b/src/test-data/pug/test/cases/while.html new file mode 100644 index 0000000..dff7ff6 --- /dev/null +++ b/src/test-data/pug/test/cases/while.html @@ -0,0 +1,11 @@ +
      +
    • 2
    • +
    • 3
    • +
    • 4
    • +
    • 5
    • +
    • 6
    • +
    • 7
    • +
    • 8
    • +
    • 9
    • +
    • 10
    • +
    diff --git a/src/test-data/pug/test/cases/while.pug b/src/test-data/pug/test/cases/while.pug new file mode 100644 index 0000000..059b54b --- /dev/null +++ b/src/test-data/pug/test/cases/while.pug @@ -0,0 +1,5 @@ +- var x = 1; +ul + while x < 10 + - x++; + li= x diff --git a/src/test-data/pug/test/cases/xml.html b/src/test-data/pug/test/cases/xml.html new file mode 100644 index 0000000..5fd9f1a --- /dev/null +++ b/src/test-data/pug/test/cases/xml.html @@ -0,0 +1,3 @@ + + +http://google.com \ No newline at end of file diff --git a/src/test-data/pug/test/cases/xml.pug b/src/test-data/pug/test/cases/xml.pug new file mode 100644 index 0000000..2b21fa4 --- /dev/null +++ b/src/test-data/pug/test/cases/xml.pug @@ -0,0 +1,3 @@ +doctype xml +category(term='some term')/ +link http://google.com \ No newline at end of file diff --git a/src/test-data/pug/test/cases/yield-before-conditional-head.html b/src/test-data/pug/test/cases/yield-before-conditional-head.html new file mode 100644 index 0000000..35ace64 --- /dev/null +++ b/src/test-data/pug/test/cases/yield-before-conditional-head.html @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/yield-before-conditional-head.pug b/src/test-data/pug/test/cases/yield-before-conditional-head.pug new file mode 100644 index 0000000..8515406 --- /dev/null +++ b/src/test-data/pug/test/cases/yield-before-conditional-head.pug @@ -0,0 +1,5 @@ +head + script(src='/jquery.js') + yield + if false + script(src='/jquery.ui.js') diff --git a/src/test-data/pug/test/cases/yield-before-conditional.html b/src/test-data/pug/test/cases/yield-before-conditional.html new file mode 100644 index 0000000..7a3f184 --- /dev/null +++ b/src/test-data/pug/test/cases/yield-before-conditional.html @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/yield-before-conditional.pug b/src/test-data/pug/test/cases/yield-before-conditional.pug new file mode 100644 index 0000000..56b3385 --- /dev/null +++ b/src/test-data/pug/test/cases/yield-before-conditional.pug @@ -0,0 +1,5 @@ +html + body + include yield-before-conditional-head.pug + script(src='/caustic.js') + script(src='/app.js') diff --git a/src/test-data/pug/test/cases/yield-head.html b/src/test-data/pug/test/cases/yield-head.html new file mode 100644 index 0000000..83f92b5 --- /dev/null +++ b/src/test-data/pug/test/cases/yield-head.html @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/yield-head.pug b/src/test-data/pug/test/cases/yield-head.pug new file mode 100644 index 0000000..1428be6 --- /dev/null +++ b/src/test-data/pug/test/cases/yield-head.pug @@ -0,0 +1,4 @@ +head + script(src='/jquery.js') + yield + script(src='/jquery.ui.js') diff --git a/src/test-data/pug/test/cases/yield-title-head.html b/src/test-data/pug/test/cases/yield-title-head.html new file mode 100644 index 0000000..ae62c27 --- /dev/null +++ b/src/test-data/pug/test/cases/yield-title-head.html @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/yield-title-head.pug b/src/test-data/pug/test/cases/yield-title-head.pug new file mode 100644 index 0000000..5ec7d32 --- /dev/null +++ b/src/test-data/pug/test/cases/yield-title-head.pug @@ -0,0 +1,5 @@ +head + title + yield + script(src='/jquery.js') + script(src='/jquery.ui.js') diff --git a/src/test-data/pug/test/cases/yield-title.html b/src/test-data/pug/test/cases/yield-title.html new file mode 100644 index 0000000..83ef1fb --- /dev/null +++ b/src/test-data/pug/test/cases/yield-title.html @@ -0,0 +1,9 @@ + + + + My Title + + + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/yield-title.pug b/src/test-data/pug/test/cases/yield-title.pug new file mode 100644 index 0000000..54b5f4d --- /dev/null +++ b/src/test-data/pug/test/cases/yield-title.pug @@ -0,0 +1,4 @@ +html + body + include yield-title-head.pug + | My Title diff --git a/src/test-data/pug/test/cases/yield.html b/src/test-data/pug/test/cases/yield.html new file mode 100644 index 0000000..b16459d --- /dev/null +++ b/src/test-data/pug/test/cases/yield.html @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/test-data/pug/test/cases/yield.pug b/src/test-data/pug/test/cases/yield.pug new file mode 100644 index 0000000..7579241 --- /dev/null +++ b/src/test-data/pug/test/cases/yield.pug @@ -0,0 +1,5 @@ +html + body + include yield-head.pug + script(src='/caustic.js') + script(src='/app.js') diff --git a/src/test-data/pug/test/dependencies/dependency1.pug b/src/test-data/pug/test/dependencies/dependency1.pug new file mode 100644 index 0000000..5c24ab5 --- /dev/null +++ b/src/test-data/pug/test/dependencies/dependency1.pug @@ -0,0 +1 @@ +strong dependency1 diff --git a/src/test-data/pug/test/dependencies/dependency2.pug b/src/test-data/pug/test/dependencies/dependency2.pug new file mode 100644 index 0000000..76c1170 --- /dev/null +++ b/src/test-data/pug/test/dependencies/dependency2.pug @@ -0,0 +1 @@ +include dependency3.pug diff --git a/src/test-data/pug/test/dependencies/dependency3.pug b/src/test-data/pug/test/dependencies/dependency3.pug new file mode 100644 index 0000000..8cb467c --- /dev/null +++ b/src/test-data/pug/test/dependencies/dependency3.pug @@ -0,0 +1 @@ +strong dependency3 diff --git a/src/test-data/pug/test/dependencies/extends1.pug b/src/test-data/pug/test/dependencies/extends1.pug new file mode 100644 index 0000000..9fe5a9e --- /dev/null +++ b/src/test-data/pug/test/dependencies/extends1.pug @@ -0,0 +1 @@ +extends dependency1.pug diff --git a/src/test-data/pug/test/dependencies/extends2.pug b/src/test-data/pug/test/dependencies/extends2.pug new file mode 100644 index 0000000..802810c --- /dev/null +++ b/src/test-data/pug/test/dependencies/extends2.pug @@ -0,0 +1 @@ +extends dependency2.pug diff --git a/src/test-data/pug/test/dependencies/include1.pug b/src/test-data/pug/test/dependencies/include1.pug new file mode 100644 index 0000000..923e9ea --- /dev/null +++ b/src/test-data/pug/test/dependencies/include1.pug @@ -0,0 +1 @@ +include dependency1.pug diff --git a/src/test-data/pug/test/dependencies/include2.pug b/src/test-data/pug/test/dependencies/include2.pug new file mode 100644 index 0000000..0f93cec --- /dev/null +++ b/src/test-data/pug/test/dependencies/include2.pug @@ -0,0 +1 @@ +include dependency2.pug diff --git a/src/test-data/pug/test/duplicate-block/__snapshots__/index.test.js.snap b/src/test-data/pug/test/duplicate-block/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..b861222 --- /dev/null +++ b/src/test-data/pug/test/duplicate-block/__snapshots__/index.test.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`layout with duplicate block 1`] = `"
    Hello World
    "`; + +exports[`layout with duplicate block 2`] = `"
    Hello World
    "`; diff --git a/src/test-data/pug/test/duplicate-block/index.pug b/src/test-data/pug/test/duplicate-block/index.pug new file mode 100644 index 0000000..87a454b --- /dev/null +++ b/src/test-data/pug/test/duplicate-block/index.pug @@ -0,0 +1,4 @@ +extends ./layout-with-duplicate-block.pug + +block content + div Hello World diff --git a/src/test-data/pug/test/duplicate-block/index.test.js b/src/test-data/pug/test/duplicate-block/index.test.js new file mode 100644 index 0000000..d77631f --- /dev/null +++ b/src/test-data/pug/test/duplicate-block/index.test.js @@ -0,0 +1,10 @@ +const pug = require('../../'); + +test('layout with duplicate block', () => { + const outputWithAjax = pug.renderFile(__dirname + '/index.pug', {ajax: true}); + const outputWithoutAjax = pug.renderFile(__dirname + '/index.pug', { + ajax: false, + }); + expect(outputWithAjax).toMatchSnapshot(); + expect(outputWithoutAjax).toMatchSnapshot(); +}); diff --git a/src/test-data/pug/test/duplicate-block/layout-with-duplicate-block.pug b/src/test-data/pug/test/duplicate-block/layout-with-duplicate-block.pug new file mode 100644 index 0000000..41f1160 --- /dev/null +++ b/src/test-data/pug/test/duplicate-block/layout-with-duplicate-block.pug @@ -0,0 +1,8 @@ +if ajax + block content +else + doctype html + html + head + body + block content diff --git a/src/test-data/pug/test/eachOf/__snapshots__/index.test.js.snap b/src/test-data/pug/test/eachOf/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..b898335 --- /dev/null +++ b/src/test-data/pug/test/eachOf/__snapshots__/index.test.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Proper Usage Brackets 1`] = `"
  • a
  • b
  • foo
  • bar
  • "`; + +exports[`Proper Usage No Brackets 1`] = `"
  • a
  • b
  • foo
  • bar
  • "`; diff --git a/src/test-data/pug/test/eachOf/error/left-side.pug b/src/test-data/pug/test/eachOf/error/left-side.pug new file mode 100644 index 0000000..b2a081d --- /dev/null +++ b/src/test-data/pug/test/eachOf/error/left-side.pug @@ -0,0 +1,3 @@ +each [key, val of users + li= key + li= val diff --git a/src/test-data/pug/test/eachOf/error/no-brackets.pug b/src/test-data/pug/test/eachOf/error/no-brackets.pug new file mode 100644 index 0000000..83fbbff --- /dev/null +++ b/src/test-data/pug/test/eachOf/error/no-brackets.pug @@ -0,0 +1,3 @@ +each key, val of users + li= key + li= val diff --git a/src/test-data/pug/test/eachOf/error/one-val.pug b/src/test-data/pug/test/eachOf/error/one-val.pug new file mode 100644 index 0000000..0c6ff70 --- /dev/null +++ b/src/test-data/pug/test/eachOf/error/one-val.pug @@ -0,0 +1,3 @@ +each [key] of users + li= key + li= val diff --git a/src/test-data/pug/test/eachOf/error/right-side.pug b/src/test-data/pug/test/eachOf/error/right-side.pug new file mode 100644 index 0000000..6a6aab5 --- /dev/null +++ b/src/test-data/pug/test/eachOf/error/right-side.pug @@ -0,0 +1,3 @@ +each key, val] of users + li= key + li= val diff --git a/src/test-data/pug/test/eachOf/index.test.js b/src/test-data/pug/test/eachOf/index.test.js new file mode 100644 index 0000000..69da0ba --- /dev/null +++ b/src/test-data/pug/test/eachOf/index.test.js @@ -0,0 +1,44 @@ +const pug = require('../../'); + +describe('Inproper Usage', () => { + test('Only left-side bracket', () => { + expect(() => pug.compileFile(__dirname + '/error/left-side.pug')).toThrow( + 'The value variable for each must either be a valid identifier (e.g. `item`) or a pair of identifiers in square brackets (e.g. `[key, value]`).' + ); + }); + test('Only right-side bracket', () => { + expect(() => pug.compileFile(__dirname + '/error/right-side.pug')).toThrow( + 'The value variable for each must either be a valid identifier (e.g. `item`) or a pair of identifiers in square brackets (e.g. `[key, value]`).' + ); + }); + test('Only one value inside brackets', () => { + expect(() => pug.compileFile(__dirname + '/error/one-val.pug')).toThrow( + 'The value variable for each must either be a valid identifier (e.g. `item`) or a pair of identifiers in square brackets (e.g. `[key, value]`).' + ); + }); + test('No brackets', () => { + expect(() => pug.compileFile(__dirname + '/error/no-brackets.pug')).toThrow( + 'The value variable for each must either be a valid identifier (e.g. `item`) or a pair of identifiers in square brackets (e.g. `[key, value]`).' + ); + }); +}); +describe('Proper Usage', () => { + test('Brackets', () => { + const html = pug.renderFile(__dirname + '/passing/brackets.pug', { + users: new Map([ + ['a', 'b'], + ['foo', 'bar'], + ]), + }); + expect(html).toMatchSnapshot(); + }); + test('No Brackets', () => { + const html = pug.renderFile(__dirname + '/passing/no-brackets.pug', { + users: new Map([ + ['a', 'b'], + ['foo', 'bar'], + ]), + }); + expect(html).toMatchSnapshot(); + }); +}); diff --git a/src/test-data/pug/test/eachOf/passing/brackets.pug b/src/test-data/pug/test/eachOf/passing/brackets.pug new file mode 100644 index 0000000..b8193d5 --- /dev/null +++ b/src/test-data/pug/test/eachOf/passing/brackets.pug @@ -0,0 +1,3 @@ +each [key, val] of users + li= key + li= val diff --git a/src/test-data/pug/test/eachOf/passing/no-brackets.pug b/src/test-data/pug/test/eachOf/passing/no-brackets.pug new file mode 100644 index 0000000..880fee5 --- /dev/null +++ b/src/test-data/pug/test/eachOf/passing/no-brackets.pug @@ -0,0 +1,3 @@ +each data of users + li= data[0] + li= data[1] diff --git a/src/test-data/pug/test/error.reporting.test.js b/src/test-data/pug/test/error.reporting.test.js new file mode 100644 index 0000000..99ee8e3 --- /dev/null +++ b/src/test-data/pug/test/error.reporting.test.js @@ -0,0 +1,260 @@ +/** + * Module dependencies. + */ + +var pug = require('../'); +var assert = require('assert'); +var fs = require('fs'); + +// Shortcut + +function getError(str, options) { + try { + pug.render(str, options); + } catch (ex) { + return ex; + } + throw new Error('Input was supposed to result in an error.'); +} +function getFileError(name, options) { + try { + pug.renderFile(name, options); + } catch (ex) { + return ex; + } + throw new Error('Input was supposed to result in an error.'); +} + +describe('error reporting', function() { + describe('compile time errors', function() { + describe('with no filename', function() { + it('includes detail of where the error was thrown', function() { + var err = getError('foo('); + expect(err.message).toMatch(/Pug:1/); + expect(err.message).toMatch(/foo\(/); + }); + }); + describe('with a filename', function() { + it('includes detail of where the error was thrown including the filename', function() { + var err = getError('foo(', {filename: 'test.pug'}); + expect(err.message).toMatch(/test\.pug:1/); + expect(err.message).toMatch(/foo\(/); + }); + }); + describe('with a layout without block declaration (syntax)', function() { + it('includes detail of where the error was thrown including the filename', function() { + var err = getFileError( + __dirname + '/fixtures/compile.with.layout.syntax.error.pug', + {} + ); + expect(err.message).toMatch(/[\\\/]layout.syntax.error.pug:2/); + expect(err.message).toMatch(/foo\(/); + }); + }); + describe('with a layout without block declaration (locals)', function() { + it('includes detail of where the error was thrown including the filename', function() { + var err = getFileError( + __dirname + '/fixtures/compile.with.layout.locals.error.pug', + {} + ); + expect(err.message).toMatch(/[\\\/]layout.locals.error.pug:2/); + expect(err.message).toMatch(/is not a function/); + }); + }); + describe('with a include (syntax)', function() { + it('includes detail of where the error was thrown including the filename', function() { + var err = getFileError( + __dirname + '/fixtures/compile.with.include.syntax.error.pug', + {} + ); + expect(err.message).toMatch(/[\\\/]include.syntax.error.pug:2/); + expect(err.message).toMatch(/foo\(/); + }); + }); + describe('with a include (locals)', function() { + it('includes detail of where the error was thrown including the filename', function() { + var err = getFileError( + __dirname + '/fixtures/compile.with.include.locals.error.pug', + {} + ); + expect(err.message).toMatch(/[\\\/]include.locals.error.pug:2/); + expect(err.message).toMatch(/foo\(/); + }); + + it('handles compileDebug option properly', function() { + var err = getFileError( + __dirname + '/fixtures/compile.with.include.locals.error.pug', + { + compileDebug: true, + } + ); + expect(err.message).toMatch(/[\\\/]include.locals.error.pug:2/); + expect(err.message).toMatch(/foo is not a function/); + }); + }); + + describe('with a layout (without block) with an include (syntax)', function() { + it('includes detail of where the error was thrown including the filename', function() { + var err = getFileError( + __dirname + + '/fixtures/compile.with.layout.with.include.syntax.error.pug', + {} + ); + expect(err.message).toMatch(/[\\\/]include.syntax.error.pug:2/); + expect(err.message).toMatch(/foo\(/); + }); + }); + describe('with a layout (without block) with an include (locals)', function() { + it('includes detail of where the error was thrown including the filename', function() { + var err = getFileError( + __dirname + + '/fixtures/compile.with.layout.with.include.locals.error.pug', + {} + ); + expect(err.message).toMatch(/[\\\/]include.locals.error.pug:2/); + expect(err.message).toMatch(/foo\(/); + }); + }); + describe('block that is never actually used', function() { + it('includes detail of where the error was thrown including the filename', function() { + var err = getFileError( + __dirname + '/fixtures/invalid-block-in-extends.pug', + {} + ); + expect(err.message).toMatch(/invalid-block-in-extends.pug:6/); + expect(err.message).toMatch(/content/); + }); + }); + describe('Unexpected character', function() { + it('includes details of where the error was thrown', function() { + var err = getError('ul?', {}); + expect(err.message).toMatch(/unexpected text \"\?\"/); + }); + }); + describe('Include filtered', function() { + it('includes details of where the error was thrown', function() { + var err = getError('include:verbatim()!', {}); + assert(err.message.indexOf('unexpected text "!"') !== -1); + var err = getError('include:verbatim ', {}); + assert(err.message.indexOf('missing path for include') !== -1); + }); + }); + describe('mixin block followed by a lot of blank lines', function() { + it('reports the correct line number', function() { + var err = getError('mixin test\n block\n\ndiv()Test'); + var line = /Pug\:(\d+)/.exec(err.message); + assert(line, 'Line number must be included in error message'); + assert( + line[1] === '4', + 'The error should be reported on line 4, not line ' + line[1] + ); + }); + }); + }); + describe('runtime errors', function() { + describe('with no filename and `compileDebug` left undefined', function() { + it('just reports the line number', function() { + var sentinel = new Error('sentinel'); + var err = getError('-foo()', { + foo: function() { + throw sentinel; + }, + }); + expect(err.message).toMatch(/on line 1/); + }); + }); + describe('with no filename and `compileDebug` set to `true`', function() { + it('includes detail of where the error was thrown', function() { + var sentinel = new Error('sentinel'); + var err = getError('-foo()', { + foo: function() { + throw sentinel; + }, + compileDebug: true, + }); + expect(err.message).toMatch(/Pug:1/); + expect(err.message).toMatch(/-foo\(\)/); + }); + }); + describe('with a filename that does not correspond to a real file and `compileDebug` left undefined', function() { + it('just reports the line number', function() { + var sentinel = new Error('sentinel'); + var err = getError('-foo()', { + foo: function() { + throw sentinel; + }, + filename: 'fake.pug', + }); + expect(err.message).toMatch(/on line 1/); + }); + }); + describe('with a filename that corresponds to a real file and `compileDebug` left undefined', function() { + it('includes detail of where the error was thrown including the filename', function() { + var sentinel = new Error('sentinel'); + var path = __dirname + '/fixtures/runtime.error.pug'; + var err = getError(fs.readFileSync(path, 'utf8'), { + foo: function() { + throw sentinel; + }, + filename: path, + }); + expect(err.message).toMatch(/fixtures[\\\/]runtime\.error\.pug:1/); + expect(err.message).toMatch(/-foo\(\)/); + }); + }); + describe('in a mixin', function() { + it('includes detail of where the error was thrown including the filename', function() { + var err = getFileError( + __dirname + '/fixtures/runtime.with.mixin.error.pug', + {} + ); + expect(err.message).toMatch(/mixin.error.pug:2/); + expect(err.message).toMatch(/Cannot read property 'length' of null/); + }); + }); + describe('in a layout', function() { + it('includes detail of where the error was thrown including the filename', function() { + var err = getFileError( + __dirname + '/fixtures/runtime.layout.error.pug', + {} + ); + expect(err.message).toMatch(/layout.with.runtime.error.pug:3/); + expect(err.message).toMatch( + /Cannot read property 'length' of undefined/ + ); + }); + }); + }); + describe('deprecated features', function() { + it('warns about element-with-multiple-attributes', function() { + var consoleWarn = console.warn; + var log = ''; + console.warn = function(str) { + log += str; + }; + var res = pug.renderFile( + __dirname + '/fixtures/element-with-multiple-attributes.pug' + ); + console.warn = consoleWarn; + expect(log).toMatch(/element-with-multiple-attributes.pug, line 1:/); + expect(log).toMatch( + /You should not have pug tags with multiple attributes/ + ); + expect(res).toBe('
    '); + }); + }); + describe("if you throw something that isn't an error", function() { + it('just rethrows without modification', function() { + var err = getError('- throw "foo"'); + expect(err).toBe('foo'); + }); + }); + describe('import without a filename for a basedir', function() { + it('throws an error', function() { + var err = getError('include foo.pug'); + expect(err.message).toMatch(/the "filename" option is required to use/); + var err = getError('include /foo.pug'); + expect(err.message).toMatch(/the "basedir" option is required to use/); + }); + }); +}); diff --git a/src/test-data/pug/test/examples.test.js b/src/test-data/pug/test/examples.test.js new file mode 100644 index 0000000..a4a4c22 --- /dev/null +++ b/src/test-data/pug/test/examples.test.js @@ -0,0 +1,23 @@ +'use strict'; + +var fs = require('fs'); +var pug = require('../'); + +describe('examples', function() { + fs.readdirSync(__dirname + '/../examples').forEach(function(example) { + if (/\.js$/.test(example)) { + it(example + ' does not throw any error', function() { + var log = console.log; + var err = console.error; + console.log = function() {}; + console.error = function() {}; + try { + require('../examples/' + example); + } finally { + console.log = log; + console.error = err; + } + }); + } + }); +}); diff --git a/src/test-data/pug/test/extends-not-top-level/default.pug b/src/test-data/pug/test/extends-not-top-level/default.pug new file mode 100644 index 0000000..94ae27e --- /dev/null +++ b/src/test-data/pug/test/extends-not-top-level/default.pug @@ -0,0 +1,2 @@ +body + block content diff --git a/src/test-data/pug/test/extends-not-top-level/duplicate.pug b/src/test-data/pug/test/extends-not-top-level/duplicate.pug new file mode 100644 index 0000000..3786c4c --- /dev/null +++ b/src/test-data/pug/test/extends-not-top-level/duplicate.pug @@ -0,0 +1,2 @@ +extends default +extends default diff --git a/src/test-data/pug/test/extends-not-top-level/index.pug b/src/test-data/pug/test/extends-not-top-level/index.pug new file mode 100644 index 0000000..8064ff4 --- /dev/null +++ b/src/test-data/pug/test/extends-not-top-level/index.pug @@ -0,0 +1,10 @@ +mixin content + if bar + extends default + block content + block + else + block + ++content + h1 Hello! diff --git a/src/test-data/pug/test/extends-not-top-level/index.test.js b/src/test-data/pug/test/extends-not-top-level/index.test.js new file mode 100644 index 0000000..a0fbf27 --- /dev/null +++ b/src/test-data/pug/test/extends-not-top-level/index.test.js @@ -0,0 +1,15 @@ +const pug = require('../../'); + +// regression test for #2404 + +test('extends not top level should throw an error', () => { + expect(() => pug.compileFile(__dirname + '/index.pug')).toThrow( + 'Declaration of template inheritance ("extends") should be the first thing in the file. There can only be one extends statement per file.' + ); +}); + +test('duplicate extends should throw an error', () => { + expect(() => pug.compileFile(__dirname + '/duplicate.pug')).toThrow( + 'Declaration of template inheritance ("extends") should be the first thing in the file. There can only be one extends statement per file.' + ); +}); diff --git a/src/test-data/pug/test/fixtures/append-without-block/app-layout.pug b/src/test-data/pug/test/fixtures/append-without-block/app-layout.pug new file mode 100644 index 0000000..1b55872 --- /dev/null +++ b/src/test-data/pug/test/fixtures/append-without-block/app-layout.pug @@ -0,0 +1,5 @@ + +extends layout.pug + +append head + script(src='app.js') diff --git a/src/test-data/pug/test/fixtures/append-without-block/layout.pug b/src/test-data/pug/test/fixtures/append-without-block/layout.pug new file mode 100644 index 0000000..671b3c9 --- /dev/null +++ b/src/test-data/pug/test/fixtures/append-without-block/layout.pug @@ -0,0 +1,7 @@ + +html + block head + script(src='vendor/jquery.js') + script(src='vendor/caustic.js') + body + block body \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/append-without-block/page.pug b/src/test-data/pug/test/fixtures/append-without-block/page.pug new file mode 100644 index 0000000..e607ae7 --- /dev/null +++ b/src/test-data/pug/test/fixtures/append-without-block/page.pug @@ -0,0 +1,6 @@ + +extends app-layout.pug + +append head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug/test/fixtures/append/app-layout.pug b/src/test-data/pug/test/fixtures/append/app-layout.pug new file mode 100644 index 0000000..48bf886 --- /dev/null +++ b/src/test-data/pug/test/fixtures/append/app-layout.pug @@ -0,0 +1,5 @@ + +extends layout + +block append head + script(src='app.js') \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/append/layout.pug b/src/test-data/pug/test/fixtures/append/layout.pug new file mode 100644 index 0000000..671b3c9 --- /dev/null +++ b/src/test-data/pug/test/fixtures/append/layout.pug @@ -0,0 +1,7 @@ + +html + block head + script(src='vendor/jquery.js') + script(src='vendor/caustic.js') + body + block body \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/append/page.html b/src/test-data/pug/test/fixtures/append/page.html new file mode 100644 index 0000000..bc5e126 --- /dev/null +++ b/src/test-data/pug/test/fixtures/append/page.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test-data/pug/test/fixtures/append/page.pug b/src/test-data/pug/test/fixtures/append/page.pug new file mode 100644 index 0000000..1ae9909 --- /dev/null +++ b/src/test-data/pug/test/fixtures/append/page.pug @@ -0,0 +1,6 @@ + +extends app-layout + +block append head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug/test/fixtures/compile.with.include.locals.error.pug b/src/test-data/pug/test/fixtures/compile.with.include.locals.error.pug new file mode 100644 index 0000000..0cabc64 --- /dev/null +++ b/src/test-data/pug/test/fixtures/compile.with.include.locals.error.pug @@ -0,0 +1 @@ +include include.locals.error.pug \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/compile.with.include.syntax.error.pug b/src/test-data/pug/test/fixtures/compile.with.include.syntax.error.pug new file mode 100644 index 0000000..3ab355a --- /dev/null +++ b/src/test-data/pug/test/fixtures/compile.with.include.syntax.error.pug @@ -0,0 +1 @@ +include include.syntax.error.pug \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/compile.with.layout.locals.error.pug b/src/test-data/pug/test/fixtures/compile.with.layout.locals.error.pug new file mode 100644 index 0000000..d6df843 --- /dev/null +++ b/src/test-data/pug/test/fixtures/compile.with.layout.locals.error.pug @@ -0,0 +1 @@ +extends layout.locals.error.pug \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/compile.with.layout.syntax.error.pug b/src/test-data/pug/test/fixtures/compile.with.layout.syntax.error.pug new file mode 100644 index 0000000..616d7e8 --- /dev/null +++ b/src/test-data/pug/test/fixtures/compile.with.layout.syntax.error.pug @@ -0,0 +1 @@ +extends layout.syntax.error.pug \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/compile.with.layout.with.include.locals.error.pug b/src/test-data/pug/test/fixtures/compile.with.layout.with.include.locals.error.pug new file mode 100644 index 0000000..cd5ebb1 --- /dev/null +++ b/src/test-data/pug/test/fixtures/compile.with.layout.with.include.locals.error.pug @@ -0,0 +1 @@ +extends compile.with.include.locals.error.pug \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/compile.with.layout.with.include.syntax.error.pug b/src/test-data/pug/test/fixtures/compile.with.layout.with.include.syntax.error.pug new file mode 100644 index 0000000..a6221b3 --- /dev/null +++ b/src/test-data/pug/test/fixtures/compile.with.layout.with.include.syntax.error.pug @@ -0,0 +1 @@ +extends compile.with.include.syntax.error.pug \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/element-with-multiple-attributes.pug b/src/test-data/pug/test/fixtures/element-with-multiple-attributes.pug new file mode 100644 index 0000000..e76f560 --- /dev/null +++ b/src/test-data/pug/test/fixtures/element-with-multiple-attributes.pug @@ -0,0 +1 @@ +div(attr='val')(foo='bar') \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/include.locals.error.pug b/src/test-data/pug/test/fixtures/include.locals.error.pug new file mode 100644 index 0000000..bd604a9 --- /dev/null +++ b/src/test-data/pug/test/fixtures/include.locals.error.pug @@ -0,0 +1,2 @@ + += foo() \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/include.syntax.error.pug b/src/test-data/pug/test/fixtures/include.syntax.error.pug new file mode 100644 index 0000000..8b0542a --- /dev/null +++ b/src/test-data/pug/test/fixtures/include.syntax.error.pug @@ -0,0 +1,2 @@ + += foo( \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/invalid-block-in-extends.pug b/src/test-data/pug/test/fixtures/invalid-block-in-extends.pug new file mode 100644 index 0000000..981321a --- /dev/null +++ b/src/test-data/pug/test/fixtures/invalid-block-in-extends.pug @@ -0,0 +1,7 @@ +extends ./layout.pug + +block title + title My Article + +block contents + // oops, that's not a block \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/issue-1593/include-layout.pug b/src/test-data/pug/test/fixtures/issue-1593/include-layout.pug new file mode 100644 index 0000000..224225f --- /dev/null +++ b/src/test-data/pug/test/fixtures/issue-1593/include-layout.pug @@ -0,0 +1,2 @@ +.included-layout + block include-body diff --git a/src/test-data/pug/test/fixtures/issue-1593/include.pug b/src/test-data/pug/test/fixtures/issue-1593/include.pug new file mode 100644 index 0000000..2e33e5b --- /dev/null +++ b/src/test-data/pug/test/fixtures/issue-1593/include.pug @@ -0,0 +1,4 @@ +extends ./include-layout.pug + +block include-body + .include-body diff --git a/src/test-data/pug/test/fixtures/issue-1593/index.pug b/src/test-data/pug/test/fixtures/issue-1593/index.pug new file mode 100644 index 0000000..5346ac9 --- /dev/null +++ b/src/test-data/pug/test/fixtures/issue-1593/index.pug @@ -0,0 +1,7 @@ +extends ./layout.pug + +block body-a + .body-a +block body-b + .body-b + include ./include.pug diff --git a/src/test-data/pug/test/fixtures/issue-1593/layout.pug b/src/test-data/pug/test/fixtures/issue-1593/layout.pug new file mode 100644 index 0000000..a235db7 --- /dev/null +++ b/src/test-data/pug/test/fixtures/issue-1593/layout.pug @@ -0,0 +1,3 @@ +.layout-body + block body-a + block body-b diff --git a/src/test-data/pug/test/fixtures/layout.locals.error.pug b/src/test-data/pug/test/fixtures/layout.locals.error.pug new file mode 100644 index 0000000..bd604a9 --- /dev/null +++ b/src/test-data/pug/test/fixtures/layout.locals.error.pug @@ -0,0 +1,2 @@ + += foo() \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/layout.pug b/src/test-data/pug/test/fixtures/layout.pug new file mode 100644 index 0000000..87518e5 --- /dev/null +++ b/src/test-data/pug/test/fixtures/layout.pug @@ -0,0 +1,6 @@ +doctype html +html + head + block title + body + block body \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/layout.syntax.error.pug b/src/test-data/pug/test/fixtures/layout.syntax.error.pug new file mode 100644 index 0000000..8b0542a --- /dev/null +++ b/src/test-data/pug/test/fixtures/layout.syntax.error.pug @@ -0,0 +1,2 @@ + += foo( \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/layout.with.runtime.error.pug b/src/test-data/pug/test/fixtures/layout.with.runtime.error.pug new file mode 100644 index 0000000..73d3a0d --- /dev/null +++ b/src/test-data/pug/test/fixtures/layout.with.runtime.error.pug @@ -0,0 +1,5 @@ +html + body + = foo.length + block content + diff --git a/src/test-data/pug/test/fixtures/mixin-include.pug b/src/test-data/pug/test/fixtures/mixin-include.pug new file mode 100644 index 0000000..491fc70 --- /dev/null +++ b/src/test-data/pug/test/fixtures/mixin-include.pug @@ -0,0 +1,5 @@ +mixin bang + +foo + +mixin foo + p bar \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/mixin.error.pug b/src/test-data/pug/test/fixtures/mixin.error.pug new file mode 100644 index 0000000..5a4fdf4 --- /dev/null +++ b/src/test-data/pug/test/fixtures/mixin.error.pug @@ -0,0 +1,2 @@ +mixin mixin-with-error(foo) + - foo.length diff --git a/src/test-data/pug/test/fixtures/multi-append-prepend-block/redefine.pug b/src/test-data/pug/test/fixtures/multi-append-prepend-block/redefine.pug new file mode 100644 index 0000000..abc178e --- /dev/null +++ b/src/test-data/pug/test/fixtures/multi-append-prepend-block/redefine.pug @@ -0,0 +1,5 @@ +extends root.pug + +block content + .content + | Defined content diff --git a/src/test-data/pug/test/fixtures/multi-append-prepend-block/root.pug b/src/test-data/pug/test/fixtures/multi-append-prepend-block/root.pug new file mode 100644 index 0000000..8e3334a --- /dev/null +++ b/src/test-data/pug/test/fixtures/multi-append-prepend-block/root.pug @@ -0,0 +1,5 @@ +block content + | default content + +block head + script(src='/app.js') \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/perf.pug b/src/test-data/pug/test/fixtures/perf.pug new file mode 100644 index 0000000..9aa454b --- /dev/null +++ b/src/test-data/pug/test/fixtures/perf.pug @@ -0,0 +1,32 @@ +.data + ol.sortable#contents + each item in report + if (!item.parent) + div + li.chapter(data-ref= item.id) + a(href='/admin/report/detail/' + item.id) + = item.name + - var chp = item.id + ol.sortable + each item in report + if (item.parent === chp && item.type === 'section') + div + li.section(data-ref= item.id) + a(href='/admin/report/detail/' + item.id) + = item.name + - var sec = item.id + ol.sortable + each item in report + if (item.parent === sec && item.type === 'page') + div + li.page(data-ref= item.id) + a(href='/admin/report/detail/' + item.id) + = item.name + - var page = item.id + ol.sortable + each item in report + if (item.parent === page && item.type === 'subpage') + div + li.subpage(data-ref= item.id) + a(href='/admin/report/detail/' + item.id) + = item.name \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/prepend-without-block/app-layout.pug b/src/test-data/pug/test/fixtures/prepend-without-block/app-layout.pug new file mode 100644 index 0000000..53f89ba --- /dev/null +++ b/src/test-data/pug/test/fixtures/prepend-without-block/app-layout.pug @@ -0,0 +1,5 @@ + +extends layout.pug + +prepend head + script(src='app.js') diff --git a/src/test-data/pug/test/fixtures/prepend-without-block/layout.pug b/src/test-data/pug/test/fixtures/prepend-without-block/layout.pug new file mode 100644 index 0000000..671b3c9 --- /dev/null +++ b/src/test-data/pug/test/fixtures/prepend-without-block/layout.pug @@ -0,0 +1,7 @@ + +html + block head + script(src='vendor/jquery.js') + script(src='vendor/caustic.js') + body + block body \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/prepend-without-block/page.html b/src/test-data/pug/test/fixtures/prepend-without-block/page.html new file mode 100644 index 0000000..8753a42 --- /dev/null +++ b/src/test-data/pug/test/fixtures/prepend-without-block/page.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test-data/pug/test/fixtures/prepend-without-block/page.pug b/src/test-data/pug/test/fixtures/prepend-without-block/page.pug new file mode 100644 index 0000000..6b9bb01 --- /dev/null +++ b/src/test-data/pug/test/fixtures/prepend-without-block/page.pug @@ -0,0 +1,6 @@ + +extends app-layout.pug + +prepend head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug/test/fixtures/prepend/app-layout.pug b/src/test-data/pug/test/fixtures/prepend/app-layout.pug new file mode 100644 index 0000000..7040eec --- /dev/null +++ b/src/test-data/pug/test/fixtures/prepend/app-layout.pug @@ -0,0 +1,5 @@ + +extends layout.pug + +block prepend head + script(src='app.js') diff --git a/src/test-data/pug/test/fixtures/prepend/layout.pug b/src/test-data/pug/test/fixtures/prepend/layout.pug new file mode 100644 index 0000000..671b3c9 --- /dev/null +++ b/src/test-data/pug/test/fixtures/prepend/layout.pug @@ -0,0 +1,7 @@ + +html + block head + script(src='vendor/jquery.js') + script(src='vendor/caustic.js') + body + block body \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/prepend/page.html b/src/test-data/pug/test/fixtures/prepend/page.html new file mode 100644 index 0000000..8753a42 --- /dev/null +++ b/src/test-data/pug/test/fixtures/prepend/page.html @@ -0,0 +1,9 @@ + + + + + + + + + diff --git a/src/test-data/pug/test/fixtures/prepend/page.pug b/src/test-data/pug/test/fixtures/prepend/page.pug new file mode 100644 index 0000000..c2a91c9 --- /dev/null +++ b/src/test-data/pug/test/fixtures/prepend/page.pug @@ -0,0 +1,6 @@ + +extends app-layout.pug + +block prepend head + script(src='foo.js') + script(src='bar.js') diff --git a/src/test-data/pug/test/fixtures/runtime.error.pug b/src/test-data/pug/test/fixtures/runtime.error.pug new file mode 100644 index 0000000..27794a4 --- /dev/null +++ b/src/test-data/pug/test/fixtures/runtime.error.pug @@ -0,0 +1 @@ +-foo() \ No newline at end of file diff --git a/src/test-data/pug/test/fixtures/runtime.layout.error.pug b/src/test-data/pug/test/fixtures/runtime.layout.error.pug new file mode 100644 index 0000000..fc66a78 --- /dev/null +++ b/src/test-data/pug/test/fixtures/runtime.layout.error.pug @@ -0,0 +1,3 @@ +extends layout.with.runtime.error.pug +block content + | some content diff --git a/src/test-data/pug/test/fixtures/runtime.with.mixin.error.pug b/src/test-data/pug/test/fixtures/runtime.with.mixin.error.pug new file mode 100644 index 0000000..4226433 --- /dev/null +++ b/src/test-data/pug/test/fixtures/runtime.with.mixin.error.pug @@ -0,0 +1,3 @@ +include mixin.error.pug + ++mixin-with-error(null) diff --git a/src/test-data/pug/test/fixtures/scripts.pug b/src/test-data/pug/test/fixtures/scripts.pug new file mode 100644 index 0000000..30fabcf --- /dev/null +++ b/src/test-data/pug/test/fixtures/scripts.pug @@ -0,0 +1,2 @@ +script(src='/jquery.js') +script(src='/caustic.js') \ No newline at end of file diff --git a/src/test-data/pug/test/markdown-it/comment.md b/src/test-data/pug/test/markdown-it/comment.md new file mode 100644 index 0000000..b84a7bb --- /dev/null +++ b/src/test-data/pug/test/markdown-it/comment.md @@ -0,0 +1 @@ +

    Hello World!

    \ No newline at end of file diff --git a/src/test-data/pug/test/markdown-it/index.test.js b/src/test-data/pug/test/markdown-it/index.test.js new file mode 100644 index 0000000..00bef9f --- /dev/null +++ b/src/test-data/pug/test/markdown-it/index.test.js @@ -0,0 +1,13 @@ +const pug = require('../../'); + +test('inline and include markdow-it should match ', () => { + const outputMarkdownInline = pug.renderFile( + __dirname + '/layout-markdown-inline.pug' + ); + + const outputMarkdownIncludes = pug.renderFile( + __dirname + '/layout-markdown-include.pug' + ); + + expect(outputMarkdownIncludes).toEqual(outputMarkdownInline); +}); diff --git a/src/test-data/pug/test/markdown-it/layout-markdown-include.pug b/src/test-data/pug/test/markdown-it/layout-markdown-include.pug new file mode 100644 index 0000000..6a776da --- /dev/null +++ b/src/test-data/pug/test/markdown-it/layout-markdown-include.pug @@ -0,0 +1 @@ +include:markdown-it(html=true) comment.md \ No newline at end of file diff --git a/src/test-data/pug/test/markdown-it/layout-markdown-inline.pug b/src/test-data/pug/test/markdown-it/layout-markdown-inline.pug new file mode 100644 index 0000000..ee46c9c --- /dev/null +++ b/src/test-data/pug/test/markdown-it/layout-markdown-inline.pug @@ -0,0 +1,2 @@ +:markdown-it(html=true) +

    Hello World!

    \ No newline at end of file diff --git a/src/test-data/pug/test/output-es2015/attr.html b/src/test-data/pug/test/output-es2015/attr.html new file mode 100644 index 0000000..63a82c0 --- /dev/null +++ b/src/test-data/pug/test/output-es2015/attr.html @@ -0,0 +1,2 @@ + +
    \ No newline at end of file diff --git a/src/test-data/pug/test/plugins.test.js b/src/test-data/pug/test/plugins.test.js new file mode 100644 index 0000000..21447bc --- /dev/null +++ b/src/test-data/pug/test/plugins.test.js @@ -0,0 +1,18 @@ +const pug = require('../'); + +test('#3295 - lexer plugins should be used in tag interpolation', () => { + const lex = { + advance(lexer) { + if ('~' === lexer.input.charAt(0)) { + lexer.tokens.push(lexer.tok('text', 'twiddle-dee-dee')); + lexer.consume(1); + lexer.incrementColumn(1); + return true; + } + }, + }; + const input = 'p Look at #[~]'; + const expected = '

    Look at twiddle-dee-dee

    '; + const output = pug.render(input, {plugins: [{lex}]}); + expect(output).toEqual(expected); +}); diff --git a/src/test-data/pug/test/pug.test.js b/src/test-data/pug/test/pug.test.js new file mode 100644 index 0000000..f688949 --- /dev/null +++ b/src/test-data/pug/test/pug.test.js @@ -0,0 +1,1510 @@ +'use strict'; + +var assert = require('assert'); +var fs = require('fs'); +var path = require('path'); +var pug = require('../'); + +var perfTest = fs.readFileSync(__dirname + '/fixtures/perf.pug', 'utf8'); + +try { + fs.mkdirSync(__dirname + '/temp'); +} catch (ex) { + if (ex.code !== 'EEXIST') { + throw ex; + } +} + +describe('pug', function() { + describe('unit tests with .render()', function() { + it('should support doctypes', function() { + assert.equal( + '', + pug.render('doctype xml') + ); + assert.equal('', pug.render('doctype html')); + assert.equal('', pug.render('doctype foo bar baz')); + assert.equal('', pug.render('doctype html')); + assert.equal('', pug.render('doctype', {doctype: 'html'})); + assert.equal( + '', + pug.render('doctype html', {doctype: 'xml'}) + ); + assert.equal('', pug.render('html')); + assert.equal( + '', + pug.render('html', {doctype: 'html'}) + ); + assert.equal( + '', + pug.render('doctype html PUBLIC "-//W3C//DTD XHTML Basic 1.1//EN') + ); + }); + + it('should support Buffers', function() { + assert.equal('

    foo

    ', pug.render(Buffer.from('p foo'))); + }); + + it('should support line endings', function() { + var src = ['p', 'div', 'img']; + + var html = ['

    ', '
    ', ''].join(''); + + assert.equal(html, pug.render(src.join('\n'))); + assert.equal(html, pug.render(src.join('\r'))); + assert.equal(html, pug.render(src.join('\r\n'))); + + html = ['

    ', '
    ', ''].join(''); + + assert.equal(html, pug.render(src.join('\n'), {doctype: 'html'})); + assert.equal(html, pug.render(src.join('\r'), {doctype: 'html'})); + assert.equal(html, pug.render(src.join('\r\n'), {doctype: 'html'})); + }); + + it('should support single quotes', function() { + assert.equal("

    'foo'

    ", pug.render("p 'foo'")); + assert.equal("

    'foo'

    ", pug.render("p\n | 'foo'")); + assert.equal( + '', + pug.render("- var path = 'foo';\na(href='/' + path)") + ); + }); + + it('should support block-expansion', function() { + assert.equal( + '
  • foo
  • bar
  • baz
  • ', + pug.render('li: a foo\nli: a bar\nli: a baz') + ); + assert.equal( + '
  • foo
  • bar
  • baz
  • ', + pug.render('li.first: a foo\nli: a bar\nli: a baz') + ); + assert.equal( + '
    baz
    ', + pug.render('.foo: .bar baz') + ); + }); + + it('should support tags', function() { + var str = ['p', 'div', 'img', 'br/'].join('\n'); + + var html = ['

    ', '
    ', '', '
    '].join(''); + + assert.equal(html, pug.render(str), 'Test basic tags'); + assert.equal( + '', + pug.render('fb:foo-bar'), + 'Test hyphens' + ); + assert.equal( + '
    ', + pug.render('div.something'), + 'Test classes' + ); + assert.equal( + '
    ', + pug.render('div#something'), + 'Test ids' + ); + assert.equal( + '
    ', + pug.render('.something'), + 'Test stand-alone classes' + ); + assert.equal( + '
    ', + pug.render('#something'), + 'Test stand-alone ids' + ); + assert.equal('
    ', pug.render('#foo.bar')); + assert.equal('
    ', pug.render('.bar#foo')); + assert.equal( + '
    ', + pug.render('div#foo(class="bar")') + ); + assert.equal( + '
    ', + pug.render('div(class="bar")#foo') + ); + assert.equal( + '
    ', + pug.render('div(id="bar").foo') + ); + assert.equal( + '
    ', + pug.render('div.foo.bar.baz') + ); + assert.equal( + '
    ', + pug.render('div(class="foo").bar.baz') + ); + assert.equal( + '
    ', + pug.render('div.foo(class="bar").baz') + ); + assert.equal( + '
    ', + pug.render('div.foo.bar(class="baz")') + ); + assert.equal('
    ', pug.render('div.a-b2')); + assert.equal('
    ', pug.render('div.a_b2')); + assert.equal('', pug.render('fb:user')); + assert.equal('', pug.render('fb:user:role')); + assert.equal( + '', + pug.render('colgroup\n col.test') + ); + }); + + it('should support nested tags', function() { + var str = [ + 'ul', + ' li a', + ' li b', + ' li', + ' ul', + ' li c', + ' li d', + ' li e', + ].join('\n'); + + var html = [ + '
      ', + '
    • a
    • ', + '
    • b
    • ', + '
      • c
      • d
    • ', + '
    • e
    • ', + '
    ', + ].join(''); + + assert.equal(html, pug.render(str)); + + var str = ['a(href="#")', ' | foo ', ' | bar ', ' | baz'].join('\n'); + + assert.equal('foo \nbar \nbaz', pug.render(str)); + + var str = ['ul', ' li one', ' ul', ' | two', ' li three'].join( + '\n' + ); + + var html = [ + '
      ', + '
    • one
    • ', + '
        two', + '
      • three
      • ', + '
      ', + '
    ', + ].join(''); + + assert.equal(html, pug.render(str)); + }); + + it('should support variable length newlines', function() { + var str = [ + 'ul', + ' li a', + ' ', + ' li b', + ' ', + ' ', + ' li', + ' ul', + ' li c', + '', + ' li d', + ' li e', + ].join('\n'); + + var html = [ + '
      ', + '
    • a
    • ', + '
    • b
    • ', + '
      • c
      • d
    • ', + '
    • e
    • ', + '
    ', + ].join(''); + + assert.equal(html, pug.render(str)); + }); + + it('should support tab conversion', function() { + var str = [ + 'ul', + '\tli a', + '\t', + '\tli b', + '\t\t', + '\t\t\t\t\t\t', + '\tli', + '\t\tul', + '\t\t\tli c', + '', + '\t\t\tli d', + '\tli e', + ].join('\n'); + + var html = [ + '
      ', + '
    • a
    • ', + '
    • b
    • ', + '
      • c
      • d
    • ', + '
    • e
    • ', + '
    ', + ].join(''); + + assert.equal(html, pug.render(str)); + }); + + it('should support newlines', function() { + var str = [ + 'ul', + ' li a', + ' ', + ' ', + '', + ' ', + ' li b', + ' li', + ' ', + ' ', + ' ', + ' ul', + ' ', + ' li c', + ' li d', + ' li e', + ].join('\n'); + + var html = [ + '
      ', + '
    • a
    • ', + '
    • b
    • ', + '
      • c
      • d
    • ', + '
    • e
    • ', + '
    ', + ].join(''); + + assert.equal(html, pug.render(str)); + + var str = [ + 'html', + ' ', + ' head', + ' != "test"', + ' ', + ' ', + ' ', + ' body', + ].join('\n'); + + var html = [ + '', + '', + 'test', + '', + '', + '', + ].join(''); + + assert.equal(html, pug.render(str)); + assert.equal( + 'something', + pug.render('foo\n= "something"\nbar') + ); + assert.equal( + 'somethingelse', + pug.render('foo\n= "something"\nbar\n= "else"') + ); + }); + + it('should support text', function() { + assert.equal('foo\nbar\nbaz', pug.render('| foo\n| bar\n| baz')); + assert.equal('foo \nbar \nbaz', pug.render('| foo \n| bar \n| baz')); + assert.equal('(hey)', pug.render('| (hey)')); + assert.equal('some random text', pug.render('| some random text')); + assert.equal(' foo', pug.render('| foo')); + assert.equal(' foo ', pug.render('| foo ')); + assert.equal(' foo \n bar ', pug.render('| foo \n| bar ')); + }); + + it('should support pipe-less text', function() { + assert.equal( + '
    ', + pug.render('pre\n code\n foo\n\n bar') + ); + assert.equal('

    foo\n\nbar

    ', pug.render('p.\n foo\n\n bar')); + assert.equal( + '

    foo\n\n\n\nbar

    ', + pug.render('p.\n foo\n\n\n\n bar') + ); + assert.equal( + '

    foo\n bar\nfoo

    ', + pug.render('p.\n foo\n bar\n foo') + ); + assert.equal( + '', + pug.render('script.\n s.parentNode.insertBefore(g,s)\n') + ); + assert.equal( + '', + pug.render('script.\n s.parentNode.insertBefore(g,s)') + ); + }); + + it('should support tag text', function() { + assert.equal('

    some random text

    ', pug.render('p some random text')); + assert.equal( + '

    clickGoogle.

    ', + pug.render('p\n | click\n a Google\n | .') + ); + assert.equal('

    (parens)

    ', pug.render('p (parens)')); + assert.equal( + '

    (parens)

    ', + pug.render('p(foo="bar") (parens)') + ); + assert.equal( + '', + pug.render('option(value="") -- (optional) foo --') + ); + }); + + it('should support tag text block', function() { + assert.equal( + '

    foo \nbar \nbaz

    ', + pug.render('p\n | foo \n | bar \n | baz') + ); + assert.equal( + '', + pug.render('label\n | Password:\n input') + ); + assert.equal( + '', + pug.render('label Password:\n input') + ); + }); + + it('should support tag text interpolation', function() { + assert.equal( + 'yo, pug is cool', + pug.render('| yo, #{name} is cool\n', {name: 'pug'}) + ); + assert.equal( + '

    yo, pug is cool

    ', + pug.render('p yo, #{name} is cool', {name: 'pug'}) + ); + assert.equal( + 'yo, pug is cool', + pug.render('| yo, #{name || "pug"} is cool', {name: null}) + ); + assert.equal( + "yo, 'pug' is cool", + pug.render('| yo, #{name || "\'pug\'"} is cool', {name: null}) + ); + assert.equal( + 'foo <script> bar', + pug.render('| foo #{code} bar', {code: '', + '', + '', + ].join(''); + + assert.equal(html, pug.render(str)); + }); + + it('should support comments', function() { + // Regular + var str = ['//foo', 'p bar'].join('\n'); + + var html = ['', '

    bar

    '].join(''); + + assert.equal(html, pug.render(str)); + + // Between tags + + var str = ['p foo', '// bar ', 'p baz'].join('\n'); + + var html = ['

    foo

    ', '', '

    baz

    '].join(''); + + assert.equal(html, pug.render(str)); + + // Quotes + + var str = "", + js = "// script(src: '/js/validate.js') "; + assert.equal(str, pug.render(js)); + }); + + it('should support unbuffered comments', function() { + var str = ['//- foo', 'p bar'].join('\n'); + + var html = ['

    bar

    '].join(''); + + assert.equal(html, pug.render(str)); + + var str = ['p foo', '//- bar ', 'p baz'].join('\n'); + + var html = ['

    foo

    ', '

    baz

    '].join(''); + + assert.equal(html, pug.render(str)); + }); + + it('should support literal html', function() { + assert.equal( + '', + pug.render('') + ); + }); + + it('should support code', function() { + assert.equal('test', pug.render('!= "test"')); + assert.equal('test', pug.render('= "test"')); + assert.equal('test', pug.render('- var foo = "test"\n=foo')); + assert.equal( + 'footestbar', + pug.render('- var foo = "test"\n| foo\nem= foo\n| bar') + ); + assert.equal( + 'test

    something

    ', + pug.render('!= "test"\nh2 something') + ); + + var str = ['- var foo = "', + pug.render(str, {filename: __dirname + '/pug.test.js'}) + ); + }); + + it('should not fail on js newlines', function() { + assert.equal('

    foo\u2028bar

    ', pug.render('p foo\u2028bar')); + assert.equal('

    foo\u2029bar

    ', pug.render('p foo\u2029bar')); + }); + + it('should display error line number correctly up to token level', function() { + var str = [ + 'p.', + ' Lorem ipsum dolor sit amet, consectetur', + ' adipisicing elit, sed do eiusmod tempor', + ' incididunt ut labore et dolore magna aliqua.', + 'p.', + ' Ut enim ad minim veniam, quis nostrud', + ' exercitation ullamco laboris nisi ut aliquip', + ' ex ea commodo consequat.', + 'p.', + ' Duis aute irure dolor in reprehenderit', + ' in voluptate velit esse cillum dolore eu', + ' fugiat nulla pariatur.', + 'a(href="#" Next', + ].join('\n'); + var errorLocation = function(str) { + try { + pug.render(str); + } catch (err) { + return err.message.split('\n')[0]; + } + }; + assert.equal(errorLocation(str), 'Pug:13:16'); + }); + }); + + describe('.compileFile()', function() { + it('does not produce warnings for issue-1593', function() { + pug.compileFile(__dirname + '/fixtures/issue-1593/index.pug'); + }); + it('should support caching (pass 1)', function() { + fs.writeFileSync(__dirname + '/temp/input-compileFile.pug', '.foo bar'); + var fn = pug.compileFile(__dirname + '/temp/input-compileFile.pug', { + cache: true, + }); + var expected = '
    bar
    '; + assert(fn() === expected); + }); + it('should support caching (pass 2)', function() { + // Poison the input file + fs.writeFileSync( + __dirname + '/temp/input-compileFile.pug', + '.big fat hen' + ); + var fn = pug.compileFile(__dirname + '/temp/input-compileFile.pug', { + cache: true, + }); + var expected = '
    bar
    '; + assert(fn() === expected); + }); + }); + + describe('.render()', function() { + it('should support .pug.render(str, fn)', function() { + pug.render('p foo bar', function(err, str) { + assert.ok(!err); + assert.equal('

    foo bar

    ', str); + }); + }); + + it('should support .pug.render(str, options, fn)', function() { + pug.render('p #{foo}', {foo: 'bar'}, function(err, str) { + assert.ok(!err); + assert.equal('

    bar

    ', str); + }); + }); + + it('should support .pug.render(str, options, fn) cache', function() { + pug.render('p bar', {cache: true}, function(err, str) { + assert.ok( + /the "filename" option is required for caching/.test(err.message) + ); + }); + + pug.render('p foo bar', {cache: true, filename: 'test'}, function( + err, + str + ) { + assert.ok(!err); + assert.equal('

    foo bar

    ', str); + }); + }); + }); + + describe('.compile()', function() { + it('should support .compile()', function() { + var fn = pug.compile('p foo'); + assert.equal('

    foo

    ', fn()); + }); + + it('should support .compile() locals', function() { + var fn = pug.compile('p= foo'); + assert.equal('

    bar

    ', fn({foo: 'bar'})); + }); + + it("should support .compile() locals in 'self' hash", function() { + var fn = pug.compile('p= self.foo', {self: true}); + assert.equal('

    bar

    ', fn({foo: 'bar'})); + }); + + it('should support .compile() no debug', function() { + var fn = pug.compile('p foo\np #{bar}', {compileDebug: false}); + assert.equal('

    foo

    baz

    ', fn({bar: 'baz'})); + }); + + it('should support .compile() no debug and global helpers', function() { + var fn = pug.compile('p foo\np #{bar}', { + compileDebug: false, + helpers: 'global', + }); + assert.equal('

    foo

    baz

    ', fn({bar: 'baz'})); + }); + + it('should be reasonably fast', function() { + pug.compile(perfTest, {}); + }); + it('allows trailing space (see #1586)', function() { + var res = pug.render('ul \n li An Item'); + assert.equal('
    • An Item
    ', res); + }); + }); + + describe('.compileClient()', function() { + it('should support pug.compileClient(str)', function() { + var src = fs.readFileSync(__dirname + '/cases/basic.pug'); + var expected = fs + .readFileSync(__dirname + '/cases/basic.html', 'utf8') + .replace(/\s/g, ''); + var fn = pug.compileClient(src); + fn = Function('pug', fn.toString() + '\nreturn template;')(pug.runtime); + var actual = fn({name: 'foo'}).replace(/\s/g, ''); + expect(actual).toBe(expected); + }); + it('should support pug.compileClient(str, options)', function() { + var src = '.bar= self.foo'; + var fn = pug.compileClient(src, {self: true}); + fn = Function('pug', fn.toString() + '\nreturn template;')(pug.runtime); + var actual = fn({foo: 'baz'}); + expect(actual).toBe('
    baz
    '); + }); + it('should support module syntax in pug.compileClient(str, options) when inlineRuntimeFunctions it true', function() { + var src = '.bar= self.foo'; + var fn = pug.compileClient(src, { + self: true, + module: true, + inlineRuntimeFunctions: true, + }); + expect(fn).toMatchSnapshot(); + fs.writeFileSync( + __dirname + '/temp/input-compileModuleFileClient.js', + fn + ); + var fn = require(__dirname + '/temp/input-compileModuleFileClient.js'); + expect(fn({foo: 'baz'})).toBe('
    baz
    '); + }); + it('should support module syntax in pug.compileClient(str, options) when inlineRuntimeFunctions it false', function() { + var src = '.bar= self.foo'; + var fn = pug.compileClient(src, { + self: true, + module: true, + inlineRuntimeFunctions: false, + }); + expect(fn).toMatchSnapshot(); + fs.writeFileSync( + __dirname + '/temp/input-compileModuleFileClient.js', + fn + ); + var fn = require(__dirname + '/temp/input-compileModuleFileClient.js'); + expect(fn({foo: 'baz'})).toBe('
    baz
    '); + }); + }); + + describe('.renderFile()', function() { + it('will synchronously return a string', function() { + var expected = fs + .readFileSync(__dirname + '/cases/basic.html', 'utf8') + .replace(/\s/g, ''); + var actual = pug + .renderFile(__dirname + '/cases/basic.pug', {name: 'foo'}) + .replace(/\s/g, ''); + assert(actual === expected); + }); + it('when given a callback, it calls that rather than returning', function(done) { + var expected = fs + .readFileSync(__dirname + '/cases/basic.html', 'utf8') + .replace(/\s/g, ''); + pug.renderFile(__dirname + '/cases/basic.pug', {name: 'foo'}, function( + err, + actual + ) { + if (err) return done(err); + assert(actual.replace(/\s/g, '') === expected); + done(); + }); + }); + it('when given a callback, it calls that rather than returning even if there are no options', function(done) { + var expected = fs + .readFileSync(__dirname + '/cases/basic.html', 'utf8') + .replace(/\s/g, ''); + pug.renderFile(__dirname + '/cases/basic.pug', function(err, actual) { + if (err) return done(err); + assert(actual.replace(/\s/g, '') === expected); + done(); + }); + }); + it('when given a callback, it calls that with any errors', function(done) { + pug.renderFile(__dirname + '/fixtures/runtime.error.pug', function( + err, + actual + ) { + assert.ok(err); + done(); + }); + }); + it('should support caching (pass 1)', function(done) { + fs.writeFileSync(__dirname + '/temp/input-renderFile.pug', '.foo bar'); + pug.renderFile( + __dirname + '/temp/input-renderFile.pug', + {cache: true}, + function(err, actual) { + if (err) return done(err); + assert.equal('
    bar
    ', actual); + done(); + } + ); + }); + it('should support caching (pass 2)', function(done) { + // Poison the input file + fs.writeFileSync( + __dirname + '/temp/input-renderFile.pug', + '.big fat hen' + ); + pug.renderFile( + __dirname + '/temp/input-renderFile.pug', + {cache: true}, + function(err, actual) { + if (err) return done(err); + assert.equal('
    bar
    ', actual); + done(); + } + ); + }); + }); + + describe('.compileFileClient(path, options)', function() { + it('returns a string form of a function called `template`', function() { + var src = pug.compileFileClient(__dirname + '/cases/basic.pug'); + var expected = fs + .readFileSync(__dirname + '/cases/basic.html', 'utf8') + .replace(/\s/g, ''); + var fn = Function('pug', src + '\nreturn template;')(pug.runtime); + var actual = fn({name: 'foo'}).replace(/\s/g, ''); + assert(actual === expected); + }); + it('accepts the `name` option to rename the resulting function', function() { + var src = pug.compileFileClient(__dirname + '/cases/basic.pug', { + name: 'myTemplateName', + }); + var expected = fs + .readFileSync(__dirname + '/cases/basic.html', 'utf8') + .replace(/\s/g, ''); + var fn = Function('pug', src + '\nreturn myTemplateName;')(pug.runtime); + var actual = fn({name: 'foo'}).replace(/\s/g, ''); + assert(actual === expected); + }); + it('should support caching (pass 1)', function() { + fs.writeFileSync( + __dirname + '/temp/input-compileFileClient.pug', + '.foo bar' + ); + var src = pug.compileFileClient( + __dirname + '/temp/input-compileFileClient.pug', + {name: 'myTemplateName', cache: true} + ); + var expected = '
    bar
    '; + var fn = Function('pug', src + '\nreturn myTemplateName;')(pug.runtime); + assert(fn() === expected); + }); + it('should support caching (pass 2)', function() { + // Poison the input file + fs.writeFileSync( + __dirname + '/temp/input-compileFileClient.pug', + '.big fat hen' + ); + var src = pug.compileFileClient( + __dirname + '/temp/input-compileFileClient.pug', + {name: 'myTemplateName', cache: true} + ); + var expected = '
    bar
    '; + var fn = Function('pug', src + '\nreturn myTemplateName;')(pug.runtime); + assert(fn() === expected); + }); + }); + + describe('.runtime', function() { + describe('.merge', function() { + it('merges two attribute objects, giving precedensce to the second object', function() { + assert.deepEqual( + pug.runtime.merge({}, {class: ['foo', 'bar'], foo: 'bar'}), + {class: ['foo', 'bar'], foo: 'bar'} + ); + assert.deepEqual( + pug.runtime.merge( + {class: ['foo'], foo: 'baz'}, + {class: ['bar'], foo: 'bar'} + ), + {class: ['foo', 'bar'], foo: 'bar'} + ); + assert.deepEqual( + pug.runtime.merge({class: ['foo', 'bar'], foo: 'bar'}, {}), + {class: ['foo', 'bar'], foo: 'bar'} + ); + }); + }); + describe('.attrs', function() { + it('Renders the given attributes object', function() { + assert.equal(pug.runtime.attrs({}), ''); + assert.equal(pug.runtime.attrs({class: []}), ''); + assert.equal(pug.runtime.attrs({class: ['foo']}), ' class="foo"'); + assert.equal( + pug.runtime.attrs({class: ['foo'], id: 'bar'}), + ' class="foo" id="bar"' + ); + }); + }); + }); + + describe('filter indentation', function() { + it('is maintained', function() { + var filters = { + indents: function(str) { + return str + .split(/\n/) + .map(function(line) { + return line.match(/^ */)[0].length; + }) + .join(','); + }, + }; + + var indents = [ + ':indents', + ' x', + ' x', + ' x', + ' x', + ' x', + ' x', + ' x', + ' x', + ' x', + ' x', + ' x', + ' x', + ' x', + ' x', + ' x', + ].join('\n'); + + assert.equal( + pug.render(indents, {filters: filters}), + '0,1,2,3,0,4,4,3,3,4,2,0,2,0,1' + ); + }); + }); + + describe('.compile().dependencies', function() { + it('should list the filename of the template referenced by extends', function() { + var filename = __dirname + '/dependencies/extends1.pug'; + var str = fs.readFileSync(filename, 'utf8'); + var info = pug.compile(str, {filename: filename}); + assert.deepEqual( + [path.resolve(__dirname + '/dependencies/dependency1.pug')], + info.dependencies + ); + }); + it('should list the filename of the template referenced by an include', function() { + var filename = __dirname + '/dependencies/include1.pug'; + var str = fs.readFileSync(filename, 'utf8'); + var info = pug.compile(str, {filename: filename}); + assert.deepEqual( + [path.resolve(__dirname + '/dependencies/dependency1.pug')], + info.dependencies + ); + }); + it('should list the dependencies of extends dependencies', function() { + var filename = __dirname + '/dependencies/extends2.pug'; + var str = fs.readFileSync(filename, 'utf8'); + var info = pug.compile(str, {filename: filename}); + assert.deepEqual( + [ + path.resolve(__dirname + '/dependencies/dependency2.pug'), + path.resolve(__dirname + '/dependencies/dependency3.pug'), + ], + info.dependencies + ); + }); + it('should list the dependencies of include dependencies', function() { + var filename = __dirname + '/dependencies/include2.pug'; + var str = fs.readFileSync(filename, 'utf8'); + var info = pug.compile(str, {filename: filename}); + assert.deepEqual( + [ + path.resolve(__dirname + '/dependencies/dependency2.pug'), + path.resolve(__dirname + '/dependencies/dependency3.pug'), + ], + info.dependencies + ); + }); + }); + + describe('.name', function() { + it('should have a name attribute', function() { + assert.strictEqual(pug.name, 'Pug'); + }); + }); +}); diff --git a/src/test-data/pug/test/regression-2436/__snapshots__/index.test.js.snap b/src/test-data/pug/test/regression-2436/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..142ec67 --- /dev/null +++ b/src/test-data/pug/test/regression-2436/__snapshots__/index.test.js.snap @@ -0,0 +1,17 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#2436 - block with a same name extends from different layout in nesting 1`] = ` +" +

    layout

    +

    Main A

    +

    other layout

    +

    Other A

    " +`; + +exports[`#2436 - block with a same name extends from the same layout in nesting 1`] = ` +" +

    layout

    +

    Main A

    +

    layout

    +

    Other A

    " +`; diff --git a/src/test-data/pug/test/regression-2436/index.test.js b/src/test-data/pug/test/regression-2436/index.test.js new file mode 100644 index 0000000..d9647b8 --- /dev/null +++ b/src/test-data/pug/test/regression-2436/index.test.js @@ -0,0 +1,11 @@ +const pug = require('../../'); + +test('#2436 - block with a same name extends from the same layout in nesting', () => { + const output = pug.renderFile(__dirname + '/issue1.pug', {pretty: true}); + expect(output).toMatchSnapshot(); +}); + +test('#2436 - block with a same name extends from different layout in nesting', () => { + const output = pug.renderFile(__dirname + '/issue2.pug', {pretty: true}); + expect(output).toMatchSnapshot(); +}); diff --git a/src/test-data/pug/test/regression-2436/issue1.pug b/src/test-data/pug/test/regression-2436/issue1.pug new file mode 100644 index 0000000..f521c6e --- /dev/null +++ b/src/test-data/pug/test/regression-2436/issue1.pug @@ -0,0 +1,7 @@ +extends layout.pug + +block a + p Main A + +block b + include other1.pug diff --git a/src/test-data/pug/test/regression-2436/issue2.pug b/src/test-data/pug/test/regression-2436/issue2.pug new file mode 100644 index 0000000..7acba59 --- /dev/null +++ b/src/test-data/pug/test/regression-2436/issue2.pug @@ -0,0 +1,7 @@ +extends layout.pug + +block a + p Main A + +block b + include other2.pug diff --git a/src/test-data/pug/test/regression-2436/layout.pug b/src/test-data/pug/test/regression-2436/layout.pug new file mode 100644 index 0000000..71ad7eb --- /dev/null +++ b/src/test-data/pug/test/regression-2436/layout.pug @@ -0,0 +1,6 @@ +h1 layout + +block a + p block in layout + +block b diff --git a/src/test-data/pug/test/regression-2436/other1.pug b/src/test-data/pug/test/regression-2436/other1.pug new file mode 100644 index 0000000..206cb4f --- /dev/null +++ b/src/test-data/pug/test/regression-2436/other1.pug @@ -0,0 +1,4 @@ +extends layout.pug + +block a + p Other A diff --git a/src/test-data/pug/test/regression-2436/other2.pug b/src/test-data/pug/test/regression-2436/other2.pug new file mode 100644 index 0000000..6329aeb --- /dev/null +++ b/src/test-data/pug/test/regression-2436/other2.pug @@ -0,0 +1,4 @@ +extends other_layout.pug + +block a + p Other A diff --git a/src/test-data/pug/test/regression-2436/other_layout.pug b/src/test-data/pug/test/regression-2436/other_layout.pug new file mode 100644 index 0000000..edda161 --- /dev/null +++ b/src/test-data/pug/test/regression-2436/other_layout.pug @@ -0,0 +1,4 @@ +h1 other layout + +block a + p block in other layout diff --git a/src/test-data/pug/test/run-es2015.test.js b/src/test-data/pug/test/run-es2015.test.js new file mode 100644 index 0000000..0ee3d35 --- /dev/null +++ b/src/test-data/pug/test/run-es2015.test.js @@ -0,0 +1,21 @@ +'use strict'; + +const fs = require('fs'); +const assert = require('assert'); +const mkdirp = require('mkdirp').sync; +const runUtils = require('./run-utils'); +const pug = require('../'); + +var cases = runUtils.findCases(__dirname + '/cases'); +var es2015 = runUtils.findCases(__dirname + '/cases-es2015'); + +mkdirp(__dirname + '/output-es2015'); + +describe('test cases for ECMAScript 2015', function() { + try { + eval('``'); + es2015.forEach(runUtils.testSingle.bind(null, it, '-es2015')); + } catch (ex) { + es2015.forEach(runUtils.testSingle.bind(null, it.skip, '-es2015')); + } +}); diff --git a/src/test-data/pug/test/run-syntax-errors.test.js b/src/test-data/pug/test/run-syntax-errors.test.js new file mode 100644 index 0000000..379750c --- /dev/null +++ b/src/test-data/pug/test/run-syntax-errors.test.js @@ -0,0 +1,43 @@ +const assert = require('assert'); +const fs = require('fs'); +const runUtils = require('./run-utils'); +const pug = require('../'); + +const anti = runUtils.findCases(__dirname + '/anti-cases'); + +describe('certain syntax is not allowed and will throw a compile time error', function() { + anti.forEach(function(test) { + var name = test.replace(/[-.]/g, ' '); + it(name, function() { + var path = __dirname.replace(/\\/g, '/') + '/anti-cases/' + test + '.pug'; + var str = fs.readFileSync(path, 'utf8'); + try { + var fn = pug.compile(str, { + filename: path, + pretty: true, + basedir: __dirname + '/anti-cases', + filters: runUtils.filters, + }); + } catch (ex) { + if (!ex.code) { + throw ex; + } + assert(ex instanceof Error, 'Should throw a real Error'); + assert( + ex.code.indexOf('PUG:') === 0, + 'It should have a code of "PUG:SOMETHING"' + ); + assert( + ex.message.replace(/\\/g, '/').indexOf(path) === 0, + 'it should start with the path' + ); + assert( + /:\d+$/m.test(ex.message.replace(/\\/g, '/')), + 'it should include a line number.' + ); + return; + } + throw new Error(test + ' should have thrown an error'); + }); + }); +}); diff --git a/src/test-data/pug/test/run-utils.js b/src/test-data/pug/test/run-utils.js new file mode 100644 index 0000000..2d1eccf --- /dev/null +++ b/src/test-data/pug/test/run-utils.js @@ -0,0 +1,154 @@ +var fs = require('fs'); +var assert = require('assert'); +var pug = require('../'); +var uglify = require('uglify-js'); +var mkdirp = require('mkdirp').sync; + +var filters = { + custom: function(str, options) { + assert(options.opt === 'val'); + assert(options.num === 2); + return 'BEGIN' + str + 'END'; + }, +}; + +// test cases + +function writeFileSync(filename, data) { + try { + if (fs.readFileSync(filename, 'utf8') === data.toString('utf8')) { + return; + } + } catch (ex) { + if (ex.code !== 'ENOENT') { + throw ex; + } + } + fs.writeFileSync(filename, data); +} + +function findCases(dir) { + return fs + .readdirSync(dir) + .filter(function(file) { + return ~file.indexOf('.pug'); + }) + .map(function(file) { + return file.replace('.pug', ''); + }); +} + +function testSingle(it, suffix, test) { + var name = test.replace(/[-.]/g, ' '); + it(name, function() { + var path = __dirname + '/cases' + suffix + '/' + test + '.pug'; + var str = fs.readFileSync(path, 'utf8'); + var fn = pug.compile(str, { + filename: path, + pretty: true, + basedir: __dirname + '/cases' + suffix, + filters: filters, + filterAliases: {markdown: 'markdown-it'}, + }); + var actual = fn({title: 'Pug'}); + + writeFileSync( + __dirname + '/output' + suffix + '/' + test + '.html', + actual + ); + + var html = fs + .readFileSync( + __dirname + '/cases' + suffix + '/' + test + '.html', + 'utf8' + ) + .trim() + .replace(/\r/g, ''); + var clientCode = uglify.minify( + pug.compileClient(str, { + filename: path, + pretty: true, + compileDebug: false, + basedir: __dirname + '/cases' + suffix, + filters: filters, + filterAliases: {markdown: 'markdown-it'}, + }), + { + output: {beautify: true}, + mangle: false, + compress: false, + fromString: true, + } + ).code; + var clientCodeDebug = uglify.minify( + pug.compileClient(str, { + filename: path, + pretty: true, + compileDebug: true, + basedir: __dirname + '/cases' + suffix, + filters: filters, + filterAliases: {markdown: 'markdown-it'}, + }), + { + output: {beautify: true}, + mangle: false, + compress: false, + fromString: true, + } + ).code; + writeFileSync( + __dirname + '/output' + suffix + '/' + test + '.js', + uglify.minify( + pug.compileClient(str, { + filename: path, + pretty: false, + compileDebug: false, + basedir: __dirname + '/cases' + suffix, + filters: filters, + filterAliases: {markdown: 'markdown-it'}, + }), + { + output: {beautify: true}, + mangle: false, + compress: false, + fromString: true, + } + ).code + ); + if (/filter/.test(test)) { + actual = actual.replace(/\n| /g, ''); + html = html.replace(/\n| /g, ''); + } + if (/mixins-unused/.test(test)) { + assert( + /never-called/.test(str), + 'never-called is in the pug file for mixins-unused' + ); + assert( + !/never-called/.test(clientCode), + 'never-called should be removed from the code' + ); + } + expect(actual.trim()).toEqual(html); + actual = Function('pug', clientCode + '\nreturn template;')()({ + title: 'Pug', + }); + if (/filter/.test(test)) { + actual = actual.replace(/\n| /g, ''); + } + expect(actual.trim()).toEqual(html); + actual = Function('pug', clientCodeDebug + '\nreturn template;')()({ + title: 'Pug', + }); + if (/filter/.test(test)) { + actual = actual.replace(/\n| /g, ''); + } + expect(actual.trim()).toEqual(html); + }); +} + +module.exports = { + filters, + findCases, + testSingle, +}; diff --git a/src/test-data/pug/test/run.test.js b/src/test-data/pug/test/run.test.js new file mode 100644 index 0000000..b5c0270 --- /dev/null +++ b/src/test-data/pug/test/run.test.js @@ -0,0 +1,20 @@ +'use strict'; + +// even and odd tests are arbitrarily split because jest is faster that way + +const fs = require('fs'); +const assert = require('assert'); +const mkdirp = require('mkdirp').sync; +const runUtils = require('./run-utils'); +const pug = require('../'); + +var cases = runUtils.findCases(__dirname + '/cases'); +var es2015 = runUtils.findCases(__dirname + '/cases-es2015'); + +mkdirp(__dirname + '/output'); + +describe('test cases', function() { + cases.forEach((test, i) => { + runUtils.testSingle(it, '', test); + }); +}); diff --git a/src/test-data/pug/test/shadowed-block/__snapshots__/index.test.js.snap b/src/test-data/pug/test/shadowed-block/__snapshots__/index.test.js.snap new file mode 100644 index 0000000..43a917e --- /dev/null +++ b/src/test-data/pug/test/shadowed-block/__snapshots__/index.test.js.snap @@ -0,0 +1,5 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`layout with shadowed block 1`] = `""`; + +exports[`layout with shadowed block 2`] = `""`; diff --git a/src/test-data/pug/test/shadowed-block/base.pug b/src/test-data/pug/test/shadowed-block/base.pug new file mode 100644 index 0000000..7148cac --- /dev/null +++ b/src/test-data/pug/test/shadowed-block/base.pug @@ -0,0 +1,4 @@ +block root + // base.pug: root + block shadowed + // base.pug: shadowed diff --git a/src/test-data/pug/test/shadowed-block/index.pug b/src/test-data/pug/test/shadowed-block/index.pug new file mode 100644 index 0000000..dabca48 --- /dev/null +++ b/src/test-data/pug/test/shadowed-block/index.pug @@ -0,0 +1,4 @@ +extends ./layout.pug + +block shadowed + // index.pug: shadowed diff --git a/src/test-data/pug/test/shadowed-block/index.test.js b/src/test-data/pug/test/shadowed-block/index.test.js new file mode 100644 index 0000000..97999a8 --- /dev/null +++ b/src/test-data/pug/test/shadowed-block/index.test.js @@ -0,0 +1,10 @@ +const pug = require('../../'); + +test('layout with shadowed block', () => { + const outputWithAjax = pug.renderFile(__dirname + '/index.pug', {ajax: true}); + const outputWithoutAjax = pug.renderFile(__dirname + '/index.pug', { + ajax: false, + }); + expect(outputWithAjax).toMatchSnapshot(); + expect(outputWithoutAjax).toMatchSnapshot(); +}); diff --git a/src/test-data/pug/test/shadowed-block/layout.pug b/src/test-data/pug/test/shadowed-block/layout.pug new file mode 100644 index 0000000..fa27d25 --- /dev/null +++ b/src/test-data/pug/test/shadowed-block/layout.pug @@ -0,0 +1,6 @@ +extends ./base.pug + +block root + // layout.pug: root + block shadowed + // layout.pug: shadowed diff --git a/src/test-data/pug/test/temp/input-compileFile.pug b/src/test-data/pug/test/temp/input-compileFile.pug new file mode 100644 index 0000000..7a3caea --- /dev/null +++ b/src/test-data/pug/test/temp/input-compileFile.pug @@ -0,0 +1 @@ +.big fat hen \ No newline at end of file diff --git a/src/test-data/pug/test/temp/input-compileFileClient.pug b/src/test-data/pug/test/temp/input-compileFileClient.pug new file mode 100644 index 0000000..7a3caea --- /dev/null +++ b/src/test-data/pug/test/temp/input-compileFileClient.pug @@ -0,0 +1 @@ +.big fat hen \ No newline at end of file diff --git a/src/test-data/pug/test/temp/input-renderFile.pug b/src/test-data/pug/test/temp/input-renderFile.pug new file mode 100644 index 0000000..7a3caea --- /dev/null +++ b/src/test-data/pug/test/temp/input-renderFile.pug @@ -0,0 +1 @@ +.big fat hen \ No newline at end of file diff --git a/src/tests/check_list/source.html b/src/tests/check_list/source.html index 1881c0f..4248ef7 100644 --- a/src/tests/check_list/source.html +++ b/src/tests/check_list/source.html @@ -1,6 +1,6 @@ -