@@ -104,8 +104,7 @@ impl ParseContext {
104 fn eval(&mut self, src: &str, line: usize) -> Option<crate::expr::Value> {
104 fn eval(&mut self, src: &str, line: usize) -> Option<crate::expr::Value> {
105 track_var_refs(src, &mut self.used_variables);
105 track_var_refs(src, &mut self.used_variables);
106 let vars = &self.variables;
106 let vars = &self.variables;
107− let resolve = |name: &str| vars.get(name).cloned();
108− let result = crate::expr::eval(src, &resolve);
107+ let result = crate::expr::eval(src, &|name: &str| lookup(vars, name));
109 match result {
108 match result {
110 Ok(value) => Some(value),
109 Ok(value) => Some(value),
111 Err(message) => {
110 Err(message) => {
@@ -644,8 +643,8 @@ impl Evaluator {
644 // --- @data (load JSON file into variables) ---
643 // --- @data (load JSON file into variables) ---
646 // @data $name file.json values as $name.key
645 // @data $name file.json values as $name.key
647− // @data $name dir/*.json each file as $name.STEM.key; $name
646+ // @data $name dir/*.json a list of the files' records
648− // lists the stems, $name._count counts them
647+ // @data $name [...] / {...} inline JSON
649 // @data $name env:NAME [DEFAULT] an environment variable
648 // @data $name env:NAME [DEFAULT] an environment variable
650 if let Some(rest) = content.strip_prefix("@data ") {
649 if let Some(rest) = content.strip_prefix("@data ") {
651 let rest = rest.trim();
650 let rest = rest.trim();
@@ -687,56 +686,49 @@ impl Evaluator {
687 return Ok(None);
686 return Ok(None);
689+ // Inline JSON: @data $links [{"label": "Home", "url": "/"}]
690+ let (json_text, source) = if filename.starts_with(['[', '{']) {
691+ (filename, "the inline data".to_string())
690− let filename = substitute_vars(&filename, &ctx.variables);
693+ let filename = substitute_vars(&filename, &ctx.variables);
691− if filename.contains('*') {
694+ if filename.contains('*') {
692− self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
695+ self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
696− let filename = substitute_vars(&filename, &ctx.variables);
697− let resolved = match &ctx.base_path {
698− Some(base) => base.join(&filename),
699− None => PathBuf::from(&filename),
702− let json_text = match std::fs::read_to_string(&resolved) {
705− ctx.diagnostics.push(Diagnostic {
708− message: format!("cannot load data '{}': {}", filename, e),
709− severity: Severity::Error,
710− source_line: Some(content.clone()),
712 return Ok(None);
696 return Ok(None);
714− };
698+ let resolved = match &ctx.base_path {
715−
699+ Some(base) => base.join(&filename),
716− match parse_json_with_error(&json_text) {
700+ None => PathBuf::from(&filename),
717− Ok(json) => {
701+ };
718− let mut sub = HashMap::new();
702+ match std::fs::read_to_string(&resolved) {
719− flatten_json(&prefix, &json, &mut sub);
703+ Ok(text) => {
720− for (k, v) in sub {
704+ ctx.included_files.push(resolved);
721− ctx.variables.insert(k, v);
705+ (text, format!("'{}'", filename))
708+ ctx.diagnostics.push(Diagnostic {
711+ message: format!("cannot load data '{}': {}", filename, e),
712+ severity: Severity::Error,
713+ source_line: Some(content.clone()),
724− Err(detail) => {
718+ };
719+ match parse_json_with_error(&json_text) {
720+ Ok(json) => flatten_json(&prefix, &json, &mut ctx.variables),
725− ctx.diagnostics.push(Diagnostic {
721+ Err(detail) => ctx.diagnostics.push(Diagnostic {
726− line: line_num,
722+ line: line_num,
727− column: None,
723+ column: None,
728− message: format!("invalid JSON in '{}': {}", filename, detail),
724+ message: format!("invalid JSON in {}: {}", source, detail),
729− severity: Severity::Error,
725+ severity: Severity::Error,
730− source_line: Some(content.clone()),
726+ source_line: Some(content.clone()),
735− ctx.included_files.push(resolved);
736 return Ok(None);
729 return Ok(None);
740 // --- @if / @else ---
732 // --- @if / @else ---
742 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
734 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
@@ -814,8 +806,8 @@ impl Evaluator {
814 Ok(Some(vec![Node::Text(segments)]))
806 Ok(Some(vec![Node::Text(segments)]))
817− /// `@data $name dir/*.json`: load every matching file as
809+ /// `@data $name dir/*.json`: a list with one record per matching file
818− /// `$name.STEM.key`, list the stems in `$name` and count them.
810+ /// (in name order), whose `file` is the file's name without extension.
819 fn load_data_glob(
811 fn load_data_glob(
820 &mut self,
812 &mut self,
821 name: &str,
813 name: &str,
@@ -856,7 +848,7 @@ impl Evaluator {
858 files.sort();
850 files.sort();
859− let mut stems = Vec::new();
851+ let mut records = Vec::new();
860 for file in files {
852 for file in files {
861 let stem = file
853 let stem = file
862 .file_stem()
854 .file_stem()
@@ -877,12 +869,19 @@ impl Evaluator {
879 match parse_json_with_error(&text) {
871 match parse_json_with_error(&text) {
880− Ok(json) => {
872+ Ok(JsonValue::Object(mut pairs)) => {
881− let mut vars = HashMap::new();
873+ if !pairs.iter().any(|(key, _)| key == "file") {
882− flatten_json(&format!("{}.{}", name, stem), &json, &mut vars);
874+ pairs.push(("file".to_string(), JsonValue::Str(stem)));
883− ctx.variables.extend(vars);
875+ }
884− stems.push(stem);
876+ records.push(JsonValue::Object(pairs));
878+ Ok(_) => ctx.diagnostics.push(Diagnostic {
881+ message: format!("'{}' should hold a JSON object", file.display()),
882+ severity: Severity::Error,
883+ source_line: Some(content.to_string()),
886 Err(detail) => ctx.diagnostics.push(Diagnostic {
885 Err(detail) => ctx.diagnostics.push(Diagnostic {
887 line: line_num,
886 line: line_num,
888 column: None,
887 column: None,
@@ -893,11 +892,12 @@ impl Evaluator {
894 ctx.included_files.push(file);
893 ctx.included_files.push(file);
896− ctx.variables
895+ flatten_json(name, &JsonValue::Array(records), &mut ctx.variables);
897− .insert(format!("{}._count", name), stems.len().to_string());
898− ctx.variables.insert(name.to_string(), stems.join(", "));
898+ /// `@each $item in LIST` or `@each $item, $index in LIST`. A LIST from
899+ /// JSON binds each record or value to `$item`; any other list is text,
900+ /// split on commas, or a range `A..B [step N]`.
901 fn eval_each(
901 fn eval_each(
902 &mut self,
902 &mut self,
903 header: &str,
903 header: &str,
@@ -906,25 +906,24 @@ impl Evaluator {
906 line_num: usize,
906 line_num: usize,
907 ctx: &mut ParseContext,
907 ctx: &mut ParseContext,
908 ) -> Result<Vec<Node>, ParseError> {
908 ) -> Result<Vec<Node>, ParseError> {
909− // Support: @each $var in list OR @each $var, $index in list
910− // OR @each $name, $url in Alice /alice, Bob /bob (destructuring)
911− let (var_names, list_str) = if let Some((before_in, after_in)) = header.split_once(" in ") {
909+ let Some((names, list_src)) = header.split_once(" in ") else {
912− let before_in = before_in.trim();
913− let vars: Vec<String> = before_in
915− .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
917− (vars, after_in.trim().to_string())
919 return Err(ParseError {
910 return Err(ParseError {
920 line: line_num,
911 line: line_num,
921− message: "@each requires: @each $var in list".to_string(),
912+ message: "@each requires: @each $item in LIST".to_string(),
924− let var_name = var_names[0].clone();
915+ let names: Vec<&str> = names.split(',').map(|v| v.trim().trim_start_matches('$')).collect();
925− let index_var = var_names.get(1).cloned();
916+ if names.len() > 2 {
926−
917+ return Err(ParseError {
919+ message: "@each takes `$item` or `$item, $index`: to loop over records, \
920+ load them with @data and use `$item.key`"
924+ let (item, index) = (names[0], names.get(1).copied());
925+ let list_src = list_src.trim();
927− if list_str.trim_end().ends_with(']') && list_str.contains("[page ") {
926+ if list_src.ends_with(']') && list_src.contains("[page ") {
928 return Err(ParseError {
927 return Err(ParseError {
929 line: line_num,
928 line: line_num,
930 message: "@each pagination (`[page N]`) was removed: split the list or \
929 message: "@each pagination (`[page N]`) was removed: split the list or \
@@ -932,68 +931,61 @@ impl Evaluator {
932 .to_string(),
931 .to_string(),
935− track_var_refs(&list_str, &mut ctx.used_variables);
934+ track_var_refs(list_src, &mut ctx.used_variables);
936− let list_str = substitute_vars(&list_str, &ctx.variables);
935+
937− // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
936+ // A list loaded from JSON, by name
938− let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
937+ let data_list = list_src.strip_prefix('$').and_then(|name| {
939− let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
938+ let len = ctx.variables.get(&format!("{}#", name))?.parse::<usize>().ok()?;
940− (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
939+ Some((name.to_string(), len))
942− (rest.trim(), 1i64)
941+ let undefined = list_src.strip_prefix('$').filter(|name| {
943− };
942+ name.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
944− if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
943+ && !ctx.variables.contains_key(*name)
946− numeric_range(start, end, step)
945+ let text_items: Vec<String> = match (&data_list, undefined) {
947− } else {
946+ (Some(_), _) => Vec::new(),
948− list_str
947+ // A missing variable is an empty list: quietly for a record's
949− .split(',')
948+ // missing field (`$post.tags`), with a warning otherwise
950− .map(|s| s.trim().to_string())
949+ (None, Some(name)) => {
951− .filter(|s| !s.is_empty())
950+ let root = name.split('.').next().unwrap_or(name);
952− .collect()
951+ if !ctx.variables.contains_key(root) {
952+ ctx.diagnostics.push(Diagnostic {
955+ message: format!("undefined variable '${}'", name),
956+ severity: Severity::Warning,
957+ source_line: Some(format!("@each {}", header)),
954− } else {
962+ (None, None) => text_list_items(&substitute_vars(list_src, &ctx.variables)),
957− .map(|s| s.trim().to_string())
958− .filter(|s| !s.is_empty())
964+ let count = data_list.as_ref().map_or(text_items.len(), |(_, len)| *len);
962 if body.is_empty() {
966 if body.is_empty() {
963 return Ok(Vec::new());
967 return Ok(Vec::new());
965− // An empty list renders the @else body
969+ if count == 0 {
966− if items.is_empty() {
967 return Ok(self.eval_scoped(empty, ctx));
970 return Ok(self.eval_scoped(empty, ctx));
970 let saved_vars = ctx.variables.clone();
972 let saved_vars = ctx.variables.clone();
971− let mut all_nodes = Vec::new();
973+ let mut nodes = Vec::new();
972−
974+ for i in 0..count {
973− let has_extra_vars = var_names.len() > 2
975+ match &data_list {
974− || (var_names.len() == 2 && items.first().is_some_and(|it| it.contains(' ')));
976+ Some((name, _)) => bind_item(&mut ctx.variables, &format!("{}.{}", name, i), item),
976− for (i, item) in items.iter().enumerate() {
978+ let text = text_items.get(i).cloned().unwrap_or_default();
977− // Always expose $_index for the current iteration
978− ctx.variables.insert("_index".to_string(), i.to_string());
980− // Destructuring: split item by spaces and assign to each variable
981− let parts: Vec<&str> = item.splitn(var_names.len(), ' ').collect();
982− for (vi, vn) in var_names.iter().enumerate() {
983− let val = parts.get(vi).unwrap_or(&"").to_string();
984− ctx.variables.insert(vn.clone(), val);
979+ ctx.variables.insert(item.to_string(), text);
987− ctx.variables.insert(var_name.clone(), item.clone());
988− if let Some(ref idx_name) = index_var {
989− ctx.variables.insert(idx_name.clone(), i.to_string());
982+ if let Some(index) = index {
983+ ctx.variables.insert(index.to_string(), i.to_string());
992− all_nodes.extend(self.eval_block(body, ctx));
985+ nodes.extend(self.eval_block(body, ctx));
995 ctx.variables = saved_vars;
987 ctx.variables = saved_vars;
996− Ok(all_nodes)
988+ Ok(nodes)
999 fn define_function(
991 fn define_function(
@@ -1656,7 +1648,7 @@ fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<Strin
1656 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
1648 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
1657 let mut best: Option<String> = None;
1649 let mut best: Option<String> = None;
1658 let mut best_dist = usize::MAX;
1650 let mut best_dist = usize::MAX;
1659− for name in vars.keys() {
1651+ for name in vars.keys().filter(|k| !k.ends_with('#')) {
1660 let nlen = name.chars().count();
1652 let nlen = name.chars().count();
1661 if nlen.abs_diff(input_chars.len()) > max_allowed {
1653 if nlen.abs_diff(input_chars.len()) > max_allowed {
1662 continue;
1654 continue;
@@ -2498,6 +2490,41 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
2499/// Inclusive integer range from `start` to `end` (counting down when
2491/// Inclusive integer range from `start` to `end` (counting down when
2500/// `start > end`), stepping by `step`. Stops instead of overflowing.
2492/// `start > end`), stepping by `step`. Stops instead of overflowing.
2493+/// The items of a text list: a range `A..B [step N]`, or comma-separated.
2494+fn text_list_items(list: &str) -> Vec<String> {
2495+ if let Some((start, rest)) = list.split_once("..") {
2496+ let (end, step) = match rest.split_once(" step ") {
2497+ Some((end, step)) => (end.trim(), step.trim().parse::<i64>().unwrap_or(1).max(1)),
2498+ None => (rest.trim(), 1),
2500+ if let (Ok(start), Ok(end)) = (start.trim().parse::<i64>(), end.parse::<i64>()) {
2501+ return numeric_range(start, end, step);
2505+ .map(|s| s.trim().to_string())
2506+ .filter(|s| !s.is_empty())
2510+/// Make `$target` (and `$target.key`, and nested lists) a copy of the list
2511+/// item stored under `source`.
2512+fn bind_item(vars: &mut HashMap<String, String>, source: &str, target: &str) {
2513+ let under = |key: &str, name: &str| {
2514+ key.strip_prefix(name)
2515+ .is_some_and(|rest| rest.is_empty() || rest.starts_with(['.', '#']))
2517+ vars.retain(|key, _| !under(key, target));
2518+ let copies: Vec<(String, String)> = vars
2520+ .filter(|(key, _)| under(key, source))
2521+ .map(|(key, value)| (format!("{}{}", target, &key[source.len()..]), value.clone()))
2523+ vars.extend(copies);
2524+ // A record has no text of its own
2525+ vars.entry(target.to_string()).or_default();
2501fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
2528fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
2502 let mut items = Vec::new();
2529 let mut items = Vec::new();
2503 let mut n = start;
2530 let mut n = start;
@@ -3029,8 +3056,7 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
3029 && let Some(close) = matching_brace(after)
3056 && let Some(close) = matching_brace(after)
3031 let source = &after[1..close];
3058 let source = &after[1..close];
3032− let resolve = |name: &str| vars.get(name).cloned();
3033− match crate::expr::eval(source, &resolve) {
3059+ match crate::expr::eval(source, &|name: &str| lookup(vars, name)) {
3034 Ok(value) => result.push_str(&value.to_string()),
3060 Ok(value) => result.push_str(&value.to_string()),
3035 Err(_) => result.push_str(&rest[pos..pos + 1 + close + 1]),
3061 Err(_) => result.push_str(&rest[pos..pos + 1 + close + 1]),
@@ -3541,36 +3567,15 @@ fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, Stri
3543 JsonValue::Array(items) => {
3569 JsonValue::Array(items) => {
3570+ // A list: its length under `NAME#` (see `lookup`), its text
3571+ // items joined as its value, and each item as `NAME.INDEX`
3544− vars.insert(format!("{}._count", prefix), items.len().to_string());
3572+ vars.insert(format!("{}#", prefix), items.len().to_string());
3545− // Check if all items are objects with the same keys
3573+ let text: Vec<String> = items
3546− let all_objects = items.iter().all(|v| matches!(v, JsonValue::Object(_)));
3574+ .iter()
3547− if all_objects && !items.is_empty() {
3575+ .map(json_value_to_string)
3548− // Collect keys from first object for destructuring
3576+ .filter(|s| !s.is_empty())
3549− if let JsonValue::Object(first_pairs) = &items[0] {
3550− let keys: Vec<String> = first_pairs.iter().map(|(k, _)| k.clone()).collect();
3551− vars.insert(format!("{}._keys", prefix), keys.join(","));
3553− // Each item becomes space-separated values, items comma-separated
3554− let csv: Vec<String> = items
3557− if let JsonValue::Object(pairs) = item {
3560− .map(|(_, v)| json_value_to_string(v))
3561− .collect::<Vec<_>>()
3564− json_value_to_string(item)
3567− .collect();
3577+ .collect();
3568− vars.insert(prefix.to_string(), csv.join(","));
3570− // Primitive array: comma-separated
3571− let csv: Vec<String> = items.iter().map(json_value_to_string).collect();
3572− vars.insert(prefix.to_string(), csv.join(","));
3578+ vars.insert(prefix.to_string(), text.join(", "));
3574 // Also set indexed access: prefix.0, prefix.1, etc.
3579 // Also set indexed access: prefix.0, prefix.1, etc.
3575 for (i, item) in items.iter().enumerate() {
3580 for (i, item) in items.iter().enumerate() {
3576 flatten_json(&format!("{}.{}", prefix, i), item, vars);
3581 flatten_json(&format!("{}.{}", prefix, i), item, vars);
@@ -3579,6 +3584,20 @@ fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, Stri
3587+/// Resolve `$name` for an expression: a list if `name` was loaded from a
3588+/// JSON array, else its text.
3589+fn lookup(vars: &HashMap<String, String>, name: &str) -> Option<crate::expr::Value> {
3590+ use crate::expr::Value;
3591+ match vars.get(&format!("{}#", name)).and_then(|n| n.parse::<usize>().ok()) {
3592+ Some(len) => Some(Value::List(
3594+ .map(|i| vars.get(&format!("{}.{}", name, i)).cloned().unwrap_or_default())
3597+ None => vars.get(name).cloned().map(Value::Str),
3582fn json_value_to_string(v: &JsonValue) -> String {
3601fn json_value_to_string(v: &JsonValue) -> String {
3583 match v {
3602 match v {
3584 JsonValue::Str(s) => s.clone(),
3603 JsonValue::Str(s) => s.clone(),