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
f281debiko
DESIGN.md+69
@@ -185,6 +185,39 @@ Like `@include`, but only imports definitions (`@let`, `@define`, `@fn`) without
185@include header.hl -- inlines everything including DOM nodes
185@include header.hl -- inlines everything including DOM nodes
186```
186```
187
187
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+
188### `@meta`
221### `@meta`
189
222
190Adds a `<meta>` tag to the document `<head>`. Requires `@page`.
223Adds a `<meta>` tag to the document `<head>`. Requires `@page`.
@@ -696,6 +729,39 @@ This generates a `@media print` rule.
696| `id NAME` | HTML id attribute |
729| `id NAME` | HTML id attribute |
697| `class NAME` | HTML class attribute |
730| `class NAME` | HTML class attribute |
698
731
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+
699## Asset inlining
765## Asset inlining
700
766
701SVG images can be inlined directly into the HTML output using the `[inline]` attribute:
767SVG 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
722htmlang check src/ # check all .hl files in a directory
788htmlang check src/ # check all .hl files in a directory
723htmlang convert page.html # convert HTML to .hl format (stdout)
789htmlang convert page.html # convert HTML to .hl format (stdout)
724htmlang build src/ -o dist/ # parallel compile + copy static assets
790htmlang build src/ -o dist/ # parallel compile + copy static assets
791+htmlang --compat page.hl # compile with vendor prefixes
725htmlang --format json page.hl # output diagnostics as JSON
792htmlang --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
726htmlang lint page.hl # stricter lint checks (accessibility, nesting)
795htmlang lint page.hl # stricter lint checks (accessibility, nesting)
727htmlang lint src/ # lint all .hl files in a directory
796htmlang lint src/ # lint all .hl files in a directory
728htmlang stats page.hl # show file statistics (elements, CSS, colors)
797htmlang 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> {
722 ("@json-ld", "Add JSON-LD structured data to head", "@json-ld"),
722 ("@json-ld", "Add JSON-LD structured data to head", "@json-ld"),
723 ("@mixin", "Define a composable style group (use with ...$name)", "@mixin "),
723 ("@mixin", "Define a composable style group (use with ...$name)", "@mixin "),
724 ("@assert", "Compile-time assertion for variable values", "@assert "),
724 ("@assert", "Compile-time assertion for variable values", "@assert "),
725+ ("@data", "Load JSON data file into template variables", "@data "),
725 ]
726 ]
726 .iter()
727 .iter()
727 .map(|(name, detail, insert)| {
728 .map(|(name, detail, insert)| {
@@ -1119,6 +1120,18 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
1119 // Modern form theming
1120 // Modern form theming
1120 ("accent-color", "Accent color for form controls", true),
1121 ("accent-color", "Accent color for form controls", true),
1121 ("caret-color", "Text cursor color", true),
1122 ("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),
1122 // List styling
1135 // List styling
1123 ("list-style", "List style type (disc/circle/square/none)", true),
1136 ("list-style", "List style type (disc/circle/square/none)", true),
1124 // Table styling
1137 // Table styling
@@ -1920,6 +1933,16 @@ fn hover_builtin(word: &str) -> Option<String> {
1920 "rowspan" => "**rowspan** `<value>` \u{2014} Number of rows a cell spans.",
1933 "rowspan" => "**rowspan** `<value>` \u{2014} Number of rows a cell spans.",
1921 "scope" => "**scope** `<value>` \u{2014} Header scope (`col`, `row`, `colgroup`, `rowgroup`).",
1934 "scope" => "**scope** `<value>` \u{2014} Header scope (`col`, `row`, `colgroup`, `rowgroup`).",
1922 "inline" => "**inline** \u{2014} Inline SVG image content into the HTML output.",
1935 "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`).",
1923 "hidden" => "**hidden** \u{2014} Hide element (`display: none`).",
1946 "hidden" => "**hidden** \u{2014} Hide element (`display: none`).",
1924 "overflow-x" => "**overflow-x** `<value>` \u{2014} Horizontal overflow (`hidden`, `scroll`, `auto`, `visible`).",
1947 "overflow-x" => "**overflow-x** `<value>` \u{2014} Horizontal overflow (`hidden`, `scroll`, `auto`, `visible`).",
1925 "overflow-y" => "**overflow-y** `<value>` \u{2014} Vertical overflow (`hidden`, `scroll`, `auto`, `visible`).",
1948 "overflow-y" => "**overflow-y** `<value>` \u{2014} Vertical overflow (`hidden`, `scroll`, `auto`, `visible`).",
@@ -3164,7 +3187,7 @@ fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
3164 | "@unless" | "@og" | "@breakpoint"
3187 | "@unless" | "@og" | "@breakpoint"
3165 | "@canonical" | "@base" | "@font-face" | "@json-ld"
3188 | "@canonical" | "@base" | "@font-face" | "@json-ld"
3166 | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends"
3189 | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends"
3167− | "@use" => 0, // keyword
3190+ | "@use" | "@data" => 0, // keyword
3168 _ => {
3191 _ => {
3169 // Check if it's a user function call (starts with @ but not a builtin element)
3192 // Check if it's a user function call (starts with @ but not a builtin element)
3170 if is_builtin_element(word) { 0 } else { 2 } // function
3193 if is_builtin_element(word) { 0 } else { 2 } // function
src/codegen.rs+144
@@ -368,6 +368,25 @@ pub fn generate_minified(doc: &Document) -> String {
368 minify_html(&html)
368 minify_html(&html)
369}
369}
370
370
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+
371fn minify_html(html: &str) -> String {
390fn minify_html(html: &str) -> String {
372 let mut result = String::with_capacity(html.len());
391 let mut result = String::with_capacity(html.len());
373 let mut in_pre = false;
392 let mut in_pre = false;
@@ -448,6 +467,109 @@ fn minify_html(html: &str) -> String {
448 result
467 result
449}
468}
450
469
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+
451fn generate_with_options(doc: &Document, dev: bool) -> String {
573fn generate_with_options(doc: &Document, dev: bool) -> String {
452 let mut styles = StyleCollector::new();
574 let mut styles = StyleCollector::new();
453 let mut ctx = GenContext { dev, depth: 0 };
575 let mut ctx = GenContext { dev, depth: 0 };
@@ -831,6 +953,14 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
831 "low", "high", "optimum",
953 "low", "high", "optimum",
832 // Table
954 // Table
833 "colspan", "rowspan", "scope",
955 "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",
834];
964];
835
965
836/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
966/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
@@ -839,6 +969,8 @@ const BOOLEAN_HTML_ATTRS: &[&str] = &[
839 "controls", "autoplay", "loop", "muted",
969 "controls", "autoplay", "loop", "muted",
840 "open", "novalidate", "autofocus",
970 "open", "novalidate", "autofocus",
841 "defer", "async", "nomodule",
971 "defer", "async", "nomodule",
972+ // Popover
973+ "popover",
842];
974];
843
975
844fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
976fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
@@ -2182,6 +2314,18 @@ fn attrs_to_css(
2182 }
2314 }
2183 }
2315 }
2184
2316
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+
2185 // List style
2329 // List style
2186 "list-style" => {
2330 "list-style" => {
2187 if let Some(v) = val {
2331 if let Some(v) = val {
src/main.rs+96−33
@@ -14,14 +14,10 @@ struct DiagnosticJson {
14}
14}
15
15
16fn 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>) {
16fn 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)
18}
18}
19
19
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>) {
25 let input = match fs::read_to_string(input_path) {
21 let input = match fs::read_to_string(input_path) {
26 Ok(s) => s,
22 Ok(s) => s,
27 Err(e) => {
23 Err(e) => {
@@ -90,12 +86,13 @@ fn compile_inner(input_path: &str, dev: bool, error_overlay: bool, check_only: b
90 let _ = fs::write(&out_path, &error_html);
86 let _ = fs::write(&out_path, &error_html);
91 }
87 }
92 } else {
88 } else {
93− let html = if minify {
89+ let html = match (minify, dev, compat) {
94− htmlang::codegen::generate_minified(&result.document)
90+ (true, _, true) => htmlang::codegen::generate_minified_compat(&result.document),
95− } else if dev {
91+ (true, _, false) => htmlang::codegen::generate_minified(&result.document),
92+ (false, true, true) => htmlang::codegen::generate_dev_compat(&result.document),
96− htmlang::codegen::generate_dev(&result.document)
93+ (false, true, false) => htmlang::codegen::generate_dev(&result.document),
97− } else {
94+ (false, false, true) => htmlang::codegen::generate_compat(&result.document),
98− htmlang::codegen::generate(&result.document)
95+ (false, false, false) => htmlang::codegen::generate(&result.document),
99 };
96 };
100 match fs::write(&out_path, &html) {
97 match fs::write(&out_path, &html) {
101 Ok(()) => eprintln!("wrote {}", out_path.display()),
98 Ok(()) => eprintln!("wrote {}", out_path.display()),
@@ -124,6 +121,11 @@ fn print_json_diagnostics(diagnostics: &[DiagnosticJson]) {
124 println!("{}", json);
121 println!("{}", json);
125}
122}
126
123
124+fn json_escape_string(s: &str) -> String {
125+ let escaped = s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
126+ format!("\"{}\"", escaped)
127+}
128+
127fn kebab_to_title(s: &str) -> String {
129fn kebab_to_title(s: &str) -> String {
128 s.split('-')
130 s.split('-')
129 .map(|word| {
131 .map(|word| {
@@ -247,6 +249,7 @@ Options:
247 -o, --output <path> Output file/directory path
249 -o, --output <path> Output file/directory path
248 -d, --dev Development mode
250 -d, --dev Development mode
249 -c, --check Check for errors without writing output
251 -c, --check Check for errors without writing output
252+ --compat Add vendor prefixes for broader browser support
250 --format json Output diagnostics as JSON to stdout
253 --format json Output diagnostics as JSON to stdout
251 -h, --help Show this help
254 -h, --help Show this help
252 -V, --version Show version
255 -V, --version Show version
@@ -677,6 +680,7 @@ fn main() {
677 let mut dev = false;
680 let mut dev = false;
678 let mut check = false;
681 let mut check = false;
679 let mut format_json = false;
682 let mut format_json = false;
683+ let mut compat = false;
680 let mut open_browser = false;
684 let mut open_browser = false;
681 let mut port: u16 = 3000;
685 let mut port: u16 = 3000;
682 let mut output_path: Option<String> = None;
686 let mut output_path: Option<String> = None;
@@ -716,6 +720,7 @@ fn main() {
716 let mut src_dir = None;
720 let mut src_dir = None;
717 let mut out_dir = None;
721 let mut out_dir = None;
718 let mut build_minify = false;
722 let mut build_minify = false;
723+ let mut build_compat = false;
719 let mut i = 2;
724 let mut i = 2;
720 while i < args.len() {
725 while i < args.len() {
721 match args[i].as_str() {
726 match args[i].as_str() {
@@ -724,6 +729,7 @@ fn main() {
724 out_dir = args.get(i).map(|s| s.as_str());
729 out_dir = args.get(i).map(|s| s.as_str());
725 }
730 }
726 "--minify" => build_minify = true,
731 "--minify" => build_minify = true,
732+ "--compat" => build_compat = true,
727 _ if src_dir.is_none() => src_dir = Some(args[i].as_str()),
733 _ if src_dir.is_none() => src_dir = Some(args[i].as_str()),
728 _ => {
734 _ => {
729 eprintln!("unknown argument: {}", args[i]);
735 eprintln!("unknown argument: {}", args[i]);
@@ -782,8 +788,8 @@ fn main() {
782 }
788 }
783 }
789 }
784 let path_str = file.to_string_lossy().to_string();
790 let path_str = file.to_string_lossy().to_string();
785− let (has_errors, _) = if build_minify {
791+ let (has_errors, _) = if build_minify || build_compat {
786− compile_minified(&path_str, effective_out.as_deref())
792+ compile_inner(&path_str, false, false, false, effective_out.as_deref(), false, None, build_minify, build_compat)
787 } else {
793 } else {
788 compile(&path_str, false, false, false, effective_out.as_deref(), false, None)
794 compile(&path_str, false, false, false, effective_out.as_deref(), false, None)
789 };
795 };
@@ -832,13 +838,28 @@ fn main() {
832
838
833 // Handle "lint" subcommand
839 // Handle "lint" subcommand
834 if args.len() >= 2 && args[1] == "lint" {
840 if args.len() >= 2 && args[1] == "lint" {
835− let target = if args.len() >= 3 { &args[2] } else { "." };
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+ }
836− let path = Path::new(target);
857+ let path = Path::new(lint_target);
837 let mut all_warnings = Vec::new();
858 let mut all_warnings = Vec::new();
838 if path.is_dir() {
859 if path.is_dir() {
839 let hl_files = collect_hl_files_recursive(path);
860 let hl_files = collect_hl_files_recursive(path);
840 if hl_files.is_empty() {
861 if hl_files.is_empty() {
841− eprintln!("no .hl files found in {}", target);
862+ eprintln!("no .hl files found in {}", lint_target);
842 process::exit(1);
863 process::exit(1);
843 }
864 }
844 for file in &hl_files {
865 for file in &hl_files {
@@ -846,9 +867,20 @@ fn main() {
846 all_warnings.extend(lint_file(&path_str));
867 all_warnings.extend(lint_file(&path_str));
847 }
868 }
848 } else {
869 } else {
849− all_warnings.extend(lint_file(target));
870+ all_warnings.extend(lint_file(lint_target));
850− }
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);
851− if all_warnings.is_empty() {
883+ } else if all_warnings.is_empty() {
852 eprintln!("no issues found");
884 eprintln!("no issues found");
853 } else {
885 } else {
854 for w in &all_warnings {
886 for w in &all_warnings {
@@ -1438,7 +1470,23 @@ fn main() {
1438
1470
1439 // Handle "dead-code" subcommand — project-wide unused definitions
1471 // Handle "dead-code" subcommand — project-wide unused definitions
1440 if args.len() >= 2 && args[1] == "dead-code" {
1472 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();
1442 let path = Path::new(target);
1490 let path = Path::new(target);
1443 let hl_files = if path.is_dir() {
1491 let hl_files = if path.is_dir() {
1444 collect_hl_files_recursive(path)
1492 collect_hl_files_recursive(path)
@@ -1506,29 +1554,43 @@ fn main() {
1506 }
1554 }
1507 }
1555 }
1508 // Pass 2: report unused
1556 // Pass 2: report unused
1509− let mut count = 0;
1557+ let mut unused: Vec<(String, String, String, usize)> = Vec::new(); // (kind, name, file, line)
1510 for (name, file, line) in &all_fn_defs {
1558 for (name, file, line) in &all_fn_defs {
1511 if !all_refs.contains(name) {
1559 if !all_refs.contains(name) {
1512− println!("unused @fn '{}' — {}:{}", name, file, line);
1560+ unused.push(("@fn".to_string(), name.clone(), file.clone(), *line));
1513− count += 1;
1514 }
1561 }
1515 }
1562 }
1516 for (name, file, line) in &all_def_defs {
1563 for (name, file, line) in &all_def_defs {
1517 if !all_refs.contains(name) {
1564 if !all_refs.contains(name) {
1518− println!("unused @define '{}' — {}:{}", name, file, line);
1565+ unused.push(("@define".to_string(), name.clone(), file.clone(), *line));
1519− count += 1;
1520 }
1566 }
1521 }
1567 }
1522 for (name, file, line) in &all_let_defs {
1568 for (name, file, line) in &all_let_defs {
1523 if !all_refs.contains(name) && !name.starts_with("--") {
1569 if !all_refs.contains(name) && !name.starts_with("--") {
1524− println!("unused @let '{}' — {}:{}", name, file, line);
1570+ unused.push(("@let".to_string(), name.clone(), file.clone(), *line));
1525− count += 1;
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+ ));
1526 }
1584 }
1527− }
1585+ json.push_str("]}");
1528− if count == 0 {
1586+ println!("{}", json);
1587+ } else if unused.is_empty() {
1529 eprintln!("no unused definitions found");
1588 eprintln!("no unused definitions found");
1530 } else {
1589 } else {
1590+ for (kind, name, file, line) in &unused {
1591+ println!("unused {} '{}' — {}:{}", kind, name, file, line);
1592+ }
1531− eprintln!("\n{} unused definition(s) found", count);
1593+ eprintln!("\n{} unused definition(s) found", unused.len());
1532 }
1594 }
1533 return;
1595 return;
1534 }
1596 }
@@ -2364,6 +2426,7 @@ compile();
2364 "--watch" | "-w" => watch = true,
2426 "--watch" | "-w" => watch = true,
2365 "--dev" | "-d" => dev = true,
2427 "--dev" | "-d" => dev = true,
2366 "--check" | "-c" => check = true,
2428 "--check" | "-c" => check = true,
2429+ "--compat" => compat = true,
2367 "--open" => open_browser = true,
2430 "--open" => open_browser = true,
2368 "--format" => {
2431 "--format" => {
2369 i += 1;
2432 i += 1;
@@ -2454,7 +2517,7 @@ compile();
2454 }
2517 }
2455 out_p.to_string_lossy().to_string()
2518 out_p.to_string_lossy().to_string()
2456 });
2519 });
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);
2458 if has_errors {
2521 if has_errors {
2459 any_errors = true;
2522 any_errors = true;
2460 }
2523 }
@@ -2497,7 +2560,7 @@ compile();
2497
2560
2498 // --- Single file mode ---
2561 // --- Single file mode ---
2499 let json_collector_single = if format_json { Some(Mutex::new(Vec::new())) } else { None };
2562 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);
2501 if format_json {
2564 if format_json {
2502 if let Some(ref collector) = json_collector_single {
2565 if let Some(ref collector) = json_collector_single {
2503 print_json_diagnostics(&collector.lock().unwrap());
2566 print_json_diagnostics(&collector.lock().unwrap());
src/parser.rs+329
@@ -631,6 +631,85 @@ impl Parser {
631 return Ok(None); // No nodes emitted
631 return Ok(None); // No nodes emitted
632 }
632 }
633
633
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+
634 // --- @use (selective import) ---
713 // --- @use (selective import) ---
635
714
636 if let Some(rest) = content.strip_prefix("@use ") {
715 if let Some(rest) = content.strip_prefix("@use ") {
@@ -2134,6 +2213,8 @@ const KNOWN_ATTRS: &[&str] = &[
2134 "inset",
2213 "inset",
2135 // Modern form theming
2214 // Modern form theming
2136 "accent-color", "caret-color",
2215 "accent-color", "caret-color",
2216+ // Color scheme & appearance
2217+ "color-scheme", "appearance",
2137 // List styling
2218 // List styling
2138 "list-style",
2219 "list-style",
2139 // Table styling
2220 // Table styling
@@ -2161,6 +2242,14 @@ const KNOWN_ATTRS: &[&str] = &[
2161 // Iframe/output attrs
2242 // Iframe/output attrs
2162 "sandbox", "allow", "allowfullscreen", "referrerpolicy",
2243 "sandbox", "allow", "allowfullscreen", "referrerpolicy",
2163 "formaction", "formmethod", "formtarget", "target",
2244 "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",
2164 // Script attributes
2253 // Script attributes
2165 "defer", "async", "crossorigin", "integrity", "nomodule",
2254 "defer", "async", "crossorigin", "integrity", "nomodule",
2166 // Pseudo-element content
2255 // Pseudo-element content
@@ -3500,3 +3589,243 @@ fn check_unused(ctx: &mut ParseContext) {
3500 }
3589 }
3501 }
3590 }
3502}
3591}
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 {
14 htmlang::codegen::generate(&result.document)
14 htmlang::codegen::generate(&result.document)
15}
15}
16
16
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+
17fn parse_diagnostics(input: &str) -> Vec<htmlang::parser::Diagnostic> {
30fn parse_diagnostics(input: &str) -> Vec<htmlang::parser::Diagnostic> {
18 htmlang::parser::parse(input).diagnostics
31 htmlang::parser::parse(input).diagnostics
19}
32}
@@ -25,7 +38,7 @@ fn snapshot_test(name: &str) {
25
38
26 let input = fs::read_to_string(&hl_path)
39 let input = fs::read_to_string(&hl_path)
27 .unwrap_or_else(|e| panic!("failed to read {}: {}", hl_path.display(), e));
40 .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);
29
42
30 if std::env::var("UPDATE_SNAPSHOTS").is_ok() {
43 if std::env::var("UPDATE_SNAPSHOTS").is_ok() {
31 fs::write(&html_path, &actual).unwrap();
44 fs::write(&html_path, &actual).unwrap();
@@ -3224,3 +3237,65 @@ fn no_warning_input_in_label() {
3224 );
3237 );
3225 assert!(!has_label_warning, "should not warn when input is inside @label");
3238 assert!(!has_label_warning, "should not warn when input is inside @label");
3226}
3239}
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+}