on master help sign in
@data always takes a name

The unnamed form spread a file's top-level keys into global variables. Now every source is @data $name SOURCE and its values are $name.key. htmlang upgrade flags unnamed uses for a manual rename.

commit01778ce7ff1a30e67cef61e87e474a49ec32ceca authoriko <mail@iko.soy> dateSat 26 Sep 2026, 03:01 (2 weeks ago) parentbde0f30
7 files changed+45−62
DESIGN.md+2−1 crates/htmlang-core/src/parser.rs+16−51 src/bin/htmlang_lsp/docs.rs+1−1 src/upgrade.rs+17 tests/regressions.rs+3−3 tests/snapshots.rs+1−1 tests/snapshots/data_directive.hl+5−5
DESIGN.md+2−1
@@ -389,7 +389,7 @@ A range counts down when its start is greater than its end.
389389|---|---|
390390| `@include file.hl` | Insert another file: its content and definitions (a library of `@let`s emits nothing) |
391391| `@include lib.hl as ui` | Take only its definitions, named `ui.name` |
392−| `@data file.json` / `@data $name file.json` | Load JSON values as variables (`$name.key`) |
392+| `@data $name file.json` | Load JSON values as variables (`$name.key`) |
393393| `@data $name dir/*.json` | Load each file as `$name.STEM.key`; `$name` lists the stems |
394394| `@data $name env:NAME [default]` | Read an environment variable |
395395| `@markdown` / `@markdown file.md` | Markdown, converted to HTML |
@@ -486,6 +486,7 @@ removed form with its replacement.
486486| `$a ~ " " ~ $b` | `"$a $b"` |
487487| `@each $x in LIST [page N]` | Split the list, or filter it with `@if` |
488488| `...$bundle` | `$bundle` |
489+| `@data file.json` (keys as `$key`) | `@data $name file.json` (keys as `$name.key`) |
489490| `[attrs]` alone on a line | `@el [attrs]` |
490491| `animate`, `inset-area` | `animation`, `position-area` |
491492| `$x\|uppercase`, `$c\|darken:10` (filters) | `${uppercase($x)}`, `${darken($c, 10)}` |
crates/htmlang-core/src/parser.rs+16−51
@@ -715,25 +715,24 @@ impl Evaluator {
715715
716716 // --- @data (load JSON file into variables) ---
717717
718− // @data file.json top-level keys become variables
719718 // @data $name file.json values as $name.key
720719 // @data $name dir/*.json each file as $name.STEM.key; $name
721720 // lists the stems, $name._count counts them
722721 // @data $name env:NAME [DEFAULT] an environment variable
723722 if let Some(rest) = content.strip_prefix("@data ") {
724723 let rest = rest.trim();
725− let (prefix, filename) = match rest.strip_prefix('$') {
726− Some(named) => match named.split_once(char::is_whitespace) {
727− Some((name, source)) => (name.to_string(), source.trim().to_string()),
728− None => {
729− return Err(ParseError {
730− line: line_num,
731− message: "@data requires: @data $name SOURCE or @data file.json"
732− .to_string(),
733− });
734− }
735− },
736− None => (String::new(), rest.to_string()),
724+ let Some((prefix, filename)) = rest
725+ .strip_prefix('$')
726+ .and_then(|named| named.split_once(char::is_whitespace))
727+ .map(|(name, source)| (name.to_string(), source.trim().to_string()))
728+ else {
729+ return Err(ParseError {
730+ line: line_num,
731+ message: format!(
732+ "@data needs a name: write `@data $name {}` and use `$name.key`",
733+ rest.trim_start_matches('$')
734+ ),
735+ });
737736 };
738737
739738 if let Some(env) = filename.strip_prefix("env:") {
@@ -741,12 +740,6 @@ impl Evaluator {
741740 Some((var, default)) => (var, Some(default.trim())),
742741 None => (env, None),
743742 };
744− if prefix.is_empty() {
745− return Err(ParseError {
746− line: line_num,
747− message: format!("@data env:{} needs a name: @data $name env:{}", var, var),
748− });
749− }
750743 let value = std::env::var(var)
751744 .ok()
752745 .or_else(|| default.map(|d| substitute_vars(d, &ctx.variables)));
@@ -768,12 +761,6 @@ impl Evaluator {
768761
769762 let filename = substitute_vars(&filename, &ctx.variables);
770763 if filename.contains('*') {
771− if prefix.is_empty() {
772− return Err(ParseError {
773− line: line_num,
774− message: format!("@data {} needs a name: @data $name {}", filename, filename),
775− });
776− }
777764 self.load_data_glob(&prefix, &filename, line_num, &content, ctx);
778765 return Ok(None);
779766 }
@@ -800,32 +787,10 @@ impl Evaluator {
800787
801788 match parse_json_with_error(&json_text) {
802789 Ok(json) => {
803− if prefix.is_empty() {
804− // No prefix: top-level object keys become variables directly
805− if let JsonValue::Object(pairs) = &json {
806− for (key, val) in pairs {
807− let mut sub = HashMap::new();
808− flatten_json(key, val, &mut sub);
809− for (k, v) in sub {
810− ctx.variables.insert(k, v);
811− }
812− }
813− } else {
814− ctx.diagnostics.push(Diagnostic {
815− line: line_num,
816− column: None,
817− message: "@data without prefix requires a JSON object at top level"
818− .to_string(),
819− severity: Severity::Error,
820− source_line: Some(content.clone()),
821− });
822− }
823− } else {
824− let mut sub = HashMap::new();
825− flatten_json(&prefix, &json, &mut sub);
826− for (k, v) in sub {
827− ctx.variables.insert(k, v);
828− }
790+ let mut sub = HashMap::new();
791+ flatten_json(&prefix, &json, &mut sub);
792+ for (k, v) in sub {
793+ ctx.variables.insert(k, v);
829794 }
830795 }
831796 Err(detail) => {
src/bin/htmlang_lsp/docs.rs+1−1
@@ -108,7 +108,7 @@ pub(crate) const DIRECTIVES: &[Doc] = &[
108108 "Repeats its body for each item of a list or range; `$_index` counts from 0.",
109109 "@each $item in apple, banana\n @text $item\n@each $i in 1..10 step 2\n @text $i",
110110 ),
111− doc("data", "Loads a JSON file as variables.", "@data site.json"),
111+ doc("data", "Loads data as variables: a JSON file, a glob of them, or `env:NAME`.", "@data $site site.json"),
112112];
113113
114114/// htmlang's own attributes, plus CSS properties htmlang treats specially.
src/upgrade.rs+17
@@ -785,6 +785,16 @@ fn rewrite_directive_line(
785785 ));
786786 return line.to_string();
787787 }
788+ if let Some(rest) = trimmed.strip_prefix("@data ")
789+ && !rest.trim_start().starts_with('$')
790+ {
791+ manual.push((
792+ idx + 1,
793+ "@data needs a name now: write `@data $name FILE` and use `$name.key` for `$key`"
794+ .to_string(),
795+ ));
796+ return line.to_string();
797+ }
788798 if trimmed.starts_with("@fetch ") {
789799 manual.push((
790800 idx + 1,
@@ -1579,6 +1589,13 @@ mod tests {
15791589 );
15801590 }
15811591
1592+ #[test]
1593+ fn unnamed_data_needs_a_manual_name() {
1594+ let result = super::upgrade("@data site.json\n@data $ok ok.json\n");
1595+ assert_eq!(result.manual.len(), 1);
1596+ assert_eq!(result.manual[0].0, 1);
1597+ }
1598+
15821599 #[test]
15831600 fn bare_attribute_lines_become_el() {
15841601 assert_eq!(up("[padding 4]\n x\n"), "@el [padding 4]\n x\n");
tests/regressions.rs+3−3
@@ -52,9 +52,9 @@ fn non_ascii_hex_color_does_not_panic() {
5252fn truncated_json_data_does_not_panic() {
5353 let dir = scratch_dir("json_trunc");
5454 std::fs::write(dir.join("d.json"), "{\"a\": 1,").unwrap();
55− compile_in(&dir, "@data d.json\n@text ok");
55+ compile_in(&dir, "@data $d d.json\n@text ok");
5656 std::fs::write(dir.join("d.json"), "{").unwrap();
57− compile_in(&dir, "@data d.json\n@text ok");
57+ compile_in(&dir, "@data $d d.json\n@text ok");
5858}
5959
6060#[test]
@@ -65,7 +65,7 @@ fn json_unicode_escapes_are_decoded() {
6565 r#"{"name": "Caf\u00e9", "emoji": "\ud83d\ude00"}"#,
6666 )
6767 .unwrap();
68− let out = compile_in(&dir, "@data d.json\n@text $name $emoji");
68+ let out = compile_in(&dir, "@data $d d.json\n@text $d.name $d.emoji");
6969 assert!(out.contains("Café 😀"), "{}", out);
7070}
7171
tests/snapshots.rs+1−1
@@ -4886,7 +4886,7 @@ fn error_invalid_json_in_data_directive() {
48864886 let data_path = dir.join("bad.json");
48874887 std::fs::write(&data_path, "{ not: valid json }").unwrap();
48884888
4889− let input = "@data bad.json\n";
4889+ let input = "@data $bad bad.json\n";
48904890 let result = htmlang::parser::parse_with_base(input, Some(&dir));
48914891 let has_err = result.diagnostics.iter().any(|d| {
48924892 d.severity == htmlang::parser::Severity::Error && d.message.contains("invalid JSON")
tests/snapshots/data_directive.hl+5−5
@@ -1,11 +1,11 @@
11@page Data Test
2−@data test_data.json
2+@data $site test_data.json
33
4−@text [bold, size 24] $title
5−@text Version: $version
4+@text [bold, size 24] $site.title
5+@text Version: $site.version
66
7−@each $tag in $tags
7+@each $tag in $site.tags
88 @text [color blue] $tag
99
10−@each $label, $url in $links
10+@each $label, $url in $site.links
1111 @link $url $label