// Copyright 2025 International Digital Economy Academy
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
///|
/// Mixin storage for rendering
priv struct MixinStore {
mixins : Map[String, (Array[(String, String)], Array[Node])] // name -> (params with defaults, body)
}
///|
fn MixinStore::new() -> MixinStore {
{ mixins: {} }
}
///|
/// Block storage for template inheritance
priv struct BlockStore {
// name -> (prepend nodes, append nodes)
blocks : Map[String, (Array[Node], Array[Node])]
}
///|
fn BlockStore::new() -> BlockStore {
{ blocks: {} }
}
///|
fn BlockStore::add_prepend(
self : BlockStore,
name : String,
nodes : Array[Node],
) -> Unit {
match self.blocks.get(name) {
Some((prepend, _append)) =>
for node in nodes {
prepend.push(node)
}
None => self.blocks[name] = (nodes, [])
}
}
///|
fn BlockStore::add_append(
self : BlockStore,
name : String,
nodes : Array[Node],
) -> Unit {
match self.blocks.get(name) {
Some((_prepend, append)) =>
for node in nodes {
append.push(node)
}
None => self.blocks[name] = ([], nodes)
}
}
///|
fn BlockStore::get(
self : BlockStore,
name : String,
) -> (Array[Node], Array[Node])? {
self.blocks.get(name)
}
///|
fn MixinStore::add(
self : MixinStore,
name : String,
params : Array[(String, String)],
body : Array[Node],
) -> Unit {
self.mixins[name] = (params, body)
}
///|
fn MixinStore::get(
self : MixinStore,
name : String,
) -> (Array[(String, String)], Array[Node])? {
self.mixins.get(name)
}
///|
/// Collect all mixin definitions from nodes
fn collect_mixins(nodes : Iter[Node], store : MixinStore) -> Unit {
for node in nodes {
match node {
MixinDef(name~, params~, body~) => store.add(name, params, body)
Element(children~, ..) => collect_mixins(children.iter(), store)
Conditional(if_true~, if_false~, ..) => {
collect_mixins(if_true.iter(), store)
collect_mixins(if_false.iter(), store)
}
Each(body~, else_body~, ..) => {
collect_mixins(body.iter(), store)
collect_mixins(else_body.iter(), store)
}
Case(cases~, default~, ..) => {
for pair in cases {
let (_, case_body) = pair
collect_mixins(case_body.iter(), store)
}
collect_mixins(default.iter(), store)
}
_ => ()
}
}
}
///|
/// Collect all append/prepend blocks from nodes
fn collect_blocks(nodes : Iter[Node], store : BlockStore) -> Unit {
for node in nodes {
match node {
NamedBlock(name~, body~, mode~) =>
if mode == "append" {
store.add_append(name, body)
} else if mode == "prepend" {
store.add_prepend(name, body)
}
Element(children~, ..) => collect_blocks(children.iter(), store)
Conditional(if_true~, if_false~, ..) => {
collect_blocks(if_true.iter(), store)
collect_blocks(if_false.iter(), store)
}
Each(body~, else_body~, ..) => {
collect_blocks(body.iter(), store)
collect_blocks(else_body.iter(), store)
}
Case(cases~, default~, ..) => {
for pair in cases {
let (_, case_body) = pair
collect_blocks(case_body.iter(), store)
}
collect_blocks(default.iter(), store)
}
_ => ()
}
}
}
///|
/// Self-closing HTML tags
fn is_void_element(tag : String) -> Bool {
match tag {
"area"
| "base"
| "br"
| "col"
| "embed"
| "hr"
| "img"
| "input"
| "link"
| "meta"
| "param"
| "source"
| "track"
| "wbr" => true
_ => false
}
}
///|
/// Raw text elements - content should NOT be HTML-escaped
fn is_raw_text_element(tag : String) -> Bool {
match tag {
"script" | "style" | "pre" | "code" | "textarea" => true
_ => false
}
}
///|
/// Escape HTML special characters
fn escape_html(s : String) -> String {
let buf = StringBuilder::new()
for c in s {
match c {
'&' => buf.write_string("&")
'<' => buf.write_string("<")
'>' => buf.write_string(">")
'"' => buf.write_string(""")
'\'' => buf.write_string("'")
_ => buf.write_char(c)
}
}
buf.to_string()
}
///|
/// Check if a string is a quoted literal
fn is_quoted_literal(s : String) -> Bool {
let chars = s.to_array()
if chars.length() < 2 {
return false
}
(chars[0] == '"' && chars[chars.length() - 1] == '"') ||
(chars[0] == '\'' && chars[chars.length() - 1] == '\'')
}
///|
/// Check if a string looks like a simple variable name (identifier)
fn is_simple_identifier(s : String) -> Bool {
let trimmed = s.trim(chars=" \t").to_string()
if trimmed.length() == 0 {
return false
}
let chars = trimmed.to_array()
// First char must be letter or underscore
let first = chars[0]
if not(
(first >= 'a' && first <= 'z') ||
(first >= 'A' && first <= 'Z') ||
first == '_',
) {
return false
}
// Rest must be alphanumeric or underscore
for i in 1..= 'a' && c <= 'z') ||
(c >= 'A' && c <= 'Z') ||
(c >= '0' && c <= '9') ||
c == '_',
) {
return false
}
}
true
}
///|
/// Check if an expression needs JS evaluation (contains operators, method calls, etc.)
/// Excludes quoted string literals and plain URLs which should be used as-is
fn needs_js_eval(expr : String) -> Bool {
// Quoted strings are literals, not expressions
if is_quoted_literal(expr) {
return false
}
// Simple identifiers are variable lookups, not JS expressions
if is_simple_identifier(expr) {
return false
}
// Look for specific JS expression patterns that distinguish from URLs/paths:
// 1. Method call: identifier.identifier(
// 2. Ternary: identifier ? value : value
// 3. Operators with spaces: " + ", " - ", " * ", " / "
// 4. Comparison operators: ==, !=, <=, >=
// 5. Array access: identifier[
// Check for ternary operator (? followed by :)
if expr.contains(" ? ") && expr.contains(" : ") {
return true
}
// Check for operators with spaces (common in expressions, not in URLs)
if expr.contains(" + ") || expr.contains(" - ") || expr.contains(" * ") {
return true
}
// Check for comparison operators
if expr.contains("==") ||
expr.contains("!=") ||
expr.contains("<=") ||
expr.contains(">=") {
return true
}
// Check for method call pattern: word.word(
let chars = expr.to_array()
let mut i = 0
while i < chars.length() {
if chars[i] == '.' {
// Found a dot, check if followed by identifier and (
let mut j = i + 1
// Skip identifier after dot
while j < chars.length() &&
(
(chars[j] >= 'a' && chars[j] <= 'z') ||
(chars[j] >= 'A' && chars[j] <= 'Z') ||
(chars[j] >= '0' && chars[j] <= '9') ||
chars[j] == '_'
) {
j += 1
}
// Check if followed by (
if j < chars.length() && j > i + 1 && chars[j] == '(' {
return true
}
}
i += 1
}
// Check for array access: identifier[
if expr.contains("[") && not(expr.has_prefix("[")) {
return true
}
false
}
///|
/// Process string with interpolations, replacing #{name} with values from locals
fn interpolate_string(s : String, locals : Locals, escape : Bool) -> String {
let chars = s.to_array()
let buf = StringBuilder::new()
let literal_buf = StringBuilder::new() // Accumulate literal text
let mut i = 0
while i < chars.length() {
// Check for #{...}
if i + 1 < chars.length() && chars[i] == '#' && chars[i + 1] == '{' {
// Flush accumulated literal text
let literal = literal_buf.to_string()
if literal.length() > 0 {
if escape {
buf.write_string(escape_html(literal))
} else {
buf.write_string(literal)
}
literal_buf.reset()
}
i += 2 // skip #{
// Read expression until }
let expr_buf = StringBuilder::new()
let mut brace_depth = 1
while i < chars.length() && brace_depth > 0 {
if chars[i] == '{' {
brace_depth += 1
expr_buf.write_char(chars[i])
} else if chars[i] == '}' {
brace_depth -= 1
if brace_depth > 0 {
expr_buf.write_char(chars[i])
}
} else {
expr_buf.write_char(chars[i])
}
i += 1
}
let expr = expr_buf.to_string()
// Evaluate the expression
let value = if needs_js_eval(expr) {
// Use JS evaluation for complex expressions
eval_js_expression(expr, locals)
} else {
// Simple variable lookup
match locals.get(expr) {
Some(v) => v
None => ""
}
}
if escape {
buf.write_string(escape_html(value))
} else {
buf.write_string(value)
}
} else {
literal_buf.write_char(chars[i])
i += 1
}
}
// Flush remaining literal text
let literal = literal_buf.to_string()
if literal.length() > 0 {
if escape {
buf.write_string(escape_html(literal))
} else {
buf.write_string(literal)
}
}
buf.to_string()
}
///|
/// Render options
pub struct RenderOptions {
/// Whether to pretty print with indentation
pretty : Bool
/// Indentation string (e.g., " " for 2 spaces)
indent_str : String
}
///|
pub fn RenderOptions::default() -> RenderOptions {
{ pretty: false, indent_str: " " }
}
///|
pub fn RenderOptions::pretty() -> RenderOptions {
{ pretty: true, indent_str: " " }
}
///|
/// Render a single node to HTML with locals, mixins, blocks, and optional block content
fn render_node_with_mixins(
node : Node,
buf : StringBuilder,
options : RenderOptions,
locals : Locals,
mixins : MixinStore,
blocks : BlockStore,
depth : Int,
block_content : Array[Node], // Content passed to mixin via block
) -> Unit {
let indent = if options.pretty {
let mut s = ""
for _ in 0.. {
buf.write_string(indent)
buf.write_string(get_doctype(value))
buf.write_string(newline)
}
Text(text) => {
buf.write_string(indent)
buf.write_string(escape_html(text))
buf.write_string(newline)
}
Interpolation(name) => {
// Check for post-increment (n++)
let (expr, is_post_increment) = if name.has_suffix("++") {
(try! name[0:name.length() - 2].to_string(), true)
} else {
(name, false)
}
// Evaluate the expression
let value = if needs_js_eval(expr) {
// Use JS evaluation for complex expressions (method calls, operators)
eval_js_expression(expr, locals)
} else {
// Simple variable lookup
match locals.get(expr) {
Some(v) => v
None => "" // Missing variable renders as empty
}
}
buf.write_string(indent)
buf.write_string(escape_html(value))
buf.write_string(newline)
// Handle post-increment
if is_post_increment {
let parse_result : Result[Int, _] = try? @strconv.parse_int(value)
match parse_result {
Ok(n) => locals.set(expr, (n + 1).to_string())
Err(_) => ()
}
}
}
UnescapedInterpolation(name) => {
// Evaluate the expression (unescaped - raw HTML)
let value = if needs_js_eval(name) {
eval_js_expression(name, locals)
} else {
match locals.get(name) {
Some(v) => v
None => "" // Missing variable renders as empty
}
}
buf.write_string(indent)
buf.write_string(value) // No escaping!
buf.write_string(newline)
}
Comment(text~, render~) =>
if render {
buf.write_string(indent)
buf.write_string("")
buf.write_string(newline)
}
Case(expr~, cases~, default~) => {
// Evaluate the expression to get the value to match
let value = match locals.get(expr) {
Some(v) => v
None => ""
}
// Find matching case or use default
let mut matched = false
let fall_through_bodies : Array[Array[Node]] = []
for pair in cases {
let (case_value, case_body) = pair
if case_value == value || fall_through_bodies.length() > 0 {
// Match found or falling through
if case_body.is_empty() {
// Empty body - fall through to next case
fall_through_bodies.push(case_body)
} else {
// Found body - render it
for child in case_body {
render_node_with_mixins(
child, buf, options, locals, mixins, blocks, depth, block_content,
)
}
matched = true
break
}
}
}
if not(matched) {
// No match found, render default
for child in default {
render_node_with_mixins(
child, buf, options, locals, mixins, blocks, depth, block_content,
)
}
}
}
When(..) | Default(..) => () // These should be consumed by Case during parsing
MixinDef(..) => () // Mixin definitions are collected, not rendered
Include(..) => () // Include requires template registry, not yet supported
IncludeFiltered(..) => () // IncludeFiltered requires template registry
Extends(..) => () // Extends requires template registry, not yet supported
Filter(name~, content~) =>
// For :plain filter, output the content as-is (escaped)
// For other filters, output as-is for now (requires external filter handlers)
if name == "plain" {
buf.write_string(indent)
buf.write_string(escape_html(content))
buf.write_string(newline)
} else {
// For unknown filters, just output the content
buf.write_string(indent)
buf.write_string(escape_html(content))
buf.write_string(newline)
}
Block =>
// Render the block content passed to the mixin
for child in block_content {
render_node_with_mixins(
child,
buf,
options,
locals,
mixins,
blocks,
depth,
[],
)
}
VarAssign(name~, value~) =>
// If name is empty, execute value as a JS statement
// Otherwise, set variable in locals for subsequent nodes
if name.is_empty() {
exec_js_statement(value, locals)
} else {
locals.set(name, value)
}
While(condition~, body~) => {
// While loop - keep rendering body while condition is true
let max_iterations = 1000 // Safety limit
let mut iterations = 0
while evaluate_while_condition(condition, locals) &&
iterations < max_iterations {
for child in body {
render_node_with_mixins(
child, buf, options, locals, mixins, blocks, depth, block_content,
)
}
iterations += 1
}
}
NamedBlock(name~, body~, mode~) =>
// For named blocks, merge prepend/append content from BlockStore
if mode == "replace" {
// Get any prepend/append content for this block
match blocks.get(name) {
Some((prepend, append)) => {
// Render prepend content first
for child in prepend {
render_node_with_mixins(
child, buf, options, locals, mixins, blocks, depth, block_content,
)
}
// Then render the block's own content
for child in body {
render_node_with_mixins(
child, buf, options, locals, mixins, blocks, depth, block_content,
)
}
// Finally render append content
for child in append {
render_node_with_mixins(
child, buf, options, locals, mixins, blocks, depth, block_content,
)
}
}
None =>
// No prepend/append, just render the body
for child in body {
render_node_with_mixins(
child, buf, options, locals, mixins, blocks, depth, block_content,
)
}
}
}
// append/prepend blocks are already collected, skip rendering them directly
MixinCall(name~, args~, block~, attrs~) =>
// Look up the mixin and expand it
match mixins.get(name) {
Some((params, mixin_body)) => {
// Create locals with arguments bound to parameters
let mixin_locals = Locals::new()
// Copy parent locals
for k, v in locals.0 {
mixin_locals.set(k, v)
}
// Set block local for "if block" checks
if block.length() > 0 {
mixin_locals.set("block", "true")
}
// Store extra attributes as attributes.X locals
for attr_pair in attrs {
let (attr_name, attr_value) = attr_pair
mixin_locals.set("attributes." + attr_name, attr_value)
}
// Bind arguments to parameters, handling defaults and rest params
let mut arg_idx = 0
for param_pair in params {
let (param_name, default_val) = param_pair
// Check if this is a rest parameter
if param_name.has_prefix("...") {
// Rest parameter - collect remaining args as JSON array
let rest_name = try! param_name[3:].to_string()
let rest_args : Array[String] = []
while arg_idx < args.length() {
rest_args.push(args[arg_idx])
arg_idx += 1
}
mixin_locals.set_array(rest_name, rest_args)
} else if arg_idx < args.length() {
// Regular parameter with provided argument
mixin_locals.set(param_name, args[arg_idx])
arg_idx += 1
} else if default_val.length() > 0 {
// No argument provided, use default value
mixin_locals.set(param_name, default_val)
}
}
// Render mixin body, passing the block content for "block" keyword
for child in mixin_body {
render_node_with_mixins(
child, buf, options, mixin_locals, mixins, blocks, depth, block,
)
}
}
None => () // Unknown mixin, skip
}
Conditional(condition~, if_true~, if_false~, is_unless~) => {
// Evaluate the condition
let cond_result = evaluate_condition(condition, locals)
// For unless, invert the result
let should_render_true = if is_unless {
not(cond_result)
} else {
cond_result
}
if should_render_true {
for child in if_true {
render_node_with_mixins(
child, buf, options, locals, mixins, blocks, depth, block_content,
)
}
} else {
for child in if_false {
render_node_with_mixins(
child, buf, options, locals, mixins, blocks, depth, block_content,
)
}
}
}
Each(item_var~, index_var~, collection~, body~, else_body~) => {
// Get the collection from locals and iterate
let coll_value = locals.get(collection)
let is_object = match coll_value {
Some(s) => s.length() > 0 && s.to_array()[0] == '{'
None => false
}
if is_object {
// Iterate over object key-value pairs
let pairs = parse_json_object_pairs(coll_value)
if pairs.is_empty() {
for child in else_body {
render_node_with_mixins(
child, buf, options, locals, mixins, blocks, depth, block_content,
)
}
} else {
for pair in pairs {
let (key, value) = pair
let loop_locals = Locals::new()
for k, v in locals.0 {
loop_locals.set(k, v)
}
// For objects: item_var = value, index_var = key
loop_locals.set(item_var, value)
if index_var.length() > 0 {
loop_locals.set(index_var, key)
}
for child in body {
render_node_with_mixins(
child, buf, options, loop_locals, mixins, blocks, depth, block_content,
)
}
}
}
} else {
// Iterate over array
let items = parse_collection(coll_value)
if items.is_empty() {
for child in else_body {
render_node_with_mixins(
child, buf, options, locals, mixins, blocks, depth, block_content,
)
}
} else {
for i, item in items {
let loop_locals = Locals::new()
for k, v in locals.0 {
loop_locals.set(k, v)
}
loop_locals.set(item_var, item)
if index_var.length() > 0 {
loop_locals.set(index_var, i.to_string())
}
for child in body {
render_node_with_mixins(
child, buf, options, loop_locals, mixins, blocks, depth, block_content,
)
}
}
}
}
}
Element(tag~, id~, classes~, attributes~, children~, self_closing~) => {
buf.write_string(indent)
buf.write_string("<")
buf.write_string(tag)
// Add id attribute
if id.length() > 0 {
buf.write_string(" id=\"")
buf.write_string(escape_html(id))
buf.write_string("\"")
}
// Add class attribute
if classes.length() > 0 {
buf.write_string(" class=\"")
for i, cls in classes {
if i > 0 {
buf.write_string(" ")
}
buf.write_string(escape_html(cls))
}
buf.write_string("\"")
}
// Add other attributes
for attr in attributes {
buf.write_string(" ")
buf.write_string(attr.name)
match attr.value {
Some(v) => {
// Has explicit value (even if empty string)
let should_escape = not(attr.unescaped)
let processed = if v.contains("#{") {
// Has interpolation, process normally
interpolate_string(v, locals, should_escape)
} else if needs_js_eval(v) {
// Expression needs JS evaluation (method calls, operators, etc.)
let result = eval_js_expression(v, locals)
if should_escape {
escape_html(result)
} else {
result
}
} else if v.contains("\"") || v.contains("'") {
// Quoted literal - strip quotes
let chars = v.to_array()
if chars.length() >= 2 &&
(
(chars[0] == '"' && chars[chars.length() - 1] == '"') ||
(chars[0] == '\'' && chars[chars.length() - 1] == '\'')
) {
let inner = StringBuilder::new()
for i in 1..<(chars.length() - 1) {
inner.write_char(chars[i])
}
if should_escape {
escape_html(inner.to_string())
} else {
inner.to_string()
}
} else if should_escape {
escape_html(v)
} else {
v
}
} else {
// Simple variable reference, try to look it up
match locals.get(v) {
Some(local_val) =>
if should_escape {
escape_html(local_val)
} else {
local_val
}
None => if should_escape { escape_html(v) } else { v }
}
}
buf.write_string("=\"")
buf.write_string(processed)
buf.write_string("\"")
}
None => () // Boolean attribute, no value output
}
}
if self_closing {
buf.write_string("/>")
buf.write_string(newline)
} else if is_void_element(tag) {
buf.write_string(">")
buf.write_string(newline)
} else if children.length() == 0 {
buf.write_string(">")
buf.write_string(tag)
buf.write_string(">")
buf.write_string(newline)
} else if children.length() == 1 && children[0] is Text(_) {
// Inline text content
buf.write_string(">")
match children[0] {
Text(t) =>
if is_raw_text_element(tag) {
buf.write_string(t) // No escaping for script/style
} else {
buf.write_string(escape_html(t))
}
_ => ()
}
buf.write_string("")
buf.write_string(tag)
buf.write_string(">")
buf.write_string(newline)
} else {
buf.write_string(">")
buf.write_string(newline)
for child in children {
render_node_with_mixins(
child,
buf,
options,
locals,
mixins,
blocks,
depth + 1,
block_content,
)
}
buf.write_string(indent)
buf.write_string("")
buf.write_string(tag)
buf.write_string(">")
buf.write_string(newline)
}
}
}
}
///|
/// Parse a collection value (array or object) stored as JSON-like string
fn parse_collection(value : String?) -> Array[String] {
match value {
None => []
Some(s) => {
let chars = s.to_array()
if chars.is_empty() {
return []
}
// Check if it's an array [...]
if chars[0] == '[' {
return parse_json_array(s)
}
// Check if it's an object {...}
if chars[0] == '{' {
return parse_json_object_values(s)
}
// Otherwise treat as single value
[s]
}
}
}
///|
/// Parse a JSON-like array: ["a","b","c"] -> ["a", "b", "c"]
fn parse_json_array(s : String) -> Array[String] {
let result : Array[String] = []
let chars = s.to_array()
let mut i = 1 // skip [
while i < chars.length() && chars[i] != ']' {
// Skip whitespace and commas
while i < chars.length() &&
(chars[i] == ' ' || chars[i] == ',' || chars[i] == '\n') {
i += 1
}
if i >= chars.length() || chars[i] == ']' {
break
}
// Check for nested array
if chars[i] == '[' {
// Find matching ]
let start = i
let mut depth = 1
i += 1
while i < chars.length() && depth > 0 {
if chars[i] == '[' {
depth += 1
} else if chars[i] == ']' {
depth -= 1
}
i += 1
}
// Extract the nested array as string
let nested = StringBuilder::new()
for j in start.. [("a","1"), ("b","2")]
fn parse_json_object_pairs(value : String?) -> Array[(String, String)] {
match value {
None => []
Some(s) => {
let result : Array[(String, String)] = []
let chars = s.to_array()
if chars.is_empty() || chars[0] != '{' {
return []
}
let mut i = 1 // skip {
while i < chars.length() && chars[i] != '}' {
// Skip whitespace and commas
while i < chars.length() &&
(chars[i] == ' ' || chars[i] == ',' || chars[i] == '\n') {
i += 1
}
if i >= chars.length() || chars[i] == '}' {
break
}
// Parse key (quoted)
let mut key = ""
if chars[i] == '"' {
i += 1 // skip opening "
let buf = StringBuilder::new()
while i < chars.length() && chars[i] != '"' {
buf.write_char(chars[i])
i += 1
}
key = buf.to_string()
i += 1 // skip closing "
}
// Skip colon
while i < chars.length() && (chars[i] == ':' || chars[i] == ' ') {
i += 1
}
// Parse value (quoted)
let mut val = ""
if i < chars.length() && chars[i] == '"' {
i += 1 // skip opening "
let buf = StringBuilder::new()
while i < chars.length() && chars[i] != '"' {
buf.write_char(chars[i])
i += 1
}
val = buf.to_string()
i += 1 // skip closing "
}
result.push((key, val))
}
result
}
}
}
///|
/// Parse a JSON-like object and return values: {"a":"1","b":"2"} -> ["1", "2"]
fn parse_json_object_values(s : String) -> Array[String] {
let result : Array[String] = []
let chars = s.to_array()
let mut i = 1 // skip {
while i < chars.length() && chars[i] != '}' {
// Skip whitespace and commas
while i < chars.length() &&
(chars[i] == ' ' || chars[i] == ',' || chars[i] == '\n') {
i += 1
}
if i >= chars.length() || chars[i] == '}' {
break
}
// Skip key (quoted)
if chars[i] == '"' {
i += 1 // skip opening "
while i < chars.length() && chars[i] != '"' {
i += 1
}
i += 1 // skip closing "
}
// Skip colon
while i < chars.length() && (chars[i] == ':' || chars[i] == ' ') {
i += 1
}
// Parse value (quoted)
if i < chars.length() && chars[i] == '"' {
i += 1 // skip opening "
let buf = StringBuilder::new()
while i < chars.length() && chars[i] != '"' {
buf.write_char(chars[i])
i += 1
}
result.push(buf.to_string())
i += 1 // skip closing "
}
}
result
}
///|
/// Split a condition string on a logical operator (&&, ||)
fn split_on_operator(condition : String, op : String) -> Array[String] {
let parts : Array[String] = []
let chars = condition.to_array()
let op_chars = op.to_array()
let buf = StringBuilder::new()
let mut i = 0
while i < chars.length() {
// Check for operator
if i + op_chars.length() <= chars.length() {
let mut matches = true
for j in 0.. 0 {
parts.push(remaining)
}
parts
}
///|
/// Evaluate a condition expression against locals
fn evaluate_condition(condition : String, locals : Locals) -> Bool {
// Handle logical AND (&&)
if condition.contains("&&") {
let parts = split_on_operator(condition, "&&")
for part in parts {
if not(evaluate_condition(part, locals)) {
return false
}
}
return true
}
// Handle logical OR (||)
if condition.contains("||") {
let parts = split_on_operator(condition, "||")
for part in parts {
if evaluate_condition(part, locals) {
return true
}
}
return false
}
// Handle equality comparison: variable == "value"
if condition.contains("==") {
let parts = condition.to_array()
let buf = StringBuilder::new()
let mut found_eq = false
let mut var_name = ""
let mut expected = ""
let mut i = 0
while i < parts.length() {
if i + 1 < parts.length() && parts[i] == '=' && parts[i + 1] == '=' {
var_name = buf.to_string().trim(chars=" \t").to_string()
buf.reset()
i += 2
found_eq = true
// Skip spaces after ==
while i < parts.length() && parts[i] == ' ' {
i += 1
}
continue
}
buf.write_char(parts[i])
i += 1
}
if found_eq {
expected = buf.to_string().trim(chars=" \t").to_string()
// Remove quotes from expected value
if expected.has_prefix("\"") && expected.has_suffix("\"") {
expected = try! expected[1:expected.length() - 1].to_string()
}
match locals.get(var_name) {
Some(v) => return v == expected
None => return false
}
}
}
// Simple variable check - truthy if exists and not "false" or empty
let var_name = condition.trim(chars=" \t").to_string()
match locals.get(var_name) {
Some(v) => v != "" && v != "false"
None => false
}
}
///|
/// Evaluate while loop condition (supports comparison operators like <, >, <=, >=)
fn evaluate_while_condition(condition : String, locals : Locals) -> Bool {
let cond = condition.trim(chars=" \t").to_string()
// Handle < comparison: var < number
if cond.contains("<") && not(cond.contains("<=")) {
let parts = cond.to_array()
let buf = StringBuilder::new()
let mut found_op = false
let mut var_name = ""
let mut i = 0
while i < parts.length() {
if parts[i] == '<' {
var_name = buf.to_string().trim(chars=" \t").to_string()
buf.reset()
i += 1
found_op = true
while i < parts.length() && parts[i] == ' ' {
i += 1
}
continue
}
buf.write_char(parts[i])
i += 1
}
if found_op {
let rhs_str = buf.to_string().trim(chars=" \t").to_string()
match locals.get(var_name) {
Some(v) => {
let rhs_result = try? @strconv.parse_int(rhs_str)
let lhs_result = try? @strconv.parse_int(v)
match (lhs_result, rhs_result) {
(Ok(lhs_num), Ok(rhs_num)) => return lhs_num < rhs_num
_ => return false
}
}
_ => return false
}
}
}
// Fall back to regular condition evaluation
evaluate_condition(condition, locals)
}
///|
/// Render a document to HTML string with locals
pub fn Document::render_with_locals(
self : Document,
locals : Locals,
options~ : RenderOptions,
) -> String {
// First collect all mixin definitions
let mixins = MixinStore::new()
collect_mixins(self.iter(), mixins)
// Collect all append/prepend blocks
let blocks = BlockStore::new()
collect_blocks(self.iter(), blocks)
// Then render all nodes
let buf = StringBuilder::new()
for node in self.iter() {
render_node_with_mixins(node, buf, options, locals, mixins, blocks, 0, [])
}
buf.to_string()
}
///|
/// Render a document to HTML string (without locals)
pub fn Document::render(self : Document, options~ : RenderOptions) -> String {
self.render_with_locals(Locals::new(), options~)
}
///|
/// Render a document to HTML string with default options
pub fn Document::to_html(self : Document) -> String {
self.render(options=RenderOptions::default())
}
///|
/// Render a document to pretty-printed HTML string
pub fn Document::to_pretty_html(self : Document) -> String {
self.render(options=RenderOptions::pretty())
}
///|
/// Compiled template - holds parsed document for repeated rendering
pub struct CompiledTemplate {
doc : Document
}
///|
/// Compile a Pug template string into a reusable template
pub fn compile(input : String) -> CompiledTemplate {
{ doc: parse(input) }
}
///|
/// Render a compiled template with locals
pub fn CompiledTemplate::render(
self : CompiledTemplate,
locals : Locals,
) -> String {
self.doc.render_with_locals(locals, options=RenderOptions::default())
}
///|
/// Render a compiled template with locals and options
pub fn CompiledTemplate::render_with_options(
self : CompiledTemplate,
locals : Locals,
options : RenderOptions,
) -> String {
self.doc.render_with_locals(locals, options~)
}
///|
/// Convenience function: parse Pug and render to HTML
pub fn render(input : String) -> String {
parse(input).to_html()
}
///|
/// Convenience function: parse Pug and render to HTML with locals
pub fn render_with_locals(input : String, locals : Locals) -> String {
parse(input).render_with_locals(locals, options=RenderOptions::default())
}
///|
/// Convenience function: parse Pug and render to pretty HTML
pub fn render_pretty(input : String) -> String {
parse(input).to_pretty_html()
}
///|
/// Collect named blocks from a document (for extends support)
fn collect_child_blocks(
nodes : Iter[Node],
blocks : Map[String, (Array[Node], String)],
) -> Unit {
for node in nodes {
match node {
NamedBlock(name~, body~, mode~) => blocks[name] = (body, mode)
Element(children~, ..) => collect_child_blocks(children.iter(), blocks)
Conditional(if_true~, if_false~, ..) => {
collect_child_blocks(if_true.iter(), blocks)
collect_child_blocks(if_false.iter(), blocks)
}
_ => ()
}
}
}
///|
/// Find extends declaration in document (should be first node)
fn find_extends(doc : Document) -> String? {
for node in doc.iter() {
match node {
Extends(path~) => return Some(path)
_ => ()
}
}
None
}
///|
/// Render a node with registry support for includes
fn render_node_with_registry(
node : Node,
buf : StringBuilder,
options : RenderOptions,
locals : Locals,
mixins : MixinStore,
blocks : BlockStore,
child_blocks : Map[String, (Array[Node], String)],
registry : TemplateRegistry,
depth : Int,
block_content : Array[Node],
visited : Map[String, Bool],
) -> Unit {
let indent = if options.pretty {
let mut s = ""
for _ in 0..
// Load and render included template
match registry.get(path) {
Some(source) =>
// Check for circular includes
if visited.contains(path) {
buf.write_string("")
} else {
visited[path] = true
let inc_doc = parse(source)
// Render included document nodes
for inc_node in inc_doc.iter() {
render_node_with_registry(
inc_node, buf, options, locals, mixins, blocks, child_blocks, registry,
depth, block_content, visited,
)
}
}
None => {
buf.write_string("")
}
}
IncludeFiltered(filter~, path~) =>
// Load file content and apply filter
match registry.get(path) {
Some(content) => {
let filtered = apply_filter(filter, content)
buf.write_string(filtered)
}
None => {
buf.write_string("")
}
}
NamedBlock(name~, body~, mode~) =>
// For extends: merge with child blocks
if mode == "replace" {
match child_blocks.get(name) {
Some((child_body, child_mode)) =>
// Child has a block with this name
if child_mode == "replace" {
// Replace: render child's block content
for child in child_body {
render_node_with_registry(
child, buf, options, locals, mixins, blocks, child_blocks, registry,
depth, block_content, visited,
)
}
} else if child_mode == "append" {
// Append: render parent first, then child
for child in body {
render_node_with_registry(
child, buf, options, locals, mixins, blocks, child_blocks, registry,
depth, block_content, visited,
)
}
for child in child_body {
render_node_with_registry(
child, buf, options, locals, mixins, blocks, child_blocks, registry,
depth, block_content, visited,
)
}
} else if child_mode == "prepend" {
// Prepend: render child first, then parent
for child in child_body {
render_node_with_registry(
child, buf, options, locals, mixins, blocks, child_blocks, registry,
depth, block_content, visited,
)
}
for child in body {
render_node_with_registry(
child, buf, options, locals, mixins, blocks, child_blocks, registry,
depth, block_content, visited,
)
}
}
None =>
// No child block, render parent's default content
for child in body {
render_node_with_registry(
child, buf, options, locals, mixins, blocks, child_blocks, registry,
depth, block_content, visited,
)
}
}
}
Element(tag~, id~, classes~, attributes~, children~, self_closing~) => {
buf.write_string(indent)
buf.write_string("<")
buf.write_string(tag)
if id.length() > 0 {
buf.write_string(" id=\"")
buf.write_string(escape_html(id))
buf.write_string("\"")
}
if classes.length() > 0 {
buf.write_string(" class=\"")
for i, cls in classes {
if i > 0 {
buf.write_string(" ")
}
buf.write_string(escape_html(cls))
}
buf.write_string("\"")
}
for attr in attributes {
buf.write_string(" ")
buf.write_string(attr.name)
match attr.value {
Some(v) => {
let should_escape = not(attr.unescaped)
let processed = if v.contains("#{") {
interpolate_string(v, locals, should_escape)
} else if needs_js_eval(v) {
let result = eval_js_expression(v, locals)
if should_escape {
escape_html(result)
} else {
result
}
} else if v.contains("\"") || v.contains("'") {
// Quoted literal - strip quotes
let chars = v.to_array()
if chars.length() >= 2 &&
(
(chars[0] == '"' && chars[chars.length() - 1] == '"') ||
(chars[0] == '\'' && chars[chars.length() - 1] == '\'')
) {
let inner = StringBuilder::new()
for i in 1..<(chars.length() - 1) {
inner.write_char(chars[i])
}
if should_escape {
escape_html(inner.to_string())
} else {
inner.to_string()
}
} else if should_escape {
escape_html(v)
} else {
v
}
} else {
match locals.get(v) {
Some(local_val) =>
if should_escape {
escape_html(local_val)
} else {
local_val
}
None => if should_escape { escape_html(v) } else { v }
}
}
buf.write_string("=\"")
buf.write_string(processed)
buf.write_string("\"")
}
None => ()
}
}
if self_closing {
buf.write_string("/>")
buf.write_string(newline)
} else if is_void_element(tag) {
buf.write_string(">")
buf.write_string(newline)
} else if children.length() == 0 {
buf.write_string(">")
buf.write_string(tag)
buf.write_string(">")
buf.write_string(newline)
} else if children.length() == 1 && children[0] is Text(_) {
buf.write_string(">")
match children[0] {
Text(t) =>
if is_raw_text_element(tag) {
buf.write_string(t)
} else {
buf.write_string(escape_html(t))
}
_ => ()
}
buf.write_string("")
buf.write_string(tag)
buf.write_string(">")
buf.write_string(newline)
} else {
buf.write_string(">")
buf.write_string(newline)
for child in children {
render_node_with_registry(
child,
buf,
options,
locals,
mixins,
blocks,
child_blocks,
registry,
depth + 1,
block_content,
visited,
)
}
buf.write_string(indent)
buf.write_string("")
buf.write_string(tag)
buf.write_string(">")
buf.write_string(newline)
}
}
Text(text) => {
buf.write_string(indent)
buf.write_string(escape_html(text))
buf.write_string(newline)
}
Interpolation(name) => {
let value = match locals.get(name) {
Some(v) => v
None => ""
}
buf.write_string(indent)
buf.write_string(escape_html(value))
buf.write_string(newline)
}
Doctype(value) => {
buf.write_string(indent)
buf.write_string(get_doctype(value))
buf.write_string(newline)
}
Comment(text~, render~) =>
if render {
buf.write_string(indent)
buf.write_string("")
buf.write_string(newline)
}
Extends(..) => () // Handled at document level
_ =>
// For other node types, fall back to regular rendering
render_node_with_mixins(
node, buf, options, locals, mixins, blocks, depth, block_content,
)
}
}
///|
/// Render a document with registry support for extends and includes
pub fn Document::render_with_registry(
self : Document,
locals : Locals,
registry : TemplateRegistry,
options~ : RenderOptions,
) -> String {
let visited : Map[String, Bool] = {}
// Check if this template extends another
match find_extends(self) {
Some(parent_path) =>
// Load parent template
match registry.get(parent_path) {
Some(parent_source) => {
// Check for circular extends
if visited.contains(parent_path) {
return ""
}
visited[parent_path] = true
// Collect child's named blocks
let child_blocks : Map[String, (Array[Node], String)] = {}
collect_child_blocks(self.iter(), child_blocks)
// Parse parent template
let parent_doc = parse(parent_source)
// Collect mixins from both
let mixins = MixinStore::new()
collect_mixins(self.iter(), mixins)
collect_mixins(parent_doc.iter(), mixins)
let blocks = BlockStore::new()
// Render parent with child blocks merged
let buf = StringBuilder::new()
for node in parent_doc.iter() {
render_node_with_registry(
node,
buf,
options,
locals,
mixins,
blocks,
child_blocks,
registry,
0,
[],
visited,
)
}
buf.to_string()
}
None => ""
}
None => {
// No extends, render normally but with include support
let mixins = MixinStore::new()
collect_mixins(self.iter(), mixins)
let blocks = BlockStore::new()
collect_blocks(self.iter(), blocks)
let child_blocks : Map[String, (Array[Node], String)] = {}
let buf = StringBuilder::new()
for node in self.iter() {
render_node_with_registry(
node,
buf,
options,
locals,
mixins,
blocks,
child_blocks,
registry,
0,
[],
visited,
)
}
buf.to_string()
}
}
}
///|
/// Convenience function: render with registry
pub fn render_with_registry(
input : String,
locals : Locals,
registry : TemplateRegistry,
) -> String {
parse(input).render_with_registry(
locals,
registry,
options=RenderOptions::default(),
)
}
///|
/// Get directory part of a path
fn dirname(path : String) -> String {
let chars = path.to_array()
let mut last_slash = -1
for i, c in chars {
if c == '/' {
last_slash = i
}
}
if last_slash <= 0 {
"."
} else {
let buf = StringBuilder::new()
for i in 0.. String {
let parts : Array[String] = []
let segments = path.split("/")
for seg in segments {
let s = seg.to_string()
if s == ".." {
if parts.length() > 0 && parts[parts.length() - 1] != ".." {
let _ = parts.pop()
} else {
parts.push("..")
}
} else if s != "." && s != "" {
parts.push(s)
}
}
if parts.is_empty() {
"."
} else {
parts.join("/")
}
}
///|
/// Join two paths and normalize the result
fn path_join(base : String, relative : String) -> String {
if relative.has_prefix("/") {
relative
} else if base == "." {
normalize_path(relative)
} else {
normalize_path(base + "/" + relative)
}
}
///|
/// Collect all include/extends paths from nodes
fn collect_dependencies(nodes : Iter[Node], deps : Array[String]) -> Unit {
for node in nodes {
match node {
Include(path~) => deps.push(path)
Extends(path~) => deps.push(path)
Element(children~, ..) => collect_dependencies(children.iter(), deps)
Conditional(if_true~, if_false~, ..) => {
collect_dependencies(if_true.iter(), deps)
collect_dependencies(if_false.iter(), deps)
}
NamedBlock(body~, ..) => collect_dependencies(body.iter(), deps)
_ => ()
}
}
}
///|
/// Load a template and all its dependencies recursively
fn load_template_recursive(
path : String,
base_dir : String,
registry : TemplateRegistry,
visited : Map[String, Bool],
) -> Unit raise @fs.IOError {
let full_path = path_join(base_dir, path)
if visited.contains(full_path) {
return
}
visited[full_path] = true
// Load the template
let source = @fs.read_file_to_string(full_path)
registry.register(path, source)
// Parse and find dependencies
let doc = parse(source)
let deps : Array[String] = []
collect_dependencies(doc.iter(), deps)
// Load dependencies relative to this template's directory
let template_dir = dirname(full_path)
for dep in deps {
load_template_recursive(dep, template_dir, registry, visited)
}
}
///|
/// Render a Pug file - automatically loads includes/extends
/// This is the simple API similar to pug.renderFile()
pub fn render_file(path : String) -> String raise @fs.IOError {
render_file_with_locals(path, Locals::new())
}
///|
/// Render a Pug file with locals
pub fn render_file_with_locals(
path : String,
locals : Locals,
) -> String raise @fs.IOError {
let registry = TemplateRegistry::new()
let visited : Map[String, Bool] = {}
let base_dir = dirname(path)
let filename = if path.contains("/") {
let chars = path.to_array()
let mut last_slash = 0
for i, c in chars {
if c == '/' {
last_slash = i
}
}
let buf = StringBuilder::new()
for i in (last_slash + 1).. Array[Node] {
match node {
Include(path~) =>
// Load and parse included template
match registry.get(path) {
Some(source) =>
// Check for circular includes
if visited.contains(path) {
[Comment(text="Circular include: " + path, render=true)]
} else {
visited[path] = true
let inc_doc = parse(source)
// Recursively resolve includes in included document
let resolved : Array[Node] = []
for inc_node in inc_doc.iter() {
for r in resolve_node_includes(inc_node, registry, visited) {
resolved.push(r)
}
}
resolved
}
None => [Comment(text="Include not found: " + path, render=true)]
}
Element(tag~, id~, classes~, attributes~, children~, self_closing~) => {
// Recursively resolve children
let resolved_children : Array[Node] = []
for child in children {
for r in resolve_node_includes(child, registry, visited) {
resolved_children.push(r)
}
}
[
Element(
tag~,
id~,
classes~,
attributes~,
children=resolved_children,
self_closing~,
),
]
}
Conditional(condition~, if_true~, if_false~, is_unless~) => {
let resolved_true : Array[Node] = []
for child in if_true {
for r in resolve_node_includes(child, registry, visited) {
resolved_true.push(r)
}
}
let resolved_false : Array[Node] = []
for child in if_false {
for r in resolve_node_includes(child, registry, visited) {
resolved_false.push(r)
}
}
[
Conditional(
condition~,
if_true=resolved_true,
if_false=resolved_false,
is_unless~,
),
]
}
Each(item_var~, index_var~, collection~, body~, else_body~) => {
let resolved_body : Array[Node] = []
for child in body {
for r in resolve_node_includes(child, registry, visited) {
resolved_body.push(r)
}
}
let resolved_else : Array[Node] = []
for child in else_body {
for r in resolve_node_includes(child, registry, visited) {
resolved_else.push(r)
}
}
[
Each(
item_var~,
index_var~,
collection~,
body=resolved_body,
else_body=resolved_else,
),
]
}
NamedBlock(name~, body~, mode~) => {
let resolved_body : Array[Node] = []
for child in body {
for r in resolve_node_includes(child, registry, visited) {
resolved_body.push(r)
}
}
[NamedBlock(name~, body=resolved_body, mode~)]
}
MixinDef(name~, params~, body~) => {
let resolved_body : Array[Node] = []
for child in body {
for r in resolve_node_includes(child, registry, visited) {
resolved_body.push(r)
}
}
[MixinDef(name~, params~, body=resolved_body)]
}
Case(expr~, cases~, default~) => {
let resolved_cases : Array[(String, Array[Node])] = []
for pair in cases {
let (value, case_body) = pair
let resolved_body : Array[Node] = []
for child in case_body {
for r in resolve_node_includes(child, registry, visited) {
resolved_body.push(r)
}
}
resolved_cases.push((value, resolved_body))
}
let resolved_default : Array[Node] = []
for child in default {
for r in resolve_node_includes(child, registry, visited) {
resolved_default.push(r)
}
}
[Case(expr~, cases=resolved_cases, default=resolved_default)]
}
While(condition~, body~) => {
let resolved_body : Array[Node] = []
for child in body {
for r in resolve_node_includes(child, registry, visited) {
resolved_body.push(r)
}
}
[While(condition~, body=resolved_body)]
}
_ => [node] // Other nodes pass through unchanged
}
}
///|
/// Parse Pug and resolve all includes using registry
/// Returns a Document with Include nodes replaced by actual content
pub fn parse_with_registry(
input : String,
registry : TemplateRegistry,
) -> Document {
let doc = parse(input)
let visited : Map[String, Bool] = {}
let resolved_nodes : Array[Node] = []
for node in doc.iter() {
for r in resolve_node_includes(node, registry, visited) {
resolved_nodes.push(r)
}
}
{ nodes: resolved_nodes }
}