on master help sign in
Add 6 improvements: @data JSON loading, Popover API, --compat vendor prefixing, color-scheme/appearance CSS, input hint attributes, and JSON output for lint/dead-code commands
commitf281deb398d12418dcc51929a41e89e5763e9e95 authoriko <mail@iko.soy> dateWed 15 Apr 2026, 21:48 (6 months ago) parentc5a281e
12 files changed+787−35
DESIGN.md+69 src/bin/htmlang-lsp.rs+24−1 src/codegen.rs+144 src/main.rs+96−33 src/parser.rs+329 tests/snapshots.rs+76−1 tests/snapshots/color_scheme.hl+6 tests/snapshots/compat_test.hl+9 tests/snapshots/data_directive.hl+11 tests/snapshots/new_html_attrs.hl+7 tests/snapshots/popover_api.hl+6 tests/snapshots/test_data.json+10
DESIGN.md+69
@@ -185,6 +185,39 @@ Like `@include`, but only imports definitions (`@let`, `@define`, `@fn`) without
185185@include header.hl -- inlines everything including DOM nodes
186186```
187187
188+### `@data`
189+
190+Loads a JSON file and makes its values available as template variables. Useful for data-driven static sites.
191+
192+```
193+@data site.json -- top-level object keys become variables
194+@data $posts data/posts.json -- load with a prefix
195+```
196+
197+For a JSON file like:
198+```json
199+{
200+ "title": "My Site",
201+ "tags": ["rust", "html", "css"],
202+ "links": [
203+ {"label": "Home", "url": "/"},
204+ {"label": "About", "url": "/about"}
205+ ]
206+}
207+```
208+
209+Without prefix (`@data site.json`):
210+- `$title` → `"My Site"`
211+- `$tags` → iterable with `@each`
212+- `$links` → destructurable: `@each $label, $url in $links`
213+
214+With prefix (`@data $site site.json`):
215+- `$site.title` → `"My Site"`
216+- `$site.tags` → iterable
217+- `$site.links` → destructurable
218+
219+Arrays of objects are automatically formatted for `@each` destructuring. Indexed access is also available: `$links.0.label`, `$links.1.url`, etc. The count is available as `$links._count`.
220+
188221### `@meta`
189222
190223Adds a `<meta>` tag to the document `<head>`. Requires `@page`.
@@ -696,6 +729,39 @@ This generates a `@media print` rule.
696729| `id NAME` | HTML id attribute |
697730| `class NAME` | HTML class attribute |
698731
732+### Modern CSS
733+
734+| Attribute | Effect |
735+|------------------------|----------------------------------|
736+| `color-scheme VALUE` | Color scheme (`light`, `dark`, `light dark`) |
737+| `appearance VALUE` | Form element appearance (`none`) |
738+
739+### Popover API
740+
741+Use HTML Popover API attributes for declarative popovers without JavaScript.
742+
743+```
744+@button [popovertarget info-panel] Show Info
745+@el [popover, id info-panel, padding 20, background white, rounded 8]
746+ @text This is a popover
747+```
748+
749+| Attribute | Effect |
750+|-------------------------------|---------------------------------|
751+| `popover` | Make element a popover |
752+| `popovertarget ID` | Open popover with given ID |
753+| `popovertargetaction VALUE` | Action (toggle/show/hide) |
754+
755+### Input hints
756+
757+| Attribute | Effect |
758+|------------------------|----------------------------------|
759+| `inputmode VALUE` | Virtual keyboard type (numeric/email/search/tel/url) |
760+| `enterkeyhint VALUE` | Enter key label (enter/done/go/next/previous/search/send) |
761+| `fetchpriority VALUE` | Resource priority (high/low/auto) |
762+| `translate VALUE` | Whether to translate (yes/no) |
763+| `spellcheck VALUE` | Spell check (true/false) |
764+
699765## Asset inlining
700766
701767SVG images can be inlined directly into the HTML output using the `[inline]` attribute:
@@ -722,7 +788,10 @@ htmlang check page.hl # check for errors without writing output
722788htmlang check src/ # check all .hl files in a directory
723789htmlang convert page.html # convert HTML to .hl format (stdout)
724790htmlang build src/ -o dist/ # parallel compile + copy static assets
791+htmlang --compat page.hl # compile with vendor prefixes
725792htmlang --format json page.hl # output diagnostics as JSON
793+htmlang lint --format json src/ # lint with JSON output
794+htmlang dead-code --format json src/ # dead code report as JSON
726795htmlang lint page.hl # stricter lint checks (accessibility, nesting)
727796htmlang lint src/ # lint all .hl files in a directory
728797htmlang stats page.hl # show file statistics (elements, CSS, colors)
src/bin/htmlang-lsp.rs+24−1
@@ -722,6 +722,7 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
722722 ("@json-ld", "Add JSON-LD structured data to head", "@json-ld"),
723723 ("@mixin", "Define a composable style group (use with ...$name)", "@mixin "),
724724 ("@assert", "Compile-time assertion for variable values", "@assert "),
725+ ("@data", "Load JSON data file into template variables", "@data "),
725726 ]
726727 .iter()
727728 .map(|(name, detail, insert)| {
@@ -1119,6 +1120,18 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
11191120 // Modern form theming
11201121 ("accent-color", "Accent color for form controls", true),
11211122 ("caret-color", "Text cursor color", true),
1123+ ("color-scheme", "Color scheme preference (light/dark/light dark)", true),
1124+ ("appearance", "Form element appearance (none to reset)", true),
1125+ // Popover API
1126+ ("popover", "Make element a popover (HTML Popover API)", false),
1127+ ("popovertarget", "ID of popover to toggle", true),
1128+ ("popovertargetaction", "Popover action (toggle/show/hide)", true),
1129+ // Input hints
1130+ ("inputmode", "Virtual keyboard type (numeric/email/search/tel/url)", true),
1131+ ("enterkeyhint", "Enter key label (done/go/next/search/send)", true),
1132+ ("fetchpriority", "Resource fetch priority (high/low/auto)", true),
1133+ ("translate", "Whether element should be translated (yes/no)", true),
1134+ ("spellcheck", "Spell check mode (true/false)", true),
11221135 // List styling
11231136 ("list-style", "List style type (disc/circle/square/none)", true),
11241137 // Table styling
@@ -1920,6 +1933,16 @@ fn hover_builtin(word: &str) -> Option<String> {
19201933 "rowspan" => "**rowspan** `<value>` \u{2014} Number of rows a cell spans.",
19211934 "scope" => "**scope** `<value>` \u{2014} Header scope (`col`, `row`, `colgroup`, `rowgroup`).",
19221935 "inline" => "**inline** \u{2014} Inline SVG image content into the HTML output.",
1936+ "color-scheme" => "**color-scheme** `<value>` \u{2014} Color scheme preference (`light`, `dark`, `light dark`).",
1937+ "appearance" => "**appearance** `<value>` \u{2014} Form element appearance (`none` to remove native styling).",
1938+ "popover" => "**popover** \u{2014} Make element a popover (HTML Popover API). Shown/hidden declaratively.",
1939+ "popovertarget" => "**popovertarget** `<id>` \u{2014} ID of the popover element to toggle.",
1940+ "popovertargetaction" => "**popovertargetaction** `<value>` \u{2014} Popover action (`toggle`, `show`, `hide`).",
1941+ "inputmode" => "**inputmode** `<value>` \u{2014} Virtual keyboard type (`numeric`, `email`, `search`, `tel`, `url`).",
1942+ "enterkeyhint" => "**enterkeyhint** `<value>` \u{2014} Enter key label (`done`, `go`, `next`, `search`, `send`).",
1943+ "fetchpriority" => "**fetchpriority** `<value>` \u{2014} Resource fetch priority (`high`, `low`, `auto`).",
1944+ "translate" => "**translate** `<value>` \u{2014} Whether element should be translated (`yes`, `no`).",
1945+ "spellcheck" => "**spellcheck** `<value>` \u{2014} Spell check mode (`true`, `false`).",
19231946 "hidden" => "**hidden** \u{2014} Hide element (`display: none`).",
19241947 "overflow-x" => "**overflow-x** `<value>` \u{2014} Horizontal overflow (`hidden`, `scroll`, `auto`, `visible`).",
19251948 "overflow-y" => "**overflow-y** `<value>` \u{2014} Vertical overflow (`hidden`, `scroll`, `auto`, `visible`).",
@@ -3164,7 +3187,7 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
31643187 | "@unless" | "@og" | "@breakpoint"
31653188 | "@canonical" | "@base" | "@font-face" | "@json-ld"
31663189 | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends"
3167− | "@use" => 0, // keyword
3190+ | "@use" | "@data" => 0, // keyword
31683191 _ => {
31693192 // Check if it's a user function call (starts with @ but not a builtin element)
31703193 if is_builtin_element(word) { 0 } else { 2 } // function
src/codegen.rs+144
@@ -368,6 +368,25 @@ pub fn generate_minified(doc: &Document) -> String {
368368 minify_html(&html)
369369}
370370
371+/// Generate with vendor prefixes for broader browser compatibility.
372+pub fn generate_compat(doc: &Document) -> String {
373+ let html = generate_with_options(doc, false);
374+ add_vendor_prefixes(&html)
375+}
376+
377+/// Generate dev mode with vendor prefixes.
378+pub fn generate_dev_compat(doc: &Document) -> String {
379+ let html = generate_with_options(doc, true);
380+ add_vendor_prefixes(&html)
381+}
382+
383+/// Generate minified with vendor prefixes.
384+pub fn generate_minified_compat(doc: &Document) -> String {
385+ let html = generate_with_options(doc, false);
386+ let html = minify_html(&html);
387+ add_vendor_prefixes(&html)
388+}
389+
371390fn minify_html(html: &str) -> String {
372391 let mut result = String::with_capacity(html.len());
373392 let mut in_pre = false;
@@ -448,6 +467,109 @@ fn minify_html(html: &str) -> String {
448467 result
449468}
450469
470+/// Add vendor prefixes to CSS within <style> tags for broader browser compatibility.
471+fn add_vendor_prefixes(html: &str) -> String {
472+ // Find CSS within <style>...</style> and add vendor prefixes
473+ let mut result = String::with_capacity(html.len() + 512);
474+ let mut rest = html;
475+ while let Some(start) = rest.find("<style>") {
476+ let after_tag = start + 7;
477+ result.push_str(&rest[..after_tag]);
478+ rest = &rest[after_tag..];
479+ if let Some(end) = rest.find("</style>") {
480+ let css = &rest[..end];
481+ result.push_str(&prefix_css(css));
482+ result.push_str("</style>");
483+ rest = &rest[end + 8..];
484+ } else {
485+ break;
486+ }
487+ }
488+ result.push_str(rest);
489+ result
490+}
491+
492+fn prefix_css(css: &str) -> String {
493+ let mut out = String::with_capacity(css.len() + 256);
494+ let mut i = 0;
495+ let bytes = css.as_bytes();
496+ while i < bytes.len() {
497+ // Find property declarations
498+ if let Some(pos) = css[i..].find('{') {
499+ let brace = i + pos;
500+ out.push_str(&css[i..=brace]);
501+ i = brace + 1;
502+ // Process declarations within this block
503+ if let Some(close) = css[i..].find('}') {
504+ let block = &css[i..i + close];
505+ out.push_str(&prefix_declarations(block));
506+ out.push('}');
507+ i = i + close + 1;
508+ }
509+ } else {
510+ out.push_str(&css[i..]);
511+ break;
512+ }
513+ }
514+ out
515+}
516+
517+fn prefix_declarations(block: &str) -> String {
518+ let mut out = String::with_capacity(block.len() + 128);
519+ for decl in block.split(';') {
520+ let decl = decl.trim();
521+ if decl.is_empty() {
522+ continue;
523+ }
524+ if let Some((prop, val)) = decl.split_once(':') {
525+ let prop = prop.trim();
526+ let val = val.trim();
527+ match prop {
528+ "backdrop-filter" => {
529+ out.push_str(&format!("-webkit-backdrop-filter:{};", val));
530+ out.push_str(&format!("backdrop-filter:{};", val));
531+ }
532+ "user-select" => {
533+ out.push_str(&format!("-webkit-user-select:{};", val));
534+ out.push_str(&format!("-moz-user-select:{};", val));
535+ out.push_str(&format!("user-select:{};", val));
536+ }
537+ "appearance" => {
538+ out.push_str(&format!("-webkit-appearance:{};", val));
539+ out.push_str(&format!("-moz-appearance:{};", val));
540+ out.push_str(&format!("appearance:{};", val));
541+ }
542+ "background-clip" if val.contains("text") => {
543+ out.push_str(&format!("-webkit-background-clip:{};", val));
544+ out.push_str(&format!("background-clip:{};", val));
545+ }
546+ "hyphens" => {
547+ out.push_str(&format!("-webkit-hyphens:{};", val));
548+ out.push_str(&format!("-ms-hyphens:{};", val));
549+ out.push_str(&format!("hyphens:{};", val));
550+ }
551+ "text-size-adjust" => {
552+ out.push_str(&format!("-webkit-text-size-adjust:{};", val));
553+ out.push_str(&format!("-ms-text-size-adjust:{};", val));
554+ out.push_str(&format!("text-size-adjust:{};", val));
555+ }
556+ "mask-image" | "mask-size" | "mask-repeat" | "mask-position" => {
557+ out.push_str(&format!("-webkit-{}:{};", prop, val));
558+ out.push_str(&format!("{}:{};", prop, val));
559+ }
560+ _ => {
561+ out.push_str(decl);
562+ out.push(';');
563+ }
564+ }
565+ } else {
566+ out.push_str(decl);
567+ out.push(';');
568+ }
569+ }
570+ out
571+}
572+
451573fn generate_with_options(doc: &Document, dev: bool) -> String {
452574 let mut styles = StyleCollector::new();
453575 let mut ctx = GenContext { dev, depth: 0 };
@@ -831,6 +953,14 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
831953 "low", "high", "optimum",
832954 // Table
833955 "colspan", "rowspan", "scope",
956+ // Popover API
957+ "popover", "popovertarget", "popovertargetaction",
958+ // Modern form/input hints
959+ "inputmode", "enterkeyhint",
960+ // Performance hints
961+ "fetchpriority", "blocking",
962+ // Global attrs
963+ "translate", "spellcheck",
834964];
835965
836966/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
@@ -839,6 +969,8 @@ const BOOLEAN_HTML_ATTRS: &[&str] = &[
839969 "controls", "autoplay", "loop", "muted",
840970 "open", "novalidate", "autofocus",
841971 "defer", "async", "nomodule",
972+ // Popover
973+ "popover",
842974];
843975
844976fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
@@ -2182,6 +2314,18 @@ fn attrs_to_css(
21822314 }
21832315 }
21842316
2317+ // Color scheme & appearance
2318+ "color-scheme" => {
2319+ if let Some(v) = val {
2320+ push_css(&mut css, "color-scheme", v);
2321+ }
2322+ }
2323+ "appearance" => {
2324+ if let Some(v) = val {
2325+ push_css(&mut css, "appearance", v);
2326+ }
2327+ }
2328+
21852329 // List style
21862330 "list-style" => {
21872331 if let Some(v) = val {
src/main.rs+96−33
@@ -14,14 +14,10 @@ struct DiagnosticJson {
1414}
1515
1616fn compile(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>) -> (bool, Vec<PathBuf>) {
17− compile_inner(input_path, dev, error_overlay, check_only, output_path, format_json, json_collector, false)
17+ compile_inner(input_path, dev, error_overlay, check_only, output_path, format_json, json_collector, false, false)
1818}
1919
20−fn compile_minified(input_path: &str, output_path: Option<&str>) -> (bool, Vec<PathBuf>) {
21− compile_inner(input_path, false, false, false, output_path, false, None, true)
22−}
23−
24−fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>, minify: bool) -> (bool, Vec<PathBuf>) {
20+fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: bool, output_path: Option<&str>, format_json: bool, json_collector: Option<&Mutex<Vec<DiagnosticJson>>>, minify: bool, compat: bool) -> (bool, Vec<PathBuf>) {
2521 let input = match fs::read_to_string(input_path) {
2622 Ok(s) => s,
2723 Err(e) => {
@@ -90,12 +86,13 @@ fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: b
9086 let _ = fs::write(&out_path, &error_html);
9187 }
9288 } else {
93− let html = if minify {
94− htmlang::codegen::generate_minified(&result.document)
95− } else if dev {
96− htmlang::codegen::generate_dev(&result.document)
97− } else {
98− htmlang::codegen::generate(&result.document)
89+ let html = match (minify, dev, compat) {
90+ (true, _, true) => htmlang::codegen::generate_minified_compat(&result.document),
91+ (true, _, false) => htmlang::codegen::generate_minified(&result.document),
92+ (false, true, true) => htmlang::codegen::generate_dev_compat(&result.document),
93+ (false, true, false) => htmlang::codegen::generate_dev(&result.document),
94+ (false, false, true) => htmlang::codegen::generate_compat(&result.document),
95+ (false, false, false) => htmlang::codegen::generate(&result.document),
9996 };
10097 match fs::write(&out_path, &html) {
10198 Ok(()) => eprintln!("wrote {}", out_path.display()),
@@ -124,6 +121,11 @@ fn print_json_diagnostics(diagnostics: &[DiagnosticJson]) {
124121 println!("{}", json);
125122}
126123
124+fn json_escape_string(s: &str) -> String {
125+ let escaped = s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
126+ format!("\"{}\"", escaped)
127+}
128+
127129fn kebab_to_title(s: &str) -> String {
128130 s.split('-')
129131 .map(|word| {
@@ -247,6 +249,7 @@ Options:
247249 -o, --output <path> Output file/directory path
248250 -d, --dev Development mode
249251 -c, --check Check for errors without writing output
252+ --compat Add vendor prefixes for broader browser support
250253 --format json Output diagnostics as JSON to stdout
251254 -h, --help Show this help
252255 -V, --version Show version
@@ -677,6 +680,7 @@ fn main() {
677680 let mut dev = false;
678681 let mut check = false;
679682 let mut format_json = false;
683+ let mut compat = false;
680684 let mut open_browser = false;
681685 let mut port: u16 = 3000;
682686 let mut output_path: Option<String> = None;
@@ -716,6 +720,7 @@ fn main() {
716720 let mut src_dir = None;
717721 let mut out_dir = None;
718722 let mut build_minify = false;
723+ let mut build_compat = false;
719724 let mut i = 2;
720725 while i < args.len() {
721726 match args[i].as_str() {
@@ -724,6 +729,7 @@ fn main() {
724729 out_dir = args.get(i).map(|s| s.as_str());
725730 }
726731 "--minify" => build_minify = true,
732+ "--compat" => build_compat = true,
727733 _ if src_dir.is_none() => src_dir = Some(args[i].as_str()),
728734 _ => {
729735 eprintln!("unknown argument: {}", args[i]);
@@ -782,8 +788,8 @@ fn main() {
782788 }
783789 }
784790 let path_str = file.to_string_lossy().to_string();
785− let (has_errors, _) = if build_minify {
786− compile_minified(&path_str, effective_out.as_deref())
791+ let (has_errors, _) = if build_minify || build_compat {
792+ compile_inner(&path_str, false, false, false, effective_out.as_deref(), false, None, build_minify, build_compat)
787793 } else {
788794 compile(&path_str, false, false, false, effective_out.as_deref(), false, None)
789795 };
@@ -832,13 +838,28 @@ fn main() {
832838
833839 // Handle "lint" subcommand
834840 if args.len() >= 2 && args[1] == "lint" {
835− let target = if args.len() >= 3 { &args[2] } else { "." };
836− let path = Path::new(target);
841+ let mut lint_target = ".";
842+ let mut lint_json = false;
843+ let mut i = 2;
844+ while i < args.len() {
845+ match args[i].as_str() {
846+ "--format" => {
847+ i += 1;
848+ if args.get(i).map(|s| s.as_str()) == Some("json") {
849+ lint_json = true;
850+ }
851+ }
852+ _ if lint_target == "." => lint_target = &args[i],
853+ _ => {}
854+ }
855+ i += 1;
856+ }
857+ let path = Path::new(lint_target);
837858 let mut all_warnings = Vec::new();
838859 if path.is_dir() {
839860 let hl_files = collect_hl_files_recursive(path);
840861 if hl_files.is_empty() {
841− eprintln!("no .hl files found in {}", target);
862+ eprintln!("no .hl files found in {}", lint_target);
842863 process::exit(1);
843864 }
844865 for file in &hl_files {
@@ -846,9 +867,20 @@ fn main() {
846867 all_warnings.extend(lint_file(&path_str));
847868 }
848869 } else {
849− all_warnings.extend(lint_file(target));
850− }
851− if all_warnings.is_empty() {
870+ all_warnings.extend(lint_file(lint_target));
871+ }
872+ if lint_json {
873+ let mut json = String::from("{\"diagnostics\":[");
874+ for (i, w) in all_warnings.iter().enumerate() {
875+ if i > 0 { json.push(','); }
876+ json.push_str(&format!(
877+ "{{\"severity\":\"warning\",\"message\":{}}}",
878+ json_escape_string(w)
879+ ));
880+ }
881+ json.push_str("]}");
882+ println!("{}", json);
883+ } else if all_warnings.is_empty() {
852884 eprintln!("no issues found");
853885 } else {
854886 for w in &all_warnings {
@@ -1438,7 +1470,23 @@ fn main() {
14381470
14391471 // Handle "dead-code" subcommand — project-wide unused definitions
14401472 if args.len() >= 2 && args[1] == "dead-code" {
1441− let target = if args.len() >= 3 { &args[2] } else { "." };
1473+ let mut dc_json = false;
1474+ let mut dc_target = ".".to_string();
1475+ let mut i = 2;
1476+ while i < args.len() {
1477+ match args[i].as_str() {
1478+ "--format" => {
1479+ i += 1;
1480+ if args.get(i).map(|s| s.as_str()) == Some("json") {
1481+ dc_json = true;
1482+ }
1483+ }
1484+ _ if dc_target == "." => dc_target = args[i].clone(),
1485+ _ => {}
1486+ }
1487+ i += 1;
1488+ }
1489+ let target = dc_target.as_str();
14421490 let path = Path::new(target);
14431491 let hl_files = if path.is_dir() {
14441492 collect_hl_files_recursive(path)
@@ -1506,29 +1554,43 @@ fn main() {
15061554 }
15071555 }
15081556 // Pass 2: report unused
1509− let mut count = 0;
1557+ let mut unused: Vec<(String, String, String, usize)> = Vec::new(); // (kind, name, file, line)
15101558 for (name, file, line) in &all_fn_defs {
15111559 if !all_refs.contains(name) {
1512− println!("unused @fn '{}' — {}:{}", name, file, line);
1513− count += 1;
1560+ unused.push(("@fn".to_string(), name.clone(), file.clone(), *line));
15141561 }
15151562 }
15161563 for (name, file, line) in &all_def_defs {
15171564 if !all_refs.contains(name) {
1518− println!("unused @define '{}' — {}:{}", name, file, line);
1519− count += 1;
1565+ unused.push(("@define".to_string(), name.clone(), file.clone(), *line));
15201566 }
15211567 }
15221568 for (name, file, line) in &all_let_defs {
15231569 if !all_refs.contains(name) && !name.starts_with("--") {
1524− println!("unused @let '{}' — {}:{}", name, file, line);
1525− count += 1;
1570+ unused.push(("@let".to_string(), name.clone(), file.clone(), *line));
1571+ }
1572+ }
1573+ if dc_json {
1574+ let mut json = String::from("{\"unused\":[");
1575+ for (i, (kind, name, file, line)) in unused.iter().enumerate() {
1576+ if i > 0 { json.push(','); }
1577+ json.push_str(&format!(
1578+ "{{\"kind\":{},\"name\":{},\"file\":{},\"line\":{}}}",
1579+ json_escape_string(kind),
1580+ json_escape_string(name),
1581+ json_escape_string(file),
1582+ line
1583+ ));
15261584 }
1527− }
1528− if count == 0 {
1585+ json.push_str("]}");
1586+ println!("{}", json);
1587+ } else if unused.is_empty() {
15291588 eprintln!("no unused definitions found");
15301589 } else {
1531− eprintln!("\n{} unused definition(s) found", count);
1590+ for (kind, name, file, line) in &unused {
1591+ println!("unused {} '{}' — {}:{}", kind, name, file, line);
1592+ }
1593+ eprintln!("\n{} unused definition(s) found", unused.len());
15321594 }
15331595 return;
15341596 }
@@ -2364,6 +2426,7 @@ compile();
23642426 "--watch" | "-w" => watch = true,
23652427 "--dev" | "-d" => dev = true,
23662428 "--check" | "-c" => check = true,
2429+ "--compat" => compat = true,
23672430 "--open" => open_browser = true,
23682431 "--format" => {
23692432 i += 1;
@@ -2454,7 +2517,7 @@ compile();
24542517 }
24552518 out_p.to_string_lossy().to_string()
24562519 });
2457− let (has_errors, included) = compile(&path_str, dev, serve, check, effective_out.as_deref(), format_json, json_collector.as_ref());
2520+ let (has_errors, included) = compile_inner(&path_str, dev, serve, check, effective_out.as_deref(), format_json, json_collector.as_ref(), false, compat);
24582521 if has_errors {
24592522 any_errors = true;
24602523 }
@@ -2497,7 +2560,7 @@ compile();
24972560
24982561 // --- Single file mode ---
24992562 let json_collector_single = if format_json { Some(Mutex::new(Vec::new())) } else { None };
2500− let (has_errors, included_files) = compile(&input_path, dev, serve, check, output_path.as_deref(), format_json, json_collector_single.as_ref());
2563+ let (has_errors, included_files) = compile_inner(&input_path, dev, serve, check, output_path.as_deref(), format_json, json_collector_single.as_ref(), false, compat);
25012564 if format_json {
25022565 if let Some(ref collector) = json_collector_single {
25032566 print_json_diagnostics(&collector.lock().unwrap());
src/parser.rs+329
@@ -631,6 +631,85 @@ impl Parser {
631631 return Ok(None); // No nodes emitted
632632 }
633633
634+ // --- @data (load JSON file into variables) ---
635+
636+ if let Some(rest) = content.strip_prefix("@data ") {
637+ let rest = rest.trim();
638+ // @data $prefix file.json OR @data file.json (no prefix, top-level keys become vars)
639+ let (prefix, filename) = if rest.starts_with('$') {
640+ if let Some((p, f)) = rest.split_once(' ') {
641+ (p.strip_prefix('$').unwrap_or(p).to_string(), f.trim().to_string())
642+ } else {
643+ return Err(ParseError {
644+ line: line_num,
645+ message: "@data requires: @data $prefix file.json or @data file.json".to_string(),
646+ });
647+ }
648+ } else {
649+ (String::new(), rest.to_string())
650+ };
651+
652+ let filename = substitute_vars(&filename, &ctx.variables);
653+ let resolved = match &ctx.base_path {
654+ Some(base) => base.join(&filename),
655+ None => PathBuf::from(&filename),
656+ };
657+
658+ let json_text = match std::fs::read_to_string(&resolved) {
659+ Ok(text) => text,
660+ Err(e) => {
661+ ctx.diagnostics.push(Diagnostic {
662+ line: line_num,
663+ message: format!("cannot load data '{}': {}", filename, e),
664+ severity: Severity::Error,
665+ source_line: Some(content.clone()),
666+ });
667+ return Ok(None);
668+ }
669+ };
670+
671+ match parse_json(&json_text) {
672+ Some(json) => {
673+ if prefix.is_empty() {
674+ // No prefix: top-level object keys become variables directly
675+ if let JsonValue::Object(pairs) = &json {
676+ for (key, val) in pairs {
677+ let mut sub = HashMap::new();
678+ flatten_json(key, val, &mut sub);
679+ for (k, v) in sub {
680+ ctx.variables.insert(k, v);
681+ }
682+ }
683+ } else {
684+ ctx.diagnostics.push(Diagnostic {
685+ line: line_num,
686+ message: "@data without prefix requires a JSON object at top level".to_string(),
687+ severity: Severity::Error,
688+ source_line: Some(content.clone()),
689+ });
690+ }
691+ } else {
692+ let mut sub = HashMap::new();
693+ flatten_json(&prefix, &json, &mut sub);
694+ for (k, v) in sub {
695+ ctx.variables.insert(k, v);
696+ }
697+ }
698+ }
699+ None => {
700+ ctx.diagnostics.push(Diagnostic {
701+ line: line_num,
702+ message: format!("invalid JSON in '{}'", filename),
703+ severity: Severity::Error,
704+ source_line: Some(content.clone()),
705+ });
706+ }
707+ }
708+
709+ ctx.included_files.push(resolved);
710+ return Ok(None);
711+ }
712+
634713 // --- @use (selective import) ---
635714
636715 if let Some(rest) = content.strip_prefix("@use ") {
@@ -2134,6 +2213,8 @@ const KNOWN_ATTRS: &[&str] = &[
21342213 "inset",
21352214 // Modern form theming
21362215 "accent-color", "caret-color",
2216+ // Color scheme & appearance
2217+ "color-scheme", "appearance",
21372218 // List styling
21382219 "list-style",
21392220 // Table styling
@@ -2161,6 +2242,14 @@ const KNOWN_ATTRS: &[&str] = &[
21612242 // Iframe/output attrs
21622243 "sandbox", "allow", "allowfullscreen", "referrerpolicy",
21632244 "formaction", "formmethod", "formtarget", "target",
2245+ // Popover API
2246+ "popover", "popovertarget", "popovertargetaction",
2247+ // Modern form/input hints
2248+ "inputmode", "enterkeyhint",
2249+ // Performance hints
2250+ "fetchpriority", "blocking",
2251+ // Global attrs
2252+ "translate", "spellcheck",
21642253 // Script attributes
21652254 "defer", "async", "crossorigin", "integrity", "nomodule",
21662255 // Pseudo-element content
@@ -3500,3 +3589,243 @@ fn check_unused(ctx: &mut ParseContext) {
35003589 }
35013590 }
35023591}
3592+
3593+// ---------------------------------------------------------------------------
3594+// Minimal JSON parser for @data directive
3595+// ---------------------------------------------------------------------------
3596+
3597+#[derive(Debug, Clone)]
3598+enum JsonValue {
3599+ Null,
3600+ Bool(bool),
3601+ Number(String),
3602+ Str(String),
3603+ Array(Vec<JsonValue>),
3604+ Object(Vec<(String, JsonValue)>),
3605+}
3606+
3607+fn parse_json(input: &str) -> Option<JsonValue> {
3608+ let trimmed = input.trim();
3609+ let chars: Vec<char> = trimmed.chars().collect();
3610+ let (val, _) = parse_json_value(&chars, 0)?;
3611+ Some(val)
3612+}
3613+
3614+fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
3615+ pos = skip_ws(chars, pos);
3616+ if pos >= chars.len() {
3617+ return None;
3618+ }
3619+ match chars[pos] {
3620+ '"' => {
3621+ let (s, p) = parse_json_string(chars, pos)?;
3622+ Some((JsonValue::Str(s), p))
3623+ }
3624+ '{' => parse_json_object(chars, pos),
3625+ '[' => parse_json_array(chars, pos),
3626+ 't' => {
3627+ if chars.get(pos..pos + 4)?.iter().collect::<String>() == "true" {
3628+ Some((JsonValue::Bool(true), pos + 4))
3629+ } else {
3630+ None
3631+ }
3632+ }
3633+ 'f' => {
3634+ if chars.get(pos..pos + 5)?.iter().collect::<String>() == "false" {
3635+ Some((JsonValue::Bool(false), pos + 5))
3636+ } else {
3637+ None
3638+ }
3639+ }
3640+ 'n' => {
3641+ if chars.get(pos..pos + 4)?.iter().collect::<String>() == "null" {
3642+ Some((JsonValue::Null, pos + 4))
3643+ } else {
3644+ None
3645+ }
3646+ }
3647+ c if c == '-' || c.is_ascii_digit() => {
3648+ let start = pos;
3649+ if chars[pos] == '-' {
3650+ pos += 1;
3651+ }
3652+ while pos < chars.len() && (chars[pos].is_ascii_digit() || chars[pos] == '.' || chars[pos] == 'e' || chars[pos] == 'E' || chars[pos] == '+' || chars[pos] == '-') {
3653+ if (chars[pos] == '+' || chars[pos] == '-') && pos > start + 1 && chars[pos - 1] != 'e' && chars[pos - 1] != 'E' {
3654+ break;
3655+ }
3656+ pos += 1;
3657+ }
3658+ let num: String = chars[start..pos].iter().collect();
3659+ Some((JsonValue::Number(num), pos))
3660+ }
3661+ _ => None,
3662+ }
3663+}
3664+
3665+fn parse_json_string(chars: &[char], mut pos: usize) -> Option<(String, usize)> {
3666+ if chars[pos] != '"' {
3667+ return None;
3668+ }
3669+ pos += 1;
3670+ let mut s = String::new();
3671+ while pos < chars.len() && chars[pos] != '"' {
3672+ if chars[pos] == '\\' && pos + 1 < chars.len() {
3673+ pos += 1;
3674+ match chars[pos] {
3675+ '"' | '\\' | '/' => s.push(chars[pos]),
3676+ 'n' => s.push('\n'),
3677+ 't' => s.push('\t'),
3678+ 'r' => s.push('\r'),
3679+ _ => {
3680+ s.push('\\');
3681+ s.push(chars[pos]);
3682+ }
3683+ }
3684+ } else {
3685+ s.push(chars[pos]);
3686+ }
3687+ pos += 1;
3688+ }
3689+ if pos < chars.len() {
3690+ pos += 1; // closing quote
3691+ }
3692+ Some((s, pos))
3693+}
3694+
3695+fn parse_json_object(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
3696+ pos += 1; // skip '{'
3697+ pos = skip_ws(chars, pos);
3698+ let mut pairs = Vec::new();
3699+ if pos < chars.len() && chars[pos] == '}' {
3700+ return Some((JsonValue::Object(pairs), pos + 1));
3701+ }
3702+ loop {
3703+ pos = skip_ws(chars, pos);
3704+ let (key, p) = parse_json_string(chars, pos)?;
3705+ pos = skip_ws(chars, p);
3706+ if pos >= chars.len() || chars[pos] != ':' {
3707+ return None;
3708+ }
3709+ pos += 1;
3710+ let (val, p) = parse_json_value(chars, pos)?;
3711+ pos = p;
3712+ pairs.push((key, val));
3713+ pos = skip_ws(chars, pos);
3714+ if pos >= chars.len() {
3715+ break;
3716+ }
3717+ if chars[pos] == '}' {
3718+ pos += 1;
3719+ break;
3720+ }
3721+ if chars[pos] == ',' {
3722+ pos += 1;
3723+ }
3724+ }
3725+ Some((JsonValue::Object(pairs), pos))
3726+}
3727+
3728+fn parse_json_array(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)> {
3729+ pos += 1; // skip '['
3730+ pos = skip_ws(chars, pos);
3731+ let mut items = Vec::new();
3732+ if pos < chars.len() && chars[pos] == ']' {
3733+ return Some((JsonValue::Array(items), pos + 1));
3734+ }
3735+ loop {
3736+ let (val, p) = parse_json_value(chars, pos)?;
3737+ pos = p;
3738+ items.push(val);
3739+ pos = skip_ws(chars, pos);
3740+ if pos >= chars.len() {
3741+ break;
3742+ }
3743+ if chars[pos] == ']' {
3744+ pos += 1;
3745+ break;
3746+ }
3747+ if chars[pos] == ',' {
3748+ pos += 1;
3749+ }
3750+ }
3751+ Some((JsonValue::Array(items), pos))
3752+}
3753+
3754+fn skip_ws(chars: &[char], mut pos: usize) -> usize {
3755+ while pos < chars.len() && chars[pos].is_ascii_whitespace() {
3756+ pos += 1;
3757+ }
3758+ pos
3759+}
3760+
3761+/// Flatten a JSON value into variable assignments.
3762+/// - Top-level object: each key becomes `prefix.key`
3763+/// - Top-level array: `prefix` becomes comma-separated, `prefix._count` set
3764+/// - Array of objects: each item becomes space-separated values for @each destructuring
3765+fn flatten_json(prefix: &str, value: &JsonValue, vars: &mut HashMap<String, String>) {
3766+ match value {
3767+ JsonValue::Str(s) => {
3768+ vars.insert(prefix.to_string(), s.clone());
3769+ }
3770+ JsonValue::Number(n) => {
3771+ vars.insert(prefix.to_string(), n.clone());
3772+ }
3773+ JsonValue::Bool(b) => {
3774+ vars.insert(prefix.to_string(), b.to_string());
3775+ }
3776+ JsonValue::Null => {
3777+ vars.insert(prefix.to_string(), String::new());
3778+ }
3779+ JsonValue::Object(pairs) => {
3780+ for (key, val) in pairs {
3781+ flatten_json(&format!("{}.{}", prefix, key), val, vars);
3782+ }
3783+ }
3784+ JsonValue::Array(items) => {
3785+ vars.insert(format!("{}._count", prefix), items.len().to_string());
3786+ // Check if all items are objects with the same keys
3787+ let all_objects = items.iter().all(|v| matches!(v, JsonValue::Object(_)));
3788+ if all_objects && !items.is_empty() {
3789+ // Collect keys from first object for destructuring
3790+ if let JsonValue::Object(first_pairs) = &items[0] {
3791+ let keys: Vec<String> = first_pairs.iter().map(|(k, _)| k.clone()).collect();
3792+ vars.insert(format!("{}._keys", prefix), keys.join(","));
3793+ }
3794+ // Each item becomes space-separated values, items comma-separated
3795+ let csv: Vec<String> = items
3796+ .iter()
3797+ .map(|item| {
3798+ if let JsonValue::Object(pairs) = item {
3799+ pairs
3800+ .iter()
3801+ .map(|(_, v)| json_value_to_string(v))
3802+ .collect::<Vec<_>>()
3803+ .join(" ")
3804+ } else {
3805+ json_value_to_string(item)
3806+ }
3807+ })
3808+ .collect();
3809+ vars.insert(prefix.to_string(), csv.join(","));
3810+ } else {
3811+ // Primitive array: comma-separated
3812+ let csv: Vec<String> = items.iter().map(|v| json_value_to_string(v)).collect();
3813+ vars.insert(prefix.to_string(), csv.join(","));
3814+ }
3815+ // Also set indexed access: prefix.0, prefix.1, etc.
3816+ for (i, item) in items.iter().enumerate() {
3817+ flatten_json(&format!("{}.{}", prefix, i), item, vars);
3818+ }
3819+ }
3820+ }
3821+}
3822+
3823+fn json_value_to_string(v: &JsonValue) -> String {
3824+ match v {
3825+ JsonValue::Str(s) => s.clone(),
3826+ JsonValue::Number(n) => n.clone(),
3827+ JsonValue::Bool(b) => b.to_string(),
3828+ JsonValue::Null => String::new(),
3829+ JsonValue::Array(_) | JsonValue::Object(_) => String::new(),
3830+ }
3831+}
tests/snapshots.rs+76−1
@@ -14,6 +14,19 @@ fn compile(input: &str) -> String {
1414 htmlang::codegen::generate(&result.document)
1515}
1616
17+fn compile_with_base(input: &str, base: &Path) -> String {
18+ let result = htmlang::parser::parse_with_base(input, Some(base));
19+ assert!(
20+ result
21+ .diagnostics
22+ .iter()
23+ .all(|d| d.severity != htmlang::parser::Severity::Error),
24+ "unexpected parse errors: {:?}",
25+ result.diagnostics
26+ );
27+ htmlang::codegen::generate(&result.document)
28+}
29+
1730fn parse_diagnostics(input: &str) -> Vec<htmlang::parser::Diagnostic> {
1831 htmlang::parser::parse(input).diagnostics
1932}
@@ -25,7 +38,7 @@ fn snapshot_test(name: &str) {
2538
2639 let input = fs::read_to_string(&hl_path)
2740 .unwrap_or_else(|e| panic!("failed to read {}: {}", hl_path.display(), e));
28− let actual = compile(&input);
41+ let actual = compile_with_base(&input, &dir);
2942
3043 if std::env::var("UPDATE_SNAPSHOTS").is_ok() {
3144 fs::write(&html_path, &actual).unwrap();
@@ -3224,3 +3237,65 @@ fn no_warning_input_in_label() {
32243237 );
32253238 assert!(!has_label_warning, "should not warn when input is inside @label");
32263239}
3240+
3241+// --- New feature tests ---
3242+
3243+#[test]
3244+fn snapshot_popover_api() {
3245+ snapshot_test("popover_api");
3246+}
3247+
3248+#[test]
3249+fn snapshot_new_html_attrs() {
3250+ snapshot_test("new_html_attrs");
3251+}
3252+
3253+#[test]
3254+fn snapshot_color_scheme() {
3255+ snapshot_test("color_scheme");
3256+}
3257+
3258+#[test]
3259+fn snapshot_data_directive() {
3260+ snapshot_test("data_directive");
3261+}
3262+
3263+#[test]
3264+fn test_popover_in_output() {
3265+ let html = compile("@button [popovertarget my-pop] Open\n@el [popover, id my-pop, padding 10]\n Hello");
3266+ assert!(html.contains("popovertarget=\"my-pop\""), "should have popovertarget attr");
3267+ assert!(html.contains(" popover"), "should have popover boolean attr");
3268+}
3269+
3270+#[test]
3271+fn test_color_scheme_css() {
3272+ let html = compile("@el [color-scheme light dark]\n Test");
3273+ assert!(html.contains("color-scheme:light dark"), "should generate color-scheme CSS");
3274+}
3275+
3276+#[test]
3277+fn test_appearance_css() {
3278+ let html = compile("@input [appearance none, padding 10]");
3279+ assert!(html.contains("appearance:none"), "should generate appearance CSS");
3280+}
3281+
3282+#[test]
3283+fn test_inputmode_attr() {
3284+ let html = compile("@input [type search, inputmode search]");
3285+ assert!(html.contains("inputmode=\"search\""), "should pass through inputmode");
3286+}
3287+
3288+#[test]
3289+fn test_fetchpriority_attr() {
3290+ let html = compile("@image [fetchpriority high, width 100] hero.jpg");
3291+ assert!(html.contains("fetchpriority=\"high\""), "should pass through fetchpriority");
3292+}
3293+
3294+#[test]
3295+fn test_compat_vendor_prefixes() {
3296+ let result = htmlang::parser::parse("@el [backdrop-filter blur(10px), user-select none]\n Test");
3297+ let html = htmlang::codegen::generate_compat(&result.document);
3298+ assert!(html.contains("-webkit-backdrop-filter"), "should add webkit prefix for backdrop-filter");
3299+ assert!(html.contains("-webkit-user-select"), "should add webkit prefix for user-select");
3300+ assert!(html.contains("-moz-user-select"), "should add moz prefix for user-select");
3301+}
tests/snapshots/color_scheme.hlnew file+6
@@ -0,0 +1,6 @@
1+@page Color Scheme
2+
3+@el [color-scheme light dark, padding 20]
4+ @text Adapts to system theme
5+
6+@input [appearance none, padding 10, border 1 #ccc, rounded 4]
tests/snapshots/compat_test.hlnew file+9
@@ -0,0 +1,9 @@
1+@page Compat Test
2+
3+@el [backdrop-filter blur(10px), padding 20]
4+ @text Frosted glass
5+
6+@el [user-select none, padding 10]
7+ @text Cannot select this
8+
9+@input [appearance none, border 1 #ccc, rounded 4, padding 8]
tests/snapshots/data_directive.hlnew file+11
@@ -0,0 +1,11 @@
1+@page Data Test
2+@data test_data.json
3+
4+@text [bold, size 24] $title
5+@text Version: $version
6+
7+@each $tag in $tags
8+ @text [color blue] $tag
9+
10+@each $label, $url in $links
11+ @link $url $label
tests/snapshots/new_html_attrs.hlnew file+7
@@ -0,0 +1,7 @@
1+@page HTML Attrs
2+
3+@form [method post] /search
4+ @input [type search, inputmode search, enterkeyhint search, placeholder Search..., spellcheck false]
5+ @input [type email, inputmode email, enterkeyhint done, translate no]
6+ @image [fetchpriority high, width 200] hero.jpg
7+ @button [type submit] Go
tests/snapshots/popover_api.hlnew file+6
@@ -0,0 +1,6 @@
1+@page Popover Demo
2+
3+@button [popovertarget my-popover] Toggle Info
4+
5+@el [popover, id my-popover, padding 20, background white, rounded 8, border 1 #ccc]
6+ @text This is a popover panel
tests/snapshots/test_data.jsonnew file+10
@@ -0,0 +1,10 @@
1+{
2+ "title": "My Site",
3+ "version": "1.0",
4+ "tags": ["rust", "html", "css"],
5+ "links": [
6+ {"label": "Home", "url": "/"},
7+ {"label": "About", "url": "/about"},
8+ {"label": "Contact", "url": "/contact"}
9+ ]
10+}