@@ -226,12 +226,22 @@ impl Evaluator {
232+ ctx: &mut ParseContext,
229− fn eval(&mut self, node: &Syntax, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
233+ ) -> Result<Option<Vec<Node>>, ParseError> {
230 let line_num = node.line();
234 let line_num = node.line();
231 ctx.current_line = line_num;
235 ctx.current_line = line_num;
232 let (content, current_indent, children) = match node {
236 let (content, current_indent, children) = match node {
233 Syntax::Raw { text, .. } => return Ok(Some(vec![Node::Raw(text.clone())])),
237 Syntax::Raw { text, .. } => return Ok(Some(vec![Node::Raw(text.clone())])),
234− Syntax::Function { name, params, defaults, body, .. } => {
238+ Syntax::Function {
235 self.define_function(name, params, defaults, body, line_num, ctx);
245 self.define_function(name, params, defaults, body, line_num, ctx);
236 return Ok(None);
246 return Ok(None);
@@ -250,10 +260,20 @@ impl Evaluator {
251 return Ok(chosen.map(|body| self.eval_scoped(body, ctx)));
261 return Ok(chosen.map(|body| self.eval_scoped(body, ctx)));
253− Syntax::Each { header, body, empty, .. } => {
263+ Syntax::Each {
254 return self.eval_each(header, body, empty, line_num, ctx).map(Some);
269 return self.eval_each(header, body, empty, line_num, ctx).map(Some);
256− Syntax::Line { text, indent, children, .. } => (text.clone(), *indent, children),
276+ } => (text.clone(), *indent, children),
259 // --- Directives ---
279 // --- Directives ---
@@ -288,7 +308,6 @@ impl Evaluator {
288 return Ok(None);
308 return Ok(None);
292 if let Some(rest) = content.strip_prefix("@let ") {
311 if let Some(rest) = content.strip_prefix("@let ") {
293 let rest = rest.trim();
312 let rest = rest.trim();
@@ -355,7 +374,6 @@ impl Evaluator {
355 return Ok(None);
374 return Ok(None);
359 if content == "@head" || content.starts_with("@head ") {
377 if content == "@head" || content.starts_with("@head ") {
360 let trimmed = block_text(children).trim().to_string();
378 let trimmed = block_text(children).trim().to_string();
361 if !trimmed.is_empty() {
379 if !trimmed.is_empty() {
@@ -373,13 +391,13 @@ impl Evaluator {
373 return Ok(None);
391 return Ok(None);
377 // --- @markdown block or file (convert markdown to HTML) ---
394 // --- @markdown block or file (convert markdown to HTML) ---
378 if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
395 if content.trim() == "@markdown" || content.trim().starts_with("@markdown ") {
379 let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
396 let arg = content.trim().strip_prefix("@markdown").unwrap().trim();
380 if arg.is_empty() {
397 if arg.is_empty() {
381 // Inline markdown block: indented children
398 // Inline markdown block: indented children
399+ let md_lines: Vec<String> =
382− let md_lines: Vec<String> = block_text(children).lines().map(String::from).collect();
400+ block_text(children).lines().map(String::from).collect();
383 let html = markdown_to_html(&md_lines);
401 let html = markdown_to_html(&md_lines);
384 return Ok(Some(vec![Node::Raw(html)]));
402 return Ok(Some(vec![Node::Raw(html)]));
@@ -416,8 +434,6 @@ impl Evaluator {
421 if let Some(rest) = content.strip_prefix("@include ") {
437 if let Some(rest) = content.strip_prefix("@include ") {
422 let rest = rest.trim();
438 let rest = rest.trim();
423 let filename = rest
439 let filename = rest
@@ -487,8 +503,6 @@ impl Evaluator {
487 return Ok(Some(included_nodes));
503 return Ok(Some(included_nodes));
492 // --- @data (load JSON file into variables) ---
506 // --- @data (load JSON file into variables) ---
494 // @data $name file.json values as $name.key
508 // @data $name file.json values as $name.key
@@ -737,7 +751,10 @@ impl Evaluator {
737 message: "@each requires: @each $item in LIST".to_string(),
751 message: "@each requires: @each $item in LIST".to_string(),
754+ let names: Vec<&str> = names
740− let names: Vec<&str> = names.split(',').map(|v| v.trim().trim_start_matches('$')).collect();
756+ .map(|v| v.trim().trim_start_matches('$'))
741 if names.len() > 2 {
758 if names.len() > 2 {
742 return Err(ParseError {
759 return Err(ParseError {
743 line: line_num,
760 line: line_num,
@@ -752,11 +769,16 @@ impl Evaluator {
753 // A list loaded from JSON, by name
770 // A list loaded from JSON, by name
754 let data_list = list_src.strip_prefix('$').and_then(|name| {
771 let data_list = list_src.strip_prefix('$').and_then(|name| {
755− let len = ctx.variables.get(&format!("{}#", name))?.parse::<usize>().ok()?;
774+ .get(&format!("{}#", name))?
756 Some((name.to_string(), len))
777 Some((name.to_string(), len))
758 let undefined = list_src.strip_prefix('$').filter(|name| {
779 let undefined = list_src.strip_prefix('$').filter(|name| {
759− name.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
781+ .all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
760 && !ctx.variables.contains_key(*name)
782 && !ctx.variables.contains_key(*name)
762 let text_items: Vec<String> = match (&data_list, undefined) {
784 let text_items: Vec<String> = match (&data_list, undefined) {
@@ -963,7 +985,10 @@ impl Evaluator {
964 // Attributes that aren't parameters style the function's root
986 // Attributes that aren't parameters style the function's root
965 // element, and a scoped @style's class goes on it too.
987 // element, and a scoped @style's class goes on it too.
966− let scope_class = ctx.scoped_functions.contains(name).then(|| format!("hl-{}", name));
988+ let scope_class = ctx
991+ .then(|| format!("hl-{}", name));
967 if !forwarded.is_empty() || scope_class.is_some() {
992 if !forwarded.is_empty() || scope_class.is_some() {
968 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
993 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
969 Node::Element(e) => Some(e),
994 Node::Element(e) => Some(e),
@@ -981,7 +1006,8 @@ impl Evaluator {
981 match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
1006 match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
982 Some(attr) => {
1007 Some(attr) => {
983 let existing = attr.value.take().unwrap_or_default();
1008 let existing = attr.value.take().unwrap_or_default();
984− attr.value = Some(format!("{} {}", existing, class).trim().to_string());
1010+ Some(format!("{} {}", existing, class).trim().to_string());
986 None => root.attrs.push(Attribute {
1012 None => root.attrs.push(Attribute {
987 key: "class".to_string(),
1013 key: "class".to_string(),
@@ -1184,7 +1210,10 @@ fn parse_single_element(
1184 Some(restore_escapes(&rest_sub))
1210 Some(restore_escapes(&rest_sub))
1186 } else {
1212 } else {
1187− Some(restore_escapes(&substitute_vars(&protect_escapes(&rest), &ctx.variables)))
1213+ Some(restore_escapes(&substitute_vars(
1214+ &protect_escapes(&rest),
1190 // For @slot, the argument is the slot name
1219 // For @slot, the argument is the slot name
@@ -1231,17 +1260,7 @@ pub fn known_directives() -> &'static [&'static str] {
1233const KNOWN_DIRECTIVES: &[&str] = &[
1262const KNOWN_DIRECTIVES: &[&str] = &[
1234− "page",
1263+ "page", "let", "include", "raw", "if", "else", "each", "meta", "head", "style", "markdown",
@@ -1935,7 +1954,10 @@ fn parse_attr_list(
1936 // Color validation for hex colors
1955 // Color validation for hex colors
1937 if !attr.html
1956 if !attr.html
1938− && matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
1958+ crate::vocab::base_attribute(&attr.key),
1959+ "background" | "color"
1939 && let Some(ref val) = attr.value
1961 && let Some(ref val) = attr.value
1940 && val.starts_with('#')
1962 && val.starts_with('#')
1941 && !is_valid_hex_color(val)
1963 && !is_valid_hex_color(val)
@@ -2249,8 +2271,6 @@ fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
2254/// Evaluate `if(cond, a)` or `if(cond, a, b)` when it is all of `text`,
2274/// Evaluate `if(cond, a)` or `if(cond, a, b)` when it is all of `text`,
2255/// returning the chosen branch (empty when `cond` fails and there is no `b`).
2275/// returning the chosen branch (empty when `cond` fails and there is no `b`).
2256fn choose_if(text: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
2276fn choose_if(text: &str, ctx: &mut ParseContext, line: usize) -> Option<String> {
@@ -2376,10 +2396,11 @@ pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
2379− if matches!(elem.kind, ElementKind::Row | ElementKind::El)
2399+ if matches!(elem.kind, ElementKind::Row | ElementKind::El) && elem.children.is_empty() {
2380− && elem.children.is_empty()
2400+ warn(format!(
2382− warn(format!("empty container (@{}) has no children", elem.kind.name()));
2401+ "empty container (@{}) has no children",
2384 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
2405 if elem.kind.is_tag("button") && !elem.attrs.iter().any(|a| a.key == "type") {
2385 warn("@button missing 'type' attribute (defaults to submit)".to_string());
2406 warn("@button missing 'type' attribute (defaults to submit)".to_string());
@@ -2392,7 +2413,6 @@ pub fn lint(nodes: &[Node]) -> Vec<Diagnostic> {
2396fn validate_tree(
2416fn validate_tree(
2397 nodes: &[Node],
2417 nodes: &[Node],
2398 parent_kind: Option<&ElementKind>,
2418 parent_kind: Option<&ElementKind>,
@@ -2475,8 +2495,7 @@ fn validate_tree(
2477 // 'rows'/'cols' only on @textarea
2497 // 'rows'/'cols' only on @textarea
2478− if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea")
2498+ if (base == "rows" || base == "cols") && !elem.kind.is_tag("textarea") {
2480 diagnostics.push(Diagnostic {
2499 diagnostics.push(Diagnostic {
2481 line: elem.line_num,
2500 line: elem.line_num,
2482 column: None,
2501 column: None,
@@ -2526,9 +2545,7 @@ fn validate_tree(
2526 source_line: None,
2545 source_line: None,
2529− if elem.kind.is_tag("input")
2530− && !elem.attrs.iter().any(|a| a.key == "type")
2548+ if elem.kind.is_tag("input") && !elem.attrs.iter().any(|a| a.key == "type") {
2532 diagnostics.push(Diagnostic {
2549 diagnostics.push(Diagnostic {
2533 line: elem.line_num,
2550 line: elem.line_num,
2534 column: None,
2551 column: None,
@@ -2570,7 +2587,10 @@ fn validate_tree(
2570 .find(|a| crate::vocab::base_attribute(&a.key) == "color")
2587 .find(|a| crate::vocab::base_attribute(&a.key) == "color")
2571 .and_then(|a| a.value.as_deref());
2588 .and_then(|a| a.value.as_deref());
2572 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
2589 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
2590+ && let (Some(bg_rgb), Some(fg_rgb)) = (
2573− && let (Some(bg_rgb), Some(fg_rgb)) = (crate::expr::parse_hex_rgb(bg), crate::expr::parse_hex_rgb(fg))
2591+ crate::expr::parse_hex_rgb(bg),
2592+ crate::expr::parse_hex_rgb(fg),
2575 let ratio = contrast_ratio(bg_rgb, fg_rgb);
2595 let ratio = contrast_ratio(bg_rgb, fg_rgb);
2576 if ratio < 4.5 {
2596 if ratio < 4.5 {
@@ -2612,9 +2632,7 @@ fn validate_tree(
2614 // @iframe should have title attribute
2634 // @iframe should have title attribute
2615− if elem.kind.is_tag("iframe")
2616− && !elem.attrs.iter().any(|a| a.key == "title")
2635+ if elem.kind.is_tag("iframe") && !elem.attrs.iter().any(|a| a.key == "title") {
2618 diagnostics.push(Diagnostic {
2636 diagnostics.push(Diagnostic {
2619 line: elem.line_num,
2637 line: elem.line_num,
2620 column: None,
2638 column: None,
@@ -2729,7 +2747,9 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
2729 rest = &after[close + 1..];
2747 rest = &after[close + 1..];
2730 continue;
2748 continue;
2750+ let mut end = after
2732− let mut end = after.find(|c: char| !is_name_char(c)).unwrap_or(after.len());
2751+ .find(|c: char| !is_name_char(c))
2752+ .unwrap_or(after.len());
2733 while end > 0 && after[..end].ends_with('.') {
2753 while end > 0 && after[..end].ends_with('.') {
2734 end -= 1;
2754 end -= 1;
@@ -2756,7 +2776,10 @@ fn inline_svg(node: Node, line_num: usize, ctx: &mut ParseContext) -> Node {
2757 let is_inline_svg = elem.kind == ElementKind::Image
2777 let is_inline_svg = elem.kind == ElementKind::Image
2758 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
2778 && elem.attrs.iter().any(|a| a.key == "inline" && !a.html)
2759− && elem.argument.as_deref().is_some_and(|src| src.ends_with(".svg"));
2782+ .is_some_and(|src| src.ends_with(".svg"));
2760 if !is_inline_svg {
2783 if !is_inline_svg {
2761 return node;
2784 return node;
@@ -3231,10 +3254,17 @@ fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, Stri
3231/// JSON array, else its text.
3254/// JSON array, else its text.
3232fn lookup(vars: &HashMap<String, String>, name: &str) -> Option<crate::expr::Value> {
3255fn lookup(vars: &HashMap<String, String>, name: &str) -> Option<crate::expr::Value> {
3233 use crate::expr::Value;
3256 use crate::expr::Value;
3258+ .get(&format!("{}#", name))
3234− match vars.get(&format!("{}#", name)).and_then(|n| n.parse::<usize>().ok()) {
3259+ .and_then(|n| n.parse::<usize>().ok())
3235 Some(len) => Some(Value::List(
3261 Some(len) => Some(Value::List(
3236 (0..len)
3262 (0..len)
3237− .map(|i| vars.get(&format!("{}.{}", name, i)).cloned().unwrap_or_default())
3264+ vars.get(&format!("{}.{}", name, i))
3266+ .unwrap_or_default()
3238 .collect(),
3268 .collect(),
3240 None => vars.get(name).cloned().map(Value::Str),
3270 None => vars.get(name).cloned().map(Value::Str),