@@ -3,6 +3,7 @@ use std::fmt;
33use std::path::{Path, PathBuf};
44
55use crate::ast::*;
6+use crate::syntax::{self, Syntax};
67
78// ---------------------------------------------------------------------------
89// Public types
@@ -47,24 +48,11 @@ impl fmt::Display for ParseError {
4748// Internal types
4849// ---------------------------------------------------------------------------
4950
50−#[derive(Clone)]
51−enum LineContent {
52− Normal(String),
53− Raw(String),
54−}
55−
56−#[derive(Clone)]
57−struct Line {
58− indent: usize,
59− content: LineContent,
60− line_num: usize,
61−}
62−
6351#[derive(Clone)]
6452struct FnDef {
6553 params: Vec<String>,
6654 defaults: HashMap<String, String>,
67− body_lines: Vec<Line>,
55+ body: Vec<Syntax>,
6856}
6957
7058struct ParseContext {
@@ -111,10 +99,8 @@ struct ParseContext {
11199 import_stack: Vec<PathBuf>,
112100}
113101
114−struct Parser {
115− lines: Vec<Line>,
116− pos: usize,
117−}
102+/// Evaluates a syntax tree (see `syntax.rs`) into the document's nodes.
103+struct Evaluator;
118104
119105impl ParseContext {
120106 /// Evaluate an expression (see `expr.rs`), reporting errors at `line`.
@@ -149,11 +135,7 @@ impl ParseContext {
149135const PRELUDE: &str = include_str!("std.hl");
150136
151137fn load_prelude(ctx: &mut ParseContext) {
152− let mut prelude = Parser {
153− lines: preprocess(PRELUDE),
154− pos: 0,
155− };
156− let _ = prelude.parse_children(0, ctx);
138+ let _ = Evaluator.eval_block(&syntax::parse(PRELUDE), ctx);
157139 // Library definitions aren't the file's own: never report them unused.
158140 ctx.fn_lines.clear();
159141 ctx.define_lines.clear();
@@ -165,8 +147,7 @@ pub fn parse(input: &str) -> ParseResult {
165147}
166148
167149pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
168− let lines = preprocess(input);
169− let mut parser = Parser { lines, pos: 0 };
150+ let tree = syntax::parse(input);
170151 let mut ctx = ParseContext {
171152 current_line: 0,
172153 scoped_functions: std::collections::HashSet::new(),
@@ -199,7 +180,7 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
199180 import_stack: Vec::new(),
200181 };
201182 load_prelude(&mut ctx);
202− let nodes = parser.parse_children(0, &mut ctx);
183+ let nodes = Evaluator.eval_block(&tree, &mut ctx);
203184 validate_tree(&nodes, None, &mut ctx.diagnostics);
204185 check_unused(&mut ctx);
205186 ParseResult {
@@ -260,232 +241,43 @@ fn collect_images_recursive(nodes: &[Node], hints: &mut Vec<crate::ast::PreloadH
260241// Preprocessing: strip comments/blanks, collapse @raw blocks
261242// ---------------------------------------------------------------------------
262243
263−fn preprocess(input: &str) -> Vec<Line> {
264− let raw_lines: Vec<&str> = input.lines().collect();
265− let mut lines = Vec::new();
266− let mut i = 0;
267−
268− while i < raw_lines.len() {
269− let line = raw_lines[i];
270− let trimmed = line.trim();
271−
272− if trimmed.is_empty() || trimmed.starts_with("--") {
273− i += 1;
274− continue;
275− }
276−
277− let indent = line.len() - line.trim_start().len();
278−
279− // Inline `@markdown` and `@script` blocks keep their bodies verbatim:
280− // in Markdown, blank lines separate paragraphs, `---` is a rule (not
281− // a comment) and code indentation matters; in JavaScript, newlines,
282− // `{...}` and `$` must reach the output untouched.
283− let verbatim_body = trimmed == "@markdown"
284− || trimmed == "@script"
285− || trimmed.starts_with("@script ")
286− || trimmed.starts_with("@script[");
287− if verbatim_body {
288− lines.push(Line {
289− indent,
290− content: LineContent::Normal(trimmed.to_string()),
291− line_num: i + 1,
292− });
293− let body_start = i + 1;
294− let mut body_end = body_start;
295− let mut j = body_start;
296− while j < raw_lines.len() {
297− let l = raw_lines[j];
298− if l.trim().is_empty() {
299− j += 1;
300− continue;
301− }
302− if l.len() - l.trim_start().len() <= indent {
303− break;
304− }
305− j += 1;
306− body_end = j;
307− }
308− let body = &raw_lines[body_start..body_end];
309− let body_indent = body
310− .iter()
311− .filter(|l| !l.trim().is_empty())
312− .map(|l| l.len() - l.trim_start().len())
313− .min()
314− .unwrap_or(0);
315− if !body.is_empty() {
316− let text: Vec<&str> = body
317− .iter()
318− .map(|l| l.get(body_indent..).unwrap_or("").trim_end())
319− .collect();
320− lines.push(Line {
321− indent: indent + 1,
322− content: LineContent::Raw(text.join("\n")),
323− line_num: body_start + 1,
324− });
325− }
326− i = body_end.max(body_start);
327− continue;
328− }
329−
330− // Handle @raw """..."""
331− if let Some(raw_rest) = trimmed.strip_prefix("@raw") {
332− let after_raw = raw_rest.trim_start();
333− if let Some(after_open) = after_raw.strip_prefix("\"\"\"") {
334− // Single-line: @raw """content"""
335− if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
336− let content = &after_open[..after_open.len() - 3];
337− lines.push(Line {
338− indent,
339− content: LineContent::Raw(content.to_string()),
340− line_num: i + 1,
341− });
342− i += 1;
343− continue;
344− }
345−
346− // Multiline: collect until closing """
347− let mut raw_content = String::new();
348− if !after_open.is_empty() {
349− raw_content.push_str(after_open);
350− raw_content.push('\n');
351− }
352− i += 1;
353− while i < raw_lines.len() {
354− if raw_lines[i].trim() == "\"\"\"" {
355− i += 1;
356− break;
357− }
358− raw_content.push_str(raw_lines[i]);
359− raw_content.push('\n');
360− i += 1;
361− }
362−
363− lines.push(Line {
364− indent,
365− content: LineContent::Raw(raw_content.trim_end_matches('\n').to_string()),
366− line_num: i,
367− });
368− continue;
369− }
370− }
371−
372− // Join continuation lines for multi-line attribute brackets
373− let first_line_num = i + 1;
374− let mut full = trimmed.to_string();
375− while open_attr_depth(&full) > 0 && i + 1 < raw_lines.len() {
376− i += 1;
377− let next = raw_lines[i].trim();
378− if next.is_empty() || next.starts_with("--") {
379− continue;
380− }
381− full.push(' ');
382− full.push_str(next);
383− }
384−
385− lines.push(Line {
386− indent,
387− content: LineContent::Normal(full),
388− line_num: first_line_num,
389− });
390− i += 1;
391− }
392−
393− lines
394−}
395−
396−/// Bracket depth left open at the end of `line`, counting only attribute
397−/// lists: a `[` that starts the line or follows an `@name` token (optionally
398−/// with one more word, as in `@let name [`). Brackets in text content, such
399−/// as `@text [bold] Use [ to open`, are ignored.
400−fn open_attr_depth(line: &str) -> i32 {
401− if !line.starts_with('@') && !line.starts_with('[') {
402− return 0;
403− }
404− let bytes = line.as_bytes();
405− let mut depth: i32 = 0;
406− for (i, &b) in bytes.iter().enumerate() {
407− match b {
408− b'[' if depth > 0 => depth += 1,
409− b'[' => {
410− // Is this the start of an attribute list?
411− let before = line[..i].trim_end();
412− let last_directive = before
413− .rsplit([']', '>'])
414− .next()
415− .unwrap_or("")
416− .trim();
417− let tokens: Vec<&str> = last_directive.split_whitespace().collect();
418− let starts_list = match tokens.as_slice() {
419− [] => before.is_empty() || before.ends_with('>'),
420− [name] => name.starts_with('@'),
421− [name, _] => name.starts_with('@'),
422− _ => false,
423− };
424− if starts_list {
425− depth = 1;
426− } else if before.ends_with(']') {
427− // Text after a closed attribute list: stop scanning.
428− return 0;
429− }
430− }
431− b']' if depth > 0 => depth -= 1,
432− _ => {}
433− }
434− }
435− depth
436−}
437−
438244// ---------------------------------------------------------------------------
439−// Parser
245+// Evaluation
440246// ---------------------------------------------------------------------------
441247
442−impl Parser {
443− fn parse_children(&mut self, min_indent: usize, ctx: &mut ParseContext) -> Vec<Node> {
248+impl Evaluator {
249+ fn eval_block(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec<Node> {
444250 let mut nodes = Vec::new();
445−
446− while self.pos < self.lines.len() {
447− let indent = self.lines[self.pos].indent;
448− if indent < min_indent {
449− break;
450− }
451−
452− match self.parse_line(ctx) {
251+ for node in block {
252+ match self.eval(node, ctx) {
453253 Ok(Some(new_nodes)) => nodes.extend(new_nodes),
454254 Ok(None) => {}
455− Err(e) => {
456− // Error recovery: record the diagnostic and continue parsing
457− // to report multiple errors in a single pass
458− let source = if self.pos > 0 && self.pos <= self.lines.len() {
459− match &self.lines[self.pos.saturating_sub(1)].content {
460− LineContent::Normal(s) => Some(s.clone()),
461− LineContent::Raw(s) => Some(s.clone()),
462− }
463− } else {
464− None
465− };
466− ctx.diagnostics.push(Diagnostic {
467− line: e.line,
468− column: None,
469− message: e.message,
470− severity: Severity::Error,
471− source_line: source,
472− });
473− // Skip forward past any deeper-indented children of the errored line
474− while self.pos < self.lines.len() && self.lines[self.pos].indent > indent {
475− self.pos += 1;
476− }
477− }
255+ // Record the error and go on, to report several in one pass
256+ Err(e) => ctx.diagnostics.push(Diagnostic {
257+ line: e.line,
258+ column: None,
259+ message: e.message,
260+ severity: Severity::Error,
261+ source_line: Some(node.source()),
262+ }),
478263 }
479264 }
265+ nodes
266+ }
480267
268+ /// Evaluate a block in its own scope: `@let` inside doesn't leak out.
269+ fn eval_scoped(&mut self, block: &[Syntax], ctx: &mut ParseContext) -> Vec<Node> {
270+ let saved_vars = ctx.variables.clone();
271+ let nodes = self.eval_block(block, ctx);
272+ ctx.variables = saved_vars;
481273 nodes
482274 }
483275
484− fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
485− let line_num = self.lines[self.pos].line_num;
276+ fn eval(&mut self, node: &Syntax, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
277+ let line_num = node.line();
486278 ctx.current_line = line_num;
487− if let LineContent::Normal(text) = &self.lines[self.pos].content
488− && let Some(filter) = old_filter_syntax(text)
279+ if !matches!(node, Syntax::Raw { .. })
280+ && let Some(filter) = old_filter_syntax(&node.source())
489281 {
490282 ctx.diagnostics.push(Diagnostic {
491283 line: line_num,
@@ -495,29 +287,35 @@ impl Parser {
495287 filter.0, filter.1, filter.1, filter.0
496288 ),
497289 severity: Severity::Warning,
498− source_line: Some(text.clone()),
290+ source_line: Some(node.source()),
499291 });
500292 }
501− let current_indent = self.lines[self.pos].indent;
502−
503− // Handle raw content
504− if let LineContent::Raw(s) = &self.lines[self.pos].content {
505− let content = s.clone();
506− self.pos += 1;
507− return Ok(Some(vec![Node::Raw(content)]));
508− }
509−
510− // Normal content — clone to release borrow. Raw is already handled
511− // above; a let-else pattern avoids a panic path if new LineContent
512− // variants are added in the future.
513− let LineContent::Normal(content) = &self.lines[self.pos].content else {
514− return Err(ParseError {
515− line: line_num,
516− message: "internal: unexpected line content variant".to_string(),
517− });
293+ let (content, current_indent, children) = match node {
294+ Syntax::Raw { text, .. } => return Ok(Some(vec![Node::Raw(text.clone())])),
295+ Syntax::Function { name, params, defaults, body, .. } => {
296+ self.define_function(name, params, defaults, body, line_num, ctx);
297+ return Ok(None);
298+ }
299+ Syntax::If { branches } => {
300+ // Every condition is checked (and its errors reported);
301+ // the first that holds picks the branch.
302+ let mut chosen = None;
303+ for branch in branches {
304+ let holds = match &branch.condition {
305+ Some(condition) => ctx.condition(condition, branch.line),
306+ None => true,
307+ };
308+ if holds && chosen.is_none() {
309+ chosen = Some(&branch.body);
310+ }
311+ }
312+ return Ok(chosen.map(|body| self.eval_scoped(body, ctx)));
313+ }
314+ Syntax::Each { header, body, empty, .. } => {
315+ return self.eval_each(header, body, empty, line_num, ctx).map(Some);
316+ }
317+ Syntax::Line { text, indent, children, .. } => (text.clone(), *indent, children),
518318 };
519− let content = content.clone();
520− self.pos += 1;
521319
522320 // --- Directives ---
523321
@@ -569,76 +367,11 @@ impl Parser {
569367 if let Some(rest) = content.strip_prefix("@let ") {
570368 let rest = rest.trim();
571369
572− // A `"""` value opens a multi-line string, whose indented lines
573− // are its content — not a function body.
574− let opens_triple_quote = rest.split_once(' ').is_some_and(|(_, v)| {
575− let v = v.trim();
576− v.strip_prefix("= ").unwrap_or(v).trim_start().starts_with("\"\"\"")
577− });
578− // Check if next lines are indented (function/component definition)
579− let has_body = !opens_triple_quote
580− && self.pos < self.lines.len()
581− && self.lines[self.pos].indent > current_indent;
582−
583− if has_body {
584− // Function definition: @let name $param1 $param2=default
585− let parts: Vec<&str> = rest.split_whitespace().collect();
586− if parts.is_empty() {
587− return Err(ParseError {
588− line: line_num,
589− message: "@let with body requires a name".to_string(),
590− });
591− }
592− let name = parts[0].to_string();
593− let mut params = Vec::new();
594− let mut defaults = HashMap::new();
595− for part in &parts[1..] {
596− let part = part.strip_prefix('$').unwrap_or(part);
597− if let Some((param_name, default_val)) = part.split_once('=') {
598− params.push(param_name.to_string());
599− defaults.insert(param_name.to_string(), default_val.to_string());
600− } else {
601− params.push(part.to_string());
602− }
603− }
604−
605− // Collect body lines (all lines indented deeper than @let)
606− let mut body_lines = Vec::new();
607− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
608− body_lines.push(self.lines[self.pos].clone());
609− self.pos += 1;
610− }
611−
612− // An @style block at the top of the body is scoped to the
613− // function: its rules apply inside a `.hl-NAME` wrapper.
614− let (body_lines, style_lines) = split_style_block(body_lines);
615− if !style_lines.is_empty() {
616− let scope_class = format!("hl-{}", name);
617− let scoped_css: String = style_lines
618− .iter()
619− .filter_map(|line| match &line.content {
620− LineContent::Normal(s) if !s.trim().is_empty() => {
621− Some(format!(".{} {}\n", scope_class, s.trim()))
622− }
623− _ => None,
624− })
625− .collect();
626− if !scoped_css.is_empty() {
627− ctx.custom_css.push(scoped_css);
628− }
629− ctx.scoped_functions.insert(name.clone());
630− }
631−
632− ctx.fn_lines.entry(name.clone()).or_insert(line_num);
633− ctx.functions.insert(
634− name,
635− FnDef {
636− params,
637− defaults,
638− body_lines,
639− },
640− );
641− return Ok(None);
370+ if !children.is_empty() {
371+ return Err(ParseError {
372+ line: line_num,
373+ message: "@let with body requires a name".to_string(),
374+ });
642375 }
643376
644377 if let Some((name, value)) = rest.split_once(' ') {
@@ -666,40 +399,10 @@ impl Parser {
666399 return Ok(None);
667400 }
668401
669− // Multi-line @let with triple quotes: @let name """..."""
670− let value_str;
402+ // Triple quotes: @let name """...""" (syntax.rs joins the
403+ // lines of a multi-line string)
671404 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
672− if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
673− // Single-line triple-quote: @let name """value"""
674− value_str = after_open[..after_open.len() - 3].to_string();
675− value_str.as_str()
676− } else {
677− // Multi-line: collect indented body lines until closing """
678− let mut lines_buf = String::new();
679− if !after_open.is_empty() {
680− lines_buf.push_str(after_open);
681− lines_buf.push('\n');
682− }
683− while self.pos < self.lines.len() {
684− match &self.lines[self.pos].content {
685− LineContent::Normal(s) if s.trim() == "\"\"\"" => {
686− self.pos += 1;
687− break;
688− }
689− LineContent::Normal(s) => {
690− lines_buf.push_str(s);
691− lines_buf.push('\n');
692− }
693− LineContent::Raw(s) => {
694− lines_buf.push_str(s);
695− lines_buf.push('\n');
696− }
697− }
698− self.pos += 1;
699− }
700− value_str = lines_buf.trim_end_matches('\n').to_string();
701− value_str.as_str()
702− }
405+ after_open.strip_suffix("\"\"\"").unwrap_or(after_open)
703406 } else if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
704407 // Support quoted string interpolation: @let greeting "Hello $name"
705408 &value[1..value.len() - 1]
@@ -733,22 +436,7 @@ impl Parser {
733436
734437
735438 if content == "@head" || content.starts_with("@head ") {
736− // Collect indented body lines as raw head content
737− let mut head_content = String::new();
738− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
739− match &self.lines[self.pos].content {
740− LineContent::Normal(s) => {
741− head_content.push_str(s.trim());
742− head_content.push('\n');
743− }
744− LineContent::Raw(s) => {
745− head_content.push_str(s);
746− head_content.push('\n');
747− }
748− }
749− self.pos += 1;
750− }
751− let trimmed = head_content.trim().to_string();
439+ let trimmed = block_text(children).trim().to_string();
752440 if !trimmed.is_empty() {
753441 ctx.head_blocks.push(trimmed);
754442 }
@@ -757,21 +445,7 @@ impl Parser {
757445
758446 // --- @style block (raw CSS) ---
759447 if content.trim() == "@style" {
760− let mut style_content = String::new();
761− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
762− match &self.lines[self.pos].content {
763− LineContent::Normal(s) => {
764− style_content.push_str(s.trim());
765− style_content.push('\n');
766− }
767− LineContent::Raw(s) => {
768− style_content.push_str(s);
769− style_content.push('\n');
770− }
771− }
772− self.pos += 1;
773− }
774− let trimmed = style_content.trim().to_string();
448+ let trimmed = block_text(children).trim().to_string();
775449 if !trimmed.is_empty() {
776450 ctx.custom_css.push(trimmed);
777451 }
@@ -784,14 +458,7 @@ impl Parser {
784458 let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
785459 if arg.is_empty() {
786460 // Inline markdown block: indented children
787− let mut md_lines = Vec::new();
788− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
789− match &self.lines[self.pos].content {
790− LineContent::Normal(s) => md_lines.push(s.clone()),
791− LineContent::Raw(s) => md_lines.extend(s.lines().map(String::from)),
792− }
793− self.pos += 1;
794− }
461+ let md_lines: Vec<String> = block_text(children).lines().map(String::from).collect();
795462 let html = markdown_to_html(&md_lines);
796463 return Ok(Some(vec![Node::Raw(html)]));
797464 } else {
@@ -940,12 +607,7 @@ impl Parser {
940607 }
941608 None => format!("@include \"{}\"", rel_name),
942609 };
943− let synth_lines = preprocess(&import_line);
944− let mut synth_parser = Parser {
945− lines: synth_lines,
946− pos: 0,
947− };
948− matched_nodes.extend(synth_parser.parse_children(0, ctx));
610+ matched_nodes.extend(self.eval_block(&syntax::parse(&import_line), ctx));
949611 }
950612 return Ok(Some(matched_nodes));
951613 }
@@ -1011,12 +673,7 @@ impl Parser {
1011673 let var_keys_before: std::collections::HashSet<String> =
1012674 ctx.variables.keys().cloned().collect();
1013675
1014− let imported_lines = preprocess(&imported_text);
1015− let mut imported_parser = Parser {
1016− lines: imported_lines,
1017− pos: 0,
1018− };
1019− let _discarded_nodes = imported_parser.parse_children(0, ctx);
676+ let _discarded_nodes = self.eval_block(&syntax::parse(&imported_text), ctx);
1020677
1021678 // Remove newly added entries and re-insert them under the prefix.
1022679 let new_fn_keys: Vec<String> = ctx
@@ -1055,12 +712,7 @@ impl Parser {
1055712 }
1056713 }
1057714 } else {
1058− let included_lines = preprocess(&imported_text);
1059− let mut included_parser = Parser {
1060− lines: included_lines,
1061− pos: 0,
1062− };
1063− included_nodes = included_parser.parse_children(0, ctx);
715+ included_nodes = self.eval_block(&syntax::parse(&imported_text), ctx);
1064716 }
1065717
1066718 // Annotate new diagnostics with import chain
@@ -1210,91 +862,6 @@ impl Parser {
1210862
1211863 // --- @if / @else ---
1212864
1213− if let Some(rest) = content.strip_prefix("@if ") {
1214− let result = ctx.condition(rest.trim(), line_num);
1215−
1216− // Collect then-body lines
1217− let mut then_lines = Vec::new();
1218− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1219− then_lines.push(self.lines[self.pos].clone());
1220− self.pos += 1;
1221− }
1222−
1223− // Build branches: [(condition_result, body_lines), ...]
1224− let mut branches: Vec<(bool, Vec<Line>)> = vec![(result, then_lines)];
1225−
1226− // Check for @else if / @else chains at same indent
1227− loop {
1228− if self.pos >= self.lines.len() || self.lines[self.pos].indent != current_indent {
1229− break;
1230− }
1231− if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1232− let trimmed = s.trim();
1233− if let Some(else_if_cond) = trimmed.strip_prefix("@else if ") {
1234− self.pos += 1; // consume @else if
1235− let cond_result =
1236− ctx.condition(else_if_cond.trim(), self.lines[self.pos - 1].line_num);
1237− let mut body = Vec::new();
1238− while self.pos < self.lines.len()
1239− && self.lines[self.pos].indent > current_indent
1240− {
1241− body.push(self.lines[self.pos].clone());
1242− self.pos += 1;
1243− }
1244− branches.push((cond_result, body));
1245− } else if trimmed == "@else" {
1246− self.pos += 1; // consume @else
1247− let mut body = Vec::new();
1248− while self.pos < self.lines.len()
1249− && self.lines[self.pos].indent > current_indent
1250− {
1251− body.push(self.lines[self.pos].clone());
1252− self.pos += 1;
1253− }
1254− // @else is always true (fallback)
1255− branches.push((true, body));
1256− break;
1257− } else {
1258− break;
1259− }
1260− } else {
1261− break;
1262− }
1263− }
1264−
1265− // Pick the first branch whose condition is true
1266− let body_lines = branches
1267− .into_iter()
1268− .find(|(cond, _)| *cond)
1269− .map(|(_, lines)| lines)
1270− .unwrap_or_default();
1271−
1272− if body_lines.is_empty() {
1273− return Ok(None);
1274− }
1275−
1276− let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1277− let adjusted: Vec<Line> = body_lines
1278− .iter()
1279− .map(|l| Line {
1280− indent: l.indent - min_indent,
1281− content: l.content.clone(),
1282− line_num: l.line_num,
1283− })
1284− .collect();
1285−
1286− // Scope variables: @let inside @if doesn't leak out
1287− let saved_vars = ctx.variables.clone();
1288− let mut body_parser = Parser {
1289− lines: adjusted,
1290− pos: 0,
1291− };
1292− let nodes = body_parser.parse_children(0, ctx);
1293− ctx.variables = saved_vars;
1294− return Ok(Some(nodes));
1295− }
1296−
1297−
1298865 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
1299866 return Err(ParseError {
1300867 line: line_num,
@@ -1304,157 +871,6 @@ impl Parser {
1304871
1305872 // --- @each loop ---
1306873
1307− if let Some(rest) = content.strip_prefix("@each ") {
1308− let rest = rest.trim();
1309− // Support: @each $var in list OR @each $var, $index in list
1310− // OR @each $name, $url in Alice /alice, Bob /bob (destructuring)
1311− let (var_names, list_str) = if let Some((before_in, after_in)) = rest.split_once(" in ")
1312− {
1313− let before_in = before_in.trim();
1314− let vars: Vec<String> = before_in
1315− .split(',')
1316− .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
1317− .collect();
1318− (vars, after_in.trim().to_string())
1319− } else {
1320− return Err(ParseError {
1321− line: line_num,
1322− message: "@each requires: @each $var in list".to_string(),
1323− });
1324− };
1325− let var_name = var_names[0].clone();
1326− let index_var = var_names.get(1).cloned();
1327−
1328− if list_str.trim_end().ends_with(']') && list_str.contains("[page ") {
1329− return Err(ParseError {
1330− line: line_num,
1331− message: "@each pagination (`[page N]`) was removed: split the list or \
1332− filter it with @if"
1333− .to_string(),
1334− });
1335− }
1336− track_var_refs(&list_str, &mut ctx.used_variables);
1337− let list_str = substitute_vars(&list_str, &ctx.variables);
1338− // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
1339− let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
1340− let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
1341− (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
1342− } else {
1343− (rest.trim(), 1i64)
1344− };
1345− if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
1346− {
1347− numeric_range(start, end, step)
1348− } else {
1349− list_str
1350− .split(',')
1351− .map(|s| s.trim().to_string())
1352− .filter(|s| !s.is_empty())
1353− .collect()
1354− }
1355− } else {
1356− list_str
1357− .split(',')
1358− .map(|s| s.trim().to_string())
1359− .filter(|s| !s.is_empty())
1360− .collect()
1361− };
1362−
1363− // Collect body lines
1364− let mut body_lines = Vec::new();
1365− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1366− body_lines.push(self.lines[self.pos].clone());
1367− self.pos += 1;
1368− }
1369−
1370− // Check for @else block (empty-state fallback)
1371− let mut else_lines = Vec::new();
1372− if self.pos < self.lines.len()
1373− && self.lines[self.pos].indent == current_indent
1374− && let LineContent::Normal(ref s) = self.lines[self.pos].content
1375− && s.trim() == "@else"
1376− {
1377− self.pos += 1; // consume @else
1378− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1379− else_lines.push(self.lines[self.pos].clone());
1380− self.pos += 1;
1381− }
1382− }
1383−
1384− if body_lines.is_empty() {
1385− return Ok(None);
1386− }
1387−
1388− // If list is empty, render @else body
1389− if items.is_empty() {
1390− if else_lines.is_empty() {
1391− return Ok(None);
1392− }
1393− let min_indent = else_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1394− let adjusted: Vec<Line> = else_lines
1395− .iter()
1396− .map(|l| Line {
1397− indent: l.indent - min_indent,
1398− content: l.content.clone(),
1399− line_num: l.line_num,
1400− })
1401− .collect();
1402− let mut body_parser = Parser {
1403− lines: adjusted,
1404− pos: 0,
1405− };
1406− let saved_vars = ctx.variables.clone();
1407− let nodes = body_parser.parse_children(0, ctx);
1408− ctx.variables = saved_vars;
1409− return Ok(Some(nodes));
1410− }
1411−
1412− let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1413− let adjusted: Vec<Line> = body_lines
1414− .iter()
1415− .map(|l| Line {
1416− indent: l.indent - min_indent,
1417− content: l.content.clone(),
1418− line_num: l.line_num,
1419− })
1420− .collect();
1421−
1422− let saved_vars = ctx.variables.clone();
1423− let mut all_nodes = Vec::new();
1424−
1425− let has_extra_vars = var_names.len() > 2
1426− || (var_names.len() == 2 && items.first().is_some_and(|it| it.contains(' ')));
1427−
1428− for (i, item) in items.iter().enumerate() {
1429− // Always expose $_index for the current iteration
1430− ctx.variables.insert("_index".to_string(), i.to_string());
1431− if has_extra_vars {
1432− // Destructuring: split item by spaces and assign to each variable
1433− let parts: Vec<&str> = item.splitn(var_names.len(), ' ').collect();
1434− for (vi, vn) in var_names.iter().enumerate() {
1435− let val = parts.get(vi).unwrap_or(&"").to_string();
1436− ctx.variables.insert(vn.clone(), val);
1437− }
1438− } else {
1439− ctx.variables.insert(var_name.clone(), item.clone());
1440− if let Some(ref idx_name) = index_var {
1441− ctx.variables.insert(idx_name.clone(), i.to_string());
1442− }
1443− }
1444− let mut body_parser = Parser {
1445− lines: adjusted.clone(),
1446− pos: 0,
1447− };
1448− let nodes = body_parser.parse_children(0, ctx);
1449− all_nodes.extend(nodes);
1450− }
1451−
1452− ctx.variables = saved_vars;
1453− return Ok(Some(all_nodes));
1454− }
1455−
1456−
1457−
1458874 // --- @warn / @debug ---
1459875
1460876 if let Some(rest) = content.strip_prefix("@warn ") {
@@ -1480,11 +896,10 @@ impl Parser {
1480896 message: "@keyframes requires a name".to_string(),
1481897 });
1482898 }
1483− // Collect body lines (indented deeper)
1484899 let mut body = String::new();
1485− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1486− if let LineContent::Normal(ref s) = self.lines[self.pos].content {
1487− let trimmed = s.trim();
900+ for_each_line(children, &mut |node| {
901+ if let Syntax::Line { text, .. } = node {
902+ let trimmed = text.trim();
1488903 // Support htmlang-style: from [opacity 0] / to [opacity 1] / 50% [transform scale(1.5)]
1489904 if let Some(kf_css) = parse_keyframe_line(trimmed) {
1490905 body.push_str(&kf_css);
@@ -1492,8 +907,7 @@ impl Parser {
1492907 body.push_str(trimmed);
1493908 }
1494909 }
1495− self.pos += 1;
1496− }
910+ });
1497911 ctx.keyframes.push((name, body));
1498912 return Ok(None);
1499913 }
@@ -1506,7 +920,7 @@ impl Parser {
1506920 let name = extract_element_name(&content);
1507921 if ctx.functions.contains_key(name) {
1508922 ctx.used_functions.insert(name.to_string());
1509− let nodes = self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
923+ let nodes = self.expand_fn_call(name, &content, children, line_num, ctx)?;
1510924 return Ok(Some(nodes));
1511925 }
1512926 }
@@ -1514,7 +928,7 @@ impl Parser {
1514928 // --- Elements ---
1515929
1516930 if content.starts_with('@') || content.starts_with('[') {
1517− let node = self.parse_element_line(&content, current_indent, line_num, ctx)?;
931+ let node = self.parse_element_line(&content, children, line_num, ctx)?;
1518932 return Ok(Some(vec![inline_svg(node, line_num, ctx)]));
1519933 }
1520934
@@ -1612,11 +1026,153 @@ impl Parser {
16121026 ctx.variables.insert(name.to_string(), stems.join(", "));
16131027 }
16141028
1029+ fn eval_each(
1030+ &mut self,
1031+ header: &str,
1032+ body: &[Syntax],
1033+ empty: &[Syntax],
1034+ line_num: usize,
1035+ ctx: &mut ParseContext,
1036+ ) -> Result<Vec<Node>, ParseError> {
1037+ // Support: @each $var in list OR @each $var, $index in list
1038+ // OR @each $name, $url in Alice /alice, Bob /bob (destructuring)
1039+ let (var_names, list_str) = if let Some((before_in, after_in)) = header.split_once(" in ") {
1040+ let before_in = before_in.trim();
1041+ let vars: Vec<String> = before_in
1042+ .split(',')
1043+ .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
1044+ .collect();
1045+ (vars, after_in.trim().to_string())
1046+ } else {
1047+ return Err(ParseError {
1048+ line: line_num,
1049+ message: "@each requires: @each $var in list".to_string(),
1050+ });
1051+ };
1052+ let var_name = var_names[0].clone();
1053+ let index_var = var_names.get(1).cloned();
1054+
1055+ if list_str.trim_end().ends_with(']') && list_str.contains("[page ") {
1056+ return Err(ParseError {
1057+ line: line_num,
1058+ message: "@each pagination (`[page N]`) was removed: split the list or \
1059+ filter it with @if"
1060+ .to_string(),
1061+ });
1062+ }
1063+ track_var_refs(&list_str, &mut ctx.used_variables);
1064+ let list_str = substitute_vars(&list_str, &ctx.variables);
1065+ // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
1066+ let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
1067+ let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
1068+ (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
1069+ } else {
1070+ (rest.trim(), 1i64)
1071+ };
1072+ if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
1073+ {
1074+ numeric_range(start, end, step)
1075+ } else {
1076+ list_str
1077+ .split(',')
1078+ .map(|s| s.trim().to_string())
1079+ .filter(|s| !s.is_empty())
1080+ .collect()
1081+ }
1082+ } else {
1083+ list_str
1084+ .split(',')
1085+ .map(|s| s.trim().to_string())
1086+ .filter(|s| !s.is_empty())
1087+ .collect()
1088+ };
1089+
1090+ if body.is_empty() {
1091+ return Ok(Vec::new());
1092+ }
1093+ // An empty list renders the @else body
1094+ if items.is_empty() {
1095+ return Ok(self.eval_scoped(empty, ctx));
1096+ }
1097+
1098+ let saved_vars = ctx.variables.clone();
1099+ let mut all_nodes = Vec::new();
1100+
1101+ let has_extra_vars = var_names.len() > 2
1102+ || (var_names.len() == 2 && items.first().is_some_and(|it| it.contains(' ')));
1103+
1104+ for (i, item) in items.iter().enumerate() {
1105+ // Always expose $_index for the current iteration
1106+ ctx.variables.insert("_index".to_string(), i.to_string());
1107+ if has_extra_vars {
1108+ // Destructuring: split item by spaces and assign to each variable
1109+ let parts: Vec<&str> = item.splitn(var_names.len(), ' ').collect();
1110+ for (vi, vn) in var_names.iter().enumerate() {
1111+ let val = parts.get(vi).unwrap_or(&"").to_string();
1112+ ctx.variables.insert(vn.clone(), val);
1113+ }
1114+ } else {
1115+ ctx.variables.insert(var_name.clone(), item.clone());
1116+ if let Some(ref idx_name) = index_var {
1117+ ctx.variables.insert(idx_name.clone(), i.to_string());
1118+ }
1119+ }
1120+ all_nodes.extend(self.eval_block(body, ctx));
1121+ }
1122+
1123+ ctx.variables = saved_vars;
1124+ Ok(all_nodes)
1125+ }
1126+
1127+ fn define_function(
1128+ &mut self,
1129+ name: &str,
1130+ params: &[String],
1131+ defaults: &HashMap<String, String>,
1132+ body: &[Syntax],
1133+ line_num: usize,
1134+ ctx: &mut ParseContext,
1135+ ) {
1136+ // An @style block at the top of the body is scoped to the
1137+ // function: its rules apply inside a `.hl-NAME` wrapper.
1138+ let (style, body): (Vec<&Syntax>, Vec<&Syntax>) = body
1139+ .iter()
1140+ .partition(|node| matches!(node, Syntax::Line { text, .. } if text == "@style"));
1141+ if !style.is_empty() {
1142+ let mut scoped_css = String::new();
1143+ for node in style {
1144+ if let Syntax::Line { children, .. } = node {
1145+ for_each_line(children, &mut |node| {
1146+ if let Syntax::Line { text, .. } = node
1147+ && !text.trim().is_empty()
1148+ {
1149+ scoped_css.push_str(&format!(".hl-{} {}\n", name, text.trim()));
1150+ }
1151+ });
1152+ }
1153+ }
1154+ if !scoped_css.is_empty() {
1155+ ctx.custom_css.push(scoped_css);
1156+ }
1157+ ctx.scoped_functions.insert(name.to_string());
1158+ }
1159+
1160+ ctx.fn_lines.entry(name.to_string()).or_insert(line_num);
1161+ ctx.functions.insert(
1162+ name.to_string(),
1163+ FnDef {
1164+ params: params.to_vec(),
1165+ defaults: defaults.clone(),
1166+ body: body.into_iter().cloned().collect(),
1167+ },
1168+ );
1169+ }
1170+
16151171 fn expand_fn_call(
16161172 &mut self,
16171173 name: &str,
16181174 content: &str,
1619− current_indent: usize,
1175+ children: &[Syntax],
16201176 line_num: usize,
16211177 ctx: &mut ParseContext,
16221178 ) -> Result<Vec<Node>, ParseError> {
@@ -1658,7 +1214,7 @@ impl Parser {
16581214 };
16591215
16601216 // Parse caller's children, separating named slots from default children
1661− let all_caller_children = self.parse_children(current_indent + 1, ctx);
1217+ let all_caller_children = self.eval_block(children, ctx);
16621218 let mut slot_contents: HashMap<String, Vec<Node>> = HashMap::new();
16631219 let mut caller_children = Vec::new();
16641220 for child in all_caller_children {
@@ -1716,29 +1272,8 @@ impl Parser {
17161272 .map(|(a, _)| a.clone())
17171273 .collect();
17181274
1719− // Normalize body indentation so it parses from indent 0
1720− let min_indent = fn_def
1721− .body_lines
1722− .iter()
1723− .map(|l| l.indent)
1724− .min()
1725− .unwrap_or(0);
1726− let adjusted: Vec<Line> = fn_def
1727− .body_lines
1728− .iter()
1729− .map(|l| Line {
1730− indent: l.indent - min_indent,
1731− content: l.content.clone(),
1732− line_num: l.line_num,
1733− })
1734− .collect();
1735−
1736− // Parse body with params in scope
1737− let mut body_parser = Parser {
1738− lines: adjusted,
1739− pos: 0,
1740− };
1741− let body_nodes = body_parser.parse_children(0, ctx);
1275+ // Evaluate the body with the parameters in scope
1276+ let body_nodes = self.eval_block(&fn_def.body, ctx);
17421277
17431278 // Restore variables and call stack
17441279 ctx.variables = saved_vars;
@@ -1796,7 +1331,7 @@ impl Parser {
17961331 fn parse_element_line(
17971332 &mut self,
17981333 content: &str,
1799− current_indent: usize,
1334+ children: &[Syntax],
18001335 line_num: usize,
18011336 ctx: &mut ParseContext,
18021337 ) -> Result<Node, ParseError> {
@@ -1810,7 +1345,7 @@ impl Parser {
18101345 }
18111346
18121347 // Parse indented children (belong to the innermost element)
1813− let children = self.parse_children(current_indent + 1, ctx);
1348+ let children = self.eval_block(children, ctx);
18141349
18151350 // Build chain right-to-left: rightmost gets children, each wraps the next
18161351 let mut current_children = children;
@@ -3949,26 +3484,32 @@ fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
39493484 }
39503485}
39513486
3952−/// Split a function body into its content and an @style block at the
3953−/// body's top level (the block's CSS lines, without the `@style` line).
3954−fn split_style_block(body: Vec<Line>) -> (Vec<Line>, Vec<Line>) {
3955− let top = body.iter().map(|l| l.indent).min().unwrap_or(0);
3956− let mut content = Vec::new();
3957− let mut style = Vec::new();
3958− let mut in_style = false;
3959− for line in body {
3960− let is_style_header =
3961− line.indent == top && matches!(&line.content, LineContent::Normal(s) if s.trim() == "@style");
3962− if is_style_header {
3963− in_style = true;
3964− } else if in_style && line.indent > top {
3965− style.push(line);
3966− } else {
3967− in_style = false;
3968− content.push(line);
3487+/// Visit the lines of a block in source order, children after their line.
3488+fn for_each_line<'a>(block: &'a [Syntax], f: &mut impl FnMut(&'a Syntax)) {
3489+ for node in block {
3490+ f(node);
3491+ if let Syntax::Line { children, .. } = node {
3492+ for_each_line(children, f);
39693493 }
39703494 }
3971− (content, style)
3495+}
3496+
3497+/// The text of a block whose lines are content, not htmlang (`@head`,
3498+/// `@style`, `@markdown`): each line trimmed, verbatim text as is.
3499+fn block_text(block: &[Syntax]) -> String {
3500+ let mut text = String::new();
3501+ for_each_line(block, &mut |node| match node {
3502+ Syntax::Line { text: line, .. } => {
3503+ text.push_str(line.trim());
3504+ text.push('\n');
3505+ }
3506+ Syntax::Raw { text: raw, .. } => {
3507+ text.push_str(raw);
3508+ text.push('\n');
3509+ }
3510+ _ => {}
3511+ });
3512+ text
39723513}
39733514
39743515fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {