on master help sign in
Write HTML attributes as key=value, styles as key value

An attribute written key=value is an HTML attribute (any name, emitted as-is); key value is always a style (htmlang attribute or CSS property); bare booleans like required stay HTML. This removes the name clashes between the two (size=4 vs size 20, width=800 vs width 200) and the fixed pass-through list: target, rel, sandbox, allow, referrerpolicy and formaction were silently dropped before and now work. - Space-form HTML names warn with the key=value replacement - htmlang upgrade converts exactly the attributes that used to be emitted as HTML (and the dropped ones), only for elements and bundles (never function parameters); it is idempotent - The HTML converter emits key=value and keeps every attribute - LSP completion inserts key= for HTML attributes - @defer is removed: its marker attribute was never emitted, so deferred content stayed hidden, and hiding already-present content saves nothing - Fixes the scoped-function wrapper class after the attribute split

commit21e5176998d0f9218c9639395d5532edebd9c711 authoriko <mail@iko.soy> dateFri 25 Sep 2026, 20:37 (2 weeks ago) parent00341fd
29 files changed+375−314
crates/htmlang-core/src/ast.rs+2 crates/htmlang-core/src/codegen.rs+21−43 crates/htmlang-core/src/parser.rs+51−79 crates/htmlang-core/src/std.hl+2−2 crates/htmlang-core/src/vocab.rs+14−29 examples/demo.hl+16−16 src/bin/htmlang_lsp/completion.rs+9−4 src/convert.rs+17−26 src/lib.rs+1 src/upgrade.rs+105−6 tests/regressions.rs+33−1 tests/snapshots.rs+46−50 tests/snapshots/accessibility.hl+5−5 tests/snapshots/form_elements.hl+8−8 tests/snapshots/media_elements.hl+1−1 tests/snapshots/new_css_properties.hl+3−3 tests/snapshots/new_elements.hl+7−7 tests/snapshots/new_elements_2.hl+9−9 tests/snapshots/new_elements_3.hl+2−2 tests/snapshots/new_elements_6.hl+2−2 tests/snapshots/new_html_attrs.hl+5−5 tests/snapshots/new_pseudos.hl+2−2 tests/snapshots/popover_api.hl+2−2 tests/snapshots/pseudo_states_extended.hl+2−2 tests/snapshots/responsive_images.hl+2−2 tests/snapshots/script_element.hl+1−1 tests/snapshots/stress_deeply_nested.hl+4−4 tests/snapshots/stress_large.hl+2−2 tests/snapshots/style_block.hl+1−1
crates/htmlang-core/src/ast.rs+2
@@ -71,6 +71,8 @@ pub struct Element {
7171pub struct Attribute {
7272 pub key: String,
7373 pub value: Option<String>,
74+ /// Written `key=value`: an HTML attribute rather than a style.
75+ pub html: bool,
7476}
7577
7678#[derive(Debug, Clone, PartialEq)]
crates/htmlang-core/src/codegen.rs+21−43
@@ -419,7 +419,6 @@ struct GenContext {
419419 depth: usize,
420420 image_count: usize,
421421 has_interactive: bool,
422− has_defer: bool,
423422}
424423
425424impl GenContext {
@@ -729,7 +728,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
729728 depth: 0,
730729 image_count: 0,
731730 has_interactive: false,
732− has_defer: false,
733731 };
734732
735733 // Check if document has @main for skip-to-content link
@@ -1021,17 +1019,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
10211019 ""
10221020 };
10231021
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−
10351022 let reset_css = reset_css(dev, focus_visible_css, skip_link_css);
10361023
10371024 match &doc.page_title {
@@ -1049,7 +1036,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
10491036<style>
10501037{reset_css}{element_css}\
10511038</style>
1052−{noscript_html}\
10531039</head>
10541040<body>
10551041{body}\
@@ -1070,13 +1056,12 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
10701056 head_html = head_html,
10711057 og_html = og_html,
10721058 reset_css = reset_css,
1073− noscript_html = noscript_html,
10741059 element_css = element_css,
10751060 body = body,
10761061 )
10771062 } else {
10781063 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>",
10801065 title = html_escape(title),
10811066 lang_attr = lang_attr,
10821067 theme_color_html = theme_color_html,
@@ -1091,7 +1076,6 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
10911076 json_ld_html = json_ld_html,
10921077 head_html = head_html,
10931078 reset_css = reset_css,
1094− noscript_html = noscript_html,
10951079 element_css = element_css,
10961080 body = body,
10971081 )
@@ -1272,7 +1256,6 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
12721256 depth: 0,
12731257 image_count: 0,
12741258 has_interactive: false,
1275− has_defer: false,
12761259 };
12771260 let mut body = String::new();
12781261
@@ -1347,21 +1330,25 @@ fn emit_argument_attr(out: &mut String, elem: &Element) {
13471330 }
13481331}
13491332
1350−fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
1333+/// Emit HTML attributes: `key=value` ones (except `id` / `class`, which
1334+/// are emitted with the generated class) and bare booleans like `required`.
1335+fn emit_html_attrs(out: &mut String, attrs: &[Attribute]) {
13511336 for attr in attrs {
13521337 let key = attr.key.as_str();
1353− if !crate::vocab::is_html_passthrough(key) {
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 {
1338+ if attr.html && key != "id" && key != "class" {
13601339 out.push(' ');
13611340 out.push_str(key);
13621341 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+ )));
13641345 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);
13651352 }
13661353 }
13671354}
@@ -1570,7 +1557,7 @@ fn generate_element(
15701557 out.push('"');
15711558 }
15721559
1573− emit_html_passthrough_attrs(out, &elem.attrs);
1560+ emit_html_attrs(out, &elem.attrs);
15741561
15751562 // Source map attributes in dev mode
15761563 if ctx.dev && elem.line_num > 0 {
@@ -1682,7 +1669,7 @@ fn generate_self_closing(
16821669 out.push('"');
16831670 }
16841671
1685− emit_html_passthrough_attrs(out, &elem.attrs);
1672+ emit_html_attrs(out, &elem.attrs);
16861673
16871674 // Source map attributes in dev mode (self-closing)
16881675 if ctx.dev && elem.line_num > 0 {
@@ -2067,6 +2054,9 @@ fn attrs_to_css(
20672054 }
20682055
20692056 for attr in attrs {
2057+ if attr.html {
2058+ continue;
2059+ }
20702060 // Determine the effective key for this pass
20712061 let effective_key = if state_prefix.is_empty() {
20722062 if crate::vocab::is_prefixed(&attr.key) {
@@ -2645,21 +2635,9 @@ fn attrs_to_css(
26452635 // Critical CSS hint — not CSS, handled elsewhere
26462636 "critical" => {}
26472637
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−
26602638 // Any other standard CSS property is copied through, with `px`
26612639 // 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) => {
26632641 if let Some(v) = val {
26642642 if crate::vocab::is_length_property(key) {
26652643 push_css(&mut css, key, &css_px_multi(v));
@@ -2938,7 +2916,7 @@ fn css_px_multi(value: &str) -> String {
29382916fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
29392917 let mut id = None;
29402918 let mut class = None;
2941− for attr in attrs {
2919+ for attr in attrs.iter().filter(|a| a.html) {
29422920 match attr.key.as_str() {
29432921 "id" => id = attr.value.clone(),
29442922 "class" => class = attr.value.clone(),
crates/htmlang-core/src/parser.rs+51−79
@@ -1870,77 +1870,6 @@ impl Parser {
18701870 }
18711871
18721872
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−
19441873 // --- @match ---
19451874
19461875 if let Some(rest) = content.strip_prefix("@match ") {
@@ -2546,6 +2475,7 @@ impl Parser {
25462475 attrs: vec![Attribute {
25472476 key: "class".to_string(),
25482477 value: Some(format!("hl-{}", name)),
2478+ html: true,
25492479 }],
25502480 argument: None,
25512481 children: result_nodes,
@@ -2841,6 +2771,7 @@ const REMOVED_SYNTAX: &[(&str, &str)] = &[
28412771 ("@base", "use `@page [base ...] Title`"),
28422772 ("@og", "use `@meta og:NAME VALUE`"),
28432773 ("@debug", "use `@warn`"),
2774+ ("@defer", "remove it: the content is already in the page"),
28442775 ("@log", "use `@warn`"),
28452776 (
28462777 "@component",
@@ -3542,16 +3473,24 @@ fn parse_attr_list(
35423473 continue;
35433474 }
35443475
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(' ') {
35463483 let value = evaluate_if_expr(value.trim());
35473484 Attribute {
35483485 key: key.trim().to_string(),
35493486 value: Some(value),
3487+ html: false,
35503488 }
35513489 } else {
35523490 Attribute {
35533491 key: part.to_string(),
35543492 value: None,
3493+ html: false,
35553494 }
35563495 };
35573496
@@ -3571,7 +3510,8 @@ fn parse_attr_list(
35713510 }
35723511
35733512 // Color validation for hex colors
3574− if matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
3513+ if !attr.html
3514+ && matches!(crate::vocab::base_attribute(&attr.key), "background" | "color")
35753515 && let Some(ref val) = attr.value
35763516 && val.starts_with('#')
35773517 && !is_valid_hex_color(val)
@@ -3586,10 +3526,31 @@ fn parse_attr_list(
35863526 }
35873527 }
35883528
3589− // Warn on unknown attributes
3590− if validate {
3529+ // Warn on unknown attributes. `key=value` HTML attributes may use any
3530+ // name; everything else must be a known style.
3531+ if validate && !attr.html {
35913532 let base_key = crate::vocab::base_attribute(attr.key.as_str());
3592− if let Some(hint) = removed_attribute_hint(base_key) {
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+ });
3553+ } else if let Some(hint) = removed_attribute_hint(base_key) {
35933554 ctx.diagnostics.push(Diagnostic {
35943555 line: line_num,
35953556 column: None,
@@ -3600,7 +3561,7 @@ fn parse_attr_list(
36003561 severity: Severity::Warning,
36013562 source_line: None,
36023563 });
3603− } else if !crate::vocab::is_known_attribute(base_key) {
3564+ } else {
36043565 let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
36053566 let msg = match suggestion {
36063567 Some(closest) => {
@@ -3618,8 +3579,6 @@ fn parse_attr_list(
36183579 severity: Severity::Warning,
36193580 source_line: None,
36203581 });
3621− } else {
3622− validate_attr_value(&attr, line_num, ctx);
36233582 }
36243583 }
36253584
@@ -3629,6 +3588,19 @@ fn parse_attr_list(
36293588 attrs
36303589}
36313590
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+
36323604fn split_commas(input: &str) -> Vec<&str> {
36333605 let mut parts = Vec::new();
36343606 let mut start = 0;
crates/htmlang-core/src/std.hl+2−2
@@ -29,7 +29,7 @@
2929
3030-- Text that shows `tip` when hovered.
3131@let tooltip $tip
32− @text [position relative, cursor help, title $tip]
32+ @text [position relative, cursor help, title=$tip]
3333 @children
3434
3535-- Horizontally scrolling row that snaps to each child.
@@ -39,7 +39,7 @@
3939
4040-- Breadcrumb trail: each crumb is an @item.
4141@let breadcrumb
42− @nav [aria-label breadcrumb]
42+ @nav [aria-label=breadcrumb]
4343 @list [ordered, display flex, flex-direction row, flex-wrap wrap, gap 8px]
4444 @children
4545
crates/htmlang-core/src/vocab.rs+14−29
@@ -4,7 +4,8 @@
44//! - one of htmlang's own attributes, which mean more than a single CSS
55//! property (`spacing`, `center-x`, `rounded`, ...), handled in codegen;
66//! - a standard CSS property, copied into the generated CSS unchanged;
7−//! - an HTML attribute, emitted on the element.
7+//! - an HTML attribute, written `key=value` (or bare, for booleans such as
8+//! `required`) and emitted on the element.
89
910/// htmlang's own attributes (those that are neither a CSS property nor an
1011/// HTML attribute).
@@ -114,26 +115,18 @@ pub const CSS_PROPERTIES: &[&str] = &[
114115 "writing-mode", "z-index", "zoom",
115116];
116117
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`.
130121pub const BOOLEAN_HTML_ATTRS: &[&str] = &[
131− "disabled", "required", "checked", "multiple", "controls", "autoplay", "loop", "muted",
132− "playsinline", "open", "novalidate", "autofocus", "defer", "async", "nomodule", "popover",
122+ "allowfullscreen", "async", "autofocus", "autoplay", "checked", "controls", "defer",
123+ "disabled", "download", "formnovalidate", "inert", "loop", "multiple", "muted", "nomodule",
124+ "novalidate", "open", "playsinline", "popover", "readonly", "required", "reversed",
125+ "sandbox", "selected",
133126];
134127
135−/// Every HTML attribute htmlang knows, including ones only certain elements
136−/// emit (`href`, `sandbox`, `defer`, ...).
128+/// Common HTML attribute names, used to suggest `key=value` when one is
129+/// written like a style (`type email`). Any name works with `=`.
137130pub const HTML_ATTRIBUTES: &[&str] = &[
138131 "abbr", "accept", "action", "allow", "allowfullscreen", "alt", "aria-atomic", "aria-live",
139132 "aria-relevant", "async", "autocomplete", "autofocus", "autoplay", "blocking", "charset",
@@ -245,17 +238,9 @@ pub fn is_css_property(name: &str) -> bool {
245238 CSS_PROPERTIES.binary_search(&name).is_ok()
246239}
247240
248−/// Emitted as an HTML attribute on any element.
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 {
254− HTMLANG_ATTRIBUTES.contains(&name)
255− || is_css_property(name)
256− || HTML_ATTRIBUTES.contains(&name)
257− || name.starts_with("aria-")
258− || name.starts_with("data-")
241+/// A style attribute: one of htmlang's own or a CSS property.
242+pub fn is_style_attribute(name: &str) -> bool {
243+ HTMLANG_ATTRIBUTES.contains(&name) || is_css_property(name)
259244}
260245
261246/// All known attribute names, for "did you mean" suggestions.
examples/demo.hl+16−16
@@ -84,7 +84,7 @@
8484 A minimalist layout language that compiles to
8585 {@text [bold, color $primary] static HTML} — no JS, no build step.
8686 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
8888
8989 @hr [border-top 1 #e5e7eb]
9090
@@ -141,7 +141,7 @@
141141 @each $x in $nothing
142142 @text $x
143143 @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)
145145
146146 -- repeat N times
147147 @row [spacing 6]
@@ -195,24 +195,24 @@
195195 -- Forms + pseudo-states
196196 @titled [title Forms]
197197 @form [spacing 10, max-width 420] /subscribe
198− @label [bold, size 13, for email] Email
198+ @label [bold, size 13, for=email] Email
199199 @input [
200− id email, type email, name email,
201− placeholder you@example.com, required,
200+ id=email, type=email, name=email,
201+ placeholder=you@example.com, required,
202202 padding 10, border 1 #e5e7eb, rounded 6,
203203 focus:border 1 $primary,
204204 valid:border 1 #10b981,
205205 invalid:border 1 #ef4444,
206206 transition border 0.15s ease
207207 ]
208− @label [bold, size 13, for role] Role
209− @select [id role, name role, padding 10, border 1 #e5e7eb, rounded 6]
210− @option [value dev] Developer
211− @option [value designer] Designer
212− @option [value other] Other
213− @label [bold, size 13, for msg] Message
208+ @label [bold, size 13, for=role] Role
209+ @select [id=role, name=role, padding 10, border 1 #e5e7eb, rounded 6]
210+ @option [value=dev] Developer
211+ @option [value=designer] Designer
212+ @option [value=other] Other
213+ @label [bold, size 13, for=msg] Message
214214 @textarea [
215− id msg, name msg, rows 3,
215+ id=msg, name=msg, rows=3,
216216 padding 10, border 1 #e5e7eb, rounded 6
217217 ]
218218 @row [spacing 8]
@@ -230,7 +230,7 @@
230230 padding 10 16, background white,
231231 border 1 #e5e7eb, rounded 6, cursor pointer
232232 ] Reset
233− @progress [value 60, max 100]
233+ @progress [value=60, max=100]
234234
235235 -- Lists and tables
236236 @titled [title Data]
@@ -263,7 +263,7 @@
263263 @td [padding 8, border-top 1 #e5e7eb, text-align right] 7
264264
265265 @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
267267 @figcaption [color $muted, size 13, text-align center]
268268 A placeholder image.
269269
@@ -300,10 +300,10 @@
300300 @link [color $primary] mailto:hi@htmlang.dev hi@htmlang.dev
301301 @row [spacing 10, align-items center]
302302 @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
304304 @row [spacing 10, align-items center]
305305 @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
307307
308308 -- Footer: hover/active button, visited link, print-hidden
309309 @footer [
src/bin/htmlang_lsp/completion.rs+9−4
@@ -1494,10 +1494,15 @@ fn attr_completions(range: Range, element: Option<&str>) -> Vec<CompletionItem>
14941494 ]
14951495 .iter()
14961496 .map(|(name, detail, takes_value)| {
1497− let insert = if *takes_value {
1498− format!("{} ", name)
1499− } else {
1500− name.to_string()
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-"));
1502+ let insert = match (*takes_value, is_html) {
1503+ (true, true) => format!("{}=", name),
1504+ (true, false) => format!("{} ", name),
1505+ (false, _) => name.to_string(),
15011506 };
15021507 let mut completion = item(name, CompletionItemKind::PROPERTY, detail, &insert, range);
15031508 // Boost element-specific attributes to the top of the list when we
src/convert.rs+17−26
@@ -979,7 +979,7 @@ fn emit_element(
979979
980980 // Add id if present
981981 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())));
983983 }
984984
985985 // Bold/italic for strong/em/b/i/u
@@ -1006,33 +1006,21 @@ fn emit_element(
10061006 && let Some(alt_text) = &alt
10071007 && !alt_text.is_empty()
10081008 {
1009− hl_attrs.push(("alt".into(), Some(alt_text.clone())));
1009+ hl_attrs.push(("alt=".into(), Some(alt_text.clone())));
10101010 }
10111011
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`.
10131015 for (key, val) in attrs {
10141016 match key.as_str() {
10151017 "style" | "class" | "id" | "href" | "src" | "alt" => {}
1016− "type" | "name" | "value" | "placeholder" | "disabled" | "required" | "checked"
1017− | "readonly" | "maxlength" | "min" | "max" | "step" | "action" | "method"
1018− | "target" | "rel" | "title" | "role" | "aria-label" | "aria-hidden" | "tabindex"
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())));
1033− }
1018+ _ if val.is_empty()
1019+ && htmlang_core::vocab::BOOLEAN_HTML_ATTRS.contains(&key.as_str()) =>
1020+ {
1021+ hl_attrs.push((key.clone(), None));
10341022 }
1035− _ => {}
1023+ _ => hl_attrs.push((format!("{}=", key), Some(val.clone()))),
10361024 }
10371025 }
10381026
@@ -1105,7 +1093,10 @@ fn push_attr_block(attrs: &[HlAttr], out: &mut String) {
11051093 }
11061094 out.push_str(key);
11071095 if let Some(v) = val {
1108− out.push(' ');
1096+ // HTML attributes (`key=`) take the value right after the `=`.
1097+ if !key.ends_with('=') {
1098+ out.push(' ');
1099+ }
11091100 if v.contains(',') || v.contains(']') {
11101101 out.push('"');
11111102 out.push_str(&v.replace('"', "'"));
@@ -1354,8 +1345,8 @@ mod tests {
13541345 fn form_elements() {
13551346 let html = r#"<form action="/submit"><input type="text" placeholder="Name"><button>Go</button></form>"#;
13561347 let result = convert(html);
1357− assert!(result.contains("@form [action /submit]"));
1358− assert!(result.contains("@input [type text, placeholder Name]"));
1348+ assert!(result.contains("@form [action=/submit]"));
1349+ assert!(result.contains("@input [type=text, placeholder=Name]"));
13591350 assert!(result.contains("@button"));
13601351 }
13611352
@@ -1400,7 +1391,7 @@ mod tests {
14001391 #[test]
14011392 fn values_with_commas_are_quoted() {
14021393 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}");
14041395 }
14051396
14061397 #[test]
src/lib.rs+1
@@ -1,6 +1,7 @@
11pub use htmlang_core::ast;
22pub use htmlang_core::codegen;
33pub use htmlang_core::parser;
4+pub use htmlang_core::vocab;
45pub mod convert;
56pub mod fmt;
67pub mod serve;
src/upgrade.rs+105−6
@@ -50,6 +50,9 @@ pub fn upgrade(input: &str) -> Upgrade {
5050 let lines: Vec<&str> = folded.lines().collect();
5151 let mut out: Vec<String> = Vec::with_capacity(lines.len());
5252 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;
5356 let mut i = 0;
5457 // A file may define its own function named like an old alias (e.g.
5558 // `@let divider`); calls to it must not be renamed.
@@ -71,8 +74,15 @@ pub fn upgrade(input: &str) -> Upgrade {
7174 continue;
7275 }
7376 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.
7478 let end = block_end(&lines, i);
75− out.extend(lines[i..end].iter().map(|l| l.to_string()));
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);
85+ out.extend(lines[i + 1..end].iter().map(|l| l.to_string()));
7686 i = end;
7787 continue;
7888 }
@@ -93,7 +103,10 @@ pub fn upgrade(input: &str) -> Upgrade {
93103 new_line = rewrite_directive_line(&new_line, indent, i, &mut manual);
94104 new_line = rename_elements(&new_line, &user_defined);
95105 }
96− new_line = rewrite_attr_regions(&new_line, &mut attr_depth);
106+ if attr_depth == 0 {
107+ element_attrs = takes_element_attributes(new_line.trim_start(), &user_defined);
108+ }
109+ new_line = rewrite_attr_regions(&new_line, &mut attr_depth, element_attrs);
97110 new_line = rename_filters(&new_line);
98111 if new_line != line {
99112 changes += 1;
@@ -216,6 +229,14 @@ fn rewrite_block(
216229 return Some(Block { lines: out, end });
217230 }
218231
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+
219240 // @with $x as y → @let y $x, body dedented
220241 if let Some(rest) = trimmed.strip_prefix("@with ") {
221242 let (source, alias) = rest.split_once(" as ")?;
@@ -466,7 +487,50 @@ fn rename_elements(line: &str, user_defined: &[&str]) -> String {
466487
467488/// Apply attribute-level rewrites inside `[...]` regions. `depth` carries an
468489/// unclosed bracket over to the following (continuation) lines.
469−fn rewrite_attr_regions(line: &str, depth: &mut i32) -> String {
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+
533+fn rewrite_attr_regions(line: &str, depth: &mut i32, element_attrs: bool) -> String {
470534 let trimmed = line.trim_start();
471535 if *depth == 0 && !trimmed.starts_with('@') && !trimmed.starts_with('[') {
472536 return line.to_string();
@@ -481,7 +545,7 @@ fn rewrite_attr_regions(line: &str, depth: &mut i32) -> String {
481545 _ => {}
482546 }
483547 if *depth == 0 {
484− out.push_str(&rewrite_attr_list(&region));
548+ out.push_str(&rewrite_attr_list(&region, element_attrs));
485549 region.clear();
486550 out.push(ch);
487551 } else {
@@ -494,12 +558,12 @@ fn rewrite_attr_regions(line: &str, depth: &mut i32) -> String {
494558 }
495559 }
496560 }
497− out.push_str(&rewrite_attr_list(&region));
561+ out.push_str(&rewrite_attr_list(&region, element_attrs));
498562 out
499563}
500564
501565/// 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 {
503567 if list.is_empty() {
504568 return String::new();
505569 }
@@ -547,6 +611,20 @@ fn rewrite_attr_list(list: &str) -> String {
547611 },
548612 None => body,
549613 };
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+ };
550628 // Renamed keys (keeping any `hover:` / `md:` style prefix)
551629 let key_end = body.find(char::is_whitespace).unwrap_or(body.len());
552630 let (key, value) = body.split_at(key_end);
@@ -754,6 +832,7 @@ mod tests {
754832 #[test]
755833 fn with_and_layout() {
756834 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");
757836 assert_eq!(up("@layout base.hl\n @text x\n"), "@extends base.hl\n@text x\n");
758837 let r = upgrade("@layout base.hl\n @text x\n@text after");
759838 assert_eq!(r.output, "@layout base.hl\n @text x\n@text after");
@@ -817,9 +896,29 @@ mod tests {
817896 assert_eq!(r.manual.len(), 1);
818897 }
819898
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+
820918 #[test]
821919 fn verbatim_regions_are_untouched() {
822920 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
823921 assert_eq!(up(src), src);
922+ assert_eq!(up("@script [src app.js, defer]"), "@script [src=app.js, defer]");
824923 }
825924}
tests/regressions.rs+33−1
@@ -292,7 +292,7 @@ fn container_arguments_are_rendered_as_text() {
292292 "@paragraph Hello",
293293 "@row Hello",
294294 "@section Hello",
295− "@nav [id x] Hello",
295+ "@nav [id=x] Hello",
296296 ] {
297297 let out = compile(src);
298298 assert!(out.contains("Hello"), "{:?} dropped its text: {}", src, out);
@@ -300,3 +300,35 @@ fn container_arguments_are_rendered_as_text() {
300300 let out = compile("@paragraph Read {@link /more more}");
301301 assert!(out.contains("<a href=\"/more\">more</a>"), "{}", out);
302302}
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() {
406406
407407#[test]
408408fn 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]");
410410 assert!(
411411 !diags
412412 .iter()
@@ -518,7 +518,7 @@ fn no_warning_spacing_on_row() {
518518
519519#[test]
520520fn warning_placeholder_on_row() {
521− let diags = parse_diagnostics("@row [placeholder test]");
521+ let diags = parse_diagnostics("@row [placeholder=test]");
522522 assert!(
523523 diags
524524 .iter()
@@ -530,7 +530,7 @@ fn warning_placeholder_on_row() {
530530
531531#[test]
532532fn warning_for_on_non_label() {
533− let diags = parse_diagnostics("@el [for email]");
533+ let diags = parse_diagnostics("@el [for=email]");
534534 assert!(
535535 diags
536536 .iter()
@@ -583,7 +583,7 @@ fn named_slot_default_content() {
583583#[test]
584584fn style_block_output() {
585585 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",
587587 );
588588 assert!(output.contains(".custom{color:red;}") || output.contains(".custom { color: red; }"));
589589 assert!(output.contains("styled"));
@@ -965,7 +965,7 @@ fn image_auto_lazy_loading() {
965965
966966#[test]
967967fn 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");
969969 assert!(output.contains("loading=\"eager\""));
970970 assert!(!output.contains("loading=\"lazy\""));
971971}
@@ -1102,22 +1102,18 @@ fn theme_color_meta_from_theme() {
11021102
11031103#[test]
11041104fn 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");
11061106 assert!(output.contains("aria-live=\"polite\""));
11071107}
11081108
11091109#[test]
1110−fn defer_directive() {
1111− let result = htmlang::parser::parse("@page T\n@defer\n @el\n Lazy content");
1110+fn defer_directive_was_removed() {
1111+ let diags = parse_diagnostics("@page T\n@defer\n @el\n Lazy content");
11121112 assert!(
1113− result
1114− .diagnostics
1115− .iter()
1116− .all(|d| d.severity != htmlang::parser::Severity::Error)
1113+ diags.iter().any(|d| d.message.contains("`@defer` was removed")),
1114+ "{:?}",
1115+ diags
11171116 );
1118− let html = htmlang::codegen::generate(&result.document);
1119− assert!(html.contains("data-hl-defer"));
1120− assert!(html.contains("IntersectionObserver"));
11211117}
11221118
11231119#[test]
@@ -1245,7 +1241,7 @@ fn snapshot_each_destructuring() {
12451241
12461242#[test]
12471243fn 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]");
12491245 assert!(output.contains("<form"));
12501246 assert!(output.contains("action=\"/submit\""));
12511247 assert!(output.contains("method=\"post\""));
@@ -1290,7 +1286,7 @@ fn hr_renders_self_closing() {
12901286
12911287#[test]
12921288fn 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");
12941290 assert!(output.contains("<figure"));
12951291 assert!(output.contains("<figcaption"));
12961292 assert!(output.contains("Caption"));
@@ -1298,7 +1294,7 @@ fn figure_figcaption_renders() {
12981294
12991295#[test]
13001296fn 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]");
13021298 assert!(output.contains("<progress"));
13031299 assert!(output.contains("value=\"70\""));
13041300 assert!(output.contains("max=\"100\""));
@@ -1306,7 +1302,7 @@ fn progress_renders() {
13061302
13071303#[test]
13081304fn 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]");
13101306 assert!(output.contains("<meter"));
13111307 assert!(output.contains("value=\"6\""));
13121308}
@@ -1464,7 +1460,7 @@ fn warning_missing_alt_on_image() {
14641460
14651461#[test]
14661462fn 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");
14681464 assert!(
14691465 !diags
14701466 .iter()
@@ -1621,7 +1617,7 @@ fn checked_generates_pseudo() {
16211617
16221618#[test]
16231619fn 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]");
16251621 assert!(output.contains("::placeholder"));
16261622 assert!(output.contains("color:#999"));
16271623}
@@ -1692,13 +1688,13 @@ fn css_inset() {
16921688
16931689#[test]
16941690fn 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]");
16961692 assert!(output.contains("accent-color:blue"));
16971693}
16981694
16991695#[test]
17001696fn 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]");
17021698 assert!(output.contains("caret-color:red"));
17031699}
17041700
@@ -1876,7 +1872,7 @@ fn snapshot_arithmetic() {
18761872
18771873#[test]
18781874fn 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");
18801876 assert!(output.contains("<dialog"));
18811877 assert!(output.contains("id=\"modal\""));
18821878 assert!(output.contains("open"));
@@ -1894,7 +1890,7 @@ fn element_definition_list() {
18941890
18951891#[test]
18961892fn 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]");
18981894 assert!(output.contains("<fieldset"));
18991895 assert!(output.contains("<legend>Info</legend>"));
19001896 assert!(output.contains("</fieldset>"));
@@ -1903,7 +1899,7 @@ fn element_fieldset_legend() {
19031899#[test]
19041900fn element_picture_source() {
19051901 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",
19071903 );
19081904 assert!(output.contains("<picture"));
19091905 assert!(output.contains("<source"));
@@ -1913,7 +1909,7 @@ fn element_picture_source() {
19131909
19141910#[test]
19151911fn 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");
19171913 assert!(output.contains("<time"));
19181914 assert!(output.contains("datetime=\"2026-04-15\""));
19191915 assert!(output.contains("April 15"));
@@ -1935,7 +1931,7 @@ fn element_kbd() {
19351931
19361932#[test]
19371933fn 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");
19391935 assert!(output.contains("<abbr"));
19401936 assert!(output.contains("title=\"HyperText Markup Language\""));
19411937 assert!(output.contains("HTML"));
@@ -1943,7 +1939,7 @@ fn element_abbr() {
19431939
19441940#[test]
19451941fn 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");
19471943 assert!(output.contains("<datalist"));
19481944 assert!(output.contains("id=\"browsers\""));
19491945 assert!(output.contains("</datalist>"));
@@ -2135,7 +2131,7 @@ fn let_arithmetic_divide() {
21352131
21362132#[test]
21372133fn warning_missing_input_type() {
2138− let diags = parse_diagnostics("@input [name email]");
2134+ let diags = parse_diagnostics("@input [name=email]");
21392135 assert!(
21402136 diags.iter().any(|d| d.message.contains("missing 'type'")),
21412137 "should warn about missing type on @input, got: {:?}",
@@ -2300,7 +2296,7 @@ fn convert_image() {
23002296 let hl = htmlang::convert::convert("<img src=\"photo.jpg\" alt=\"A photo\">");
23012297 assert!(hl.contains("@image"), "img should become @image: {}", hl);
23022298 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);
23042300}
23052301
23062302#[test]
@@ -2531,7 +2527,7 @@ fn element_iframe() {
25312527
25322528#[test]
25332529fn element_canvas() {
2534− let output = compile("@canvas [width 400, height 300, id myCanvas]");
2530+ let output = compile("@canvas [width 400, height 300, id=myCanvas]");
25352531 assert!(
25362532 output.contains("<canvas"),
25372533 "should generate canvas tag: {}",
@@ -2546,7 +2542,7 @@ fn element_canvas() {
25462542
25472543#[test]
25482544fn element_output() {
2549− let output = compile("@output [for a b]\n 42");
2545+ let output = compile("@output [for=a b]\n 42");
25502546 assert!(
25512547 output.contains("<output"),
25522548 "should generate output tag: {}",
@@ -3329,7 +3325,7 @@ fn test_color_filter_mix() {
33293325
33303326#[test]
33313327fn test_autofocus_attribute() {
3332− let result = htmlang::parser::parse("@input [type text, autofocus]");
3328+ let result = htmlang::parser::parse("@input [type=text, autofocus]");
33333329 let html = htmlang::codegen::generate(&result.document);
33343330 assert!(
33353331 html.contains("autofocus"),
@@ -3588,7 +3584,7 @@ fn snapshot_new_css_properties_4() {
35883584
35893585#[test]
35903586fn script_element_with_src() {
3591− let output = compile("@script [src app.js, defer]");
3587+ let output = compile("@script [src=app.js, defer]");
35923588 assert!(
35933589 output.contains("<script src=\"app.js\" defer>"),
35943590 "script src: {}",
@@ -3622,7 +3618,7 @@ fn address_element() {
36223618
36233619#[test]
36243620fn search_element() {
3625− let output = compile("@search\n @input [type search]");
3621+ let output = compile("@search\n @input [type=search]");
36263622 assert!(output.contains("<search>"), "search: {}", output);
36273623}
36283624
@@ -3699,7 +3695,7 @@ fn target_pseudo() {
36993695
37003696#[test]
37013697fn 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]");
37033699 assert!(output.contains(":valid"), "valid pseudo: {}", output);
37043700}
37053701
@@ -3750,7 +3746,7 @@ fn no_warning_new_attrs_batch7() {
37503746#[test]
37513747fn no_warning_script_attrs() {
37523748 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]",
37543750 );
37553751 assert!(
37563752 !diags
@@ -4031,7 +4027,7 @@ fn conditional_attr_boolean_false() {
40314027#[test]
40324028fn function_with_style_is_scoped() {
40334029 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",
40354031 );
40364032 assert!(html.contains("hl-card\"><span class=\"t\">Hello"), "{}", html);
40374033 assert!(html.contains(".hl-card .t { color: red; }"), "{}", html);
@@ -4125,7 +4121,7 @@ fn critical_attr_inlines_styles() {
41254121
41264122#[test]
41274123fn warning_input_without_label() {
4128− let diags = parse_diagnostics("@input [type text]\n");
4124+ let diags = parse_diagnostics("@input [type=text]\n");
41294125 let has_label_warning = diags
41304126 .iter()
41314127 .any(|d| d.message.contains("aria-label") || d.message.contains("@label"));
@@ -4156,14 +4152,14 @@ fn warning_button_without_text() {
41564152
41574153#[test]
41584154fn 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");
41604156 let has_warning = diags.iter().any(|d| d.message.contains("tabindex"));
41614157 assert!(has_warning, "should warn about positive tabindex");
41624158}
41634159
41644160#[test]
41654161fn 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");
41674163 let has_label_warning = diags
41684164 .iter()
41694165 .any(|d| d.message.contains("should have an") && d.message.contains("@label"));
@@ -4175,7 +4171,7 @@ fn no_warning_input_with_aria_label() {
41754171
41764172#[test]
41774173fn 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");
41794175 let has_label_warning = diags
41804176 .iter()
41814177 .any(|d| d.message.contains("should have an") && d.message.contains("@label"));
@@ -4210,7 +4206,7 @@ fn snapshot_data_directive() {
42104206#[test]
42114207fn test_popover_in_output() {
42124208 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",
42144210 );
42154211 assert!(
42164212 html.contains("popovertarget=\"my-pop\""),
@@ -4242,7 +4238,7 @@ fn test_appearance_css() {
42424238
42434239#[test]
42444240fn test_inputmode_attr() {
4245− let html = compile("@input [type search, inputmode search]");
4241+ let html = compile("@input [type=search, inputmode=search]");
42464242 assert!(
42474243 html.contains("inputmode=\"search\""),
42484244 "should pass through inputmode"
@@ -4251,7 +4247,7 @@ fn test_inputmode_attr() {
42514247
42524248#[test]
42534249fn 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");
42554251 assert!(
42564252 html.contains("fetchpriority=\"high\""),
42574253 "should pass through fetchpriority"
@@ -5010,7 +5006,7 @@ fn partial_output_dev() {
50105006
50115007#[test]
50125008fn 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");
50145010 assert!(
50155011 output.contains("srcset=\""),
50165012 "should generate srcset, got: {}",
@@ -5063,7 +5059,7 @@ fn auto_image_dimensions_png() {
50635059 ];
50645060 std::fs::write(&png_path, &png_data).unwrap();
50655061
5066− let input = format!("@image [alt test] {}", png_path.display());
5062+ let input = format!("@image [alt=test] {}", png_path.display());
50675063 let result = htmlang::parser::parse(&input);
50685064 let html = htmlang::codegen::generate(&result.document);
50695065 assert!(
@@ -5083,7 +5079,7 @@ fn auto_image_dimensions_png() {
50835079#[test]
50845080fn auto_image_dimensions_not_for_urls() {
50855081 // 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");
50875083 // Should not crash or add dimensions for remote URLs
50885084 assert!(
50895085 output.contains("src=\"https://example.com/photo.png\""),
@@ -5108,7 +5104,7 @@ fn auto_image_dimensions_respects_explicit() {
51085104 std::fs::write(&png_path, &png_data).unwrap();
51095105
51105106 let input = format!(
5111− "@image [width 100, height 100, alt test] {}",
5107+ "@image [width 100, height 100, alt=test] {}",
51125108 png_path.display()
51135109 );
51145110 let result = htmlang::parser::parse(&input);
tests/snapshots/accessibility.hl+5−5
@@ -1,6 +1,6 @@
11@page Accessibility
2−@image [alt A photo of sunset, width 200] https://example.com/sunset.jpg
3−@el [role navigation, aria-label Main menu]
4− @link [tabindex 0, aria-current page] / Home
5− @link [tabindex 0] /about About
6−@button [aria-pressed false, data-action toggle] Toggle
2+@image [alt=A photo of sunset, width 200] https://example.com/sunset.jpg
3+@el [role=navigation, aria-label=Main menu]
4+ @link [tabindex=0, aria-current=page] / Home
5+ @link [tabindex=0] /about About
6+@button [aria-pressed=false, data-action=toggle] Toggle
tests/snapshots/form_elements.hl+8−8
@@ -1,12 +1,12 @@
11@page Form Elements
22@row [spacing 10]
3− @button [type submit] Click me
4− @input [type text, placeholder Enter name, name username]
5− @input [type email, required, disabled]
3+ @button [type=submit] Click me
4+ @input [type=text, placeholder=Enter name, name=username]
5+ @input [type=email, required, disabled]
66@column [spacing 5]
7− @label [for bio] Biography
8− @textarea [name bio, rows 4] Default text
9−@select [name color]
10− @option [value red] Red
11− @option [value green] Green
7+ @label [for=bio] Biography
8+ @textarea [name=bio, rows=4] Default text
9+@select [name=color]
10+ @option [value=red] Red
11+ @option [value=green] Green
1212 @option Blue
tests/snapshots/media_elements.hl+1−1
@@ -4,5 +4,5 @@
44
55@audio [controls, autoplay, loop] music.mp3
66
7−@video [controls, muted, poster poster.jpg, width 640]
7+@video [controls, muted, poster=poster.jpg, width 640]
88 @text Your browser does not support video
tests/snapshots/new_css_properties.hl+3−3
@@ -6,9 +6,9 @@
66@el [position absolute, inset 0]
77 @text Overlay
88
9−@input [type checkbox, accent-color #3b82f6]
9+@input [type=checkbox, accent-color #3b82f6]
1010
11−@input [type text, caret-color red]
11+@input [type=text, caret-color red]
1212
1313@list [list-style disc]
1414 @item One
@@ -29,4 +29,4 @@
2929@el [overflow auto, scroll-behavior smooth]
3030 @text Smooth scroll
3131
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 @@
11@page New Elements
22
3−@form [method post, action /submit]
4− @label [for email] Email
5− @input [type email, name email, required]
6− @button [type submit] Submit
3+@form [method=post, action=/submit]
4+ @label [for=email] Email
5+ @input [type=email, name=email, required]
6+ @button [type=submit] Submit
77
88@hr
99
@@ -16,12 +16,12 @@
1616 @cite Shakespeare
1717
1818@figure
19− @image [alt A code example, width 200] example.png
19+ @image [alt=A code example, width 200] example.png
2020 @figcaption A code example
2121
2222@pre
2323 @code console.log("hello")
2424
25−@progress [value 70, max 100]
25+@progress [value=70, max=100]
2626
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 @@
11@page New Elements 2
22
3−@dialog [id modal, open]
3+@dialog [id=modal, open]
44 @text [bold] Dialog Title
55 @text Dialog content here
66 @button Close
@@ -13,23 +13,23 @@
1313
1414@fieldset
1515 @legend Personal Info
16− @label [for name] Name
17− @input [type text, name name]
16+ @label [for=name] Name
17+ @input [type=text, name=name]
1818
1919@picture
20− @source [srcset photo-wide.jpg, media (min-width: 800px)]
21− @image [alt A photo] photo.jpg
20+ @source [srcset=photo-wide.jpg, media=(min-width: 800px)]
21+ @image [alt=A photo] photo.jpg
2222
23−@time [datetime 2026-04-15] April 15, 2026
23+@time [datetime=2026-04-15] April 15, 2026
2424
2525@text This is {@mark highlighted} text
2626
2727@text Press {@kbd Ctrl+C} to copy
2828
29−@abbr [title HyperText Markup Language] HTML
29+@abbr [title=HyperText Markup Language] HTML
3030
31−@input [type text, name browser, list browsers]
32−@datalist [id browsers]
31+@input [type=text, name=browser, list=browsers]
32+@datalist [id=browsers]
3333 @option Chrome
3434 @option Firefox
3535 @option Safari
tests/snapshots/new_elements_3.hl+2−2
@@ -2,7 +2,7 @@
22
33@iframe [width fill, height 400] https://example.com
44
5−@output [id result, for a b]
5+@output [id=result, for=a b]
66 42
77
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 @@
66 @link mailto:john@example.com john@example.com
77
88@search
9− @input [type search, placeholder Search...]
10− @button [type submit] Go
9+ @input [type=search, placeholder=Search...]
10+ @button [type=submit] Go
tests/snapshots/new_html_attrs.hl+5−5
@@ -1,7 +1,7 @@
11@page HTML Attrs
22
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
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/new_pseudos.hl+2−2
@@ -4,5 +4,5 @@
44@el [empty:display none, target:background yellow]
55 Conditional content
66
7−@input [type email, valid:border 2 green]
8−@input [type text, invalid:border 2 red]
7+@input [type=email, valid:border 2 green]
8+@input [type=text, invalid:border 2 red]
tests/snapshots/popover_api.hl+2−2
@@ -1,6 +1,6 @@
11@page Popover Demo
22
3−@button [popovertarget my-popover] Toggle Info
3+@button [popovertarget=my-popover] Toggle Info
44
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]
66 @text This is a popover panel
tests/snapshots/pseudo_states_extended.hl+2−2
@@ -1,11 +1,11 @@
11@page Pseudo States Extended
22
33@el [padding 10, focus-visible:border 2 blue, focus-within:background #eee]
4− @input [type text, placeholder Name]
4+ @input [type=text, placeholder=Name]
55
66@el [disabled:opacity 0.5, checked:background green]
77
8−@input [type text, placeholder:color #999]
8+@input [type=text, placeholder:color #999]
99
1010@el [first:border-top 0, last:border-bottom 0]
1111
tests/snapshots/responsive_images.hl+2−2
@@ -1,3 +1,3 @@
11@page Responsive Images
2−@image [responsive 400 800 1200, alt Hero photo] hero.jpg
3−@image [alt Small icon] icon.png
2+@image [responsive 400 800 1200, alt=Hero photo] hero.jpg
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]
22
33@script
44 console.log("hello world");
tests/snapshots/stress_deeply_nested.hl+4−4
@@ -88,7 +88,7 @@
8888 @column [spacing 8, margin-top 8]
8989 @el [padding 8, border 1 $--border, rounded 4]
9090 @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]
9292 @el [margin-top 4, padding 4 8, background #fef2f2, rounded 4]
9393 @text [size 11, color #991b1b] Level 6 - Validation Error
9494 @el [margin-top 2]
@@ -99,9 +99,9 @@
9999 @el [padding 8, border 1 $--border, rounded 4]
100100 @text [size 12, color #64748b, margin-bottom 4] Level 5 - Select Group
101101 @select [padding 6 10, rounded 4, border 1 $--border, width fill]
102− @option [value a] Option A
103− @option [value b] Option B
104− @option [value c] Option C
102+ @option [value=a] Option A
103+ @option [value=b] Option B
104+ @option [value=c] Option C
105105
106106 @deep-card [title Level 3 - Nested Lists Card]
107107 @inner-panel [label Level 4 - Category Browser]
tests/snapshots/stress_large.hl+2−2
@@ -176,7 +176,7 @@
176176 @el [width fill]
177177
178178 @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]
180180 @el [position relative]
181181 @icon-button [label Alerts]
182182 @el [position absolute, top -2, right -2]
@@ -313,7 +313,7 @@
313313 @card [title Data Table]
314314 @column [spacing 0, margin-top 12]
315315 @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]
317317 @el [width fill]
318318 @row [spacing 8]
319319 @each $cat in $categories
tests/snapshots/style_block.hl+1−1
@@ -4,5 +4,5 @@
44 .highlight { background: yellow; border: 2px solid orange; }
55 .highlight:hover { background: gold; }
66
7−@el [class highlight]
7+@el [class=highlight]
88 @text Custom styled