@@ -104,8 +104,7 @@ impl ParseContext {
104104 fn eval(&mut self, src: &str, line: usize) -> Option<crate::expr::Value> {
105105 track_var_refs(src, &mut self.used_variables);
106106 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));
109108 match result {
110109 Ok(value) => Some(value),
111110 Err(message) => {
@@ -644,8 +643,8 @@ impl Evaluator {
644643 // --- @data (load JSON file into variables) ---
645644
646645 // @data $name file.json values as $name.key
647− // @data $name dir/*.json each file as $name.STEM.key; $name
648− // lists the stems, $name._count counts them
646+ // @data $name dir/*.json a list of the files' records
647+ // @data $name [...] / {...} inline JSON
649648 // @data $name env:NAME [DEFAULT] an environment variable
650649 if let Some(rest) = content.strip_prefix("@data ") {
651650 let rest = rest.trim();
@@ -687,56 +686,49 @@ impl Evaluator {
687686 return Ok(None);
688687 }
689688
690− let filename = substitute_vars(&filename, &ctx.variables);
691− if filename.contains('*') {
692− self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
693− return Ok(None);
694− }
695−
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),
700− };
701−
702− let json_text = match std::fs::read_to_string(&resolved) {
703− Ok(text) => text,
704− Err(e) => {
705− ctx.diagnostics.push(Diagnostic {
706− line: line_num,
707− column: None,
708− message: format!("cannot load data '{}': {}", filename, e),
709− severity: Severity::Error,
710− source_line: Some(content.clone()),
711− });
689+ // Inline JSON: @data $links [{"label": "Home", "url": "/"}]
690+ let (json_text, source) = if filename.starts_with(['[', '{']) {
691+ (filename, "the inline data".to_string())
692+ } else {
693+ let filename = substitute_vars(&filename, &ctx.variables);
694+ if filename.contains('*') {
695+ self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
712696 return Ok(None);
713697 }
714− };
715−
716− match parse_json_with_error(&json_text) {
717− Ok(json) => {
718− let mut sub = HashMap::new();
719− flatten_json(&prefix, &json, &mut sub);
720− for (k, v) in sub {
721− ctx.variables.insert(k, v);
698+ let resolved = match &ctx.base_path {
699+ Some(base) => base.join(&filename),
700+ None => PathBuf::from(&filename),
701+ };
702+ match std::fs::read_to_string(&resolved) {
703+ Ok(text) => {
704+ ctx.included_files.push(resolved);
705+ (text, format!("'{}'", filename))
706+ }
707+ Err(e) => {
708+ ctx.diagnostics.push(Diagnostic {
709+ line: line_num,
710+ column: None,
711+ message: format!("cannot load data '{}': {}", filename, e),
712+ severity: Severity::Error,
713+ source_line: Some(content.clone()),
714+ });
715+ return Ok(None);
722716 }
723717 }
724− Err(detail) => {
725− ctx.diagnostics.push(Diagnostic {
726− line: line_num,
727− column: None,
728− message: format!("invalid JSON in '{}': {}", filename, detail),
729− severity: Severity::Error,
730− source_line: Some(content.clone()),
731− });
732− }
718+ };
719+ match parse_json_with_error(&json_text) {
720+ Ok(json) => flatten_json(&prefix, &json, &mut ctx.variables),
721+ Err(detail) => ctx.diagnostics.push(Diagnostic {
722+ line: line_num,
723+ column: None,
724+ message: format!("invalid JSON in {}: {}", source, detail),
725+ severity: Severity::Error,
726+ source_line: Some(content.clone()),
727+ }),
733728 }
734−
735− ctx.included_files.push(resolved);
736729 return Ok(None);
737730 }
738731
739−
740732 // --- @if / @else ---
741733
742734 if content.trim() == "@else" || content.trim().starts_with("@else if ") {
@@ -814,8 +806,8 @@ impl Evaluator {
814806 Ok(Some(vec![Node::Text(segments)]))
815807 }
816808
817− /// `@data $name dir/*.json`: load every matching file as
818− /// `$name.STEM.key`, list the stems in `$name` and count them.
809+ /// `@data $name dir/*.json`: a list with one record per matching file
810+ /// (in name order), whose `file` is the file's name without extension.
819811 fn load_data_glob(
820812 &mut self,
821813 name: &str,
@@ -856,7 +848,7 @@ impl Evaluator {
856848 }
857849 };
858850 files.sort();
859− let mut stems = Vec::new();
851+ let mut records = Vec::new();
860852 for file in files {
861853 let stem = file
862854 .file_stem()
@@ -877,12 +869,19 @@ impl Evaluator {
877869 }
878870 };
879871 match parse_json_with_error(&text) {
880− Ok(json) => {
881− let mut vars = HashMap::new();
882− flatten_json(&format!("{}.{}", name, stem), &json, &mut vars);
883− ctx.variables.extend(vars);
884− stems.push(stem);
872+ Ok(JsonValue::Object(mut pairs)) => {
873+ if !pairs.iter().any(|(key, _)| key == "file") {
874+ pairs.push(("file".to_string(), JsonValue::Str(stem)));
875+ }
876+ records.push(JsonValue::Object(pairs));
885877 }
878+ Ok(_) => ctx.diagnostics.push(Diagnostic {
879+ line: line_num,
880+ column: None,
881+ message: format!("'{}' should hold a JSON object", file.display()),
882+ severity: Severity::Error,
883+ source_line: Some(content.to_string()),
884+ }),
886885 Err(detail) => ctx.diagnostics.push(Diagnostic {
887886 line: line_num,
888887 column: None,
@@ -893,11 +892,12 @@ impl Evaluator {
893892 }
894893 ctx.included_files.push(file);
895894 }
896− ctx.variables
897− .insert(format!("{}._count", name), stems.len().to_string());
898− ctx.variables.insert(name.to_string(), stems.join(", "));
895+ flatten_json(name, &JsonValue::Array(records), &mut ctx.variables);
899896 }
900897
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]`.
901901 fn eval_each(
902902 &mut self,
903903 header: &str,
@@ -906,25 +906,24 @@ impl Evaluator {
906906 line_num: usize,
907907 ctx: &mut ParseContext,
908908 ) -> 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 ") {
912− let before_in = before_in.trim();
913− let vars: Vec<String> = before_in
914− .split(',')
915− .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
916− .collect();
917− (vars, after_in.trim().to_string())
918− } else {
909+ let Some((names, list_src)) = header.split_once(" in ") else {
919910 return Err(ParseError {
920911 line: line_num,
921− message: "@each requires: @each $var in list".to_string(),
912+ message: "@each requires: @each $item in LIST".to_string(),
922913 });
923914 };
924− let var_name = var_names[0].clone();
925− let index_var = var_names.get(1).cloned();
926−
927− if list_str.trim_end().ends_with(']') && list_str.contains("[page ") {
915+ let names: Vec<&str> = names.split(',').map(|v| v.trim().trim_start_matches('$')).collect();
916+ if names.len() > 2 {
917+ return Err(ParseError {
918+ line: line_num,
919+ message: "@each takes `$item` or `$item, $index`: to loop over records, \
920+ load them with @data and use `$item.key`"
921+ .to_string(),
922+ });
923+ }
924+ let (item, index) = (names[0], names.get(1).copied());
925+ let list_src = list_src.trim();
926+ if list_src.ends_with(']') && list_src.contains("[page ") {
928927 return Err(ParseError {
929928 line: line_num,
930929 message: "@each pagination (`[page N]`) was removed: split the list or \
@@ -932,68 +931,61 @@ impl Evaluator {
932931 .to_string(),
933932 });
934933 }
935− track_var_refs(&list_str, &mut ctx.used_variables);
936− let list_str = substitute_vars(&list_str, &ctx.variables);
937− // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
938− let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
939− let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
940− (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
941− } else {
942− (rest.trim(), 1i64)
943− };
944− if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
945− {
946− numeric_range(start, end, step)
947− } else {
948− list_str
949− .split(',')
950− .map(|s| s.trim().to_string())
951− .filter(|s| !s.is_empty())
952− .collect()
934+ track_var_refs(list_src, &mut ctx.used_variables);
935+
936+ // A list loaded from JSON, by name
937+ let data_list = list_src.strip_prefix('$').and_then(|name| {
938+ let len = ctx.variables.get(&format!("{}#", name))?.parse::<usize>().ok()?;
939+ Some((name.to_string(), len))
940+ });
941+ let undefined = list_src.strip_prefix('$').filter(|name| {
942+ name.chars().all(|c| c.is_alphanumeric() || matches!(c, '_' | '-' | '.'))
943+ && !ctx.variables.contains_key(*name)
944+ });
945+ let text_items: Vec<String> = match (&data_list, undefined) {
946+ (Some(_), _) => Vec::new(),
947+ // A missing variable is an empty list: quietly for a record's
948+ // missing field (`$post.tags`), with a warning otherwise
949+ (None, Some(name)) => {
950+ let root = name.split('.').next().unwrap_or(name);
951+ if !ctx.variables.contains_key(root) {
952+ ctx.diagnostics.push(Diagnostic {
953+ line: line_num,
954+ column: None,
955+ message: format!("undefined variable '${}'", name),
956+ severity: Severity::Warning,
957+ source_line: Some(format!("@each {}", header)),
958+ });
959+ }
960+ Vec::new()
953961 }
954− } else {
955− list_str
956− .split(',')
957− .map(|s| s.trim().to_string())
958− .filter(|s| !s.is_empty())
959− .collect()
962+ (None, None) => text_list_items(&substitute_vars(list_src, &ctx.variables)),
960963 };
964+ let count = data_list.as_ref().map_or(text_items.len(), |(_, len)| *len);
961965
962966 if body.is_empty() {
963967 return Ok(Vec::new());
964968 }
965− // An empty list renders the @else body
966− if items.is_empty() {
969+ if count == 0 {
967970 return Ok(self.eval_scoped(empty, ctx));
968971 }
969−
970972 let saved_vars = ctx.variables.clone();
971− let mut all_nodes = Vec::new();
972−
973− let has_extra_vars = var_names.len() > 2
974− || (var_names.len() == 2 && items.first().is_some_and(|it| it.contains(' ')));
975−
976− for (i, item) in items.iter().enumerate() {
977− // Always expose $_index for the current iteration
978− ctx.variables.insert("_index".to_string(), i.to_string());
979− if has_extra_vars {
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);
985− }
986− } else {
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());
973+ let mut nodes = Vec::new();
974+ for i in 0..count {
975+ match &data_list {
976+ Some((name, _)) => bind_item(&mut ctx.variables, &format!("{}.{}", name, i), item),
977+ None => {
978+ let text = text_items.get(i).cloned().unwrap_or_default();
979+ ctx.variables.insert(item.to_string(), text);
990980 }
991981 }
992− all_nodes.extend(self.eval_block(body, ctx));
982+ if let Some(index) = index {
983+ ctx.variables.insert(index.to_string(), i.to_string());
984+ }
985+ nodes.extend(self.eval_block(body, ctx));
993986 }
994−
995987 ctx.variables = saved_vars;
996− Ok(all_nodes)
988+ Ok(nodes)
997989 }
998990
999991 fn define_function(
@@ -1656,7 +1648,7 @@ fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<Strin
16561648 let max_allowed = 2usize.min(input_chars.len().saturating_sub(1));
16571649 let mut best: Option<String> = None;
16581650 let mut best_dist = usize::MAX;
1659− for name in vars.keys() {
1651+ for name in vars.keys().filter(|k| !k.ends_with('#')) {
16601652 let nlen = name.chars().count();
16611653 if nlen.abs_diff(input_chars.len()) > max_allowed {
16621654 continue;
@@ -2498,6 +2490,41 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
24982490
24992491/// Inclusive integer range from `start` to `end` (counting down when
25002492/// `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),
2499+ };
2500+ if let (Ok(start), Ok(end)) = (start.trim().parse::<i64>(), end.parse::<i64>()) {
2501+ return numeric_range(start, end, step);
2502+ }
2503+ }
2504+ list.split(',')
2505+ .map(|s| s.trim().to_string())
2506+ .filter(|s| !s.is_empty())
2507+ .collect()
2508+}
2509+
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(['.', '#']))
2516+ };
2517+ vars.retain(|key, _| !under(key, target));
2518+ let copies: Vec<(String, String)> = vars
2519+ .iter()
2520+ .filter(|(key, _)| under(key, source))
2521+ .map(|(key, value)| (format!("{}{}", target, &key[source.len()..]), value.clone()))
2522+ .collect();
2523+ vars.extend(copies);
2524+ // A record has no text of its own
2525+ vars.entry(target.to_string()).or_default();
2526+}
2527+
25012528fn numeric_range(start: i64, end: i64, step: i64) -> Vec<String> {
25022529 let mut items = Vec::new();
25032530 let mut n = start;
@@ -3029,8 +3056,7 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
30293056 && let Some(close) = matching_brace(after)
30303057 {
30313058 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)) {
30343060 Ok(value) => result.push_str(&value.to_string()),
30353061 Err(_) => result.push_str(&rest[pos..pos + 1 + close + 1]),
30363062 }
@@ -3541,36 +3567,15 @@ fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, Stri
35413567 }
35423568 }
35433569 JsonValue::Array(items) => {
3544− vars.insert(format!("{}._count", prefix), items.len().to_string());
3545− // Check if all items are objects with the same keys
3546− let all_objects = items.iter().all(|v| matches!(v, JsonValue::Object(_)));
3547− if all_objects && !items.is_empty() {
3548− // Collect keys from first object for destructuring
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(","));
3552− }
3553− // Each item becomes space-separated values, items comma-separated
3554− let csv: Vec<String> = items
3555− .iter()
3556− .map(|item| {
3557− if let JsonValue::Object(pairs) = item {
3558− pairs
3559− .iter()
3560− .map(|(_, v)| json_value_to_string(v))
3561− .collect::<Vec<_>>()
3562− .join(" ")
3563− } else {
3564− json_value_to_string(item)
3565− }
3566− })
3567− .collect();
3568− vars.insert(prefix.to_string(), csv.join(","));
3569− } else {
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(","));
3573− }
3570+ // A list: its length under `NAME#` (see `lookup`), its text
3571+ // items joined as its value, and each item as `NAME.INDEX`
3572+ vars.insert(format!("{}#", prefix), items.len().to_string());
3573+ let text: Vec<String> = items
3574+ .iter()
3575+ .map(json_value_to_string)
3576+ .filter(|s| !s.is_empty())
3577+ .collect();
3578+ vars.insert(prefix.to_string(), text.join(", "));
35743579 // Also set indexed access: prefix.0, prefix.1, etc.
35753580 for (i, item) in items.iter().enumerate() {
35763581 flatten_json(&format!("{}.{}", prefix, i), item, vars);
@@ -3579,6 +3584,20 @@ fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, Stri
35793584 }
35803585}
35813586
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(
3593+ (0..len)
3594+ .map(|i| vars.get(&format!("{}.{}", name, i)).cloned().unwrap_or_default())
3595+ .collect(),
3596+ )),
3597+ None => vars.get(name).cloned().map(Value::Str),
3598+ }
3599+}
3600+
35823601fn json_value_to_string(v: &JsonValue) -> String {
35833602 match v {
35843603 JsonValue::Str(s) => s.clone(),