@@ -120,10 +120,6 @@ struct ParseContext {
120 manifest: Option<crate::ast::ManifestConfig>,
120 manifest: Option<crate::ast::ManifestConfig>,
121 /// Track @import paths for circular dependency detection
121 /// Track @import paths for circular dependency detection
122 import_stack: Vec<PathBuf>,
122 import_stack: Vec<PathBuf>,
123− /// i18n translations: locale -> (key -> value)
124− translations: HashMap<String, HashMap<String, String>>,
125− /// Current active locale for translations
126− active_locale: Option<String>,
129struct Parser {
125struct Parser {
@@ -222,8 +218,6 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
222 json_ld_blocks: Vec::new(),
218 json_ld_blocks: Vec::new(),
223 manifest: None,
219 manifest: None,
224 import_stack: Vec::new(),
220 import_stack: Vec::new(),
225− translations: HashMap::new(),
226− active_locale: None,
228 load_prelude(&mut ctx);
222 load_prelude(&mut ctx);
229 let nodes = parser.parse_children(0, &mut ctx);
223 let nodes = parser.parse_children(0, &mut ctx);
@@ -1159,113 +1153,6 @@ impl Parser {
1159 return Ok(Some(included_nodes));
1153 return Ok(Some(included_nodes));
1162− // --- @env (compile-time environment variables) ---
1164− if let Some(rest) = content.strip_prefix("@env ") {
1165− let rest = rest.trim();
1166− // @env VAR_NAME default_value OR @env VAR_NAME
1167− let (var_name, default_val) = if let Some((name, default)) = rest.split_once(' ') {
1168− (name.trim(), Some(default.trim()))
1172− let env_val = std::env::var(var_name)
1174− .or_else(|| default_val.map(|d| substitute_vars(d, &ctx.variables)));
1177− // Store as variable with lowercase name
1178− let key = var_name.to_lowercase().replace('-', "_");
1179− ctx.variables.insert(key, val);
1182− ctx.diagnostics.push(Diagnostic {
1186− "environment variable '{}' is not set and no default provided",
1189− severity: Severity::Warning,
1190− source_line: Some(content.clone()),
1197− // --- @fetch (compile-time HTTP data fetching) ---
1199− if let Some(rest) = content.strip_prefix("@fetch ") {
1200− let rest = rest.trim();
1201− // @fetch $prefix https://url OR @fetch https://url
1202− let (prefix, url) = if rest.starts_with('$') {
1203− if let Some((p, u)) = rest.split_once(' ') {
1205− p.strip_prefix('$').unwrap_or(p).to_string(),
1206− u.trim().to_string(),
1209− return Err(ParseError {
1211− message: "@fetch requires: @fetch $prefix url or @fetch url".to_string(),
1215− (String::new(), rest.to_string())
1218− let url = substitute_vars(&url, &ctx.variables);
1220− // Synchronous HTTP GET using std::net
1221− match fetch_url_blocking(&url) {
1223− // Try to parse as JSON
1224− match parse_json(&body) {
1226− if prefix.is_empty() {
1227− if let JsonValue::Object(pairs) = &json {
1228− for (key, val) in pairs {
1229− let mut sub = HashMap::new();
1230− flatten_json(key, val, &mut sub);
1231− for (k, v) in sub {
1232− ctx.variables.insert(k, v);
1236− // Store the raw body as a single variable
1237− ctx.variables.insert("__fetch_body".to_string(), body);
1240− let mut sub = HashMap::new();
1241− flatten_json(&prefix, &json, &mut sub);
1242− for (k, v) in sub {
1243− ctx.variables.insert(k, v);
1248− // Not JSON — store raw body
1249− if prefix.is_empty() {
1250− ctx.variables.insert("__fetch_body".to_string(), body);
1252− ctx.variables.insert(prefix, body);
1258− ctx.diagnostics.push(Diagnostic {
1261− message: format!("@fetch failed for '{}': {}", url, e),
1262− severity: Severity::Error,
1263− source_line: Some(content.clone()),
1270 // --- @svg (inline SVG from file) ---
1157 // --- @svg (inline SVG from file) ---
@@ -1345,25 +1232,68 @@ impl Parser {
1346 // --- @data (load JSON file into variables) ---
1233 // --- @data (load JSON file into variables) ---
1235+ // @data file.json top-level keys become variables
1236+ // @data $name file.json values as $name.key
1237+ // @data $name dir/*.json each file as $name.STEM.key; $name
1238+ // lists the stems, $name._count counts them
1239+ // @data $name env:NAME [DEFAULT] an environment variable
1348 if let Some(rest) = content.strip_prefix("@data ") {
1240 if let Some(rest) = content.strip_prefix("@data ") {
1349 let rest = rest.trim();
1241 let rest = rest.trim();
1350− // @data $prefix file.json OR @data file.json (no prefix, top-level keys become vars)
1242+ let (prefix, filename) = match rest.strip_prefix('$') {
1351− let (prefix, filename) = if rest.starts_with('$') {
1243+ Some(named) => match named.split_once(char::is_whitespace) {
1352− if let Some((p, f)) = rest.split_once(' ') {
1244+ Some((name, source)) => (name.to_string(), source.trim().to_string()),
1354− p.strip_prefix('$').unwrap_or(p).to_string(),
1246+ return Err(ParseError {
1355− f.trim().to_string(),
1247+ line: line_num,
1356− )
1248+ message: "@data requires: @data $name SOURCE or @data file.json"
1357− } else {
1249+ .to_string(),
1253+ None => (String::new(), rest.to_string()),
1256+ if let Some(env) = filename.strip_prefix("env:") {
1257+ let (var, default) = match env.split_once(char::is_whitespace) {
1258+ Some((var, default)) => (var, Some(default.trim())),
1259+ None => (env, None),
1261+ if prefix.is_empty() {
1358 return Err(ParseError {
1262 return Err(ParseError {
1359 line: line_num,
1263 line: line_num,
1360− message: "@data requires: @data $prefix file.json or @data file.json"
1264+ message: format!("@data env:{} needs a name: @data $name env:{}", var, var),
1364− } else {
1267+ let value = std::env::var(var)
1365− (String::new(), rest.to_string())
1268+ .ok()
1269+ .or_else(|| default.map(|d| substitute_vars(d, &ctx.variables)));
1270+ if value.is_none() {
1271+ ctx.diagnostics.push(Diagnostic {
1275+ "environment variable '{}' is not set and has no default",
1278+ severity: Severity::Warning,
1279+ source_line: Some(content.clone()),
1282+ ctx.variables.insert(prefix, value.unwrap_or_default());
1286+ let filename = substitute_vars(&filename, &ctx.variables);
1287+ if filename.contains('*') {
1288+ if prefix.is_empty() {
1289+ return Err(ParseError {
1291+ message: format!("@data {} needs a name: @data $name {}", filename, filename),
1294+ self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
1368 let filename = substitute_vars(&filename, &ctx.variables);
1298 let filename = substitute_vars(&filename, &ctx.variables);
1369 let resolved = match &ctx.base_path {
1299 let resolved = match &ctx.base_path {
@@ -1431,96 +1361,6 @@ impl Parser {
1434− // --- @collection (load multiple JSON files matching a glob pattern) ---
1436− if let Some(rest) = content.strip_prefix("@collection ") {
1437− let rest = rest.trim();
1438− // @collection $varname "pattern" OR @collection "pattern" as varname
1439− let (var_name, pattern) = if rest.starts_with('$') {
1440− let parts: Vec<&str> = rest.splitn(2, ' ').collect();
1441− if parts.len() == 2 {
1442− let name = parts[0].strip_prefix('$').unwrap_or(parts[0]);
1443− let pat = parts[1].trim().trim_matches('"');
1444− (name.to_string(), pat.to_string())
1446− return Err(ParseError {
1448− message: "@collection requires: @collection $var \"pattern\"".to_string(),
1452− let pat = rest.trim_matches('"');
1453− ("_items".to_string(), pat.to_string())
1456− let pattern = substitute_vars(&pattern, &ctx.variables);
1457− let base = ctx.base_path.clone().unwrap_or_else(|| PathBuf::from("."));
1458− let glob_pattern = base.join(&pattern);
1460− // Simple glob: support * in filename part
1461− let parent = glob_pattern.parent().unwrap_or(Path::new("."));
1462− let file_pattern = glob_pattern
1464− .map(|f| f.to_string_lossy().to_string())
1465− .unwrap_or_default();
1467− let mut items = Vec::new();
1468− if let Ok(entries) = std::fs::read_dir(parent) {
1469− let mut paths: Vec<PathBuf> = entries
1473− if let Some(name) = p.file_name().and_then(|n| n.to_str()) {
1474− if file_pattern.contains('*') {
1475− let parts: Vec<&str> = file_pattern.split('*').collect();
1476− if parts.len() == 2 {
1477− name.starts_with(parts[0]) && name.ends_with(parts[1])
1482− name == file_pattern
1490− for path in &paths {
1491− if let Ok(text) = std::fs::read_to_string(path) {
1492− // Store the file content as a JSON string for each item
1495− .and_then(|s| s.to_str())
1498− items.push((stem, text));
1503− // Store item count and serialized data as variables
1505− .insert(format!("{}_count", var_name), items.len().to_string());
1506− // Store as space-separated list of stems for @each
1507− let stems: Vec<String> = items.iter().map(|(stem, _)| stem.clone()).collect();
1508− ctx.variables.insert(var_name.clone(), stems.join(" "));
1509− // Also parse each JSON file and store keys as prefix_stem_key
1510− for (stem, text) in &items {
1511− if let Some(json) = parse_json(text.trim())
1512− && let JsonValue::Object(ref obj) = json
1514− for (key, val) in obj {
1515− let var_key = format!("{}_{}", stem, key);
1516− ctx.variables.insert(var_key, json_value_to_string(val));
1524 // --- @if / @else ---
1364 // --- @if / @else ---
1526 if let Some(rest) = content.strip_prefix("@if ") {
1366 if let Some(rest) = content.strip_prefix("@if ") {
@@ -2016,58 +1856,6 @@ impl Parser {
2016 return Ok(None);
1856 return Ok(None);
2019− // --- @translations (i18n) ---
2021− if content.trim() == "@translations" || content.starts_with("@translations ") {
2022− let locale = content
2023− .strip_prefix("@translations")
2027− let active_locale = if locale.is_empty() {
2028− ctx.lang.clone().unwrap_or_else(|| "en".to_string())
2033− // Collect indented body: key value pairs, grouped by locale headers
2034− let mut current_locale = active_locale.clone();
2035− let mut first_locale_indent: Option<usize> = None;
2036− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
2037− if let LineContent::Normal(ref s) = self.lines[self.pos].content {
2038− let trimmed = s.trim();
2039− // Check for locale sub-header: e.g., "en:" or "fr:" or "es:"
2040− if trimmed.ends_with(':') && trimmed.len() <= 10 && !trimmed.contains(' ') {
2041− current_locale = trimmed[..trimmed.len() - 1].to_string();
2042− first_locale_indent = Some(self.lines[self.pos].indent);
2047− if let Some((key, value)) = trimmed.split_once(' ') {
2048− let key = key.trim().to_string();
2049− let value = substitute_vars(value.trim(), &ctx.variables);
2051− .entry(current_locale.clone())
2053− .insert(key, value);
2058− // Set the active locale
2059− if ctx.active_locale.is_none() {
2060− ctx.active_locale = Some(active_locale.clone());
2062− let _ = first_locale_indent; // suppress unused
2063− // Inject active locale's translations as $t.key variables
2064− if let Some(strings) = ctx.translations.get(&active_locale) {
2065− for (key, value) in strings {
2066− ctx.variables.insert(format!("t.{}", key), value.clone());
2072 // --- @deprecated annotation ---
1860 // --- @deprecated annotation ---
@@ -2258,6 +2046,90 @@ impl Parser {
2258 Ok(Some(vec![Node::Text(segments)]))
2046 Ok(Some(vec![Node::Text(segments)]))
2049+ /// `@data $name dir/*.json`: load every matching file as
2050+ /// `$name.STEM.key`, list the stems in `$name` and count them.
2057+ ctx: &mut ParseContext,
2059+ let base = ctx.base_path.clone().unwrap_or_else(|| PathBuf::from("."));
2060+ let pattern_path = Path::new(pattern);
2061+ let dir = match pattern_path.parent() {
2062+ Some(dir) if !dir.as_os_str().is_empty() => base.join(dir),
2065+ let file_pattern = pattern_path
2067+ .unwrap_or_default()
2070+ let mut files: Vec<PathBuf> = match std::fs::read_dir(&dir) {
2071+ Ok(entries) => entries
2076+ .is_some_and(|n| glob_match(&file_pattern, &n.to_string_lossy()))
2080+ ctx.diagnostics.push(Diagnostic {
2083+ message: format!("cannot read directory for '{}': {}", pattern, e),
2084+ severity: Severity::Error,
2085+ source_line: Some(content.to_string()),
2091+ let mut stems = Vec::new();
2092+ for file in files {
2095+ .unwrap_or_default()
2098+ let text = match std::fs::read_to_string(&file) {
2101+ ctx.diagnostics.push(Diagnostic {
2104+ message: format!("cannot load data '{}': {}", file.display(), e),
2105+ severity: Severity::Error,
2106+ source_line: Some(content.to_string()),
2111+ match parse_json_with_error(&text) {
2113+ let mut vars = HashMap::new();
2114+ flatten_json(&format!("{}.{}", name, stem), &json, &mut vars);
2115+ ctx.variables.extend(vars);
2118+ Err(detail) => ctx.diagnostics.push(Diagnostic {
2121+ message: format!("invalid JSON in '{}': {}", file.display(), detail),
2122+ severity: Severity::Error,
2123+ source_line: Some(content.to_string()),
2126+ ctx.included_files.push(file);
2129+ .insert(format!("{}._count", name), stems.len().to_string());
2130+ ctx.variables.insert(name.to_string(), stems.join(", "));
2261 fn expand_fn_call(
2133 fn expand_fn_call(
2262 &mut self,
2134 &mut self,
2263 name: &str,
2135 name: &str,
@@ -2663,9 +2535,7 @@ const KNOWN_DIRECTIVES: &[&str] = &[
2663 "json-ld",
2535 "json-ld",
2664 "assert",
2536 "assert",
2665 "markdown",
2537 "markdown",
2667 "manifest",
2538 "manifest",
2671fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
2541fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -2726,6 +2596,19 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
2726 ("@base", "use `@page [base ...] Title`"),
2596 ("@base", "use `@page [base ...] Title`"),
2727 ("@og", "use `@meta og:NAME VALUE`"),
2597 ("@og", "use `@meta og:NAME VALUE`"),
2728 ("@debug", "use `@warn`"),
2598 ("@debug", "use `@warn`"),
2601+ "use `@data $name dir/*.json` (each file becomes `$name.STEM.key`)",
2603+ ("@env", "use `@data $name env:NAME [default]`"),
2606+ "download the data before building and use `@data $name file.json`",
2610+ "put each locale's strings in a JSON file and use `@data $t locales/$lang.json`",
2729 ("@defer", "remove it: the content is already in the page"),
2612 ("@defer", "remove it: the content is already in the page"),
2730 ("@log", "use `@warn`"),
2613 ("@log", "use `@warn`"),
@@ -4461,13 +4344,6 @@ enum JsonValue {
4461 Object(Vec<(String, JsonValue)>),
4344 Object(Vec<(String, JsonValue)>),
4464−fn parse_json(input: &str) -> Option<JsonValue> {
4465− let trimmed = input.trim();
4466− let chars: Vec<char> = trimmed.chars().collect();
4467− let (val, _) = parse_json_value(&chars, 0)?;
4471/// Parse JSON with an error message indicating where parsing failed.
4347/// Parse JSON with an error message indicating where parsing failed.
4472fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
4348fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
4473 let trimmed = input.trim();
4349 let trimmed = input.trim();
@@ -4809,109 +4685,6 @@ fn json_value_to_string(v: &JsonValue) -> String {
4812−// ---------------------------------------------------------------------------
4813−// HTTP fetch helper (blocking, minimal, no dependencies)
4814−// ---------------------------------------------------------------------------
4816−fn fetch_url_blocking(url: &str) -> Result<String, String> {
4817− use std::io::{Read, Write};
4818− use std::net::TcpStream;
4820− let is_https = url.starts_with("https://");
4821− let url_without_scheme = url
4822− .strip_prefix("https://")
4823− .or_else(|| url.strip_prefix("http://"))
4824− .ok_or_else(|| "URL must start with http:// or https://".to_string())?;
4826− let (host_port, path) = match url_without_scheme.find('/') {
4827− Some(pos) => (&url_without_scheme[..pos], &url_without_scheme[pos..]),
4828− None => (url_without_scheme, "/"),
4831− let (host, port) = match host_port.find(':') {
4834− host_port[pos + 1..]
4836− .map_err(|e| format!("invalid port: {}", e))?,
4838− None => (host_port, if is_https { 443 } else { 80 }),
4842− return Err("@fetch does not support https:// (no TLS in std). Use @data with a local JSON file, or set up a build script to fetch data before compilation.".to_string());
4845− let addr = format!("{}:{}", host, port);
4846− let mut stream = TcpStream::connect(&addr).map_err(|e| format!("connection failed: {}", e))?;
4848− .set_read_timeout(Some(std::time::Duration::from_secs(10)))
4851− let request = format!(
4852− "GET {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\nAccept: application/json, text/plain, */*\r\n\r\n",
4856− .write_all(request.as_bytes())
4857− .map_err(|e| format!("write failed: {}", e))?;
4859− let mut response = Vec::new();
4861− .read_to_end(&mut response)
4862− .map_err(|e| format!("read failed: {}", e))?;
4864− // Split headers and body (on bytes, so chunk sizes stay byte counts)
4865− if let Some(body_start) = response.windows(4).position(|w| w == b"\r\n\r\n") {
4866− let headers = String::from_utf8_lossy(&response[..body_start]);
4867− let body = &response[body_start + 4..];
4869− // Check status code
4870− if let Some(first_line) = headers.lines().next()
4871− && let Some(code_str) = first_line.split_whitespace().nth(1)
4873− let code: u16 = code_str.parse().unwrap_or(0);
4875− return Err(format!("HTTP {}", code));
4879− // Handle chunked transfer encoding
4882− .contains("transfer-encoding: chunked")
4884− return Ok(String::from_utf8_lossy(&decode_chunked(body)).into_owned());
4887− Ok(String::from_utf8_lossy(body).into_owned())
4889− Err("malformed HTTP response".to_string())
4893−fn decode_chunked(body: &[u8]) -> Vec<u8> {
4894− let mut result = Vec::new();
4895− let mut rest = body;
4896− while let Some(size_end) = rest.windows(2).position(|w| w == b"\r\n") {
4897− let size_line = String::from_utf8_lossy(&rest[..size_end]);
4898− // Chunk extensions (`;name=value`) may follow the size.
4899− let size_str = size_line.split(';').next().unwrap_or("").trim();
4900− let size = usize::from_str_radix(size_str, 16).unwrap_or(0);
4904− let chunk = &rest[size_end + 2..];
4905− if size > chunk.len() {
4906− result.extend_from_slice(chunk);
4909− result.extend_from_slice(&chunk[..size]);
4910− rest = chunk[size..].strip_prefix(b"\r\n").unwrap_or(&chunk[size..]);
4915// ---------------------------------------------------------------------------
4688// ---------------------------------------------------------------------------
4916// SVG attribute injection helper
4689// SVG attribute injection helper
4917// ---------------------------------------------------------------------------
4690// ---------------------------------------------------------------------------
@@ -5330,15 +5103,4 @@ mod tests {
5330 let r = parse("@let x = 1 + 2\n");
5103 let r = parse("@let x = 1 + 2\n");
5331 assert_eq!(r.document.variables.get("x").map(|s| s.as_str()), Some("3"));
5104 assert_eq!(r.document.variables.get("x").map(|s| s.as_str()), Some("3"));
5335− fn decode_chunked_handles_bytes_and_malformed_input() {
5336− // Chunk sizes are byte counts: "é" is 2 bytes.
5337− let body = "3\r\naé\r\n2\r\nbc\r\n0\r\n\r\n".as_bytes();
5338− assert_eq!(decode_chunked(body), "aébc".as_bytes());
5339− // No CRLF after the size line — must not panic.
5340− assert_eq!(decode_chunked(b"5"), b"");
5341− // Declared size larger than the data — take what's there.
5342− assert_eq!(decode_chunked(b"a\r\nabc"), b"abc");