on master help sign in
Write HTML attributes as key=value, styles as key value
21e5176iko
crates/htmlang-core/src/ast.rs+2
@@ -71,6 +71,8 @@ pub struct Element {
71pub struct Attribute {
71pub struct Attribute {
72 pub key: String,
72 pub key: String,
73 pub value: Option<String>,
73 pub value: Option<String>,
74+ /// Written `key=value`: an HTML attribute rather than a style.
75+ pub html: bool,
74}
76}
75
77
76#[derive(Debug, Clone, PartialEq)]
78#[derive(Debug, Clone, PartialEq)]
crates/htmlang-core/src/codegen.rs+21−43
@@ -419,7 +419,6 @@ struct GenContext {
419 depth: usize,
419 depth: usize,
420 image_count: usize,
420 image_count: usize,
421 has_interactive: bool,
421 has_interactive: bool,
422− has_defer: bool,
423}
422}
424
423
425impl GenContext {
424impl GenContext {
@@ -729,7 +728,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
729 depth: 0,
728 depth: 0,
730 image_count: 0,
729 image_count: 0,
731 has_interactive: false,
730 has_interactive: false,
732− has_defer: false,
733 };
731 };
734
732
735 // Check if document has @main for skip-to-content link
733 // Check if document has @main for skip-to-content link
@@ -1021,17 +1019,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1021 ""
1019 ""
1022 };
1020 };
1023
1021
1024− // Noscript fallback: show deferred content if JS is disabled
1025− let noscript_html = if ctx.has_defer {
1026− if dev {
1027− "<noscript><style>.hl-defer-placeholder { display: none; }</style></noscript>\n"
1028− } else {
1029− "<noscript><style>.hl-defer-placeholder{display:none}</style></noscript>"
1030− }
1031− } else {
1032− ""
1033− };
1034−
1035 let reset_css = reset_css(dev, focus_visible_css, skip_link_css);
1022 let reset_css = reset_css(dev, focus_visible_css, skip_link_css);
1036
1023
1037 match &doc.page_title {
1024 match &doc.page_title {
@@ -1049,7 +1036,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1049<style>
1036<style>
1050{reset_css}{element_css}\
1037{reset_css}{element_css}\
1051</style>
1038</style>
1052−{noscript_html}\
1053</head>
1039</head>
1054<body>
1040<body>
1055{body}\
1041{body}\
@@ -1070,13 +1056,12 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1070 head_html = head_html,
1056 head_html = head_html,
1071 og_html = og_html,
1057 og_html = og_html,
1072 reset_css = reset_css,
1058 reset_css = reset_css,
1073− noscript_html = noscript_html,
1074 element_css = element_css,
1059 element_css = element_css,
1075 body = body,
1060 body = body,
1076 )
1061 )
1077 } else {
1062 } else {
1078 format!(
1063 format!(
1079− "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}<style>{reset_css}{element_css}</style>{noscript_html}</head><body>{body}</body></html>",
1064+ "<!DOCTYPE html><html{lang_attr}><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{theme_color_html}{base_html}{canonical_html}{manifest_html}{preload_html}{dns_prefetch_html}{meta_html}{og_html}{favicon_html}{json_ld_html}{head_html}<style>{reset_css}{element_css}</style></head><body>{body}</body></html>",
1080 title = html_escape(title),
1065 title = html_escape(title),
1081 lang_attr = lang_attr,
1066 lang_attr = lang_attr,
1082 theme_color_html = theme_color_html,
1067 theme_color_html = theme_color_html,
@@ -1091,7 +1076,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
1091 json_ld_html = json_ld_html,
1076 json_ld_html = json_ld_html,
1092 head_html = head_html,
1077 head_html = head_html,
1093 reset_css = reset_css,
1078 reset_css = reset_css,
1094− noscript_html = noscript_html,
1095 element_css = element_css,
1079 element_css = element_css,
1096 body = body,
1080 body = body,
1097 )
1081 )
@@ -1272,7 +1256,6 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1272 depth: 0,
1256 depth: 0,
1273 image_count: 0,
1257 image_count: 0,
1274 has_interactive: false,
1258 has_interactive: false,
1275− has_defer: false,
1276 };
1259 };
1277 let mut body = String::new();
1260 let mut body = String::new();
1278
1261
@@ -1347,21 +1330,25 @@ fn emit_argument_attr(out: &mut String, elem: &Element) {
1347 }
1330 }
1348}
1331}
1349
1332
1333+/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1334+/// are emitted with the generated class) and bare booleans like `required`.
1350−fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
1335+fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
1351 for attr in attrs {
1336 for attr in attrs {
1352 let key = attr.key.as_str();
1337 let key = attr.key.as_str();
1353− if !crate::vocab::is_html_passthrough(key) {
1338+ if attr.html && key != "id" && key != "class" {
1354− continue;
1355− }
1356− if crate::vocab::BOOLEAN_HTML_ATTRS.contains(&key) && attr.value.is_none() {
1357− out.push(' ');
1358− out.push_str(key);
1359− } else if let Some(val) = &attr.value {
1360 out.push(' ');
1339 out.push(' ');
1361 out.push_str(key);
1340 out.push_str(key);
1362 out.push_str("=\"");
1341 out.push_str("=\"");
1363− out.push_str(&html_escape(strip_string_quotes(val)));
1342+ out.push_str(&html_escape(strip_string_quotes(
1343+ attr.value.as_deref().unwrap_or(""),
1344+ )));
1364 out.push('"');
1345 out.push('"');
1346+ } else if !attr.html
1347+ && attr.value.is_none()
1348+ && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&key)
1349+ {
1350+ out.push(' ');
1351+ out.push_str(key);
1365 }
1352 }
1366 }
1353 }
1367}
1354}
@@ -1570,7 +1557,7 @@ fn generate_element(
1570 out.push('"');
1557 out.push('"');
1571 }
1558 }
1572
1559
1573− emit_html_passthrough_attrs(out, &elem.attrs);
1560+ emit_html_attrs(out, &elem.attrs);
1574
1561
1575 // Source map attributes in dev mode
1562 // Source map attributes in dev mode
1576 if ctx.dev && elem.line_num > 0 {
1563 if ctx.dev && elem.line_num > 0 {
@@ -1682,7 +1669,7 @@ fn generate_self_closing(
1682 out.push('"');
1669 out.push('"');
1683 }
1670 }
1684
1671
1685− emit_html_passthrough_attrs(out, &elem.attrs);
1672+ emit_html_attrs(out, &elem.attrs);
1686
1673
1687 // Source map attributes in dev mode (self-closing)
1674 // Source map attributes in dev mode (self-closing)
1688 if ctx.dev && elem.line_num > 0 {
1675 if ctx.dev && elem.line_num > 0 {
@@ -2067,6 +2054,9 @@ fn attrs_to_css(
2067 }
2054 }
2068
2055
2069 for attr in attrs {
2056 for attr in attrs {
2057+ if attr.html {
2058+ continue;
2059+ }
2070 // Determine the effective key for this pass
2060 // Determine the effective key for this pass
2071 let effective_key = if state_prefix.is_empty() {
2061 let effective_key = if state_prefix.is_empty() {
2072 if crate::vocab::is_prefixed(&attr.key) {
2062 if crate::vocab::is_prefixed(&attr.key) {
@@ -2645,21 +2635,9 @@ fn attrs_to_css(
2645 // Critical CSS hint — not CSS, handled elsewhere
2635 // Critical CSS hint — not CSS, handled elsewhere
2646 "critical" => {}
2636 "critical" => {}
2647
2637
2648− // Identity and HTML passthrough — not CSS
2649− "id" | "class" => {}
2650− "type" | "placeholder" | "name" | "value" | "disabled" | "required" | "checked"
2651− | "for" | "action" | "method" | "autocomplete" | "min" | "max" | "step" | "pattern"
2652− | "maxlength" | "rows" | "cols" | "multiple" | "alt" | "role" | "tabindex"
2653− | "title" | "controls" | "autoplay" | "loop" | "muted" | "playsinline" | "poster"
2654− | "preload" | "loading" | "decoding" | "ordered" | "src" | "open" | "novalidate"
2655− | "low" | "high" | "optimum" | "colspan" | "rowspan" | "scope" | "inline"
2656− | "responsive" | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list"
2657− | "sandbox" | "allow" | "allowfullscreen" | "referrerpolicy" | "formaction"
2658− | "formmethod" | "formtarget" | "target" | "autofocus" => {}
2659−
2660 // Any other standard CSS property is copied through, with `px`
2638 // Any other standard CSS property is copied through, with `px`
2661 // added to bare numbers where the property takes a length.
2639 // added to bare numbers where the property takes a length.
2662− key if crate::vocab::is_css_property(key) && !crate::vocab::is_html_passthrough(key) => {
2640+ key if crate::vocab::is_css_property(key) => {
2663 if let Some(v) = val {
2641 if let Some(v) = val {
2664 if crate::vocab::is_length_property(key) {
2642 if crate::vocab::is_length_property(key) {
2665 push_css(&mut css, key, &css_px_multi(v));
2643 push_css(&mut css, key, &css_px_multi(v));
@@ -2938,7 +2916,7 @@ fn css_px_multi(value: &str) -> String {
2938fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2916fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
2939 let mut id = None;
2917 let mut id = None;
2940 let mut class = None;
2918 let mut class = None;
2941− for attr in attrs {
2919+ for attr in attrs.iter().filter(|a| a.html) {
2942 match attr.key.as_str() {
2920 match attr.key.as_str() {
2943 "id" => id = attr.value.clone(),
2921 "id" => id = attr.value.clone(),
2944 "class" => class = attr.value.clone(),
2922 "class" => class = attr.value.clone(),
crates/htmlang-core/src/parser.rs+51−79
@@ -1870,77 +1870,6 @@ impl Parser {
1870 }
1870 }
1871
1871
1872
1872
1873− // --- @defer (lazy-load below-fold content via IntersectionObserver) ---
1874−
1875− if content.trim() == "@defer" || content.starts_with("@defer ") {
1876− let placeholder_text = content.strip_prefix("@defer").unwrap_or("").trim();
1877−
1878− // Collect body lines
1879− let mut body_lines = Vec::new();
1880− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1881− body_lines.push(self.lines[self.pos].clone());
1882− self.pos += 1;
1883− }
1884−
1885− if body_lines.is_empty() {
1886− return Ok(None);
1887− }
1888−
1889− // Parse the body to get the actual content nodes
1890− let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1891− let adjusted: Vec<Line> = body_lines
1892− .iter()
1893− .map(|l| Line {
1894− indent: l.indent - min_indent,
1895− content: l.content.clone(),
1896− line_num: l.line_num,
1897− })
1898− .collect();
1899−
1900− let saved_vars = ctx.variables.clone();
1901− let mut body_parser = Parser {
1902− lines: adjusted,
1903− pos: 0,
1904− };
1905− let inner_nodes = body_parser.parse_children(0, ctx);
1906− ctx.variables = saved_vars;
1907−
1908− // Wrap content in a div with data-defer attribute + placeholder
1909− let _placeholder = if placeholder_text.is_empty() {
1910− "Loading...".to_string()
1911− } else {
1912− substitute_vars(placeholder_text, &ctx.variables)
1913− };
1914− let mut wrapper_attrs = vec![
1915− Attribute {
1916− key: "data-hl-defer".to_string(),
1917− value: None,
1918− },
1919− Attribute {
1920− key: "class".to_string(),
1921− value: Some("hl-defer-placeholder".to_string()),
1922− },
1923− ];
1924− // Hidden until intersection observer triggers
1925− wrapper_attrs.push(Attribute {
1926− key: "hidden".to_string(),
1927− value: None,
1928− });
1929−
1930− let wrapper = Element {
1931− kind: ElementKind::El,
1932− attrs: wrapper_attrs,
1933− argument: None,
1934− children: inner_nodes,
1935− line_num,
1936− };
1937−
1938− // Also emit the IntersectionObserver script as a sibling raw node
1939− let script = Node::Raw("<script>(function(){var d=document.querySelectorAll('[data-hl-defer]');if(!d.length)return;var o=new IntersectionObserver(function(e){e.forEach(function(i){if(i.isIntersecting){i.target.removeAttribute('hidden');i.target.classList.remove('hl-defer-placeholder');o.unobserve(i.target)}});},{rootMargin:'200px'});d.forEach(function(el){o.observe(el)})})()</script>".to_string());
1940−
1941− return Ok(Some(vec![Node::Element(wrapper), script]));
1942− }
1943−
1944 // --- @match ---
1873 // --- @match ---
1945
1874
1946 if let Some(rest) = content.strip_prefix("@match ") {
1875 if let Some(rest) = content.strip_prefix("@match ") {
@@ -2546,6 +2475,7 @@ impl Parser {
2546 attrs: vec![Attribute {
2475 attrs: vec![Attribute {
2547 key: "class".to_string(),
2476 key: "class".to_string(),
2548 value: Some(format!("hl-{}", name)),
2477 value: Some(format!("hl-{}", name)),
2478+ html: true,
2549 }],
2479 }],
2550 argument: None,
2480 argument: None,
2551 children: result_nodes,
2481 children: result_nodes,
@@ -2841,6 +2771,7 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
2841 ("@base", "use `@page [base ...] Title`"),
2771 ("@base", "use `@page [base ...] Title`"),
2842 ("@og", "use `@meta og:NAME VALUE`"),
2772 ("@og", "use `@meta og:NAME VALUE`"),
2843 ("@debug", "use `@warn`"),
2773 ("@debug", "use `@warn`"),
2774+ ("@defer", "remove it: the content is already in the page"),
2844 ("@log", "use `@warn`"),
2775 ("@log", "use `@warn`"),
2845 (
2776 (
2846 "@component",
2777 "@component",
@@ -3542,16 +3473,24 @@ fn parse_attr_list(
3542 continue;
3473 continue;
3543 }
3474 }
3544
3475
3545− let attr = if let Some((key, value)) = part.split_once(' ') {
3476+ let attr = if let Some((key, value)) = split_html_attribute(&part) {
3477+ Attribute {
3478+ key: key.to_string(),
3479+ value: Some(evaluate_if_expr(value)),
3480+ html: true,
3481+ }
3482+ } else if let Some((key, value)) = part.split_once(' ') {
3546 let value = evaluate_if_expr(value.trim());
3483 let value = evaluate_if_expr(value.trim());
3547 Attribute {
3484 Attribute {
3548 key: key.trim().to_string(),
3485 key: key.trim().to_string(),
3549 value: Some(value),
3486 value: Some(value),
3487+ html: false,
3550 }
3488 }
3551 } else {
3489 } else {
3552 Attribute {
3490 Attribute {
3553 key: part.to_string(),
3491 key: part.to_string(),
3554 value: None,
3492 value: None,
3493+ html: false,
3555 }
3494 }
3556 };
3495 };
3557
3496
@@ -3571,7 +3510,8 @@ fn parse_attr_list(
3571 }
3510 }
3572
3511
3573 // Color validation for hex colors
3512 // Color validation for hex colors
3513+ if !attr.html
3574− if matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
3514+ && matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
3575 && let Some(ref val) = attr.value
3515 && let Some(ref val) = attr.value
3576 && val.starts_with('#')
3516 && val.starts_with('#')
3577 && !is_valid_hex_color(val)
3517 && !is_valid_hex_color(val)
@@ -3586,10 +3526,31 @@ fn parse_attr_list(
3586 }
3526 }
3587 }
3527 }
3588
3528
3589− // Warn on unknown attributes
3529+ // Warn on unknown attributes. `key=value` HTML attributes may use any
3530+ // name; everything else must be a known style.
3590− if validate {
3531+ if validate && !attr.html {
3591 let base_key = crate::vocab::base_attribute(attr.key.as_str());
3532 let base_key = crate::vocab::base_attribute(attr.key.as_str());
3533+ let is_boolean_html =
3534+ attr.value.is_none() && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&base_key);
3535+ if is_boolean_html || crate::vocab::is_style_attribute(base_key) {
3536+ validate_attr_value(&attr, line_num, ctx);
3537+ } else if crate::vocab::HTML_ATTRIBUTES.contains(&base_key)
3538+ || base_key.starts_with("aria-")
3539+ || base_key.starts_with("data-")
3540+ {
3541+ ctx.diagnostics.push(Diagnostic {
3542+ line: line_num,
3543+ column: None,
3544+ message: format!(
3545+ "'{}' is an HTML attribute: write `{}={}` (run `htmlang upgrade`)",
3546+ attr.key,
3547+ attr.key,
3548+ attr.value.as_deref().unwrap_or("")
3549+ ),
3550+ severity: Severity::Warning,
3551+ source_line: None,
3552+ });
3592− if let Some(hint) = removed_attribute_hint(base_key) {
3553+ } else if let Some(hint) = removed_attribute_hint(base_key) {
3593 ctx.diagnostics.push(Diagnostic {
3554 ctx.diagnostics.push(Diagnostic {
3594 line: line_num,
3555 line: line_num,
3595 column: None,
3556 column: None,
@@ -3600,7 +3561,7 @@ fn parse_attr_list(
3600 severity: Severity::Warning,
3561 severity: Severity::Warning,
3601 source_line: None,
3562 source_line: None,
3602 });
3563 });
3603− } else if !crate::vocab::is_known_attribute(base_key) {
3564+ } else {
3604 let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
3565 let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
3605 let msg = match suggestion {
3566 let msg = match suggestion {
3606 Some(closest) => {
3567 Some(closest) => {
@@ -3618,8 +3579,6 @@ fn parse_attr_list(
3618 severity: Severity::Warning,
3579 severity: Severity::Warning,
3619 source_line: None,
3580 source_line: None,
3620 });
3581 });
3621− } else {
3622− validate_attr_value(&attr, line_num, ctx);
3623 }
3582 }
3624 }
3583 }
3625
3584
@@ -3629,6 +3588,19 @@ fn parse_attr_list(
3629 attrs
3588 attrs
3630}
3589}
3631
3590
3591+/// Split an HTML attribute written `key=value` (`alt=`, `type=email`,
3592+/// `aria-label=Close menu`). Returns `None` for style attributes.
3593+fn split_html_attribute(part: &str) -> Option<(&str, &str)> {
3594+ let first_token = part.split(char::is_whitespace).next()?;
3595+ let eq = first_token.find('=')?;
3596+ let key = &part[..eq];
3597+ let valid_key = key.starts_with(|c: char| c.is_ascii_alphabetic())
3598+ && key
3599+ .chars()
3600+ .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'));
3601+ valid_key.then(|| (key, part[eq + 1..].trim()))
3602+}
3603+
3632fn split_commas(input: &str) -> Vec<&str> {
3604fn split_commas(input: &str) -> Vec<&str> {
3633 let mut parts = Vec::new();
3605 let mut parts = Vec::new();
3634 let mut start = 0;
3606 let mut start = 0;
crates/htmlang-core/src/std.hl+2−2
@@ -29,7 +29,7 @@
29
29
30-- Text that shows `tip` when hovered.
30-- Text that shows `tip` when hovered.
31@let tooltip $tip
31@let tooltip $tip
32− @text [position relative, cursor help, title $tip]
32+ @text [position relative, cursor help, title=$tip]
33 @children
33 @children
34
34
35-- Horizontally scrolling row that snaps to each child.
35-- Horizontally scrolling row that snaps to each child.
@@ -39,7 +39,7 @@
39
39
40-- Breadcrumb trail: each crumb is an @item.
40-- Breadcrumb trail: each crumb is an @item.
41@let breadcrumb
41@let breadcrumb
42− @nav [aria-label breadcrumb]
42+ @nav [aria-label=breadcrumb]
43 @list [ordered, display flex, flex-direction row, flex-wrap wrap, gap 8px]
43 @list [ordered, display flex, flex-direction row, flex-wrap wrap, gap 8px]
44 @children
44 @children
45
45
crates/htmlang-core/src/vocab.rs+14−29
@@ -4,7 +4,8 @@
4//! - one of htmlang's own attributes, which mean more than a single CSS
4//! - one of htmlang's own attributes, which mean more than a single CSS
5//! property (`spacing`, `center-x`, `rounded`, ...), handled in codegen;
5//! property (`spacing`, `center-x`, `rounded`, ...), handled in codegen;
6//! - a standard CSS property, copied into the generated CSS unchanged;
6//! - a standard CSS property, copied into the generated CSS unchanged;
7+//! - an HTML attribute, written `key=value` (or bare, for booleans such as
7−//! - an HTML attribute, emitted on the element.
8+//! `required`) and emitted on the element.
8
9
9/// htmlang's own attributes (those that are neither a CSS property nor an
10/// htmlang's own attributes (those that are neither a CSS property nor an
10/// HTML attribute).
11/// HTML attribute).
@@ -114,26 +115,18 @@ pub const CSS_PROPERTIES: &[&str] = &[
114 "writing-mode", "z-index", "zoom",
115 "writing-mode", "z-index", "zoom",
115];
116];
116
117
117−/// HTML attributes emitted on any element when present.
118−pub const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
119− "action", "alt", "aria-atomic", "aria-live", "aria-relevant", "autocomplete", "autofocus",
120− "autoplay", "blocking", "checked", "cols", "colspan", "controls", "datetime", "decoding",
121− "disabled", "enterkeyhint", "fetchpriority", "for", "high", "inputmode", "list", "loading",
122− "loop", "low", "max", "maxlength", "media", "method", "min", "multiple", "muted", "name",
123− "novalidate", "open", "optimum", "pattern", "placeholder", "playsinline", "popover",
124− "popovertarget", "popovertargetaction", "poster", "preload", "required", "role", "rows",
125− "rowspan", "scope", "sizes", "spellcheck", "src", "srcset", "step", "tabindex", "title",
126− "translate", "type", "value",
127−];
128−
129−/// Boolean HTML attributes (rendered without a value, e.g. `<input disabled>`).
118+/// Boolean HTML attributes, written bare (`[required]`) and rendered
119+/// without a value (`<input required>`). HTML attributes with a value are
120+/// written `key=value`.
130pub const BOOLEAN_HTML_ATTRS: &[&str] = &[
121pub const BOOLEAN_HTML_ATTRS: &[&str] = &[
131− "disabled", "required", "checked", "multiple", "controls", "autoplay", "loop", "muted",
122+ "allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls", "defer",
132− "playsinline", "open", "novalidate", "autofocus", "defer", "async", "nomodule", "popover",
123+ "disabled", "download", "formnovalidate", "inert", "loop", "multiple", "muted", "nomodule",
124+ "novalidate", "open", "playsinline", "popover", "readonly", "required", "reversed",
125+ "sandbox", "selected",
133];
126];
134
127
135−/// Every HTML attribute htmlang knows, including ones only certain elements
128+/// Common HTML attribute names, used to suggest `key=value` when one is
136−/// emit (`href`, `sandbox`, `defer`, ...).
129+/// written like a style (`type email`). Any name works with `=`.
137pub const HTML_ATTRIBUTES: &[&str] = &[
130pub const HTML_ATTRIBUTES: &[&str] = &[
138 "abbr", "accept", "action", "allow", "allowfullscreen", "alt", "aria-atomic", "aria-live",
131 "abbr", "accept", "action", "allow", "allowfullscreen", "alt", "aria-atomic", "aria-live",
139 "aria-relevant", "async", "autocomplete", "autofocus", "autoplay", "blocking", "charset",
132 "aria-relevant", "async", "autocomplete", "autofocus", "autoplay", "blocking", "charset",
@@ -245,17 +238,9 @@ pub fn is_css_property(name: &str) -> bool {
245 CSS_PROPERTIES.binary_search(&name).is_ok()
238 CSS_PROPERTIES.binary_search(&name).is_ok()
246}
239}
247
240
248−/// Emitted as an HTML attribute on any element.
241+/// A style attribute: one of htmlang's own or a CSS property.
249−pub fn is_html_passthrough(name: &str) -> bool {
250− HTML_PASSTHROUGH_ATTRS.contains(&name) || name.starts_with("aria-") || name.starts_with("data-")
251−}
252−
253−pub fn is_known_attribute(name: &str) -> bool {
242+pub fn is_style_attribute(name: &str) -> bool {
254− HTMLANG_ATTRIBUTES.contains(&name)
243+ HTMLANG_ATTRIBUTES.contains(&name) || is_css_property(name)
255− || is_css_property(name)
256− || HTML_ATTRIBUTES.contains(&name)
257− || name.starts_with("aria-")
258− || name.starts_with("data-")
259}
244}
260
245
261/// All known attribute names, for "did you mean" suggestions.
246/// All known attribute names, for "did you mean" suggestions.
examples/demo.hl+16−16
@@ -84,7 +84,7 @@
84 A minimalist layout language that compiles to
84 A minimalist layout language that compiles to
85 {@text [bold, color $primary] static HTML} — no JS, no build step.
85 {@text [bold, color $primary] static HTML} — no JS, no build step.
86 Visit the {@link [color $primary, underline] https://github.com repo} for more.
86 Visit the {@link [color $primary, underline] https://github.com repo} for more.
87− @image [width 72, height 72, rounded 36, alt "Avatar"] https://picsum.photos/72
87+ @image [width 72, height 72, rounded 36, alt="Avatar"] https://picsum.photos/72
88
88
89 @hr [border-top 1 #e5e7eb]
89 @hr [border-top 1 #e5e7eb]
90
90
@@ -141,7 +141,7 @@
141 @each $x in $nothing
141 @each $x in $nothing
142 @text $x
142 @text $x
143 @else
143 @else
144− @text [color $muted, italic, data-state $nothing] (Nothing — @each ... @else fallback)
144+ @text [color $muted, italic, data-state=$nothing] (Nothing — @each ... @else fallback)
145
145
146 -- repeat N times
146 -- repeat N times
147 @row [spacing 6]
147 @row [spacing 6]
@@ -195,24 +195,24 @@
195 -- Forms + pseudo-states
195 -- Forms + pseudo-states
196 @titled [title Forms]
196 @titled [title Forms]
197 @form [spacing 10, max-width 420] /subscribe
197 @form [spacing 10, max-width 420] /subscribe
198− @label [bold, size 13, for email] Email
198+ @label [bold, size 13, for=email] Email
199 @input [
199 @input [
200− id email, type email, name email,
200+ id=email, type=email, name=email,
201− placeholder you@example.com, required,
201+ placeholder=you@example.com, required,
202 padding 10, border 1 #e5e7eb, rounded 6,
202 padding 10, border 1 #e5e7eb, rounded 6,
203 focus:border 1 $primary,
203 focus:border 1 $primary,
204 valid:border 1 #10b981,
204 valid:border 1 #10b981,
205 invalid:border 1 #ef4444,
205 invalid:border 1 #ef4444,
206 transition border 0.15s ease
206 transition border 0.15s ease
207 ]
207 ]
208− @label [bold, size 13, for role] Role
208+ @label [bold, size 13, for=role] Role
209− @select [id role, name role, padding 10, border 1 #e5e7eb, rounded 6]
209+ @select [id=role, name=role, padding 10, border 1 #e5e7eb, rounded 6]
210− @option [value dev] Developer
210+ @option [value=dev] Developer
211− @option [value designer] Designer
211+ @option [value=designer] Designer
212− @option [value other] Other
212+ @option [value=other] Other
213− @label [bold, size 13, for msg] Message
213+ @label [bold, size 13, for=msg] Message
214 @textarea [
214 @textarea [
215− id msg, name msg, rows 3,
215+ id=msg, name=msg, rows=3,
216 padding 10, border 1 #e5e7eb, rounded 6
216 padding 10, border 1 #e5e7eb, rounded 6
217 ]
217 ]
218 @row [spacing 8]
218 @row [spacing 8]
@@ -230,7 +230,7 @@
230 padding 10 16, background white,
230 padding 10 16, background white,
231 border 1 #e5e7eb, rounded 6, cursor pointer
231 border 1 #e5e7eb, rounded 6, cursor pointer
232 ] Reset
232 ] Reset
233− @progress [value 60, max 100]
233+ @progress [value=60, max=100]
234
234
235 -- Lists and tables
235 -- Lists and tables
236 @titled [title Data]
236 @titled [title Data]
@@ -263,7 +263,7 @@
263 @td [padding 8, border-top 1 #e5e7eb, text-align right] 7
263 @td [padding 8, border-top 1 #e5e7eb, text-align right] 7
264
264
265 @figure [spacing 6]
265 @figure [spacing 6]
266− @image [alt "A random placeholder", rounded 8] https://picsum.photos/640/200
266+ @image [alt="A random placeholder", rounded 8] https://picsum.photos/640/200
267 @figcaption [color $muted, size 13, text-align center]
267 @figcaption [color $muted, size 13, text-align center]
268 A placeholder image.
268 A placeholder image.
269
269
@@ -300,10 +300,10 @@
300 @link [color $primary] mailto:hi@htmlang.dev hi@htmlang.dev
300 @link [color $primary] mailto:hi@htmlang.dev hi@htmlang.dev
301 @row [spacing 10, align-items center]
301 @row [spacing 10, align-items center]
302 @text [size 13, color $muted] Published
302 @text [size 13, color $muted] Published
303− @time [datetime 2026-04-16, size 13] April 16 2026
303+ @time [datetime=2026-04-16, size 13] April 16 2026
304 @row [spacing 10, align-items center]
304 @row [spacing 10, align-items center]
305 @text [size 13, color $muted] Confidence
305 @text [size 13, color $muted] Confidence
306− @meter [value 8, min 0, max 10, low 3, high 7] 8/10
306+ @meter [value=8, min=0, max=10, low=3, high=7] 8/10
307
307
308 -- Footer: hover/active button, visited link, print-hidden
308 -- Footer: hover/active button, visited link, print-hidden
309 @footer [
309 @footer [
src/bin/htmlang_lsp/completion.rs+9−4
@@ -1494,10 +1494,15 @@ fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem>
1494 ]
1494 ]
1495 .iter()
1495 .iter()
1496 .map(|(name, detail, takes_value)| {
1496 .map(|(name, detail, takes_value)| {
1497+ // HTML attributes are written `key=value`, styles `key value`.
1498+ let is_html = !htmlang::vocab::is_style_attribute(name)
1499+ && (htmlang::vocab::HTML_ATTRIBUTES.contains(name)
1500+ || name.starts_with("aria-")
1501+ || name.starts_with("data-"));
1497− let insert = if *takes_value {
1502+ let insert = match (*takes_value, is_html) {
1498− format!("{} ", name)
1503+ (true, true) => format!("{}=", name),
1499− } else {
1504+ (true, false) => format!("{} ", name),
1500− name.to_string()
1505+ (false, _) => name.to_string(),
1501 };
1506 };
1502 let mut completion = item(name, CompletionItemKind::PROPERTY, detail, &insert, range);
1507 let mut completion = item(name, CompletionItemKind::PROPERTY, detail, &insert, range);
1503 // Boost element-specific attributes to the top of the list when we
1508 // Boost element-specific attributes to the top of the list when we
src/convert.rs+17−26
@@ -979,7 +979,7 @@ fn emit_element(
979
979
980 // Add id if present
980 // Add id if present
981 if let Some(id) = &id_str {
981 if let Some(id) = &id_str {
982− hl_attrs.insert(0, ("id".into(), Some(id.clone())));
982+ hl_attrs.insert(0, ("id=".into(), Some(id.clone())));
983 }
983 }
984
984
985 // Bold/italic for strong/em/b/i/u
985 // Bold/italic for strong/em/b/i/u
@@ -1006,33 +1006,21 @@ fn emit_element(
1006 && let Some(alt_text) = &alt
1006 && let Some(alt_text) = &alt
1007 && !alt_text.is_empty()
1007 && !alt_text.is_empty()
1008 {
1008 {
1009− hl_attrs.push(("alt".into(), Some(alt_text.clone())));
1009+ hl_attrs.push(("alt=".into(), Some(alt_text.clone())));
1010 }
1010 }
1011
1011
1012− // Passthrough HTML attributes that htmlang supports
1012+ // Every other HTML attribute carries over as `key=value` (a key ending
1013+ // in `=` marks an HTML attribute for `push_attr_block`), or bare for
1014+ // booleans like `required`.
1013 for (key, val) in attrs {
1015 for (key, val) in attrs {
1014 match key.as_str() {
1016 match key.as_str() {
1015 "style" | "class" | "id" | "href" | "src" | "alt" => {}
1017 "style" | "class" | "id" | "href" | "src" | "alt" => {}
1016− "type" | "name" | "value" | "placeholder" | "disabled" | "required" | "checked"
1018+ _ if val.is_empty()
1017− | "readonly" | "maxlength" | "min" | "max" | "step" | "action" | "method"
1019+ && htmlang_core::vocab::BOOLEAN_HTML_ATTRS.contains(&key.as_str()) =>
1018− | "target" | "rel" | "title" | "role" | "aria-label" | "aria-hidden" | "tabindex"
1020+ {
1019− | "for" | "open" | "autoplay" | "controls" | "loop" | "muted" | "preload" | "width"
1020− | "height" | "loading" | "decoding" | "srcset" | "sizes" | "media" | "datetime"
1021− | "cite" | "download" => {
1022− if val.is_empty() {
1023− hl_attrs.push((key.clone(), None));
1024− } else {
1025− hl_attrs.push((key.clone(), Some(val.clone())));
1026− }
1027− }
1028− _ if key.starts_with("data-") || key.starts_with("aria-") => {
1029− if val.is_empty() {
1030− hl_attrs.push((key.clone(), None));
1031− } else {
1032− hl_attrs.push((key.clone(), Some(val.clone())));
1021+ hl_attrs.push((key.clone(), None));
1033− }
1034 }
1022 }
1035− _ => {}
1023+ _ => hl_attrs.push((format!("{}=", key), Some(val.clone()))),
1036 }
1024 }
1037 }
1025 }
1038
1026
@@ -1105,7 +1093,10 @@ fn push_attr_block(attrs: &[HlAttr], out: &mut String) {
1105 }
1093 }
1106 out.push_str(key);
1094 out.push_str(key);
1107 if let Some(v) = val {
1095 if let Some(v) = val {
1096+ // HTML attributes (`key=`) take the value right after the `=`.
1097+ if !key.ends_with('=') {
1108− out.push(' ');
1098+ out.push(' ');
1099+ }
1109 if v.contains(',') || v.contains(']') {
1100 if v.contains(',') || v.contains(']') {
1110 out.push('"');
1101 out.push('"');
1111 out.push_str(&v.replace('"', "'"));
1102 out.push_str(&v.replace('"', "'"));
@@ -1354,8 +1345,8 @@ mod tests {
1354 fn form_elements() {
1345 fn form_elements() {
1355 let html = r#"<form action="/submit"><input type="text" placeholder="Name"><button>Go</button></form>"#;
1346 let html = r#"<form action="/submit"><input type="text" placeholder="Name"><button>Go</button></form>"#;
1356 let result = convert(html);
1347 let result = convert(html);
1357− assert!(result.contains("@form [action /submit]"));
1348+ assert!(result.contains("@form [action=/submit]"));
1358− assert!(result.contains("@input [type text, placeholder Name]"));
1349+ assert!(result.contains("@input [type=text, placeholder=Name]"));
1359 assert!(result.contains("@button"));
1350 assert!(result.contains("@button"));
1360 }
1351 }
1361
1352
@@ -1400,7 +1391,7 @@ mod tests {
1400 #[test]
1391 #[test]
1401 fn values_with_commas_are_quoted() {
1392 fn values_with_commas_are_quoted() {
1402 let result = convert(r#"<input type="text" placeholder="Name, email">"#);
1393 let result = convert(r#"<input type="text" placeholder="Name, email">"#);
1403− assert!(result.contains(r#"placeholder "Name, email""#), "{result}");
1394+ assert!(result.contains(r#"placeholder="Name, email""#), "{result}");
1404 }
1395 }
1405
1396
1406 #[test]
1397 #[test]
src/lib.rs+1
@@ -1,6 +1,7 @@
1pub use htmlang_core::ast;
1pub use htmlang_core::ast;
2pub use htmlang_core::codegen;
2pub use htmlang_core::codegen;
3pub use htmlang_core::parser;
3pub use htmlang_core::parser;
4+pub use htmlang_core::vocab;
4pub mod convert;
5pub mod convert;
5pub mod fmt;
6pub mod fmt;
6pub mod serve;
7pub mod serve;
src/upgrade.rs+105−6
@@ -50,6 +50,9 @@ pub fn upgrade(input: &str) -> Upgrade {
50 let lines: Vec<&str> = folded.lines().collect();
50 let lines: Vec<&str> = folded.lines().collect();
51 let mut out: Vec<String> = Vec::with_capacity(lines.len());
51 let mut out: Vec<String> = Vec::with_capacity(lines.len());
52 let mut attr_depth = 0i32;
52 let mut attr_depth = 0i32;
53+ // Whether the attribute list being rewritten belongs to an element (or
54+ // a bundle for elements), where HTML attributes now need `key=value`.
55+ let mut element_attrs = false;
53 let mut i = 0;
56 let mut i = 0;
54 // A file may define its own function named like an old alias (e.g.
57 // A file may define its own function named like an old alias (e.g.
55 // `@let divider`); calls to it must not be renamed.
58 // `@let divider`); calls to it must not be renamed.
@@ -71,8 +74,15 @@ pub fn upgrade(input: &str) -> Upgrade {
71 continue;
74 continue;
72 }
75 }
73 if attr_depth == 0 && VERBATIM_BODIES.iter().any(|d| starts_directive(trimmed, d)) {
76 if attr_depth == 0 && VERBATIM_BODIES.iter().any(|d| starts_directive(trimmed, d)) {
77+ // The directive line is upgraded like any other; its body is not.
74 let end = block_end(&lines, i);
78 let end = block_end(&lines, i);
79+ let mut header = rewrite_attr_regions(line, &mut attr_depth, true);
80+ header = rename_filters(&header);
81+ if header != line {
82+ changes += 1;
83+ }
84+ out.push(header);
75− out.extend(lines[i..end].iter().map(|l| l.to_string()));
85+ out.extend(lines[i + 1..end].iter().map(|l| l.to_string()));
76 i = end;
86 i = end;
77 continue;
87 continue;
78 }
88 }
@@ -93,7 +103,10 @@ pub fn upgrade(input: &str) -> Upgrade {
93 new_line = rewrite_directive_line(&new_line, indent, i, &mut manual);
103 new_line = rewrite_directive_line(&new_line, indent, i, &mut manual);
94 new_line = rename_elements(&new_line, &user_defined);
104 new_line = rename_elements(&new_line, &user_defined);
95 }
105 }
106+ if attr_depth == 0 {
107+ element_attrs = takes_element_attributes(new_line.trim_start(), &user_defined);
108+ }
96− new_line = rewrite_attr_regions(&new_line, &mut attr_depth);
109+ new_line = rewrite_attr_regions(&new_line, &mut attr_depth, element_attrs);
97 new_line = rename_filters(&new_line);
110 new_line = rename_filters(&new_line);
98 if new_line != line {
111 if new_line != line {
99 changes += 1;
112 changes += 1;
@@ -216,6 +229,14 @@ fn rewrite_block(
216 return Some(Block { lines: out, end });
229 return Some(Block { lines: out, end });
217 }
230 }
218
231
232+ // @defer → its body, dedented
233+ if trimmed == "@defer" || trimmed.starts_with("@defer ") {
234+ return Some(Block {
235+ lines: dedent_block(body, indent),
236+ end,
237+ });
238+ }
239+
219 // @with $x as y → @let y $x, body dedented
240 // @with $x as y → @let y $x, body dedented
220 if let Some(rest) = trimmed.strip_prefix("@with ") {
241 if let Some(rest) = trimmed.strip_prefix("@with ") {
221 let (source, alias) = rest.split_once(" as ")?;
242 let (source, alias) = rest.split_once(" as ")?;
@@ -466,7 +487,50 @@ fn rename_elements(line: &str, user_defined: &[&str]) -> String {
466
487
467/// Apply attribute-level rewrites inside `[...]` regions. `depth` carries an
488/// Apply attribute-level rewrites inside `[...]` regions. `depth` carries an
468/// unclosed bracket over to the following (continuation) lines.
489/// unclosed bracket over to the following (continuation) lines.
490+/// Attributes that became HTML when written `key value`, or were meant as
491+/// HTML but dropped; they are now written `key=value`.
492+const HTML_ATTRIBUTES: &[&str] = &[
493+ "accept", "action", "allow", "alt", "autocomplete", "blocking", "class", "cols", "colspan",
494+ "datetime", "decoding", "dir", "download", "enctype", "enterkeyhint", "fetchpriority", "for",
495+ "formaction", "formmethod", "formtarget", "headers", "high", "href", "hreflang", "id",
496+ "inputmode", "label", "lang", "list", "loading", "low", "max", "maxlength", "media", "method",
497+ "min", "name", "optimum", "pattern", "placeholder", "popover", "popovertarget",
498+ "popovertargetaction", "poster", "preload", "referrerpolicy", "rel", "role", "rows",
499+ "rowspan", "sandbox", "scope", "sizes", "span", "spellcheck", "src", "srcset", "start",
500+ "step", "tabindex", "target", "title", "translate", "type", "value",
501+];
502+
503+/// Standard-library components, which forward attributes to an element.
504+const STD_COMPONENTS: &[&str] = &[
505+ "badge", "tag", "chip", "avatar", "spacer", "tooltip", "carousel", "breadcrumb",
506+];
507+
508+/// Does this line's attribute list style an element (as opposed to passing
509+/// function parameters or directive options)?
510+fn takes_element_attributes(trimmed: &str, user_defined: &[&str]) -> bool {
511+ if trimmed.starts_with('[') {
512+ return true; // implicit @el
513+ }
514+ let Some(rest) = trimmed.strip_prefix('@') else {
515+ return false;
516+ };
517+ let name_end = rest
518+ .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-' || c == '_'))
519+ .unwrap_or(rest.len());
520+ let name = &rest[..name_end];
521+ if name == "let" {
522+ // An attribute bundle: `@let card [...]`
523+ return rest[name_end..]
524+ .trim_start()
525+ .split_once(' ')
526+ .is_some_and(|(_, v)| v.trim_start().starts_with('['));
527+ }
528+ !user_defined.contains(&name)
529+ && (htmlang_core::ast::ElementKind::from_name(name).is_some()
530+ || STD_COMPONENTS.contains(&name))
531+}
532+
469−fn rewrite_attr_regions(line: &str, depth: &mut i32) -> String {
533+fn rewrite_attr_regions(line: &str, depth: &mut i32, element_attrs: bool) -> String {
470 let trimmed = line.trim_start();
534 let trimmed = line.trim_start();
471 if *depth == 0 && !trimmed.starts_with('@') && !trimmed.starts_with('[') {
535 if *depth == 0 && !trimmed.starts_with('@') && !trimmed.starts_with('[') {
472 return line.to_string();
536 return line.to_string();
@@ -481,7 +545,7 @@ fn rewrite_attr_regions(line: &str, depth: &mut i32) -> String {
481 _ => {}
545 _ => {}
482 }
546 }
483 if *depth == 0 {
547 if *depth == 0 {
484− out.push_str(&rewrite_attr_list(&region));
548+ out.push_str(&rewrite_attr_list(&region, element_attrs));
485 region.clear();
549 region.clear();
486 out.push(ch);
550 out.push(ch);
487 } else {
551 } else {
@@ -494,12 +558,12 @@ fn rewrite_attr_regions(line: &str, depth: &mut i32) -> String {
494 }
558 }
495 }
559 }
496 }
560 }
497− out.push_str(&rewrite_attr_list(&region));
561+ out.push_str(&rewrite_attr_list(&region, element_attrs));
498 out
562 out
499}
563}
500
564
501/// Rewrite one attribute list's contents (without the brackets).
565/// Rewrite one attribute list's contents (without the brackets).
502−fn rewrite_attr_list(list: &str) -> String {
566+fn rewrite_attr_list(list: &str, element_attrs: bool) -> String {
503 if list.is_empty() {
567 if list.is_empty() {
504 return String::new();
568 return String::new();
505 }
569 }
@@ -547,6 +611,20 @@ fn rewrite_attr_list(list: &str) -> String {
547 },
611 },
548 None => body,
612 None => body,
549 };
613 };
614+ // HTML attributes with a value: `type email` → `type=email`
615+ // (already-converted `key=value` attributes are left alone)
616+ let body = match body.split_once(' ') {
617+ Some((key, value))
618+ if element_attrs
619+ && !key.contains('=')
620+ && (HTML_ATTRIBUTES.contains(&key)
621+ || key.starts_with("aria-")
622+ || key.starts_with("data-")) =>
623+ {
624+ format!("{}={}", key, value.trim())
625+ }
626+ _ => body,
627+ };
550 // Renamed keys (keeping any `hover:` / `md:` style prefix)
628 // Renamed keys (keeping any `hover:` / `md:` style prefix)
551 let key_end = body.find(char::is_whitespace).unwrap_or(body.len());
629 let key_end = body.find(char::is_whitespace).unwrap_or(body.len());
552 let (key, value) = body.split_at(key_end);
630 let (key, value) = body.split_at(key_end);
@@ -754,6 +832,7 @@ mod tests {
754 #[test]
832 #[test]
755 fn with_and_layout() {
833 fn with_and_layout() {
756 assert_eq!(up("@with $a as b\n @text $b"), "@let b $a\n@text $b");
834 assert_eq!(up("@with $a as b\n @text $b"), "@let b $a\n@text $b");
835+ assert_eq!(up("@defer Loading\n @text x"), "@text x");
757 assert_eq!(up("@layout base.hl\n @text x\n"), "@extends base.hl\n@text x\n");
836 assert_eq!(up("@layout base.hl\n @text x\n"), "@extends base.hl\n@text x\n");
758 let r = upgrade("@layout base.hl\n @text x\n@text after");
837 let r = upgrade("@layout base.hl\n @text x\n@text after");
759 assert_eq!(r.output, "@layout base.hl\n @text x\n@text after");
838 assert_eq!(r.output, "@layout base.hl\n @text x\n@text after");
@@ -817,9 +896,29 @@ mod tests {
817 assert_eq!(r.manual.len(), 1);
896 assert_eq!(r.manual.len(), 1);
818 }
897 }
819
898
899+ #[test]
900+ fn html_attributes_use_equals() {
901+ assert_eq!(
902+ up("@input [type email, name e, required, padding 8]"),
903+ "@input [type=email, name=e, required, padding 8]"
904+ );
905+ assert_eq!(up("[id main, aria-label Close]\n x"), "[id=main, aria-label=Close]\n x");
906+ // Idempotent: a second run changes nothing.
907+ let once = up("@el [aria-label Main menu]");
908+ assert_eq!(once, "@el [aria-label=Main menu]");
909+ assert_eq!(up(&once), once);
910+ assert_eq!(up("@let card [class note, padding 4]"), "@let card [class=note, padding 4]");
911+ // Function parameters and directive options are left alone.
912+ assert_eq!(up("@let card $title\n @text $title\n@card [title Hi]"), "@let card $title\n @text $title\n@card [title Hi]");
913+ assert_eq!(up("@page [lang en] Home"), "@page [lang en] Home");
914+ // Multi-line lists keep their element context.
915+ assert_eq!(up("@input [\n type email,\n padding 8\n]"), "@input [\n type=email,\n padding 8\n]");
916+ }
917+
820 #[test]
918 #[test]
821 fn verbatim_regions_are_untouched() {
919 fn verbatim_regions_are_untouched() {
822 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
920 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
823 assert_eq!(up(src), src);
921 assert_eq!(up(src), src);
922+ assert_eq!(up("@script [src app.js, defer]"), "@script [src=app.js, defer]");
824 }
923 }
825}
924}
tests/regressions.rs+33−1
@@ -292,7 +292,7 @@ fn container_arguments_are_rendered_as_text() {
292 "@paragraph Hello",
292 "@paragraph Hello",
293 "@row Hello",
293 "@row Hello",
294 "@section Hello",
294 "@section Hello",
295− "@nav [id x] Hello",
295+ "@nav [id=x] Hello",
296 ] {
296 ] {
297 let out = compile(src);
297 let out = compile(src);
298 assert!(out.contains("Hello"), "{:?} dropped its text: {}", src, out);
298 assert!(out.contains("Hello"), "{:?} dropped its text: {}", src, out);
@@ -300,3 +300,35 @@ fn container_arguments_are_rendered_as_text() {
300 let out = compile("@paragraph Read {@link /more more}");
300 let out = compile("@paragraph Read {@link /more more}");
301 assert!(out.contains("<a href=\"/more\">more</a>"), "{}", out);
301 assert!(out.contains("<a href=\"/more\">more</a>"), "{}", out);
302}
302}
303+
304+#[test]
305+fn html_attributes_use_equals_and_are_all_emitted() {
306+ let out = compile("@link [target=_blank, rel=me] /x Home\n@iframe [sandbox, allow=camera] https://e.com");
307+ assert!(out.contains("target=\"_blank\""), "{}", out);
308+ assert!(out.contains("rel=\"me\""), "{}", out);
309+ assert!(out.contains(" sandbox"), "{}", out);
310+ assert!(out.contains("allow=\"camera\""), "{}", out);
311+}
312+
313+#[test]
314+fn html_attribute_and_style_with_the_same_name_are_distinct() {
315+ let out = compile("@select [size=4, size 20]\n @option A");
316+ assert!(out.contains("size=\"4\""), "{}", out);
317+ assert!(out.contains("font-size:20px"), "{}", out);
318+ let out = compile("@image [width=800, width 200, alt=x] a.png");
319+ assert!(out.contains("width=\"800\""), "{}", out);
320+ assert!(out.contains("width:200px"), "{}", out);
321+}
322+
323+#[test]
324+fn space_form_html_attribute_points_to_equals() {
325+ let result = parser::parse("@input [type email]");
326+ assert!(
327+ result
328+ .diagnostics
329+ .iter()
330+ .any(|d| d.message.contains("`type=email`")),
331+ "{:?}",
332+ result.diagnostics
333+ );
334+}
tests/snapshots.rs+46−50
@@ -406,7 +406,7 @@ fn each_loop_expansion() {
406
406
407#[test]
407#[test]
408fn aria_data_attrs_accepted() {
408fn aria_data_attrs_accepted() {
409− let diags = parse_diagnostics("@el [aria-label Test, data-id 42]");
409+ let diags = parse_diagnostics("@el [aria-label=Test, data-id=42]");
410 assert!(
410 assert!(
411 !diags
411 !diags
412 .iter()
412 .iter()
@@ -518,7 +518,7 @@ fn no_warning_spacing_on_row() {
518
518
519#[test]
519#[test]
520fn warning_placeholder_on_row() {
520fn warning_placeholder_on_row() {
521− let diags = parse_diagnostics("@row [placeholder test]");
521+ let diags = parse_diagnostics("@row [placeholder=test]");
522 assert!(
522 assert!(
523 diags
523 diags
524 .iter()
524 .iter()
@@ -530,7 +530,7 @@ fn warning_placeholder_on_row() {
530
530
531#[test]
531#[test]
532fn warning_for_on_non_label() {
532fn warning_for_on_non_label() {
533− let diags = parse_diagnostics("@el [for email]");
533+ let diags = parse_diagnostics("@el [for=email]");
534 assert!(
534 assert!(
535 diags
535 diags
536 .iter()
536 .iter()
@@ -583,7 +583,7 @@ fn named_slot_default_content() {
583#[test]
583#[test]
584fn style_block_output() {
584fn style_block_output() {
585 let output = compile(
585 let output = compile(
586− "@page Test\n@style\n .custom { color: red; }\n@el [class custom]\n @text styled",
586+ "@page Test\n@style\n .custom { color: red; }\n@el [class=custom]\n @text styled",
587 );
587 );
588 assert!(output.contains(".custom{color:red;}") || output.contains(".custom { color: red; }"));
588 assert!(output.contains(".custom{color:red;}") || output.contains(".custom { color: red; }"));
589 assert!(output.contains("styled"));
589 assert!(output.contains("styled"));
@@ -965,7 +965,7 @@ fn image_auto_lazy_loading() {
965
965
966#[test]
966#[test]
967fn image_explicit_loading_not_doubled() {
967fn image_explicit_loading_not_doubled() {
968− let output = compile("@page T\n@image [loading eager] https://example.com/photo.jpg");
968+ let output = compile("@page T\n@image [loading=eager] https://example.com/photo.jpg");
969 assert!(output.contains("loading=\"eager\""));
969 assert!(output.contains("loading=\"eager\""));
970 assert!(!output.contains("loading=\"lazy\""));
970 assert!(!output.contains("loading=\"lazy\""));
971}
971}
@@ -1102,22 +1102,18 @@ fn theme_color_meta_from_theme() {
1102
1102
1103#[test]
1103#[test]
1104fn aria_live_passthrough() {
1104fn aria_live_passthrough() {
1105− let output = compile("@page T\n@el [aria-live polite]\n updating");
1105+ let output = compile("@page T\n@el [aria-live=polite]\n updating");
1106 assert!(output.contains("aria-live=\"polite\""));
1106 assert!(output.contains("aria-live=\"polite\""));
1107}
1107}
1108
1108
1109#[test]
1109#[test]
1110−fn defer_directive() {
1110+fn defer_directive_was_removed() {
1111− let result = htmlang::parser::parse("@page T\n@defer\n @el\n Lazy content");
1111+ let diags = parse_diagnostics("@page T\n@defer\n @el\n Lazy content");
1112 assert!(
1112 assert!(
1113− result
1113+ diags.iter().any(|d| d.message.contains("`@defer` was removed")),
1114− .diagnostics
1114+ "{:?}",
1115− .iter()
1115+ diags
1116− .all(|d| d.severity != htmlang::parser::Severity::Error)
1117 );
1116 );
1118− let html = htmlang::codegen::generate(&result.document);
1119− assert!(html.contains("data-hl-defer"));
1120− assert!(html.contains("IntersectionObserver"));
1121}
1117}
1122
1118
1123#[test]
1119#[test]
@@ -1245,7 +1241,7 @@ fn snapshot_each_destructuring() {
1245
1241
1246#[test]
1242#[test]
1247fn form_renders_form_tag() {
1243fn form_renders_form_tag() {
1248− let output = compile("@page T\n@form [method post] /submit\n @input [type text]");
1244+ let output = compile("@page T\n@form [method=post] /submit\n @input [type=text]");
1249 assert!(output.contains("<form"));
1245 assert!(output.contains("<form"));
1250 assert!(output.contains("action=\"/submit\""));
1246 assert!(output.contains("action=\"/submit\""));
1251 assert!(output.contains("method=\"post\""));
1247 assert!(output.contains("method=\"post\""));
@@ -1290,7 +1286,7 @@ fn hr_renders_self_closing() {
1290
1286
1291#[test]
1287#[test]
1292fn figure_figcaption_renders() {
1288fn figure_figcaption_renders() {
1293− let output = compile("@page T\n@figure\n @image [alt test] photo.jpg\n @figcaption Caption");
1289+ let output = compile("@page T\n@figure\n @image [alt=test] photo.jpg\n @figcaption Caption");
1294 assert!(output.contains("<figure"));
1290 assert!(output.contains("<figure"));
1295 assert!(output.contains("<figcaption"));
1291 assert!(output.contains("<figcaption"));
1296 assert!(output.contains("Caption"));
1292 assert!(output.contains("Caption"));
@@ -1298,7 +1294,7 @@ fn figure_figcaption_renders() {
1298
1294
1299#[test]
1295#[test]
1300fn progress_renders() {
1296fn progress_renders() {
1301− let output = compile("@page T\n@progress [value 70, max 100]");
1297+ let output = compile("@page T\n@progress [value=70, max=100]");
1302 assert!(output.contains("<progress"));
1298 assert!(output.contains("<progress"));
1303 assert!(output.contains("value=\"70\""));
1299 assert!(output.contains("value=\"70\""));
1304 assert!(output.contains("max=\"100\""));
1300 assert!(output.contains("max=\"100\""));
@@ -1306,7 +1302,7 @@ fn progress_renders() {
1306
1302
1307#[test]
1303#[test]
1308fn meter_renders() {
1304fn meter_renders() {
1309− let output = compile("@page T\n@meter [value 6, min 0, max 10]");
1305+ let output = compile("@page T\n@meter [value=6, min=0, max=10]");
1310 assert!(output.contains("<meter"));
1306 assert!(output.contains("<meter"));
1311 assert!(output.contains("value=\"6\""));
1307 assert!(output.contains("value=\"6\""));
1312}
1308}
@@ -1464,7 +1460,7 @@ fn warning_missing_alt_on_image() {
1464
1460
1465#[test]
1461#[test]
1466fn no_warning_with_alt_on_image() {
1462fn no_warning_with_alt_on_image() {
1467− let diags = parse_diagnostics("@image [alt A photo] photo.jpg");
1463+ let diags = parse_diagnostics("@image [alt=A photo] photo.jpg");
1468 assert!(
1464 assert!(
1469 !diags
1465 !diags
1470 .iter()
1466 .iter()
@@ -1621,7 +1617,7 @@ fn checked_generates_pseudo() {
1621
1617
1622#[test]
1618#[test]
1623fn placeholder_generates_pseudo() {
1619fn placeholder_generates_pseudo() {
1624− let output = compile("@page T\n@input [type text, placeholder:color #999]");
1620+ let output = compile("@page T\n@input [type=text, placeholder:color #999]");
1625 assert!(output.contains("::placeholder"));
1621 assert!(output.contains("::placeholder"));
1626 assert!(output.contains("color:#999"));
1622 assert!(output.contains("color:#999"));
1627}
1623}
@@ -1692,13 +1688,13 @@ fn css_inset() {
1692
1688
1693#[test]
1689#[test]
1694fn css_accent_color() {
1690fn css_accent_color() {
1695− let output = compile("@page T\n@input [type checkbox, accent-color blue]");
1691+ let output = compile("@page T\n@input [type=checkbox, accent-color blue]");
1696 assert!(output.contains("accent-color:blue"));
1692 assert!(output.contains("accent-color:blue"));
1697}
1693}
1698
1694
1699#[test]
1695#[test]
1700fn css_caret_color() {
1696fn css_caret_color() {
1701− let output = compile("@page T\n@input [type text, caret-color red]");
1697+ let output = compile("@page T\n@input [type=text, caret-color red]");
1702 assert!(output.contains("caret-color:red"));
1698 assert!(output.contains("caret-color:red"));
1703}
1699}
1704
1700
@@ -1876,7 +1872,7 @@ fn snapshot_arithmetic() {
1876
1872
1877#[test]
1873#[test]
1878fn element_dialog() {
1874fn element_dialog() {
1879− let output = compile("@page T\n@dialog [id modal, open]\n @text Hello");
1875+ let output = compile("@page T\n@dialog [id=modal, open]\n @text Hello");
1880 assert!(output.contains("<dialog"));
1876 assert!(output.contains("<dialog"));
1881 assert!(output.contains("id=\"modal\""));
1877 assert!(output.contains("id=\"modal\""));
1882 assert!(output.contains("open"));
1878 assert!(output.contains("open"));
@@ -1894,7 +1890,7 @@ fn element_definition_list() {
1894
1890
1895#[test]
1891#[test]
1896fn element_fieldset_legend() {
1892fn element_fieldset_legend() {
1897− let output = compile("@page T\n@fieldset\n @legend Info\n @input [type text, name n]");
1893+ let output = compile("@page T\n@fieldset\n @legend Info\n @input [type=text, name=n]");
1898 assert!(output.contains("<fieldset"));
1894 assert!(output.contains("<fieldset"));
1899 assert!(output.contains("<legend>Info</legend>"));
1895 assert!(output.contains("<legend>Info</legend>"));
1900 assert!(output.contains("</fieldset>"));
1896 assert!(output.contains("</fieldset>"));
@@ -1903,7 +1899,7 @@ fn element_fieldset_legend() {
1903#[test]
1899#[test]
1904fn element_picture_source() {
1900fn element_picture_source() {
1905 let output = compile(
1901 let output = compile(
1906− "@page T\n@picture\n @source [srcset wide.jpg, media (min-width: 800px)]\n @image [alt Photo] photo.jpg",
1902+ "@page T\n@picture\n @source [srcset=wide.jpg, media=(min-width: 800px)]\n @image [alt=Photo] photo.jpg",
1907 );
1903 );
1908 assert!(output.contains("<picture"));
1904 assert!(output.contains("<picture"));
1909 assert!(output.contains("<source"));
1905 assert!(output.contains("<source"));
@@ -1913,7 +1909,7 @@ fn element_picture_source() {
1913
1909
1914#[test]
1910#[test]
1915fn element_time() {
1911fn element_time() {
1916− let output = compile("@page T\n@time [datetime 2026-04-15] April 15");
1912+ let output = compile("@page T\n@time [datetime=2026-04-15] April 15");
1917 assert!(output.contains("<time"));
1913 assert!(output.contains("<time"));
1918 assert!(output.contains("datetime=\"2026-04-15\""));
1914 assert!(output.contains("datetime=\"2026-04-15\""));
1919 assert!(output.contains("April 15"));
1915 assert!(output.contains("April 15"));
@@ -1935,7 +1931,7 @@ fn element_kbd() {
1935
1931
1936#[test]
1932#[test]
1937fn element_abbr() {
1933fn element_abbr() {
1938− let output = compile("@page T\n@abbr [title HyperText Markup Language] HTML");
1934+ let output = compile("@page T\n@abbr [title=HyperText Markup Language] HTML");
1939 assert!(output.contains("<abbr"));
1935 assert!(output.contains("<abbr"));
1940 assert!(output.contains("title=\"HyperText Markup Language\""));
1936 assert!(output.contains("title=\"HyperText Markup Language\""));
1941 assert!(output.contains("HTML"));
1937 assert!(output.contains("HTML"));
@@ -1943,7 +1939,7 @@ fn element_abbr() {
1943
1939
1944#[test]
1940#[test]
1945fn element_datalist() {
1941fn element_datalist() {
1946− let output = compile("@page T\n@datalist [id browsers]\n @option Chrome\n @option Firefox");
1942+ let output = compile("@page T\n@datalist [id=browsers]\n @option Chrome\n @option Firefox");
1947 assert!(output.contains("<datalist"));
1943 assert!(output.contains("<datalist"));
1948 assert!(output.contains("id=\"browsers\""));
1944 assert!(output.contains("id=\"browsers\""));
1949 assert!(output.contains("</datalist>"));
1945 assert!(output.contains("</datalist>"));
@@ -2135,7 +2131,7 @@ fn let_arithmetic_divide() {
2135
2131
2136#[test]
2132#[test]
2137fn warning_missing_input_type() {
2133fn warning_missing_input_type() {
2138− let diags = parse_diagnostics("@input [name email]");
2134+ let diags = parse_diagnostics("@input [name=email]");
2139 assert!(
2135 assert!(
2140 diags.iter().any(|d| d.message.contains("missing 'type'")),
2136 diags.iter().any(|d| d.message.contains("missing 'type'")),
2141 "should warn about missing type on @input, got: {:?}",
2137 "should warn about missing type on @input, got: {:?}",
@@ -2300,7 +2296,7 @@ fn convert_image() {
2300 let hl = htmlang::convert::convert("<img src=\"photo.jpg\" alt=\"A photo\">");
2296 let hl = htmlang::convert::convert("<img src=\"photo.jpg\" alt=\"A photo\">");
2301 assert!(hl.contains("@image"), "img should become @image: {}", hl);
2297 assert!(hl.contains("@image"), "img should become @image: {}", hl);
2302 assert!(hl.contains("photo.jpg"), "src preserved: {}", hl);
2298 assert!(hl.contains("photo.jpg"), "src preserved: {}", hl);
2303− assert!(hl.contains("alt A photo"), "alt preserved: {}", hl);
2299+ assert!(hl.contains("alt=A photo"), "alt preserved: {}", hl);
2304}
2300}
2305
2301
2306#[test]
2302#[test]
@@ -2531,7 +2527,7 @@ fn element_iframe() {
2531
2527
2532#[test]
2528#[test]
2533fn element_canvas() {
2529fn element_canvas() {
2534− let output = compile("@canvas [width 400, height 300, id myCanvas]");
2530+ let output = compile("@canvas [width 400, height 300, id=myCanvas]");
2535 assert!(
2531 assert!(
2536 output.contains("<canvas"),
2532 output.contains("<canvas"),
2537 "should generate canvas tag: {}",
2533 "should generate canvas tag: {}",
@@ -2546,7 +2542,7 @@ fn element_canvas() {
2546
2542
2547#[test]
2543#[test]
2548fn element_output() {
2544fn element_output() {
2549− let output = compile("@output [for a b]\n 42");
2545+ let output = compile("@output [for=a b]\n 42");
2550 assert!(
2546 assert!(
2551 output.contains("<output"),
2547 output.contains("<output"),
2552 "should generate output tag: {}",
2548 "should generate output tag: {}",
@@ -3329,7 +3325,7 @@ fn test_color_filter_mix() {
3329
3325
3330#[test]
3326#[test]
3331fn test_autofocus_attribute() {
3327fn test_autofocus_attribute() {
3332− let result = htmlang::parser::parse("@input [type text, autofocus]");
3328+ let result = htmlang::parser::parse("@input [type=text, autofocus]");
3333 let html = htmlang::codegen::generate(&result.document);
3329 let html = htmlang::codegen::generate(&result.document);
3334 assert!(
3330 assert!(
3335 html.contains("autofocus"),
3331 html.contains("autofocus"),
@@ -3588,7 +3584,7 @@ fn snapshot_new_css_properties_4() {
3588
3584
3589#[test]
3585#[test]
3590fn script_element_with_src() {
3586fn script_element_with_src() {
3591− let output = compile("@script [src app.js, defer]");
3587+ let output = compile("@script [src=app.js, defer]");
3592 assert!(
3588 assert!(
3593 output.contains("<script src=\"app.js\" defer>"),
3589 output.contains("<script src=\"app.js\" defer>"),
3594 "script src: {}",
3590 "script src: {}",
@@ -3622,7 +3618,7 @@ fn address_element() {
3622
3618
3623#[test]
3619#[test]
3624fn search_element() {
3620fn search_element() {
3625− let output = compile("@search\n @input [type search]");
3621+ let output = compile("@search\n @input [type=search]");
3626 assert!(output.contains("<search>"), "search: {}", output);
3622 assert!(output.contains("<search>"), "search: {}", output);
3627}
3623}
3628
3624
@@ -3699,7 +3695,7 @@ fn target_pseudo() {
3699
3695
3700#[test]
3696#[test]
3701fn valid_invalid_pseudo() {
3697fn valid_invalid_pseudo() {
3702− let output = compile("@input [type email, valid:border 2 green]");
3698+ let output = compile("@input [type=email, valid:border 2 green]");
3703 assert!(output.contains(":valid"), "valid pseudo: {}", output);
3699 assert!(output.contains(":valid"), "valid pseudo: {}", output);
3704}
3700}
3705
3701
@@ -3750,7 +3746,7 @@ fn no_warning_new_attrs_batch7() {
3750#[test]
3746#[test]
3751fn no_warning_script_attrs() {
3747fn no_warning_script_attrs() {
3752 let diags = parse_diagnostics(
3748 let diags = parse_diagnostics(
3753− "@script [src app.js, defer, async, crossorigin anonymous, integrity sha384-abc, nomodule]",
3749+ "@script [src=app.js, defer, async, crossorigin anonymous, integrity sha384-abc, nomodule]",
3754 );
3750 );
3755 assert!(
3751 assert!(
3756 !diags
3752 !diags
@@ -4031,7 +4027,7 @@ fn conditional_attr_boolean_false() {
4031#[test]
4027#[test]
4032fn function_with_style_is_scoped() {
4028fn function_with_style_is_scoped() {
4033 let html = compile(
4029 let html = compile(
4034− "@let card $title\n @style\n .t { color: red; }\n @text [class t] $title\n@card [title Hello]\n",
4030+ "@let card $title\n @style\n .t { color: red; }\n @text [class=t] $title\n@card [title Hello]\n",
4035 );
4031 );
4036 assert!(html.contains("hl-card\"><span class=\"t\">Hello"), "{}", html);
4032 assert!(html.contains("hl-card\"><span class=\"t\">Hello"), "{}", html);
4037 assert!(html.contains(".hl-card .t { color: red; }"), "{}", html);
4033 assert!(html.contains(".hl-card .t { color: red; }"), "{}", html);
@@ -4125,7 +4121,7 @@ fn critical_attr_inlines_styles() {
4125
4121
4126#[test]
4122#[test]
4127fn warning_input_without_label() {
4123fn warning_input_without_label() {
4128− let diags = parse_diagnostics("@input [type text]\n");
4124+ let diags = parse_diagnostics("@input [type=text]\n");
4129 let has_label_warning = diags
4125 let has_label_warning = diags
4130 .iter()
4126 .iter()
4131 .any(|d| d.message.contains("aria-label") || d.message.contains("@label"));
4127 .any(|d| d.message.contains("aria-label") || d.message.contains("@label"));
@@ -4156,14 +4152,14 @@ fn warning_button_without_text() {
4156
4152
4157#[test]
4153#[test]
4158fn warning_positive_tabindex() {
4154fn warning_positive_tabindex() {
4159− let diags = parse_diagnostics("@el [tabindex 5]\n test\n");
4155+ let diags = parse_diagnostics("@el [tabindex=5]\n test\n");
4160 let has_warning = diags.iter().any(|d| d.message.contains("tabindex"));
4156 let has_warning = diags.iter().any(|d| d.message.contains("tabindex"));
4161 assert!(has_warning, "should warn about positive tabindex");
4157 assert!(has_warning, "should warn about positive tabindex");
4162}
4158}
4163
4159
4164#[test]
4160#[test]
4165fn no_warning_input_with_aria_label() {
4161fn no_warning_input_with_aria_label() {
4166− let diags = parse_diagnostics("@input [type text, aria-label Search]\n");
4162+ let diags = parse_diagnostics("@input [type=text, aria-label=Search]\n");
4167 let has_label_warning = diags
4163 let has_label_warning = diags
4168 .iter()
4164 .iter()
4169 .any(|d| d.message.contains("should have an") && d.message.contains("@label"));
4165 .any(|d| d.message.contains("should have an") && d.message.contains("@label"));
@@ -4175,7 +4171,7 @@ fn no_warning_input_with_aria_label() {
4175
4171
4176#[test]
4172#[test]
4177fn no_warning_input_in_label() {
4173fn no_warning_input_in_label() {
4178− let diags = parse_diagnostics("@label\n @input [type text]\n");
4174+ let diags = parse_diagnostics("@label\n @input [type=text]\n");
4179 let has_label_warning = diags
4175 let has_label_warning = diags
4180 .iter()
4176 .iter()
4181 .any(|d| d.message.contains("should have an") && d.message.contains("@label"));
4177 .any(|d| d.message.contains("should have an") && d.message.contains("@label"));
@@ -4210,7 +4206,7 @@ fn snapshot_data_directive() {
4210#[test]
4206#[test]
4211fn test_popover_in_output() {
4207fn test_popover_in_output() {
4212 let html = compile(
4208 let html = compile(
4213− "@button [popovertarget my-pop] Open\n@el [popover, id my-pop, padding 10]\n Hello",
4209+ "@button [popovertarget=my-pop] Open\n@el [popover, id=my-pop, padding 10]\n Hello",
4214 );
4210 );
4215 assert!(
4211 assert!(
4216 html.contains("popovertarget=\"my-pop\""),
4212 html.contains("popovertarget=\"my-pop\""),
@@ -4242,7 +4238,7 @@ fn test_appearance_css() {
4242
4238
4243#[test]
4239#[test]
4244fn test_inputmode_attr() {
4240fn test_inputmode_attr() {
4245− let html = compile("@input [type search, inputmode search]");
4241+ let html = compile("@input [type=search, inputmode=search]");
4246 assert!(
4242 assert!(
4247 html.contains("inputmode=\"search\""),
4243 html.contains("inputmode=\"search\""),
4248 "should pass through inputmode"
4244 "should pass through inputmode"
@@ -4251,7 +4247,7 @@ fn test_inputmode_attr() {
4251
4247
4252#[test]
4248#[test]
4253fn test_fetchpriority_attr() {
4249fn test_fetchpriority_attr() {
4254− let html = compile("@image [fetchpriority high, width 100] hero.jpg");
4250+ let html = compile("@image [fetchpriority=high, width 100] hero.jpg");
4255 assert!(
4251 assert!(
4256 html.contains("fetchpriority=\"high\""),
4252 html.contains("fetchpriority=\"high\""),
4257 "should pass through fetchpriority"
4253 "should pass through fetchpriority"
@@ -5010,7 +5006,7 @@ fn partial_output_dev() {
5010
5006
5011#[test]
5007#[test]
5012fn responsive_srcset_on_image() {
5008fn responsive_srcset_on_image() {
5013− let output = compile("@image [responsive 400 800 1200, alt Photo] photo.jpg");
5009+ let output = compile("@image [responsive 400 800 1200, alt=Photo] photo.jpg");
5014 assert!(
5010 assert!(
5015 output.contains("srcset=\""),
5011 output.contains("srcset=\""),
5016 "should generate srcset, got: {}",
5012 "should generate srcset, got: {}",
@@ -5063,7 +5059,7 @@ fn auto_image_dimensions_png() {
5063 ];
5059 ];
5064 std::fs::write(&png_path, &png_data).unwrap();
5060 std::fs::write(&png_path, &png_data).unwrap();
5065
5061
5066− let input = format!("@image [alt test] {}", png_path.display());
5062+ let input = format!("@image [alt=test] {}", png_path.display());
5067 let result = htmlang::parser::parse(&input);
5063 let result = htmlang::parser::parse(&input);
5068 let html = htmlang::codegen::generate(&result.document);
5064 let html = htmlang::codegen::generate(&result.document);
5069 assert!(
5065 assert!(
@@ -5083,7 +5079,7 @@ fn auto_image_dimensions_png() {
5083#[test]
5079#[test]
5084fn auto_image_dimensions_not_for_urls() {
5080fn auto_image_dimensions_not_for_urls() {
5085 // Remote URLs should not trigger dimension detection
5081 // Remote URLs should not trigger dimension detection
5086− let output = compile("@image [alt test] https://example.com/photo.png");
5082+ let output = compile("@image [alt=test] https://example.com/photo.png");
5087 // Should not crash or add dimensions for remote URLs
5083 // Should not crash or add dimensions for remote URLs
5088 assert!(
5084 assert!(
5089 output.contains("src=\"https://example.com/photo.png\""),
5085 output.contains("src=\"https://example.com/photo.png\""),
@@ -5108,7 +5104,7 @@ fn auto_image_dimensions_respects_explicit() {
5108 std::fs::write(&png_path, &png_data).unwrap();
5104 std::fs::write(&png_path, &png_data).unwrap();
5109
5105
5110 let input = format!(
5106 let input = format!(
5111− "@image [width 100, height 100, alt test] {}",
5107+ "@image [width 100, height 100, alt=test] {}",
5112 png_path.display()
5108 png_path.display()
5113 );
5109 );
5114 let result = htmlang::parser::parse(&input);
5110 let result = htmlang::parser::parse(&input);
tests/snapshots/accessibility.hl+5−5
@@ -1,6 +1,6 @@
1@page Accessibility
1@page Accessibility
2−@image [alt A photo of sunset, width 200] https://example.com/sunset.jpg
2+@image [alt=A photo of sunset, width 200] https://example.com/sunset.jpg
3−@el [role navigation, aria-label Main menu]
3+@el [role=navigation, aria-label=Main menu]
4− @link [tabindex 0, aria-current page] / Home
4+ @link [tabindex=0, aria-current=page] / Home
5− @link [tabindex 0] /about About
5+ @link [tabindex=0] /about About
6−@button [aria-pressed false, data-action toggle] Toggle
6+@button [aria-pressed=false, data-action=toggle] Toggle
tests/snapshots/form_elements.hl+8−8
@@ -1,12 +1,12 @@
1@page Form Elements
1@page Form Elements
2@row [spacing 10]
2@row [spacing 10]
3− @button [type submit] Click me
3+ @button [type=submit] Click me
4− @input [type text, placeholder Enter name, name username]
4+ @input [type=text, placeholder=Enter name, name=username]
5− @input [type email, required, disabled]
5+ @input [type=email, required, disabled]
6@column [spacing 5]
6@column [spacing 5]
7− @label [for bio] Biography
7+ @label [for=bio] Biography
8− @textarea [name bio, rows 4] Default text
8+ @textarea [name=bio, rows=4] Default text
9−@select [name color]
9+@select [name=color]
10− @option [value red] Red
10+ @option [value=red] Red
11− @option [value green] Green
11+ @option [value=green] Green
12 @option Blue
12 @option Blue
tests/snapshots/media_elements.hl+1−1
@@ -4,5 +4,5 @@
4
4
5@audio [controls, autoplay, loop] music.mp3
5@audio [controls, autoplay, loop] music.mp3
6
6
7−@video [controls, muted, poster poster.jpg, width 640]
7+@video [controls, muted, poster=poster.jpg, width 640]
8 @text Your browser does not support video
8 @text Your browser does not support video
tests/snapshots/new_css_properties.hl+3−3
@@ -6,9 +6,9 @@
6@el [position absolute, inset 0]
6@el [position absolute, inset 0]
7 @text Overlay
7 @text Overlay
8
8
9−@input [type checkbox, accent-color #3b82f6]
9+@input [type=checkbox, accent-color #3b82f6]
10
10
11−@input [type text, caret-color red]
11+@input [type=text, caret-color red]
12
12
13@list [list-style disc]
13@list [list-style disc]
14 @item One
14 @item One
@@ -29,4 +29,4 @@
29@el [overflow auto, scroll-behavior smooth]
29@el [overflow auto, scroll-behavior smooth]
30 @text Smooth scroll
30 @text Smooth scroll
31
31
32−@textarea [resize vertical, rows 4] Resizable
32+@textarea [resize vertical, rows=4] Resizable
tests/snapshots/new_elements.hl+7−7
@@ -1,9 +1,9 @@
1@page New Elements
1@page New Elements
2
2
3−@form [method post, action /submit]
3+@form [method=post, action=/submit]
4− @label [for email] Email
4+ @label [for=email] Email
5− @input [type email, name email, required]
5+ @input [type=email, name=email, required]
6− @button [type submit] Submit
6+ @button [type=submit] Submit
7
7
8@hr
8@hr
9
9
@@ -16,12 +16,12 @@
16 @cite Shakespeare
16 @cite Shakespeare
17
17
18@figure
18@figure
19− @image [alt A code example, width 200] example.png
19+ @image [alt=A code example, width 200] example.png
20 @figcaption A code example
20 @figcaption A code example
21
21
22@pre
22@pre
23 @code console.log("hello")
23 @code console.log("hello")
24
24
25−@progress [value 70, max 100]
25+@progress [value=70, max=100]
26
26
27−@meter [value 6, min 0, max 10, low 3, high 8]
27+@meter [value=6, min=0, max=10, low=3, high=8]
tests/snapshots/new_elements_2.hl+9−9
@@ -1,6 +1,6 @@
1@page New Elements 2
1@page New Elements 2
2
2
3−@dialog [id modal, open]
3+@dialog [id=modal, open]
4 @text [bold] Dialog Title
4 @text [bold] Dialog Title
5 @text Dialog content here
5 @text Dialog content here
6 @button Close
6 @button Close
@@ -13,23 +13,23 @@
13
13
14@fieldset
14@fieldset
15 @legend Personal Info
15 @legend Personal Info
16− @label [for name] Name
16+ @label [for=name] Name
17− @input [type text, name name]
17+ @input [type=text, name=name]
18
18
19@picture
19@picture
20− @source [srcset photo-wide.jpg, media (min-width: 800px)]
20+ @source [srcset=photo-wide.jpg, media=(min-width: 800px)]
21− @image [alt A photo] photo.jpg
21+ @image [alt=A photo] photo.jpg
22
22
23−@time [datetime 2026-04-15] April 15, 2026
23+@time [datetime=2026-04-15] April 15, 2026
24
24
25@text This is {@mark highlighted} text
25@text This is {@mark highlighted} text
26
26
27@text Press {@kbd Ctrl+C} to copy
27@text Press {@kbd Ctrl+C} to copy
28
28
29−@abbr [title HyperText Markup Language] HTML
29+@abbr [title=HyperText Markup Language] HTML
30
30
31−@input [type text, name browser, list browsers]
31+@input [type=text, name=browser, list=browsers]
32−@datalist [id browsers]
32+@datalist [id=browsers]
33 @option Chrome
33 @option Chrome
34 @option Firefox
34 @option Firefox
35 @option Safari
35 @option Safari
tests/snapshots/new_elements_3.hl+2−2
@@ -2,7 +2,7 @@
2
2
3@iframe [width fill, height 400] https://example.com
3@iframe [width fill, height 400] https://example.com
4
4
5−@output [id result, for a b]
5+@output [id=result, for=a b]
6 42
6 42
7
7
8−@canvas [width 400, height 300, id myCanvas]
8+@canvas [width 400, height 300, id=myCanvas]
tests/snapshots/new_elements_6.hl+2−2
@@ -6,5 +6,5 @@
6 @link mailto:john@example.com john@example.com
6 @link mailto:john@example.com john@example.com
7
7
8@search
8@search
9− @input [type search, placeholder Search...]
9+ @input [type=search, placeholder=Search...]
10− @button [type submit] Go
10+ @button [type=submit] Go
tests/snapshots/new_html_attrs.hl+5−5
@@ -1,7 +1,7 @@
1@page HTML Attrs
1@page HTML Attrs
2
2
3−@form [method post] /search
3+@form [method=post] /search
4− @input [type search, inputmode search, enterkeyhint search, placeholder Search..., spellcheck false]
4+ @input [type=search, inputmode=search, enterkeyhint=search, placeholder=Search..., spellcheck=false]
5− @input [type email, inputmode email, enterkeyhint done, translate no]
5+ @input [type=email, inputmode=email, enterkeyhint=done, translate=no]
6− @image [fetchpriority high, width 200] hero.jpg
6+ @image [fetchpriority=high, width 200] hero.jpg
7− @button [type submit] Go
7+ @button [type=submit] Go
tests/snapshots/new_pseudos.hl+2−2
@@ -4,5 +4,5 @@
4@el [empty:display none, target:background yellow]
4@el [empty:display none, target:background yellow]
5 Conditional content
5 Conditional content
6
6
7−@input [type email, valid:border 2 green]
7+@input [type=email, valid:border 2 green]
8−@input [type text, invalid:border 2 red]
8+@input [type=text, invalid:border 2 red]
tests/snapshots/popover_api.hl+2−2
@@ -1,6 +1,6 @@
1@page Popover Demo
1@page Popover Demo
2
2
3−@button [popovertarget my-popover] Toggle Info
3+@button [popovertarget=my-popover] Toggle Info
4
4
5−@el [popover, id my-popover, padding 20, background white, rounded 8, border 1 #ccc]
5+@el [popover, id=my-popover, padding 20, background white, rounded 8, border 1 #ccc]
6 @text This is a popover panel
6 @text This is a popover panel
tests/snapshots/pseudo_states_extended.hl+2−2
@@ -1,11 +1,11 @@
1@page Pseudo States Extended
1@page Pseudo States Extended
2
2
3@el [padding 10, focus-visible:border 2 blue, focus-within:background #eee]
3@el [padding 10, focus-visible:border 2 blue, focus-within:background #eee]
4− @input [type text, placeholder Name]
4+ @input [type=text, placeholder=Name]
5
5
6@el [disabled:opacity 0.5, checked:background green]
6@el [disabled:opacity 0.5, checked:background green]
7
7
8−@input [type text, placeholder:color #999]
8+@input [type=text, placeholder:color #999]
9
9
10@el [first:border-top 0, last:border-bottom 0]
10@el [first:border-top 0, last:border-bottom 0]
11
11
tests/snapshots/responsive_images.hl+2−2
@@ -1,3 +1,3 @@
1@page Responsive Images
1@page Responsive Images
2−@image [responsive 400 800 1200, alt Hero photo] hero.jpg
2+@image [responsive 400 800 1200, alt=Hero photo] hero.jpg
3−@image [alt Small icon] icon.png
3+@image [alt=Small icon] icon.png
tests/snapshots/script_element.hl+1−1
@@ -1,4 +1,4 @@
1−@script [src app.js, defer]
1+@script [src=app.js, defer]
2
2
3@script
3@script
4 console.log("hello world");
4 console.log("hello world");
tests/snapshots/stress_deeply_nested.hl+4−4
@@ -88,7 +88,7 @@
88 @column [spacing 8, margin-top 8]
88 @column [spacing 8, margin-top 8]
89 @el [padding 8, border 1 $--border, rounded 4]
89 @el [padding 8, border 1 $--border, rounded 4]
90 @text [size 12, color #64748b, margin-bottom 4] Level 5 - Field Group
90 @text [size 12, color #64748b, margin-bottom 4] Level 5 - Field Group
91− @input [type text, placeholder Name, padding 6 10, rounded 4, border 1 $--border, width fill]
91+ @input [type=text, placeholder=Name, padding 6 10, rounded 4, border 1 $--border, width fill]
92 @el [margin-top 4, padding 4 8, background #fef2f2, rounded 4]
92 @el [margin-top 4, padding 4 8, background #fef2f2, rounded 4]
93 @text [size 11, color #991b1b] Level 6 - Validation Error
93 @text [size 11, color #991b1b] Level 6 - Validation Error
94 @el [margin-top 2]
94 @el [margin-top 2]
@@ -99,9 +99,9 @@
99 @el [padding 8, border 1 $--border, rounded 4]
99 @el [padding 8, border 1 $--border, rounded 4]
100 @text [size 12, color #64748b, margin-bottom 4] Level 5 - Select Group
100 @text [size 12, color #64748b, margin-bottom 4] Level 5 - Select Group
101 @select [padding 6 10, rounded 4, border 1 $--border, width fill]
101 @select [padding 6 10, rounded 4, border 1 $--border, width fill]
102− @option [value a] Option A
102+ @option [value=a] Option A
103− @option [value b] Option B
103+ @option [value=b] Option B
104− @option [value c] Option C
104+ @option [value=c] Option C
105
105
106 @deep-card [title Level 3 - Nested Lists Card]
106 @deep-card [title Level 3 - Nested Lists Card]
107 @inner-panel [label Level 4 - Category Browser]
107 @inner-panel [label Level 4 - Category Browser]
tests/snapshots/stress_large.hl+2−2
@@ -176,7 +176,7 @@
176 @el [width fill]
176 @el [width fill]
177
177
178 @row [spacing 12, align-items center]
178 @row [spacing 12, align-items center]
179− @input [type search, placeholder Search..., padding 8 12, rounded 6, border 1 $--border, dark:border 1 #334155, dark:background #1e293b, dark:color white, width 240, focus:border 1 $--primary, transition all 0.2s]
179+ @input [type=search, placeholder=Search..., padding 8 12, rounded 6, border 1 $--border, dark:border 1 #334155, dark:background #1e293b, dark:color white, width 240, focus:border 1 $--primary, transition all 0.2s]
180 @el [position relative]
180 @el [position relative]
181 @icon-button [label Alerts]
181 @icon-button [label Alerts]
182 @el [position absolute, top -2, right -2]
182 @el [position absolute, top -2, right -2]
@@ -313,7 +313,7 @@
313 @card [title Data Table]
313 @card [title Data Table]
314 @column [spacing 0, margin-top 12]
314 @column [spacing 0, margin-top 12]
315 @row [spacing 16, margin-bottom 12, align-items center]
315 @row [spacing 16, margin-bottom 12, align-items center]
316− @input [type search, placeholder Filter results..., padding 8 12, rounded 6, border 1 $--border, width 240]
316+ @input [type=search, placeholder=Filter results..., padding 8 12, rounded 6, border 1 $--border, width 240]
317 @el [width fill]
317 @el [width fill]
318 @row [spacing 8]
318 @row [spacing 8]
319 @each $cat in $categories
319 @each $cat in $categories
tests/snapshots/style_block.hl+1−1
@@ -4,5 +4,5 @@
4 .highlight { background: yellow; border: 2px solid orange; }
4 .highlight { background: yellow; border: 2px solid orange; }
5 .highlight:hover { background: gold; }
5 .highlight:hover { background: gold; }
6
6
7−@el [class highlight]
7+@el [class=highlight]
8 @text Custom styled
8 @text Custom styled