on master help sign in
Fix doctype test to require @page directive for full HTML output
commit499fdcfb1eac4e631fae95d25a5b273f9e5d67ca authoriko <mail@iko.soy> dateThu 16 Apr 2026, 08:38 (6 months ago) parent503750d
14 files changed+6580−2765
crates/htmlang-core/src/codegen.rs+736−309 crates/htmlang-core/src/codegen_tests.rs+25−10 crates/htmlang-core/src/parser.rs+1359−554 src/bin/htmlang_lsp/analysis.rs+525−312 src/bin/htmlang_lsp/completion.rs+581−188 src/bin/htmlang_lsp/hover.rs+703−244 src/bin/htmlang_lsp/main.rs+94−95 src/bin/htmlang_lsp/navigation.rs+72−48 src/cli.rs+36−10 src/convert.rs+80−79 src/fmt.rs+83−53 src/main.rs+970−499 src/serve.rs+17−16 tests/snapshots.rs+1299−348
crates/htmlang-core/src/codegen.rs+736−309
@@ -28,7 +28,9 @@ pub(crate) fn short_class_name(idx: usize) -> String {
2828 n /= 26;
2929 loop {
3030 name.push((b'a' + (n % 36).min(25) as u8) as char);
31− if n < 36 { break; }
31+ if n < 36 {
32+ break;
33+ }
3234 n /= 36;
3335 }
3436 // Reverse so it reads naturally
@@ -174,9 +176,15 @@ impl StyleCollector {
174176 for (selector, pseudo_css) in &e.pseudo {
175177 if !pseudo_css.is_empty() {
176178 if use_layer && dev {
177− css.push_str(&format!(" .{}{}{sp}{{{}}}{nl}", e.class_name, selector, pseudo_css));
179+ css.push_str(&format!(
180+ " .{}{}{sp}{{{}}}{nl}",
181+ e.class_name, selector, pseudo_css
182+ ));
178183 } else {
179− css.push_str(&format!(".{}{}{sp}{{{}}}{nl}", e.class_name, selector, pseudo_css));
184+ css.push_str(&format!(
185+ ".{}{}{sp}{{{}}}{nl}",
186+ e.class_name, selector, pseudo_css
187+ ));
180188 }
181189 }
182190 }
@@ -203,10 +211,7 @@ impl StyleCollector {
203211 bp_width, bp_css
204212 ));
205213 } else {
206− css.push_str(&format!(
207− "@media(min-width:{}){{{}}}",
208− bp_width, bp_css
209− ));
214+ css.push_str(&format!("@media(min-width:{}){{{}}}", bp_width, bp_css));
210215 }
211216 }
212217 }
@@ -224,9 +229,15 @@ impl StyleCollector {
224229 }
225230 if !dark_css.is_empty() {
226231 if dev {
227− css.push_str(&format!("@media (prefers-color-scheme: dark) {{\n{}}}\n", dark_css));
232+ css.push_str(&format!(
233+ "@media (prefers-color-scheme: dark) {{\n{}}}\n",
234+ dark_css
235+ ));
228236 } else {
229− css.push_str(&format!("@media(prefers-color-scheme:dark){{{}}}", dark_css));
237+ css.push_str(&format!(
238+ "@media(prefers-color-scheme:dark){{{}}}",
239+ dark_css
240+ ));
230241 }
231242 }
232243
@@ -254,7 +265,8 @@ impl StyleCollector {
254265 for e in &self.entries {
255266 if !e.motion_safe.is_empty() {
256267 if dev {
257− motion_safe_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_safe));
268+ motion_safe_css
269+ .push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_safe));
258270 } else {
259271 motion_safe_css.push_str(&format!(".{}{{{}}}", e.class_name, e.motion_safe));
260272 }
@@ -262,9 +274,15 @@ impl StyleCollector {
262274 }
263275 if !motion_safe_css.is_empty() {
264276 if dev {
265− css.push_str(&format!("@media (prefers-reduced-motion: no-preference) {{\n{}}}\n", motion_safe_css));
277+ css.push_str(&format!(
278+ "@media (prefers-reduced-motion: no-preference) {{\n{}}}\n",
279+ motion_safe_css
280+ ));
266281 } else {
267− css.push_str(&format!("@media(prefers-reduced-motion:no-preference){{{}}}", motion_safe_css));
282+ css.push_str(&format!(
283+ "@media(prefers-reduced-motion:no-preference){{{}}}",
284+ motion_safe_css
285+ ));
268286 }
269287 }
270288
@@ -273,17 +291,25 @@ impl StyleCollector {
273291 for e in &self.entries {
274292 if !e.motion_reduce.is_empty() {
275293 if dev {
276− motion_reduce_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_reduce));
294+ motion_reduce_css
295+ .push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_reduce));
277296 } else {
278− motion_reduce_css.push_str(&format!(".{}{{{}}}", e.class_name, e.motion_reduce));
297+ motion_reduce_css
298+ .push_str(&format!(".{}{{{}}}", e.class_name, e.motion_reduce));
279299 }
280300 }
281301 }
282302 if !motion_reduce_css.is_empty() {
283303 if dev {
284− css.push_str(&format!("@media (prefers-reduced-motion: reduce) {{\n{}}}\n", motion_reduce_css));
304+ css.push_str(&format!(
305+ "@media (prefers-reduced-motion: reduce) {{\n{}}}\n",
306+ motion_reduce_css
307+ ));
285308 } else {
286− css.push_str(&format!("@media(prefers-reduced-motion:reduce){{{}}}", motion_reduce_css));
309+ css.push_str(&format!(
310+ "@media(prefers-reduced-motion:reduce){{{}}}",
311+ motion_reduce_css
312+ ));
287313 }
288314 }
289315
@@ -300,9 +326,15 @@ impl StyleCollector {
300326 }
301327 if !landscape_css.is_empty() {
302328 if dev {
303− css.push_str(&format!("@media (orientation: landscape) {{\n{}}}\n", landscape_css));
329+ css.push_str(&format!(
330+ "@media (orientation: landscape) {{\n{}}}\n",
331+ landscape_css
332+ ));
304333 } else {
305− css.push_str(&format!("@media(orientation:landscape){{{}}}", landscape_css));
334+ css.push_str(&format!(
335+ "@media(orientation:landscape){{{}}}",
336+ landscape_css
337+ ));
306338 }
307339 }
308340
@@ -319,7 +351,10 @@ impl StyleCollector {
319351 }
320352 if !portrait_css.is_empty() {
321353 if dev {
322− css.push_str(&format!("@media (orientation: portrait) {{\n{}}}\n", portrait_css));
354+ css.push_str(&format!(
355+ "@media (orientation: portrait) {{\n{}}}\n",
356+ portrait_css
357+ ));
323358 } else {
324359 css.push_str(&format!("@media(orientation:portrait){{{}}}", portrait_css));
325360 }
@@ -346,10 +381,7 @@ impl StyleCollector {
346381 bp_width, cq_css
347382 ));
348383 } else {
349− css.push_str(&format!(
350− "@container(min-width:{}){{{}}}",
351− bp_width, cq_css
352− ));
384+ css.push_str(&format!("@container(min-width:{}){{{}}}", bp_width, cq_css));
353385 }
354386 }
355387 }
@@ -422,31 +454,76 @@ pub fn generate(doc: &Document) -> String {
422454}
423455
424456pub fn generate_dev(doc: &Document) -> String {
425− generate_with(doc, &CodegenOptions { dev: true, ..Default::default() })
457+ generate_with(
458+ doc,
459+ &CodegenOptions {
460+ dev: true,
461+ ..Default::default()
462+ },
463+ )
426464}
427465
428466pub fn generate_partial(doc: &Document) -> String {
429− generate_with(doc, &CodegenOptions { partial: true, ..Default::default() })
467+ generate_with(
468+ doc,
469+ &CodegenOptions {
470+ partial: true,
471+ ..Default::default()
472+ },
473+ )
430474}
431475
432476pub fn generate_partial_dev(doc: &Document) -> String {
433− generate_with(doc, &CodegenOptions { dev: true, partial: true, ..Default::default() })
477+ generate_with(
478+ doc,
479+ &CodegenOptions {
480+ dev: true,
481+ partial: true,
482+ ..Default::default()
483+ },
484+ )
434485}
435486
436487pub fn generate_minified(doc: &Document) -> String {
437− generate_with(doc, &CodegenOptions { minify: true, ..Default::default() })
488+ generate_with(
489+ doc,
490+ &CodegenOptions {
491+ minify: true,
492+ ..Default::default()
493+ },
494+ )
438495}
439496
440497pub fn generate_compat(doc: &Document) -> String {
441− generate_with(doc, &CodegenOptions { compat: true, ..Default::default() })
498+ generate_with(
499+ doc,
500+ &CodegenOptions {
501+ compat: true,
502+ ..Default::default()
503+ },
504+ )
442505}
443506
444507pub fn generate_dev_compat(doc: &Document) -> String {
445− generate_with(doc, &CodegenOptions { dev: true, compat: true, ..Default::default() })
508+ generate_with(
509+ doc,
510+ &CodegenOptions {
511+ dev: true,
512+ compat: true,
513+ ..Default::default()
514+ },
515+ )
446516}
447517
448518pub fn generate_minified_compat(doc: &Document) -> String {
449− generate_with(doc, &CodegenOptions { minify: true, compat: true, ..Default::default() })
519+ generate_with(
520+ doc,
521+ &CodegenOptions {
522+ minify: true,
523+ compat: true,
524+ ..Default::default()
525+ },
526+ )
450527}
451528
452529fn minify_html(html: &str) -> String {
@@ -479,13 +556,18 @@ fn minify_html(html: &str) -> String {
479556 }
480557
481558 // Strip HTML comments (<!-- ... -->)
482− if !in_script && !in_style && i + 3 < chars.len()
483− && chars[i] == '<' && chars[i+1] == '!' && chars[i+2] == '-' && chars[i+3] == '-'
559+ if !in_script
560+ && !in_style
561+ && i + 3 < chars.len()
562+ && chars[i] == '<'
563+ && chars[i + 1] == '!'
564+ && chars[i + 2] == '-'
565+ && chars[i + 3] == '-'
484566 {
485567 // Skip to -->
486568 let mut j = i + 4;
487569 while j + 2 < chars.len() {
488− if chars[j] == '-' && chars[j+1] == '-' && chars[j+2] == '>' {
570+ if chars[j] == '-' && chars[j + 1] == '-' && chars[j + 2] == '>' {
489571 j += 3;
490572 break;
491573 }
@@ -634,7 +716,13 @@ fn prefix_declarations(block: &str) -> String {
634716
635717fn generate_full_inner(doc: &Document, dev: bool) -> String {
636718 let mut styles = StyleCollector::new();
637− let mut ctx = GenContext { dev, depth: 0, image_count: 0, has_interactive: false, has_defer: false };
719+ let mut ctx = GenContext {
720+ dev,
721+ depth: 0,
722+ image_count: 0,
723+ has_interactive: false,
724+ has_defer: false,
725+ };
638726
639727 // Check if document has @main for skip-to-content link
640728 let has_main = has_element_kind(&doc.nodes, &ElementKind::Main);
@@ -733,11 +821,7 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
733821 element_css.push('\n');
734822 } else {
735823 // Minify: collapse whitespace
736− let minified: String = block
737− .lines()
738− .map(|l| l.trim())
739− .collect::<Vec<_>>()
740− .join("");
824+ let minified: String = block.lines().map(|l| l.trim()).collect::<Vec<_>>().join("");
741825 element_css.push_str(&minified);
742826 }
743827 }
@@ -852,7 +936,10 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
852936 };
853937 let b64 = base64_encode(&data);
854938 if dev {
855− format!("<link rel=\"icon\" href=\"data:{};base64,{}\">\n", mime, b64)
939+ format!(
940+ "<link rel=\"icon\" href=\"data:{};base64,{}\">\n",
941+ mime, b64
942+ )
856943 } else {
857944 format!("<link rel=\"icon\" href=\"data:{};base64,{}\">", mime, b64)
858945 }
@@ -911,21 +998,31 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
911998 name, url, format_hint
912999 ));
9131000 } else {
914− element_css.insert_str(0, &format!(
915− "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
916− name, url, format_hint
917− ));
1001+ element_css.insert_str(
1002+ 0,
1003+ &format!(
1004+ "@font-face{{font-family:'{}';src:url('{}'){};font-display:swap}}",
1005+ name, url, format_hint
1006+ ),
1007+ );
9181008 }
9191009 }
9201010
9211011 // JSON-LD blocks
922− let json_ld_html: String = doc.json_ld_blocks.iter().map(|block| {
923− if dev {
924− format!("<script type=\"application/ld+json\">\n{}\n</script>\n", block)
925− } else {
926− format!("<script type=\"application/ld+json\">{}</script>", block)
927− }
928− }).collect();
1012+ let json_ld_html: String = doc
1013+ .json_ld_blocks
1014+ .iter()
1015+ .map(|block| {
1016+ if dev {
1017+ format!(
1018+ "<script type=\"application/ld+json\">\n{}\n</script>\n",
1019+ block
1020+ )
1021+ } else {
1022+ format!("<script type=\"application/ld+json\">{}</script>", block)
1023+ }
1024+ })
1025+ .collect();
9291026
9301027 // Manifest link
9311028 let manifest_html = if let Some(ref manifest) = doc.manifest {
@@ -948,8 +1045,13 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
9481045 if !manifest.icons.is_empty() {
9491046 json.push_str(",\"icons\":[");
9501047 for (i, (src, sizes)) in manifest.icons.iter().enumerate() {
951− if i > 0 { json.push(','); }
952− json.push_str(&format!("{{\"src\":\"{}\",\"sizes\":\"{}\",\"type\":\"image/png\"}}", src, sizes));
1048+ if i > 0 {
1049+ json.push(',');
1050+ }
1051+ json.push_str(&format!(
1052+ "{{\"src\":\"{}\",\"sizes\":\"{}\",\"type\":\"image/png\"}}",
1053+ src, sizes
1054+ ));
9531055 }
9541056 json.push(']');
9551057 }
@@ -958,9 +1060,15 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
9581060 // Inline the manifest as a data URI
9591061 let encoded = base64_encode(json.as_bytes());
9601062 if dev {
961− format!("<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">\n", encoded)
1063+ format!(
1064+ "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">\n",
1065+ encoded
1066+ )
9621067 } else {
963− format!("<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">", encoded)
1068+ format!(
1069+ "<link rel=\"manifest\" href=\"data:application/manifest+json;base64,{}\">",
1070+ encoded
1071+ )
9641072 }
9651073 } else {
9661074 String::new()
@@ -971,11 +1079,13 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
9711079 for (_, url) in &doc.font_faces {
9721080 if dev {
9731081 preload_html.push_str(&format!(
974− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n", url
1082+ "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>\n",
1083+ url
9751084 ));
9761085 } else {
9771086 preload_html.push_str(&format!(
978− "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>", url
1087+ "<link rel=\"preload\" href=\"{}\" as=\"font\" type=\"font/woff2\" crossorigin>",
1088+ url
9791089 ));
9801090 }
9811091 }
@@ -984,26 +1094,36 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
9841094 if dev {
9851095 preload_html.push_str(&format!(
9861096 "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>\n",
987− html_escape(&hint.href), hint.as_type,
1097+ html_escape(&hint.href),
1098+ hint.as_type,
9881099 if hint.crossorigin { " crossorigin" } else { "" }
9891100 ));
9901101 } else {
9911102 preload_html.push_str(&format!(
9921103 "<link rel=\"preload\" href=\"{}\" as=\"{}\"{}>",
993− html_escape(&hint.href), hint.as_type,
1104+ html_escape(&hint.href),
1105+ hint.as_type,
9941106 if hint.crossorigin { " crossorigin" } else { "" }
9951107 ));
9961108 }
9971109 }
9981110
9991111 // Auto theme-color meta from @theme primary token
1000− let theme_color_html = doc.theme_tokens.iter()
1112+ let theme_color_html = doc
1113+ .theme_tokens
1114+ .iter()
10011115 .find(|(name, _)| name == "primary" || name == "theme-color")
10021116 .map(|(_, value)| {
10031117 if dev {
1004− format!("<meta name=\"theme-color\" content=\"{}\">\n", html_escape(value))
1118+ format!(
1119+ "<meta name=\"theme-color\" content=\"{}\">\n",
1120+ html_escape(value)
1121+ )
10051122 } else {
1006− format!("<meta name=\"theme-color\" content=\"{}\">", html_escape(value))
1123+ format!(
1124+ "<meta name=\"theme-color\" content=\"{}\">",
1125+ html_escape(value)
1126+ )
10071127 }
10081128 })
10091129 .unwrap_or_default();
@@ -1112,12 +1232,10 @@ img {{ display: block; }}
11121232 None => {
11131233 if element_css.is_empty() {
11141234 body
1235+ } else if dev {
1236+ format!("<style>\n{}</style>\n{}", element_css, body)
11151237 } else {
1116− if dev {
1117− format!("<style>\n{}</style>\n{}", element_css, body)
1118− } else {
1119− format!("<style>{}</style>{}", element_css, body)
1120− }
1238+ format!("<style>{}</style>{}", element_css, body)
11211239 }
11221240 }
11231241 }
@@ -1126,7 +1244,13 @@ img {{ display: block; }}
11261244/// Generate an HTML fragment: body + optional <style>, no <html>/<head>/<body> wrapper.
11271245fn generate_partial_inner(doc: &Document, dev: bool) -> String {
11281246 let mut styles = StyleCollector::new();
1129− let mut ctx = GenContext { dev, depth: 0, image_count: 0, has_interactive: false, has_defer: false };
1247+ let mut ctx = GenContext {
1248+ dev,
1249+ depth: 0,
1250+ image_count: 0,
1251+ has_interactive: false,
1252+ has_defer: false,
1253+ };
11301254 let mut body = String::new();
11311255
11321256 for node in &doc.nodes {
@@ -1216,16 +1340,31 @@ fn generate_node(
12161340 Node::Text(segments) => {
12171341 let needs_wrap = matches!(
12181342 parent_kind,
1219− Some(ElementKind::Row) | Some(ElementKind::Column) | Some(ElementKind::El)
1220− | Some(ElementKind::Nav) | Some(ElementKind::Header) | Some(ElementKind::Footer)
1221− | Some(ElementKind::Main) | Some(ElementKind::Section) | Some(ElementKind::Article)
1222− | Some(ElementKind::Aside) | Some(ElementKind::ListItem)
1223− | Some(ElementKind::Form) | Some(ElementKind::Details) | Some(ElementKind::Figure)
1224− | Some(ElementKind::Blockquote)
1225− | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
1226− | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
1227− | Some(ElementKind::Datalist) | Some(ElementKind::Grid) | Some(ElementKind::Stack)
1228− | Some(ElementKind::Noscript) | Some(ElementKind::Address) | Some(ElementKind::Search)
1343+ Some(ElementKind::Row)
1344+ | Some(ElementKind::Column)
1345+ | Some(ElementKind::El)
1346+ | Some(ElementKind::Nav)
1347+ | Some(ElementKind::Header)
1348+ | Some(ElementKind::Footer)
1349+ | Some(ElementKind::Main)
1350+ | Some(ElementKind::Section)
1351+ | Some(ElementKind::Article)
1352+ | Some(ElementKind::Aside)
1353+ | Some(ElementKind::ListItem)
1354+ | Some(ElementKind::Form)
1355+ | Some(ElementKind::Details)
1356+ | Some(ElementKind::Figure)
1357+ | Some(ElementKind::Blockquote)
1358+ | Some(ElementKind::Dialog)
1359+ | Some(ElementKind::DefinitionList)
1360+ | Some(ElementKind::DefinitionDescription)
1361+ | Some(ElementKind::Fieldset)
1362+ | Some(ElementKind::Datalist)
1363+ | Some(ElementKind::Grid)
1364+ | Some(ElementKind::Stack)
1365+ | Some(ElementKind::Noscript)
1366+ | Some(ElementKind::Address)
1367+ | Some(ElementKind::Search)
12291368 );
12301369 if needs_wrap {
12311370 out.push_str(&ctx.indent());
@@ -1247,44 +1386,95 @@ fn generate_node(
12471386
12481387/// HTML attributes that are passed through to the HTML tag rather than converted to CSS.
12491388const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
1250− "type", "placeholder", "name", "value", "disabled", "required", "checked",
1251− "for", "action", "method", "autocomplete",
1252− "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
1253− "alt", "role", "tabindex", "title", "autofocus",
1389+ "type",
1390+ "placeholder",
1391+ "name",
1392+ "value",
1393+ "disabled",
1394+ "required",
1395+ "checked",
1396+ "for",
1397+ "action",
1398+ "method",
1399+ "autocomplete",
1400+ "min",
1401+ "max",
1402+ "step",
1403+ "pattern",
1404+ "maxlength",
1405+ "rows",
1406+ "cols",
1407+ "multiple",
1408+ "alt",
1409+ "role",
1410+ "tabindex",
1411+ "title",
1412+ "autofocus",
12541413 // Media
1255− "controls", "autoplay", "loop", "muted", "poster", "preload",
1414+ "controls",
1415+ "autoplay",
1416+ "loop",
1417+ "muted",
1418+ "poster",
1419+ "preload",
12561420 // Image optimization
1257− "loading", "decoding",
1421+ "loading",
1422+ "decoding",
12581423 // Media src (explicit attribute form)
12591424 "src",
12601425 // New element attributes
1261− "datetime", "media", "sizes", "srcset", "list",
1426+ "datetime",
1427+ "media",
1428+ "sizes",
1429+ "srcset",
1430+ "list",
12621431 // Details
12631432 "open",
12641433 // Form
12651434 "novalidate",
12661435 // Progress/Meter
1267− "low", "high", "optimum",
1436+ "low",
1437+ "high",
1438+ "optimum",
12681439 // Table
1269− "colspan", "rowspan", "scope",
1440+ "colspan",
1441+ "rowspan",
1442+ "scope",
12701443 // Popover API
1271− "popover", "popovertarget", "popovertargetaction",
1444+ "popover",
1445+ "popovertarget",
1446+ "popovertargetaction",
12721447 // Modern form/input hints
1273− "inputmode", "enterkeyhint",
1448+ "inputmode",
1449+ "enterkeyhint",
12741450 // Performance hints
1275− "fetchpriority", "blocking",
1451+ "fetchpriority",
1452+ "blocking",
12761453 // Global attrs
1277− "translate", "spellcheck",
1454+ "translate",
1455+ "spellcheck",
12781456 // ARIA live regions
1279− "aria-live", "aria-atomic", "aria-relevant",
1457+ "aria-live",
1458+ "aria-atomic",
1459+ "aria-relevant",
12801460];
12811461
12821462/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
12831463const BOOLEAN_HTML_ATTRS: &[&str] = &[
1284− "disabled", "required", "checked", "multiple",
1285− "controls", "autoplay", "loop", "muted",
1286− "open", "novalidate", "autofocus",
1287− "defer", "async", "nomodule",
1464+ "disabled",
1465+ "required",
1466+ "checked",
1467+ "multiple",
1468+ "controls",
1469+ "autoplay",
1470+ "loop",
1471+ "muted",
1472+ "open",
1473+ "novalidate",
1474+ "autofocus",
1475+ "defer",
1476+ "async",
1477+ "nomodule",
12881478 // Popover
12891479 "popover",
12901480];
@@ -1319,7 +1509,14 @@ fn generate_element(
13191509 ctx: &mut GenContext,
13201510) {
13211511 // Self-closing elements
1322− if matches!(elem.kind, ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule | ElementKind::Source | ElementKind::Spacer) {
1512+ if matches!(
1513+ elem.kind,
1514+ ElementKind::Image
1515+ | ElementKind::Input
1516+ | ElementKind::HorizontalRule
1517+ | ElementKind::Source
1518+ | ElementKind::Spacer
1519+ ) {
13231520 generate_self_closing(elem, parent_kind, out, styles, ctx);
13241521 return;
13251522 }
@@ -1330,7 +1527,17 @@ fn generate_element(
13301527 // Pass through src, type, defer, async, etc.
13311528 for attr in &elem.attrs {
13321529 let key = attr.key.as_str();
1333− if matches!(key, "src" | "type" | "defer" | "async" | "crossorigin" | "integrity" | "nomodule" | "id") {
1530+ if matches!(
1531+ key,
1532+ "src"
1533+ | "type"
1534+ | "defer"
1535+ | "async"
1536+ | "crossorigin"
1537+ | "integrity"
1538+ | "nomodule"
1539+ | "id"
1540+ ) {
13341541 if let Some(val) = &attr.value {
13351542 out.push(' ');
13361543 out.push_str(key);
@@ -1349,7 +1556,9 @@ fn generate_element(
13491556 match child {
13501557 Node::Text(segments) => {
13511558 for seg in segments {
1352− if let TextSegment::Plain(text) = seg { out.push_str(text) }
1559+ if let TextSegment::Plain(text) = seg {
1560+ out.push_str(text)
1561+ }
13531562 }
13541563 }
13551564 Node::Raw(content) => out.push_str(content),
@@ -1413,8 +1622,11 @@ fn generate_element(
14131622 }
14141623
14151624 let tag = match &elem.kind {
1416− ElementKind::Row | ElementKind::Column | ElementKind::El
1417− | ElementKind::Grid | ElementKind::Stack => "div",
1625+ ElementKind::Row
1626+ | ElementKind::Column
1627+ | ElementKind::El
1628+ | ElementKind::Grid
1629+ | ElementKind::Stack => "div",
14181630 ElementKind::Text => "span",
14191631 ElementKind::Paragraph => "p",
14201632 ElementKind::Link => "a",
@@ -1433,7 +1645,11 @@ fn generate_element(
14331645 ElementKind::Aside => "aside",
14341646 // List
14351647 ElementKind::List => {
1436− if elem.attrs.iter().any(|a| a.key == "ordered") { "ol" } else { "ul" }
1648+ if elem.attrs.iter().any(|a| a.key == "ordered") {
1649+ "ol"
1650+ } else {
1651+ "ul"
1652+ }
14371653 }
14381654 ElementKind::ListItem => "li",
14391655 // Table
@@ -1483,10 +1699,16 @@ fn generate_element(
14831699 ElementKind::Noscript => "noscript",
14841700 ElementKind::Address => "address",
14851701 ElementKind::Search => "search",
1486− ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
1487− | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
1488− | ElementKind::Source | ElementKind::Spacer
1489− | ElementKind::Script | ElementKind::Breadcrumb => unreachable!(),
1702+ ElementKind::Image
1703+ | ElementKind::Input
1704+ | ElementKind::HorizontalRule
1705+ | ElementKind::Children
1706+ | ElementKind::Slot(_)
1707+ | ElementKind::Fragment
1708+ | ElementKind::Source
1709+ | ElementKind::Spacer
1710+ | ElementKind::Script
1711+ | ElementKind::Breadcrumb => unreachable!(),
14901712 };
14911713
14921714 let kind_label = match elem.kind {
@@ -1558,8 +1780,14 @@ fn generate_element(
15581780 };
15591781
15601782 // Track interactive elements for focus-visible CSS
1561− if matches!(elem.kind, ElementKind::Link | ElementKind::Button | ElementKind::Input
1562− | ElementKind::Select | ElementKind::Textarea) {
1783+ if matches!(
1784+ elem.kind,
1785+ ElementKind::Link
1786+ | ElementKind::Button
1787+ | ElementKind::Input
1788+ | ElementKind::Select
1789+ | ElementKind::Textarea
1790+ ) {
15631791 ctx.has_interactive = true;
15641792 }
15651793
@@ -1569,7 +1797,10 @@ fn generate_element(
15691797
15701798 if ctx.dev && elem.line_num > 0 {
15711799 out.push_str(&ctx.indent());
1572− out.push_str(&format!("<!-- @{} line {} -->\n", kind_label, elem.line_num));
1800+ out.push_str(&format!(
1801+ "<!-- @{} line {} -->\n",
1802+ kind_label, elem.line_num
1803+ ));
15731804 }
15741805 out.push_str(&ctx.indent());
15751806 out.push('<');
@@ -1581,64 +1812,70 @@ fn generate_element(
15811812 }
15821813
15831814 if elem.kind == ElementKind::Link
1584− && let Some(url) = &elem.argument {
1585− out.push_str(" href=\"");
1586− out.push_str(&html_escape(url));
1587− out.push('"');
1588− // Auto rel="noopener noreferrer" and target="_blank" for external links
1589− let is_external = url.starts_with("http://") || url.starts_with("https://");
1590− if is_external {
1591− let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1592− let has_target = elem.attrs.iter().any(|a| a.key == "target");
1593− if !has_rel {
1594− out.push_str(" rel=\"noopener noreferrer\"");
1595− }
1596− if !has_target {
1597− out.push_str(" target=\"_blank\"");
1598− }
1815+ && let Some(url) = &elem.argument
1816+ {
1817+ out.push_str(" href=\"");
1818+ out.push_str(&html_escape(url));
1819+ out.push('"');
1820+ // Auto rel="noopener noreferrer" and target="_blank" for external links
1821+ let is_external = url.starts_with("http://") || url.starts_with("https://");
1822+ if is_external {
1823+ let has_rel = elem.attrs.iter().any(|a| a.key == "rel");
1824+ let has_target = elem.attrs.iter().any(|a| a.key == "target");
1825+ if !has_rel {
1826+ out.push_str(" rel=\"noopener noreferrer\"");
1827+ }
1828+ if !has_target {
1829+ out.push_str(" target=\"_blank\"");
15991830 }
16001831 }
1832+ }
16011833
16021834 // Video/Audio src
16031835 if matches!(elem.kind, ElementKind::Video | ElementKind::Audio)
1604− && let Some(src) = &elem.argument {
1605− out.push_str(" src=\"");
1606− out.push_str(&html_escape(src));
1607− out.push('"');
1608− }
1836+ && let Some(src) = &elem.argument
1837+ {
1838+ out.push_str(" src=\"");
1839+ out.push_str(&html_escape(src));
1840+ out.push('"');
1841+ }
16091842
16101843 // Form action
16111844 if elem.kind == ElementKind::Form
1612− && let Some(action) = &elem.argument {
1613− out.push_str(" action=\"");
1614− out.push_str(&html_escape(action));
1615− out.push('"');
1616− }
1845+ && let Some(action) = &elem.argument
1846+ {
1847+ out.push_str(" action=\"");
1848+ out.push_str(&html_escape(action));
1849+ out.push('"');
1850+ }
16171851
16181852 // Iframe src
16191853 if elem.kind == ElementKind::Iframe
1620− && let Some(src) = &elem.argument {
1621− out.push_str(" src=\"");
1622− out.push_str(&html_escape(src));
1623− out.push('"');
1624− }
1854+ && let Some(src) = &elem.argument
1855+ {
1856+ out.push_str(" src=\"");
1857+ out.push_str(&html_escape(src));
1858+ out.push('"');
1859+ }
16251860
16261861 // Tooltip title
16271862 if elem.kind == ElementKind::Tooltip
1628− && let Some(text) = &elem.argument {
1629− out.push_str(" title=\"");
1630− out.push_str(&html_escape(text));
1631− out.push('"');
1632− }
1863+ && let Some(text) = &elem.argument
1864+ {
1865+ out.push_str(" title=\"");
1866+ out.push_str(&html_escape(text));
1867+ out.push('"');
1868+ }
16331869
16341870 // Time datetime
16351871 if elem.kind == ElementKind::Time
16361872 && let Some(dt) = elem.attrs.iter().find(|a| a.key == "datetime")
1637− && let Some(val) = &dt.value {
1638− out.push_str(" datetime=\"");
1639− out.push_str(&html_escape(val));
1640− out.push('"');
1641− }
1873+ && let Some(val) = &dt.value
1874+ {
1875+ out.push_str(" datetime=\"");
1876+ out.push_str(&html_escape(val));
1877+ out.push('"');
1878+ }
16421879
16431880 // Critical CSS: inline styles directly instead of using a class
16441881 let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
@@ -1669,7 +1906,10 @@ fn generate_element(
16691906
16701907 // Source map attributes in dev mode
16711908 if ctx.dev && elem.line_num > 0 {
1672− out.push_str(&format!(" data-hl-line=\"{}\" data-hl-el=\"{}\"", elem.line_num, kind_label));
1909+ out.push_str(&format!(
1910+ " data-hl-line=\"{}\" data-hl-el=\"{}\"",
1911+ elem.line_num, kind_label
1912+ ));
16731913 }
16741914
16751915 out.push('>');
@@ -1701,10 +1941,10 @@ fn generate_element(
17011941 | ElementKind::Tooltip
17021942 | ElementKind::Chip
17031943 | ElementKind::Tag
1704− )
1705− && let Some(text) = &elem.argument {
1706− out.push_str(&html_escape(text));
1707− }
1944+ ) && let Some(text) = &elem.argument
1945+ {
1946+ out.push_str(&html_escape(text));
1947+ }
17081948
17091949 // Children
17101950 ctx.depth += 1;
@@ -1745,7 +1985,10 @@ fn generate_self_closing(
17451985
17461986 if ctx.dev && elem.line_num > 0 {
17471987 out.push_str(&ctx.indent());
1748− out.push_str(&format!("<!-- @{} line {} -->\n", kind_label, elem.line_num));
1988+ out.push_str(&format!(
1989+ "<!-- @{} line {} -->\n",
1990+ kind_label, elem.line_num
1991+ ));
17491992 }
17501993 out.push_str(&ctx.indent());
17511994 out.push('<');
@@ -1756,12 +1999,19 @@ fn generate_self_closing(
17561999 let src = elem.argument.as_deref().unwrap_or("");
17572000 let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
17582001 if is_inline && !src.is_empty() && !src.ends_with(".svg") {
1759− let mime = if src.ends_with(".png") { "image/png" }
1760− else if src.ends_with(".jpg") || src.ends_with(".jpeg") { "image/jpeg" }
1761− else if src.ends_with(".gif") { "image/gif" }
1762− else if src.ends_with(".webp") { "image/webp" }
1763− else if src.ends_with(".avif") { "image/avif" }
1764− else { "application/octet-stream" };
2002+ let mime = if src.ends_with(".png") {
2003+ "image/png"
2004+ } else if src.ends_with(".jpg") || src.ends_with(".jpeg") {
2005+ "image/jpeg"
2006+ } else if src.ends_with(".gif") {
2007+ "image/gif"
2008+ } else if src.ends_with(".webp") {
2009+ "image/webp"
2010+ } else if src.ends_with(".avif") {
2011+ "image/avif"
2012+ } else {
2013+ "application/octet-stream"
2014+ };
17652015 if let Ok(data) = std::fs::read(src) {
17662016 let b64 = base64_encode(&data);
17672017 out.push_str(" src=\"data:");
@@ -1793,7 +2043,10 @@ fn generate_self_closing(
17932043
17942044 // Source map attributes in dev mode (self-closing)
17952045 if ctx.dev && elem.line_num > 0 {
1796− out.push_str(&format!(" data-hl-line=\"{}\" data-hl-el=\"{}\"", elem.line_num, kind_label));
2046+ out.push_str(&format!(
2047+ " data-hl-line=\"{}\" data-hl-el=\"{}\"",
2048+ elem.line_num, kind_label
2049+ ));
17972050 }
17982051
17992052 // Image optimization: auto-add loading="lazy" and decoding="async"
@@ -1801,60 +2054,68 @@ fn generate_self_closing(
18012054 // SVG inlining: @image [inline] logo.svg
18022055 if elem.attrs.iter().any(|a| a.key == "inline")
18032056 && let Some(src) = &elem.argument
1804− && src.ends_with(".svg")
1805− && let Ok(svg_content) = std::fs::read_to_string(src) {
1806− // Close the tag we opened, then emit inline SVG instead
1807− out.truncate(out.rfind('<').unwrap_or(0));
1808− out.push_str(&ctx.indent());
1809− out.push_str(svg_content.trim());
1810− out.push_str(ctx.nl());
1811− return;
1812− }
2057+ && src.ends_with(".svg")
2058+ && let Ok(svg_content) = std::fs::read_to_string(src)
2059+ {
2060+ // Close the tag we opened, then emit inline SVG instead
2061+ out.truncate(out.rfind('<').unwrap_or(0));
2062+ out.push_str(&ctx.indent());
2063+ out.push_str(svg_content.trim());
2064+ out.push_str(ctx.nl());
2065+ return;
2066+ }
18132067 // Responsive srcset: @image photo.jpg [responsive 400 800 1200]
18142068 let responsive_attr = elem.attrs.iter().find(|a| a.key == "responsive");
18152069 if let Some(resp) = responsive_attr
1816− && let Some(ref sizes_str) = resp.value {
1817− let widths: Vec<&str> = sizes_str.split_whitespace().collect();
1818− if !widths.is_empty() {
1819− let src = elem.argument.as_deref().unwrap_or("");
1820− if !src.is_empty() {
1821− // Generate srcset with width descriptors
1822− // Convention: file-{width}.ext (e.g., photo-400.jpg)
1823− let dot_pos = src.rfind('.').unwrap_or(src.len());
1824− let base = &src[..dot_pos];
1825− let ext = &src[dot_pos..];
1826− let mut srcset_parts = Vec::new();
1827− for w in &widths {
1828− srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
1829− }
1830− out.push_str(" srcset=\"");
1831− out.push_str(&srcset_parts.join(", "));
1832− out.push('"');
1833− // Generate sizes attribute
1834− let max_width = widths.last().unwrap_or(&"100vw");
1835− out.push_str(&format!(" sizes=\"(max-width: {}px) 100vw, {}px\"", max_width, max_width));
2070+ && let Some(ref sizes_str) = resp.value
2071+ {
2072+ let widths: Vec<&str> = sizes_str.split_whitespace().collect();
2073+ if !widths.is_empty() {
2074+ let src = elem.argument.as_deref().unwrap_or("");
2075+ if !src.is_empty() {
2076+ // Generate srcset with width descriptors
2077+ // Convention: file-{width}.ext (e.g., photo-400.jpg)
2078+ let dot_pos = src.rfind('.').unwrap_or(src.len());
2079+ let base = &src[..dot_pos];
2080+ let ext = &src[dot_pos..];
2081+ let mut srcset_parts = Vec::new();
2082+ for w in &widths {
2083+ srcset_parts.push(format!("{}-{}{} {}w", base, w, ext, w));
18362084 }
2085+ out.push_str(" srcset=\"");
2086+ out.push_str(&srcset_parts.join(", "));
2087+ out.push('"');
2088+ // Generate sizes attribute
2089+ let max_width = widths.last().unwrap_or(&"100vw");
2090+ out.push_str(&format!(
2091+ " sizes=\"(max-width: {}px) 100vw, {}px\"",
2092+ max_width, max_width
2093+ ));
18372094 }
18382095 }
2096+ }
18392097
18402098 // Auto image dimensions: read local image file to inject width/height + aspect-ratio
18412099 let has_width = elem.attrs.iter().any(|a| a.key == "width");
18422100 let has_height = elem.attrs.iter().any(|a| a.key == "height");
18432101 if (!has_width || !has_height)
18442102 && let Some(ref src) = elem.argument
1845− && !src.starts_with("http://") && !src.starts_with("https://") && !src.starts_with("data:")
1846− && let Some((w, h)) = read_image_dimensions(src) {
1847− if !has_width {
1848− out.push_str(&format!(" width=\"{}\"", w));
1849− }
1850− if !has_height {
1851− out.push_str(&format!(" height=\"{}\"", h));
1852− }
1853− // Auto aspect-ratio to prevent CLS
1854− if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
1855− out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
1856− }
1857− }
2103+ && !src.starts_with("http://")
2104+ && !src.starts_with("https://")
2105+ && !src.starts_with("data:")
2106+ && let Some((w, h)) = read_image_dimensions(src)
2107+ {
2108+ if !has_width {
2109+ out.push_str(&format!(" width=\"{}\"", w));
2110+ }
2111+ if !has_height {
2112+ out.push_str(&format!(" height=\"{}\"", h));
2113+ }
2114+ // Auto aspect-ratio to prevent CLS
2115+ if !elem.attrs.iter().any(|a| a.key == "aspect-ratio") {
2116+ out.push_str(&format!(" style=\"aspect-ratio:{}/{}\"", w, h));
2117+ }
2118+ }
18582119
18592120 // Smart image loading: first 3 images get fetchpriority="high" (above the fold),
18602121 // subsequent images get loading="lazy" + decoding="async"
@@ -1920,7 +2181,10 @@ fn compute_class(
19202181
19212182 // Auto-add ::-webkit-scrollbar pseudo for no-scrollbar attribute
19222183 if attrs.iter().any(|a| a.key == "no-scrollbar") {
1923− pseudo.push(("::-webkit-scrollbar".to_string(), "display:none;".to_string()));
2184+ pseudo.push((
2185+ "::-webkit-scrollbar".to_string(),
2186+ "display:none;".to_string(),
2187+ ));
19242188 }
19252189
19262190 // Collect nth:EXPR: dynamic pseudo selectors
@@ -1949,12 +2213,13 @@ fn compute_class(
19492213 let mut has_prefixes: Vec<String> = Vec::new();
19502214 for attr in attrs {
19512215 if attr.key.starts_with("has(")
1952− && let Some(close) = attr.key.find("):") {
1953− let prefix = format!("{}:", &attr.key[..close + 1]);
1954− if !has_prefixes.contains(&prefix) {
1955− has_prefixes.push(prefix);
1956− }
2216+ && let Some(close) = attr.key.find("):")
2217+ {
2218+ let prefix = format!("{}:", &attr.key[..close + 1]);
2219+ if !has_prefixes.contains(&prefix) {
2220+ has_prefixes.push(prefix);
19572221 }
2222+ }
19582223 }
19592224 for prefix in &has_prefixes {
19602225 let inner = &prefix[4..prefix.len() - 2]; // extract selector from has(selector):
@@ -1992,7 +2257,18 @@ fn compute_class(
19922257 let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind);
19932258 let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind);
19942259
1995− styles.get_class(base, pseudo, responsive, dark, print, motion_safe, motion_reduce, landscape, portrait, container)
2260+ styles.get_class(
2261+ base,
2262+ pseudo,
2263+ responsive,
2264+ dark,
2265+ print,
2266+ motion_safe,
2267+ motion_reduce,
2268+ landscape,
2269+ portrait,
2270+ container,
2271+ )
19962272}
19972273
19982274fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
@@ -2046,7 +2322,14 @@ const PSEUDO_PREFIXES: &[(&str, &str)] = &[
20462322 ("invalid:", ":invalid"),
20472323];
20482324const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
2049−const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
2325+const MEDIA_PREFIXES: &[&str] = &[
2326+ "dark:",
2327+ "print:",
2328+ "motion-safe:",
2329+ "motion-reduce:",
2330+ "landscape:",
2331+ "portrait:",
2332+];
20502333const CONTAINER_QUERY_PREFIXES: &[&str] = &["cq-sm:", "cq-md:", "cq-lg:", "cq-xl:", "cq-2xl:"];
20512334
20522335fn is_prefixed_attr(key: &str) -> bool {
@@ -2443,7 +2726,11 @@ fn attrs_to_css(
24432726 "grid-cols" => {
24442727 if let Some(v) = val {
24452728 if let Ok(n) = v.parse::<u32>() {
2446− push_css(&mut css, "grid-template-columns", &format!("repeat({},1fr)", n));
2729+ push_css(
2730+ &mut css,
2731+ "grid-template-columns",
2732+ &format!("repeat({},1fr)", n),
2733+ );
24472734 } else {
24482735 push_css(&mut css, "grid-template-columns", v);
24492736 }
@@ -2452,7 +2739,11 @@ fn attrs_to_css(
24522739 "grid-rows" => {
24532740 if let Some(v) = val {
24542741 if let Ok(n) = v.parse::<u32>() {
2455− push_css(&mut css, "grid-template-rows", &format!("repeat({},1fr)", n));
2742+ push_css(
2743+ &mut css,
2744+ "grid-template-rows",
2745+ &format!("repeat({},1fr)", n),
2746+ );
24562747 } else {
24572748 push_css(&mut css, "grid-template-rows", v);
24582749 }
@@ -2542,10 +2833,14 @@ fn attrs_to_css(
25422833 }
25432834
25442835 // Logical property start/end variants
2545− "padding-inline-start" | "padding-inline-end" |
2546− "padding-block-start" | "padding-block-end" |
2547− "margin-inline-start" | "margin-inline-end" |
2548− "margin-block-start" | "margin-block-end" => {
2836+ "padding-inline-start"
2837+ | "padding-inline-end"
2838+ | "padding-block-start"
2839+ | "padding-block-end"
2840+ | "margin-inline-start"
2841+ | "margin-inline-end"
2842+ | "margin-block-start"
2843+ | "margin-block-end" => {
25492844 if let Some(v) = val {
25502845 push_css(&mut css, effective_key, &css_px(v));
25512846 }
@@ -2557,42 +2852,47 @@ fn attrs_to_css(
25572852 push_css(&mut css, effective_key, &css_px_multi(v));
25582853 }
25592854 }
2560− "inset-inline-start" | "inset-inline-end" |
2561− "inset-block-start" | "inset-block-end" => {
2855+ "inset-inline-start" | "inset-inline-end" | "inset-block-start" | "inset-block-end" => {
25622856 if let Some(v) = val {
25632857 push_css(&mut css, effective_key, &css_px(v));
25642858 }
25652859 }
25662860
25672861 // Logical border
2568− "border-inline" | "border-block" |
2569− "border-inline-start" | "border-inline-end" |
2570− "border-block-start" | "border-block-end" => {
2862+ "border-inline"
2863+ | "border-block"
2864+ | "border-inline-start"
2865+ | "border-inline-end"
2866+ | "border-block-start"
2867+ | "border-block-end" => {
25712868 if let Some(v) = val {
25722869 push_css(&mut css, effective_key, v);
25732870 }
25742871 }
25752872
25762873 // Logical border-radius
2577− "border-start-start-radius" | "border-start-end-radius" |
2578− "border-end-start-radius" | "border-end-end-radius" => {
2874+ "border-start-start-radius"
2875+ | "border-start-end-radius"
2876+ | "border-end-start-radius"
2877+ | "border-end-end-radius" => {
25792878 if let Some(v) = val {
25802879 push_css(&mut css, effective_key, &css_px(v));
25812880 }
25822881 }
25832882
25842883 // Logical scroll margins & padding
2585− "scroll-margin-inline" | "scroll-margin-block" |
2586− "scroll-padding-inline" | "scroll-padding-block" => {
2884+ "scroll-margin-inline"
2885+ | "scroll-margin-block"
2886+ | "scroll-padding-inline"
2887+ | "scroll-padding-block" => {
25872888 if let Some(v) = val {
25882889 push_css(&mut css, effective_key, &css_px_multi(v));
25892890 }
25902891 }
25912892
25922893 // Logical sizing
2593− "inline-size" | "block-size" |
2594− "min-inline-size" | "max-inline-size" |
2595− "min-block-size" | "max-block-size" => {
2894+ "inline-size" | "block-size" | "min-inline-size" | "max-inline-size"
2895+ | "min-block-size" | "max-block-size" => {
25962896 if let Some(v) = val {
25972897 push_css(&mut css, effective_key, &css_px(v));
25982898 }
@@ -2866,85 +3166,147 @@ fn attrs_to_css(
28663166
28673167 // New CSS properties
28683168 "clip-path" => {
2869− if let Some(v) = val { push_css(&mut css, "clip-path", v); }
3169+ if let Some(v) = val {
3170+ push_css(&mut css, "clip-path", v);
3171+ }
28703172 }
28713173 "mix-blend-mode" => {
2872− if let Some(v) = val { push_css(&mut css, "mix-blend-mode", v); }
3174+ if let Some(v) = val {
3175+ push_css(&mut css, "mix-blend-mode", v);
3176+ }
28733177 }
28743178 "background-blend-mode" => {
2875− if let Some(v) = val { push_css(&mut css, "background-blend-mode", v); }
3179+ if let Some(v) = val {
3180+ push_css(&mut css, "background-blend-mode", v);
3181+ }
28763182 }
28773183 "writing-mode" => {
2878− if let Some(v) = val { push_css(&mut css, "writing-mode", v); }
3184+ if let Some(v) = val {
3185+ push_css(&mut css, "writing-mode", v);
3186+ }
28793187 }
28803188 "column-count" => {
2881− if let Some(v) = val { push_css(&mut css, "column-count", v); }
3189+ if let Some(v) = val {
3190+ push_css(&mut css, "column-count", v);
3191+ }
28823192 }
28833193 "column-gap" => {
2884− if let Some(v) = val { push_css(&mut css, "column-gap", &css_px(v)); }
3194+ if let Some(v) = val {
3195+ push_css(&mut css, "column-gap", &css_px(v));
3196+ }
28853197 }
28863198 "text-indent" => {
2887− if let Some(v) = val { push_css(&mut css, "text-indent", &css_px(v)); }
3199+ if let Some(v) = val {
3200+ push_css(&mut css, "text-indent", &css_px(v));
3201+ }
28883202 }
28893203 "hyphens" => {
2890− if let Some(v) = val { push_css(&mut css, "hyphens", v); }
3204+ if let Some(v) = val {
3205+ push_css(&mut css, "hyphens", v);
3206+ }
28913207 }
28923208 "flex-grow" => {
2893− if let Some(v) = val { push_css(&mut css, "flex-grow", v); }
3209+ if let Some(v) = val {
3210+ push_css(&mut css, "flex-grow", v);
3211+ }
28943212 }
28953213 "flex-shrink" => {
2896− if let Some(v) = val { push_css(&mut css, "flex-shrink", v); }
3214+ if let Some(v) = val {
3215+ push_css(&mut css, "flex-shrink", v);
3216+ }
28973217 }
28983218 "flex-basis" => {
2899− if let Some(v) = val { push_css(&mut css, "flex-basis", &css_px(v)); }
3219+ if let Some(v) = val {
3220+ push_css(&mut css, "flex-basis", &css_px(v));
3221+ }
29003222 }
29013223 "isolation" => {
2902− if let Some(v) = val { push_css(&mut css, "isolation", v); }
3224+ if let Some(v) = val {
3225+ push_css(&mut css, "isolation", v);
3226+ }
29033227 }
29043228 "place-content" => {
2905− if let Some(v) = val { push_css(&mut css, "place-content", v); }
3229+ if let Some(v) = val {
3230+ push_css(&mut css, "place-content", v);
3231+ }
29063232 }
29073233 "background-image" => {
2908− if let Some(v) = val { push_css(&mut css, "background-image", v); }
3234+ if let Some(v) = val {
3235+ push_css(&mut css, "background-image", v);
3236+ }
29093237 }
29103238 "font-weight" => {
2911− if let Some(v) = val { push_css(&mut css, "font-weight", v); }
3239+ if let Some(v) = val {
3240+ push_css(&mut css, "font-weight", v);
3241+ }
29123242 }
29133243 "font-style" => {
2914− if let Some(v) = val { push_css(&mut css, "font-style", v); }
3244+ if let Some(v) = val {
3245+ push_css(&mut css, "font-style", v);
3246+ }
29153247 }
29163248 "text-wrap" => {
2917− if let Some(v) = val { push_css(&mut css, "text-wrap", v); }
3249+ if let Some(v) = val {
3250+ push_css(&mut css, "text-wrap", v);
3251+ }
29183252 }
29193253 "will-change" => {
2920− if let Some(v) = val { push_css(&mut css, "will-change", v); }
3254+ if let Some(v) = val {
3255+ push_css(&mut css, "will-change", v);
3256+ }
29213257 }
29223258 "touch-action" => {
2923− if let Some(v) = val { push_css(&mut css, "touch-action", v); }
3259+ if let Some(v) = val {
3260+ push_css(&mut css, "touch-action", v);
3261+ }
29243262 }
29253263 "vertical-align" => {
2926− if let Some(v) = val { push_css(&mut css, "vertical-align", v); }
3264+ if let Some(v) = val {
3265+ push_css(&mut css, "vertical-align", v);
3266+ }
29273267 }
29283268 "scroll-margin" => {
2929− if let Some(v) = val { push_css(&mut css, "scroll-margin", &css_px(v)); }
3269+ if let Some(v) = val {
3270+ push_css(&mut css, "scroll-margin", &css_px(v));
3271+ }
29303272 }
2931− "scroll-margin-top" | "scroll-margin-bottom" | "scroll-margin-left" | "scroll-margin-right" => {
2932− if let Some(v) = val { push_css(&mut css, effective_key, &css_px(v)); }
3273+ "scroll-margin-top"
3274+ | "scroll-margin-bottom"
3275+ | "scroll-margin-left"
3276+ | "scroll-margin-right" => {
3277+ if let Some(v) = val {
3278+ push_css(&mut css, effective_key, &css_px(v));
3279+ }
29333280 }
29343281 "scroll-padding" => {
2935− if let Some(v) = val { push_css(&mut css, "scroll-padding", &css_px(v)); }
3282+ if let Some(v) = val {
3283+ push_css(&mut css, "scroll-padding", &css_px(v));
3284+ }
29363285 }
2937− "scroll-padding-top" | "scroll-padding-bottom" | "scroll-padding-left" | "scroll-padding-right" => {
2938− if let Some(v) = val { push_css(&mut css, effective_key, &css_px(v)); }
3286+ "scroll-padding-top"
3287+ | "scroll-padding-bottom"
3288+ | "scroll-padding-left"
3289+ | "scroll-padding-right" => {
3290+ if let Some(v) = val {
3291+ push_css(&mut css, effective_key, &css_px(v));
3292+ }
29393293 }
29403294 "direction" => {
2941− if let Some(v) = val { push_css(&mut css, "direction", v); }
3295+ if let Some(v) = val {
3296+ push_css(&mut css, "direction", v);
3297+ }
29423298 }
29433299
29443300 "content" => {
29453301 if let Some(v) = val {
29463302 // Wrap in quotes if not already quoted and not a CSS keyword
2947− if v.starts_with('"') || v.starts_with('\'') || v == "none" || v == "normal" || v.starts_with("attr(") || v.starts_with("counter(") {
3303+ if v.starts_with('"')
3304+ || v.starts_with('\'')
3305+ || v == "none"
3306+ || v == "normal"
3307+ || v.starts_with("attr(")
3308+ || v.starts_with("counter(")
3309+ {
29483310 push_css(&mut css, "content", v);
29493311 } else {
29503312 push_css(&mut css, "content", &format!("\"{}\"", v));
@@ -2953,7 +3315,6 @@ fn attrs_to_css(
29533315 }
29543316
29553317 // --- CSS Shorthands ---
2956−
29573318 "truncate" => {
29583319 push_css(&mut css, "overflow", "hidden");
29593320 push_css(&mut css, "text-overflow", "ellipsis");
@@ -2982,15 +3343,27 @@ fn attrs_to_css(
29823343 push_css(&mut css, "-ms-overflow-style", "none");
29833344 }
29843345 "skeleton" => {
2985− push_css(&mut css, "background", "linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%)");
3346+ push_css(
3347+ &mut css,
3348+ "background",
3349+ "linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%)",
3350+ );
29863351 push_css(&mut css, "background-size", "200% 100%");
2987− push_css(&mut css, "animation", "hl-skeleton 1.5s ease-in-out infinite");
3352+ push_css(
3353+ &mut css,
3354+ "animation",
3355+ "hl-skeleton 1.5s ease-in-out infinite",
3356+ );
29883357 }
29893358 "gradient" => {
29903359 if let Some(v) = val {
29913360 // Parse: "from to [angle]" or "color1 color2 [angle]"
29923361 let parts: Vec<&str> = v.split_whitespace().collect();
2993− let bg = if parts.len() >= 3 && (parts[2].ends_with("deg") || parts[2].ends_with("turn") || parts[2].ends_with("rad")) {
3362+ let bg = if parts.len() >= 3
3363+ && (parts[2].ends_with("deg")
3364+ || parts[2].ends_with("turn")
3365+ || parts[2].ends_with("rad"))
3366+ {
29943367 format!("linear-gradient({},{},{})", parts[2], parts[0], parts[1])
29953368 } else if parts.len() >= 2 {
29963369 format!("linear-gradient({},{})", parts[0], parts[1])
@@ -3003,68 +3376,102 @@ fn attrs_to_css(
30033376
30043377 // Grid areas
30053378 "grid-template-areas" => {
3006− if let Some(v) = val { push_css(&mut css, "grid-template-areas", v); }
3379+ if let Some(v) = val {
3380+ push_css(&mut css, "grid-template-areas", v);
3381+ }
30073382 }
30083383 "grid-area" => {
3009− if let Some(v) = val { push_css(&mut css, "grid-area", v); }
3384+ if let Some(v) = val {
3385+ push_css(&mut css, "grid-area", v);
3386+ }
30103387 }
30113388
30123389 // View transitions
30133390 "view-transition-name" => {
3014− if let Some(v) = val { push_css(&mut css, "view-transition-name", v); }
3391+ if let Some(v) = val {
3392+ push_css(&mut css, "view-transition-name", v);
3393+ }
30153394 }
30163395
30173396 // Animate shorthand (alias for animation)
30183397 "animate" => {
3019− if let Some(v) = val { push_css(&mut css, "animation", v); }
3398+ if let Some(v) = val {
3399+ push_css(&mut css, "animation", v);
3400+ }
30203401 }
30213402
30223403 // CSS subgrid
30233404 "grid-template-columns" => {
3024− if let Some(v) = val { push_css(&mut css, "grid-template-columns", v); }
3405+ if let Some(v) = val {
3406+ push_css(&mut css, "grid-template-columns", v);
3407+ }
30253408 }
30263409 "grid-template-rows" => {
3027− if let Some(v) = val { push_css(&mut css, "grid-template-rows", v); }
3410+ if let Some(v) = val {
3411+ push_css(&mut css, "grid-template-rows", v);
3412+ }
30283413 }
30293414
30303415 // Scroll-driven animations
30313416 "animation-timeline" => {
3032− if let Some(v) = val { push_css(&mut css, "animation-timeline", v); }
3417+ if let Some(v) = val {
3418+ push_css(&mut css, "animation-timeline", v);
3419+ }
30333420 }
30343421 "animation-range" => {
3035− if let Some(v) = val { push_css(&mut css, "animation-range", v); }
3422+ if let Some(v) = val {
3423+ push_css(&mut css, "animation-range", v);
3424+ }
30363425 }
30373426 "view-timeline-name" => {
3038− if let Some(v) = val { push_css(&mut css, "view-timeline-name", v); }
3427+ if let Some(v) = val {
3428+ push_css(&mut css, "view-timeline-name", v);
3429+ }
30393430 }
30403431 "view-timeline-axis" => {
3041− if let Some(v) = val { push_css(&mut css, "view-timeline-axis", v); }
3432+ if let Some(v) = val {
3433+ push_css(&mut css, "view-timeline-axis", v);
3434+ }
30423435 }
30433436 "scroll-timeline-name" => {
3044− if let Some(v) = val { push_css(&mut css, "scroll-timeline-name", v); }
3437+ if let Some(v) = val {
3438+ push_css(&mut css, "scroll-timeline-name", v);
3439+ }
30453440 }
30463441 "scroll-timeline-axis" => {
3047− if let Some(v) = val { push_css(&mut css, "scroll-timeline-axis", v); }
3442+ if let Some(v) = val {
3443+ push_css(&mut css, "scroll-timeline-axis", v);
3444+ }
30483445 }
30493446
30503447 // Anchor positioning
30513448 "anchor-name" => {
3052− if let Some(v) = val { push_css(&mut css, "anchor-name", v); }
3449+ if let Some(v) = val {
3450+ push_css(&mut css, "anchor-name", v);
3451+ }
30533452 }
30543453 "position-anchor" => {
3055− if let Some(v) = val { push_css(&mut css, "position-anchor", v); }
3454+ if let Some(v) = val {
3455+ push_css(&mut css, "position-anchor", v);
3456+ }
30563457 }
30573458 "position-area" => {
3058− if let Some(v) = val { push_css(&mut css, "position-area", v); }
3459+ if let Some(v) = val {
3460+ push_css(&mut css, "position-area", v);
3461+ }
30593462 }
30603463 "inset-area" => {
30613464 // Legacy alias for position-area
3062− if let Some(v) = val { push_css(&mut css, "position-area", v); }
3465+ if let Some(v) = val {
3466+ push_css(&mut css, "position-area", v);
3467+ }
30633468 }
30643469
30653470 // initial-letter (drop caps)
30663471 "initial-letter" => {
3067− if let Some(v) = val { push_css(&mut css, "initial-letter", v); }
3472+ if let Some(v) = val {
3473+ push_css(&mut css, "initial-letter", v);
3474+ }
30683475 }
30693476
30703477 // Critical CSS hint — not CSS, handled elsewhere
@@ -3072,18 +3479,15 @@ fn attrs_to_css(
30723479
30733480 // Identity and HTML passthrough — not CSS
30743481 "id" | "class" => {}
3075− "type" | "placeholder" | "name" | "value" | "disabled" | "required"
3076− | "checked" | "for" | "action" | "method" | "autocomplete" | "min"
3077− | "max" | "step" | "pattern" | "maxlength" | "rows" | "cols"
3078− | "multiple" | "alt" | "role" | "tabindex" | "title"
3079− | "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload"
3080− | "loading" | "decoding" | "ordered" | "src"
3081− | "open" | "novalidate" | "low" | "high" | "optimum"
3082− | "colspan" | "rowspan" | "scope" | "inline" | "responsive"
3083− | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list"
3084− | "sandbox" | "allow" | "allowfullscreen" | "referrerpolicy"
3085− | "formaction" | "formmethod" | "formtarget" | "target"
3086− | "autofocus" => {}
3482+ "type" | "placeholder" | "name" | "value" | "disabled" | "required" | "checked"
3483+ | "for" | "action" | "method" | "autocomplete" | "min" | "max" | "step" | "pattern"
3484+ | "maxlength" | "rows" | "cols" | "multiple" | "alt" | "role" | "tabindex"
3485+ | "title" | "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload"
3486+ | "loading" | "decoding" | "ordered" | "src" | "open" | "novalidate" | "low"
3487+ | "high" | "optimum" | "colspan" | "rowspan" | "scope" | "inline" | "responsive"
3488+ | "datetime" | "media" | "sizes" | "srcset" | "cite" | "list" | "sandbox" | "allow"
3489+ | "allowfullscreen" | "referrerpolicy" | "formaction" | "formmethod" | "formtarget"
3490+ | "target" | "autofocus" => {}
30873491
30883492 _ => {}
30893493 }
@@ -3101,8 +3505,8 @@ fn push_css(css: &mut String, prop: &str, value: &str) {
31013505
31023506/// Known CSS units — if a value ends with one, skip appending `px`.
31033507const CSS_UNITS: &[&str] = &[
3104− "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh",
3105− "ch", "ex", "cm", "mm", "in", "pt", "pc", "fr",
3508+ "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm", "mm",
3509+ "in", "pt", "pc", "fr",
31063510];
31073511
31083512/// Format a numeric value: if it already has a CSS unit, pass through as-is;
@@ -3115,7 +3519,12 @@ fn css_px(value: &str) -> String {
31153519 if CSS_UNITS.iter().any(|u| v.ends_with(u)) {
31163520 return v.to_string();
31173521 }
3118− if v.starts_with("var(") || v.starts_with("calc(") || v.starts_with("clamp(") || v.starts_with("min(") || v.starts_with("max(") {
3522+ if v.starts_with("var(")
3523+ || v.starts_with("calc(")
3524+ || v.starts_with("clamp(")
3525+ || v.starts_with("min(")
3526+ || v.starts_with("max(")
3527+ {
31193528 return v.to_string();
31203529 }
31213530 format!("{}px", v)
@@ -3230,9 +3639,11 @@ fn read_svg_dimensions(text: &str) -> Option<(u32, u32)> {
32303639 let parts: Vec<&str> = rest[..end].split_whitespace().collect();
32313640 if parts.len() == 4
32323641 && let (Ok(w), Ok(h)) = (parts[2].parse::<f64>(), parts[3].parse::<f64>())
3233− && w > 0.0 && h > 0.0 {
3234− return Some((w.round() as u32, h.round() as u32));
3235− }
3642+ && w > 0.0
3643+ && h > 0.0
3644+ {
3645+ return Some((w.round() as u32, h.round() as u32));
3646+ }
32363647 }
32373648 }
32383649 // Fall back to width/height attributes on <svg>
@@ -3258,7 +3669,8 @@ fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
32583669 // Walk ISOBMFF boxes looking for "ispe" (image spatial extents)
32593670 let mut i = 0;
32603671 while i + 8 <= data.len() {
3261− let box_size = u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
3672+ let box_size =
3673+ u32::from_be_bytes([data[i], data[i + 1], data[i + 2], data[i + 3]]) as usize;
32623674 let box_type = &data[i + 4..i + 8];
32633675 if box_size < 8 {
32643676 break;
@@ -3271,12 +3683,16 @@ fn read_avif_dimensions(data: &[u8]) -> Option<(u32, u32)> {
32713683 return Some((w, h));
32723684 }
32733685 // Recurse into container boxes (meta, iprp, ipco)
3274− if matches!(box_type, b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia") {
3686+ if matches!(
3687+ box_type,
3688+ b"meta" | b"iprp" | b"ipco" | b"moov" | b"trak" | b"mdia"
3689+ ) {
32753690 let header_size = if box_type == b"meta" { 12 } else { 8 };
32763691 if i + header_size < box_end
3277− && let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end]) {
3278− return Some(dims);
3279− }
3692+ && let Some(dims) = read_avif_dimensions(&data[i + header_size..box_end])
3693+ {
3694+ return Some(dims);
3695+ }
32803696 }
32813697 i = box_end;
32823698 }
@@ -3342,16 +3758,19 @@ pub fn generate_source_map(doc: &Document, source_file: &str) -> String {
33423758 if mapping_idx < mappings.len() && mappings[mapping_idx].0 == gen_line {
33433759 let source_line = mappings[mapping_idx].1 as i64 - 1; // 0-based
33443760 // Segment: gen_col=0, source_idx=0, source_line=delta, source_col=0
3345− vlq_encode(0, &mut vlq); // generated column (always 0)
3346− vlq_encode(0, &mut vlq); // source file index (always 0)
3761+ vlq_encode(0, &mut vlq); // generated column (always 0)
3762+ vlq_encode(0, &mut vlq); // source file index (always 0)
33473763 vlq_encode(source_line - prev_source_line, &mut vlq); // source line delta
3348− vlq_encode(0, &mut vlq); // source column (always 0)
3764+ vlq_encode(0, &mut vlq); // source column (always 0)
33493765 prev_source_line = source_line;
33503766 mapping_idx += 1;
33513767 }
33523768 }
33533769
3354− let escaped_file = source_file.replace(".hl", ".html").replace('\\', "\\\\").replace('"', "\\\"");
3770+ let escaped_file = source_file
3771+ .replace(".hl", ".html")
3772+ .replace('\\', "\\\\")
3773+ .replace('"', "\\\"");
33553774 let escaped_source = source_file.replace('\\', "\\\\").replace('"', "\\\"");
33563775
33573776 format!(
@@ -3414,7 +3833,9 @@ fn collect_dns_prefetch(html: &str, dev: bool) -> String {
34143833 while let Some(pos) = rest.find("https://") {
34153834 let start = pos + 8; // skip "https://"
34163835 rest = &rest[start..];
3417− let end = rest.find(['/', '"', '\'', ' ', '>', ')']).unwrap_or(rest.len());
3836+ let end = rest
3837+ .find(['/', '"', '\'', ' ', '>', ')'])
3838+ .unwrap_or(rest.len());
34183839 let domain = &rest[..end];
34193840 if !domain.is_empty() && domain.contains('.') && !domains.contains(&domain.to_string()) {
34203841 domains.push(domain.to_string());
@@ -3423,9 +3844,15 @@ fn collect_dns_prefetch(html: &str, dev: bool) -> String {
34233844 let mut out = String::new();
34243845 for domain in &domains {
34253846 if dev {
3426− out.push_str(&format!("<link rel=\"dns-prefetch\" href=\"//{}\">\n", domain));
3847+ out.push_str(&format!(
3848+ "<link rel=\"dns-prefetch\" href=\"//{}\">\n",
3849+ domain
3850+ ));
34273851 } else {
3428− out.push_str(&format!("<link rel=\"dns-prefetch\" href=\"//{}\">", domain));
3852+ out.push_str(&format!(
3853+ "<link rel=\"dns-prefetch\" href=\"//{}\">",
3854+ domain
3855+ ));
34293856 }
34303857 }
34313858 out
crates/htmlang-core/src/codegen_tests.rs+25−10
@@ -1,7 +1,7 @@
11#[cfg(test)]
22mod tests {
3− use crate::parser::parse;
43 use crate::codegen::{generate, generate_dev, generate_partial, short_class_name};
4+ use crate::parser::parse;
55
66 fn compile(src: &str) -> String {
77 let r = parse(src);
@@ -17,15 +17,24 @@ mod tests {
1717
1818 #[test]
1919 fn non_empty_document_emits_doctype() {
20− let out = compile("@text hello\n");
21− assert!(out.to_lowercase().contains("<!doctype html>"), "missing doctype: {out}");
20+ let out = compile("@page Hello\n@text hello\n");
21+ assert!(
22+ out.to_lowercase().contains("<!doctype html>"),
23+ "missing doctype: {out}"
24+ );
2225 }
2326
2427 #[test]
2528 fn text_content_is_html_escaped() {
2629 let out = compile("@text <script>alert(1)</script>\n");
27− assert!(!out.contains("<script>alert(1)</script>"), "raw script should be escaped: {out}");
28− assert!(out.contains("&lt;script&gt;"), "escaped entities missing: {out}");
30+ assert!(
31+ !out.contains("<script>alert(1)</script>"),
32+ "raw script should be escaped: {out}"
33+ );
34+ assert!(
35+ out.contains("&lt;script&gt;"),
36+ "escaped entities missing: {out}"
37+ );
2938 }
3039
3140 #[test]
@@ -49,22 +58,25 @@ mod tests {
4958 assert_eq!(short_class_name(0), "a");
5059 assert_eq!(short_class_name(25), "z");
5160 // Higher indexes must produce unique, stable names.
52− let names: std::collections::HashSet<_> =
53− (0..200).map(short_class_name).collect();
61+ let names: std::collections::HashSet<_> = (0..200).map(short_class_name).collect();
5462 assert_eq!(names.len(), 200, "short_class_name collisions");
5563 }
5664
5765 #[test]
5866 fn page_title_appears_in_head() {
5967 let out = compile("@page Welcome\n");
60− assert!(out.contains("<title>Welcome</title>"), "title missing in:\n{out}");
68+ assert!(
69+ out.contains("<title>Welcome</title>"),
70+ "title missing in:\n{out}"
71+ );
6172 }
6273
6374 #[test]
6475 fn same_styles_share_one_class() {
6576 // Both elements request padding:10 + background red. The collector must
6677 // dedupe them into a single generated class rather than emitting two.
67− let src = "@row\n @el [padding 10, background red] a\n @el [padding 10, background red] b\n";
78+ let src =
79+ "@row\n @el [padding 10, background red] a\n @el [padding 10, background red] b\n";
6880 let out = compile(src);
6981 // Count occurrences of a `.X{` CSS class declaration that contains both
7082 // padding and the red color. With dedup, the rule body should appear at
@@ -76,6 +88,9 @@ mod tests {
7688 count += 1;
7789 rest = &rest[pos + needle.len()..];
7890 }
79− assert!(count <= 1, "duplicate CSS rule emitted ({count} times) in:\n{out}");
91+ assert!(
92+ count <= 1,
93+ "duplicate CSS rule emitted ({count} times) in:\n{out}"
94+ );
8095 }
8196}
crates/htmlang-core/src/parser.rs+1359−554
@@ -228,20 +228,22 @@ fn collect_image_preload_hints(nodes: &[Node]) -> Vec<crate::ast::PreloadHint> {
228228
229229fn collect_images_recursive(nodes: &[Node], hints: &mut Vec<crate::ast::PreloadHint>, max: usize) {
230230 for node in nodes {
231− if hints.len() >= max { return; }
231+ if hints.len() >= max {
232+ return;
233+ }
232234 if let Node::Element(elem) = node {
233235 if elem.kind == ElementKind::Image
234236 && let Some(ref src) = elem.argument
235− && !src.is_empty()
236− && !src.starts_with("data:")
237− && !src.starts_with('#')
238− {
239− hints.push(crate::ast::PreloadHint {
240− href: src.clone(),
241− as_type: "image".to_string(),
242− crossorigin: false,
243− });
244− }
237+ && !src.is_empty()
238+ && !src.starts_with("data:")
239+ && !src.starts_with('#')
240+ {
241+ hints.push(crate::ast::PreloadHint {
242+ href: src.clone(),
243+ as_type: "image".to_string(),
244+ crossorigin: false,
245+ });
246+ }
245247 collect_images_recursive(&elem.children, hints, max);
246248 }
247249 }
@@ -302,9 +304,7 @@ fn preprocess(input: &str) -> Vec<Line> {
302304
303305 lines.push(Line {
304306 indent,
305− content: LineContent::Raw(
306− raw_content.trim_end_matches('\n').to_string(),
307− ),
307+ content: LineContent::Raw(raw_content.trim_end_matches('\n').to_string()),
308308 line_num: i,
309309 });
310310 continue;
@@ -344,11 +344,7 @@ fn preprocess(input: &str) -> Vec<Line> {
344344// ---------------------------------------------------------------------------
345345
346346impl Parser {
347− fn parse_children(
348− &mut self,
349− min_indent: usize,
350− ctx: &mut ParseContext,
351− ) -> Vec<Node> {
347+ fn parse_children(&mut self, min_indent: usize, ctx: &mut ParseContext) -> Vec<Node> {
352348 let mut nodes = Vec::new();
353349
354350 while self.pos < self.lines.len() {
@@ -379,9 +375,7 @@ impl Parser {
379375 source_line: source,
380376 });
381377 // Skip forward past any deeper-indented children of the errored line
382− while self.pos < self.lines.len()
383− && self.lines[self.pos].indent > indent
384− {
378+ while self.pos < self.lines.len() && self.lines[self.pos].indent > indent {
385379 self.pos += 1;
386380 }
387381 }
@@ -391,10 +385,7 @@ impl Parser {
391385 nodes
392386 }
393387
394− fn parse_line(
395− &mut self,
396− ctx: &mut ParseContext,
397− ) -> Result<Option<Vec<Node>>, ParseError> {
388+ fn parse_line(&mut self, ctx: &mut ParseContext) -> Result<Option<Vec<Node>>, ParseError> {
398389 let line_num = self.lines[self.pos].line_num;
399390 let current_indent = self.lines[self.pos].indent;
400391
@@ -452,7 +443,7 @@ impl Parser {
452443 let value = if let Some(after_open) = value.strip_prefix("\"\"\"") {
453444 if after_open.ends_with("\"\"\"") && after_open.len() >= 3 {
454445 // Single-line triple-quote: @let name """value"""
455− value_str = after_open[..after_open.len()-3].to_string();
446+ value_str = after_open[..after_open.len() - 3].to_string();
456447 value_str.as_str()
457448 } else {
458449 // Multi-line: collect indented body lines until closing """
@@ -483,7 +474,7 @@ impl Parser {
483474 }
484475 } else if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
485476 // Support quoted string interpolation: @let greeting "Hello $name"
486− &value[1..value.len()-1]
477+ &value[1..value.len() - 1]
487478 } else {
488479 value
489480 };
@@ -515,7 +506,7 @@ impl Parser {
515506 if parts.len() == 2 {
516507 let value = substitute_vars(parts[1].trim(), &ctx.variables);
517508 let value = if value.starts_with('"') && value.ends_with('"') && value.len() >= 2 {
518− value[1..value.len()-1].to_string()
509+ value[1..value.len() - 1].to_string()
519510 } else {
520511 value
521512 };
@@ -527,7 +518,10 @@ impl Parser {
527518 if let Some(rest) = content.strip_prefix("@breakpoint ") {
528519 let rest = rest.trim();
529520 if let Some((name, value)) = rest.split_once(' ') {
530− ctx.custom_breakpoints.push((name.trim().to_string(), substitute_vars(value.trim(), &ctx.variables)));
521+ ctx.custom_breakpoints.push((
522+ name.trim().to_string(),
523+ substitute_vars(value.trim(), &ctx.variables),
524+ ));
531525 }
532526 return Ok(None);
533527 }
@@ -678,14 +672,18 @@ impl Parser {
678672 "short_name" | "short-name" => manifest.short_name = Some(value),
679673 "start_url" | "start-url" => manifest.start_url = value,
680674 "display" => manifest.display = value,
681− "background_color" | "background-color" => manifest.background_color = Some(value),
675+ "background_color" | "background-color" => {
676+ manifest.background_color = Some(value)
677+ }
682678 "theme_color" | "theme-color" => manifest.theme_color = Some(value),
683679 "description" => manifest.description = Some(value),
684680 "icon" => {
685681 // icon src sizes (e.g., icon /icon-192.png 192x192)
686682 let parts: Vec<&str> = value.splitn(2, ' ').collect();
687683 if parts.len() == 2 {
688− manifest.icons.push((parts[0].to_string(), parts[1].to_string()));
684+ manifest
685+ .icons
686+ .push((parts[0].to_string(), parts[1].to_string()));
689687 } else {
690688 manifest.icons.push((value, "192x192".to_string()));
691689 }
@@ -705,7 +703,10 @@ impl Parser {
705703 let rest = rest.trim();
706704 // Parse: @with $source as alias
707705 if let Some((source_part, alias)) = rest.split_once(" as ") {
708− let source_name = source_part.trim().strip_prefix('$').unwrap_or(source_part.trim());
706+ let source_name = source_part
707+ .trim()
708+ .strip_prefix('$')
709+ .unwrap_or(source_part.trim());
709710 let alias = alias.trim();
710711 ctx.used_variables.insert(source_name.to_string());
711712 let value = ctx.variables.get(source_name).cloned().unwrap_or_default();
@@ -784,7 +785,10 @@ impl Parser {
784785 ctx.diagnostics.push(Diagnostic {
785786 line: line_num,
786787 column: None,
787− message: format!("circular include '{}' (cycle: {} → {})", filename, cycle_chain, filename),
788+ message: format!(
789+ "circular include '{}' (cycle: {} → {})",
790+ filename, cycle_chain, filename
791+ ),
788792 severity: Severity::Error,
789793 source_line: Some(content.clone()),
790794 });
@@ -842,17 +846,22 @@ impl Parser {
842846 if let Some(rest) = content.strip_prefix("@import ") {
843847 let rest = rest.trim();
844848 // Support @import "file.hl" as prefix — namespace imported definitions
845− let (filename, alias) = if let Some((file_part, alias_part)) = rest.rsplit_once(" as ") {
846− (file_part.trim().to_string(), Some(alias_part.trim().to_string()))
849+ let (filename, alias) = if let Some((file_part, alias_part)) = rest.rsplit_once(" as ")
850+ {
851+ (
852+ file_part.trim().to_string(),
853+ Some(alias_part.trim().to_string()),
854+ )
847855 } else {
848856 (rest.to_string(), None)
849857 };
850858 // Strip quotes from filename
851− let filename = if filename.starts_with('"') && filename.ends_with('"') && filename.len() >= 2 {
852− filename[1..filename.len()-1].to_string()
853− } else {
854− filename
855− };
859+ let filename =
860+ if filename.starts_with('"') && filename.ends_with('"') && filename.len() >= 2 {
861+ filename[1..filename.len() - 1].to_string()
862+ } else {
863+ filename
864+ };
856865 let filename = substitute_vars(&filename, &ctx.variables);
857866
858867 // Glob support: if filename contains *, expand to multiple imports
@@ -863,7 +872,14 @@ impl Parser {
863872 };
864873 let pattern_path = Path::new(&filename);
865874 let (glob_dir, glob_pattern) = match pattern_path.parent() {
866− Some(dir) if !dir.as_os_str().is_empty() => (base_dir.join(dir), pattern_path.file_name().unwrap_or_default().to_string_lossy().to_string()),
875+ Some(dir) if !dir.as_os_str().is_empty() => (
876+ base_dir.join(dir),
877+ pattern_path
878+ .file_name()
879+ .unwrap_or_default()
880+ .to_string_lossy()
881+ .to_string(),
882+ ),
867883 _ => (base_dir.clone(), filename.clone()),
868884 };
869885 let matched_files = match std::fs::read_dir(&glob_dir) {
@@ -883,7 +899,10 @@ impl Parser {
883899 ctx.diagnostics.push(Diagnostic {
884900 line: line_num,
885901 column: None,
886− message: format!("cannot read directory for glob '{}': {}", filename, e),
902+ message: format!(
903+ "cannot read directory for glob '{}': {}",
904+ filename, e
905+ ),
887906 severity: Severity::Error,
888907 source_line: Some(content.clone()),
889908 });
@@ -901,11 +920,19 @@ impl Parser {
901920 return Ok(None);
902921 }
903922 for file_path in matched_files {
904− let rel_name = file_path.strip_prefix(&base_dir).unwrap_or(&file_path).to_string_lossy().to_string();
923+ let rel_name = file_path
924+ .strip_prefix(&base_dir)
925+ .unwrap_or(&file_path)
926+ .to_string_lossy()
927+ .to_string();
905928 // Synthesize an @import line for each matched file
906929 let import_line = match &alias {
907930 Some(pfx) => {
908− let stem = file_path.file_stem().unwrap_or_default().to_string_lossy().to_string();
931+ let stem = file_path
932+ .file_stem()
933+ .unwrap_or_default()
934+ .to_string_lossy()
935+ .to_string();
909936 format!("@import \"{}\" as {}.{}", rel_name, pfx, stem)
910937 }
911938 None => format!("@import \"{}\"", rel_name),
@@ -930,7 +957,10 @@ impl Parser {
930957 ctx.diagnostics.push(Diagnostic {
931958 line: line_num,
932959 column: None,
933− message: format!("circular import '{}' (cycle: {} → {})", filename, cycle_chain, filename),
960+ message: format!(
961+ "circular import '{}' (cycle: {} → {})",
962+ filename, cycle_chain, filename
963+ ),
934964 severity: Severity::Error,
935965 source_line: Some(content.clone()),
936966 });
@@ -987,7 +1017,9 @@ impl Parser {
9871017 let _discarded_nodes = imported_parser.parse_children(0, ctx);
9881018
9891019 // Remove newly added entries and re-insert them under the prefix.
990− let new_fn_keys: Vec<String> = ctx.functions.keys()
1020+ let new_fn_keys: Vec<String> = ctx
1021+ .functions
1022+ .keys()
9911023 .filter(|k| !fn_keys_before.contains(*k))
9921024 .cloned()
9931025 .collect();
@@ -996,7 +1028,9 @@ impl Parser {
9961028 ctx.functions.insert(format!("{}.{}", prefix, name), def);
9971029 }
9981030 }
999− let new_define_keys: Vec<String> = ctx.defines.keys()
1031+ let new_define_keys: Vec<String> = ctx
1032+ .defines
1033+ .keys()
10001034 .filter(|k| !define_keys_before.contains(*k))
10011035 .cloned()
10021036 .collect();
@@ -1005,7 +1039,9 @@ impl Parser {
10051039 ctx.defines.insert(format!("{}.{}", prefix, name), attrs);
10061040 }
10071041 }
1008− let new_mixin_keys: Vec<String> = ctx.mixins.keys()
1042+ let new_mixin_keys: Vec<String> = ctx
1043+ .mixins
1044+ .keys()
10091045 .filter(|k| !mixin_keys_before.contains(*k))
10101046 .cloned()
10111047 .collect();
@@ -1014,15 +1050,18 @@ impl Parser {
10141050 ctx.mixins.insert(format!("{}.{}", prefix, name), attrs);
10151051 }
10161052 }
1017− let new_var_keys: Vec<String> = ctx.variables.keys()
1053+ let new_var_keys: Vec<String> = ctx
1054+ .variables
1055+ .keys()
10181056 .filter(|k| !var_keys_before.contains(*k))
10191057 .cloned()
10201058 .collect();
10211059 for name in new_var_keys {
10221060 if let Some(val) = ctx.variables.remove(&name)
1023− && !name.starts_with("__") {
1024− ctx.variables.insert(format!("{}.{}", prefix, name), val);
1025− }
1061+ && !name.starts_with("__")
1062+ {
1063+ ctx.variables.insert(format!("{}.{}", prefix, name), val);
1064+ }
10261065 }
10271066 } else {
10281067 // Parse the file but discard DOM nodes — only keep definitions
@@ -1056,10 +1095,9 @@ impl Parser {
10561095 } else {
10571096 (rest, None)
10581097 };
1059− let env_val = std::env::var(var_name).ok().or_else(|| default_val.map(|d| {
1060−
1061− substitute_vars(d, &ctx.variables)
1062− }));
1098+ let env_val = std::env::var(var_name)
1099+ .ok()
1100+ .or_else(|| default_val.map(|d| substitute_vars(d, &ctx.variables)));
10631101 match env_val {
10641102 Some(val) => {
10651103 // Store as variable with lowercase name
@@ -1070,7 +1108,10 @@ impl Parser {
10701108 ctx.diagnostics.push(Diagnostic {
10711109 line: line_num,
10721110 column: None,
1073− message: format!("environment variable '{}' is not set and no default provided", var_name),
1111+ message: format!(
1112+ "environment variable '{}' is not set and no default provided",
1113+ var_name
1114+ ),
10741115 severity: Severity::Warning,
10751116 source_line: Some(content.clone()),
10761117 });
@@ -1086,7 +1127,10 @@ impl Parser {
10861127 // @fetch $prefix https://url OR @fetch https://url
10871128 let (prefix, url) = if rest.starts_with('$') {
10881129 if let Some((p, u)) = rest.split_once(' ') {
1089− (p.strip_prefix('$').unwrap_or(p).to_string(), u.trim().to_string())
1130+ (
1131+ p.strip_prefix('$').unwrap_or(p).to_string(),
1132+ u.trim().to_string(),
1133+ )
10901134 } else {
10911135 return Err(ParseError {
10921136 line: line_num,
@@ -1271,11 +1315,15 @@ impl Parser {
12711315 // @data $prefix file.json OR @data file.json (no prefix, top-level keys become vars)
12721316 let (prefix, filename) = if rest.starts_with('$') {
12731317 if let Some((p, f)) = rest.split_once(' ') {
1274− (p.strip_prefix('$').unwrap_or(p).to_string(), f.trim().to_string())
1318+ (
1319+ p.strip_prefix('$').unwrap_or(p).to_string(),
1320+ f.trim().to_string(),
1321+ )
12751322 } else {
12761323 return Err(ParseError {
12771324 line: line_num,
1278− message: "@data requires: @data $prefix file.json or @data file.json".to_string(),
1325+ message: "@data requires: @data $prefix file.json or @data file.json"
1326+ .to_string(),
12791327 });
12801328 }
12811329 } else {
@@ -1318,7 +1366,8 @@ impl Parser {
13181366 ctx.diagnostics.push(Diagnostic {
13191367 line: line_num,
13201368 column: None,
1321− message: "@data without prefix requires a JSON object at top level".to_string(),
1369+ message: "@data without prefix requires a JSON object at top level"
1370+ .to_string(),
13221371 severity: Severity::Error,
13231372 source_line: Some(content.clone()),
13241373 });
@@ -1425,7 +1474,10 @@ impl Parser {
14251474 let saved_defines = ctx.defines.clone();
14261475
14271476 let use_lines = preprocess(&use_text);
1428− let mut use_parser = Parser { lines: use_lines, pos: 0 };
1477+ let mut use_parser = Parser {
1478+ lines: use_lines,
1479+ pos: 0,
1480+ };
14291481 let _discarded = use_parser.parse_children(0, ctx);
14301482
14311483 // Keep only the wanted functions/defines, restore everything else
@@ -1477,7 +1529,8 @@ impl Parser {
14771529
14781530 // Simple glob: support * in filename part
14791531 let parent = glob_pattern.parent().unwrap_or(Path::new("."));
1480− let file_pattern = glob_pattern.file_name()
1532+ let file_pattern = glob_pattern
1533+ .file_name()
14811534 .map(|f| f.to_string_lossy().to_string())
14821535 .unwrap_or_default();
14831536
@@ -1507,7 +1560,8 @@ impl Parser {
15071560 for path in &paths {
15081561 if let Ok(text) = std::fs::read_to_string(path) {
15091562 // Store the file content as a JSON string for each item
1510− let stem = path.file_stem()
1563+ let stem = path
1564+ .file_stem()
15111565 .and_then(|s| s.to_str())
15121566 .unwrap_or("")
15131567 .to_string();
@@ -1517,19 +1571,21 @@ impl Parser {
15171571 }
15181572
15191573 // Store item count and serialized data as variables
1520− ctx.variables.insert(format!("{}_count", var_name), items.len().to_string());
1574+ ctx.variables
1575+ .insert(format!("{}_count", var_name), items.len().to_string());
15211576 // Store as space-separated list of stems for @each
15221577 let stems: Vec<String> = items.iter().map(|(stem, _)| stem.clone()).collect();
15231578 ctx.variables.insert(var_name.clone(), stems.join(" "));
15241579 // Also parse each JSON file and store keys as prefix_stem_key
15251580 for (stem, text) in &items {
15261581 if let Some(json) = parse_json(text.trim())
1527− && let JsonValue::Object(ref obj) = json {
1528− for (key, val) in obj {
1529− let var_key = format!("{}_{}", stem, key);
1530− ctx.variables.insert(var_key, json_value_to_string(val));
1531− }
1582+ && let JsonValue::Object(ref obj) = json
1583+ {
1584+ for (key, val) in obj {
1585+ let var_key = format!("{}_{}", stem, key);
1586+ ctx.variables.insert(var_key, json_value_to_string(val));
15321587 }
1588+ }
15331589 }
15341590
15351591 return Ok(None);
@@ -1636,13 +1692,19 @@ impl Parser {
16361692 return Ok(None);
16371693 }
16381694 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1639− let adjusted: Vec<Line> = body_lines.iter().map(|l| Line {
1640− indent: l.indent - min_indent,
1641− content: l.content.clone(),
1642− line_num: l.line_num,
1643− }).collect();
1695+ let adjusted: Vec<Line> = body_lines
1696+ .iter()
1697+ .map(|l| Line {
1698+ indent: l.indent - min_indent,
1699+ content: l.content.clone(),
1700+ line_num: l.line_num,
1701+ })
1702+ .collect();
16441703 let saved_vars = ctx.variables.clone();
1645− let mut body_parser = Parser { lines: adjusted, pos: 0 };
1704+ let mut body_parser = Parser {
1705+ lines: adjusted,
1706+ pos: 0,
1707+ };
16461708 let nodes = body_parser.parse_children(0, ctx);
16471709 ctx.variables = saved_vars;
16481710 return Ok(Some(nodes));
@@ -1661,9 +1723,11 @@ impl Parser {
16611723 let rest = rest.trim();
16621724 // Support: @each $var in list OR @each $var, $index in list
16631725 // OR @each $name, $url in Alice /alice, Bob /bob (destructuring)
1664− let (var_names, list_str) = if let Some((before_in, after_in)) = rest.split_once(" in ") {
1726+ let (var_names, list_str) = if let Some((before_in, after_in)) = rest.split_once(" in ")
1727+ {
16651728 let before_in = before_in.trim();
1666− let vars: Vec<String> = before_in.split(',')
1729+ let vars: Vec<String> = before_in
1730+ .split(',')
16671731 .map(|v| v.trim().strip_prefix('$').unwrap_or(v.trim()).to_string())
16681732 .collect();
16691733 (vars, after_in.trim().to_string())
@@ -1685,23 +1749,38 @@ impl Parser {
16851749 } else {
16861750 (rest.trim(), 1i64)
16871751 };
1688− if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>()) {
1752+ if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
1753+ {
16891754 if start <= end {
16901755 let mut items = Vec::new();
16911756 let mut n = start;
1692− while n <= end { items.push(n.to_string()); n += step; }
1757+ while n <= end {
1758+ items.push(n.to_string());
1759+ n += step;
1760+ }
16931761 items
16941762 } else {
16951763 let mut items = Vec::new();
16961764 let mut n = start;
1697− while n >= end { items.push(n.to_string()); n -= step; }
1765+ while n >= end {
1766+ items.push(n.to_string());
1767+ n -= step;
1768+ }
16981769 items
16991770 }
17001771 } else {
1701− list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
1772+ list_str
1773+ .split(',')
1774+ .map(|s| s.trim().to_string())
1775+ .filter(|s| !s.is_empty())
1776+ .collect()
17021777 }
17031778 } else {
1704− list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
1779+ list_str
1780+ .split(',')
1781+ .map(|s| s.trim().to_string())
1782+ .filter(|s| !s.is_empty())
1783+ .collect()
17051784 };
17061785
17071786 // Pagination: check for [page N] or [page N per P] suffix
@@ -1723,7 +1802,9 @@ impl Parser {
17231802 };
17241803
17251804 if let Some(size) = page_size {
1726− let current_page: usize = ctx.variables.get("_page")
1805+ let current_page: usize = ctx
1806+ .variables
1807+ .get("_page")
17271808 .and_then(|v| v.parse().ok())
17281809 .unwrap_or(1);
17291810 let total_items = items.len();
@@ -1735,7 +1816,10 @@ impl Parser {
17351816 } else {
17361817 Vec::new()
17371818 };
1738− (page_items, Some((current_page, total_pages, size, total_items)))
1819+ (
1820+ page_items,
1821+ Some((current_page, total_pages, size, total_items)),
1822+ )
17391823 } else {
17401824 (items, None)
17411825 }
@@ -1750,15 +1834,17 @@ impl Parser {
17501834
17511835 // Check for @else block (empty-state fallback)
17521836 let mut else_lines = Vec::new();
1753− if self.pos < self.lines.len() && self.lines[self.pos].indent == current_indent
1837+ if self.pos < self.lines.len()
1838+ && self.lines[self.pos].indent == current_indent
17541839 && let LineContent::Normal(ref s) = self.lines[self.pos].content
1755− && s.trim() == "@else" {
1756− self.pos += 1; // consume @else
1757− while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1758− else_lines.push(self.lines[self.pos].clone());
1759− self.pos += 1;
1760− }
1761− }
1840+ && s.trim() == "@else"
1841+ {
1842+ self.pos += 1; // consume @else
1843+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
1844+ else_lines.push(self.lines[self.pos].clone());
1845+ self.pos += 1;
1846+ }
1847+ }
17621848
17631849 if body_lines.is_empty() {
17641850 return Ok(None);
@@ -1802,9 +1888,12 @@ impl Parser {
18021888 // Inject pagination variables if pagination is active
18031889 if let Some((page, total_pages, page_size, total_items)) = pagination_info {
18041890 ctx.variables.insert("_page".to_string(), page.to_string());
1805− ctx.variables.insert("_total_pages".to_string(), total_pages.to_string());
1806− ctx.variables.insert("_page_size".to_string(), page_size.to_string());
1807− ctx.variables.insert("_total_items".to_string(), total_items.to_string());
1891+ ctx.variables
1892+ .insert("_total_pages".to_string(), total_pages.to_string());
1893+ ctx.variables
1894+ .insert("_page_size".to_string(), page_size.to_string());
1895+ ctx.variables
1896+ .insert("_total_items".to_string(), total_items.to_string());
18081897 }
18091898
18101899 let has_extra_vars = var_names.len() > 2
@@ -1843,8 +1932,12 @@ impl Parser {
18431932 if let Some(rest) = content.strip_prefix("@for ") {
18441933 let rest = rest.trim();
18451934 // @for $var in start..end OR @for $var in start..end step N
1846− let (var_name, range_str) = if let Some((before_in, after_in)) = rest.split_once(" in ") {
1847− let var = before_in.trim().strip_prefix('$').unwrap_or(before_in.trim());
1935+ let (var_name, range_str) = if let Some((before_in, after_in)) = rest.split_once(" in ")
1936+ {
1937+ let var = before_in
1938+ .trim()
1939+ .strip_prefix('$')
1940+ .unwrap_or(before_in.trim());
18481941 (var.to_string(), after_in.trim().to_string())
18491942 } else {
18501943 return Err(ParseError {
@@ -1855,22 +1948,30 @@ impl Parser {
18551948 let range_str = substitute_vars(&range_str, &ctx.variables);
18561949 track_var_refs(&range_str, &mut ctx.used_variables);
18571950
1858− let items: Vec<String> = if let Some((start_s, rest_range)) = range_str.split_once("..") {
1951+ let items: Vec<String> = if let Some((start_s, rest_range)) = range_str.split_once("..")
1952+ {
18591953 let (end_s, step) = if let Some((e, s)) = rest_range.split_once(" step ") {
18601954 (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
18611955 } else {
18621956 (rest_range.trim(), 1i64)
18631957 };
1864− if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>()) {
1958+ if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>())
1959+ {
18651960 if start <= end {
18661961 let mut items = Vec::new();
18671962 let mut n = start;
1868− while n <= end { items.push(n.to_string()); n += step; }
1963+ while n <= end {
1964+ items.push(n.to_string());
1965+ n += step;
1966+ }
18691967 items
18701968 } else {
18711969 let mut items = Vec::new();
18721970 let mut n = start;
1873− while n >= end { items.push(n.to_string()); n -= step; }
1971+ while n >= end {
1972+ items.push(n.to_string());
1973+ n -= step;
1974+ }
18741975 items
18751976 }
18761977 } else {
@@ -1911,7 +2012,10 @@ impl Parser {
19112012 let mut all_nodes = Vec::new();
19122013 for item in &items {
19132014 ctx.variables.insert(var_name.clone(), item.clone());
1914− let mut body_parser = Parser { lines: adjusted.clone(), pos: 0 };
2015+ let mut body_parser = Parser {
2016+ lines: adjusted.clone(),
2017+ pos: 0,
2018+ };
19152019 let nodes = body_parser.parse_children(0, ctx);
19162020 all_nodes.extend(nodes);
19172021 }
@@ -1939,18 +2043,25 @@ impl Parser {
19392043 }
19402044
19412045 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1942− let adjusted: Vec<Line> = body_lines.iter().map(|l| Line {
1943− indent: l.indent - min_indent,
1944− content: l.content.clone(),
1945− line_num: l.line_num,
1946− }).collect();
2046+ let adjusted: Vec<Line> = body_lines
2047+ .iter()
2048+ .map(|l| Line {
2049+ indent: l.indent - min_indent,
2050+ content: l.content.clone(),
2051+ line_num: l.line_num,
2052+ })
2053+ .collect();
19472054
19482055 let mut all_nodes = Vec::new();
19492056 let saved_vars = ctx.variables.clone();
19502057 for i in 0..count {
19512058 ctx.variables.insert("_index".to_string(), i.to_string());
1952− ctx.variables.insert("_count".to_string(), count.to_string());
1953− let mut iter_parser = Parser { lines: adjusted.clone(), pos: 0 };
2059+ ctx.variables
2060+ .insert("_count".to_string(), count.to_string());
2061+ let mut iter_parser = Parser {
2062+ lines: adjusted.clone(),
2063+ pos: 0,
2064+ };
19542065 let nodes = iter_parser.parse_children(0, ctx);
19552066 all_nodes.extend(nodes);
19562067 }
@@ -1976,14 +2087,20 @@ impl Parser {
19762087
19772088 // Parse the body to get the actual content nodes
19782089 let min_indent = body_lines.iter().map(|l| l.indent).min().unwrap_or(0);
1979− let adjusted: Vec<Line> = body_lines.iter().map(|l| Line {
1980− indent: l.indent - min_indent,
1981− content: l.content.clone(),
1982− line_num: l.line_num,
1983− }).collect();
2090+ let adjusted: Vec<Line> = body_lines
2091+ .iter()
2092+ .map(|l| Line {
2093+ indent: l.indent - min_indent,
2094+ content: l.content.clone(),
2095+ line_num: l.line_num,
2096+ })
2097+ .collect();
19842098
19852099 let saved_vars = ctx.variables.clone();
1986− let mut body_parser = Parser { lines: adjusted, pos: 0 };
2100+ let mut body_parser = Parser {
2101+ lines: adjusted,
2102+ pos: 0,
2103+ };
19872104 let inner_nodes = body_parser.parse_children(0, ctx);
19882105 ctx.variables = saved_vars;
19892106
@@ -1994,11 +2111,20 @@ impl Parser {
19942111 substitute_vars(placeholder_text, &ctx.variables)
19952112 };
19962113 let mut wrapper_attrs = vec![
1997− Attribute { key: "data-hl-defer".to_string(), value: None },
1998− Attribute { key: "class".to_string(), value: Some("hl-defer-placeholder".to_string()) },
2114+ Attribute {
2115+ key: "data-hl-defer".to_string(),
2116+ value: None,
2117+ },
2118+ Attribute {
2119+ key: "class".to_string(),
2120+ value: Some("hl-defer-placeholder".to_string()),
2121+ },
19992122 ];
20002123 // Hidden until intersection observer triggers
2001− wrapper_attrs.push(Attribute { key: "hidden".to_string(), value: None });
2124+ wrapper_attrs.push(Attribute {
2125+ key: "hidden".to_string(),
2126+ value: None,
2127+ });
20022128
20032129 let wrapper = Element {
20042130 kind: ElementKind::El,
@@ -2135,11 +2261,18 @@ impl Parser {
21352261 if let Some(case_rest) = trimmed.strip_prefix("@case ") {
21362262 let case_rest = case_rest.trim();
21372263 // Extract case value and optional [attrs]
2138− let (case_val, case_attrs_str) = if let Some(bracket_pos) = case_rest.find('[') {
2139− (substitute_vars(case_rest[..bracket_pos].trim(), &ctx.variables), Some(&case_rest[bracket_pos..]))
2140− } else {
2141− (substitute_vars(case_rest, &ctx.variables), None)
2142− };
2264+ let (case_val, case_attrs_str) =
2265+ if let Some(bracket_pos) = case_rest.find('[') {
2266+ (
2267+ substitute_vars(
2268+ case_rest[..bracket_pos].trim(),
2269+ &ctx.variables,
2270+ ),
2271+ Some(&case_rest[bracket_pos..]),
2272+ )
2273+ } else {
2274+ (substitute_vars(case_rest, &ctx.variables), None)
2275+ };
21432276 si += 1;
21442277 // Collect body lines
21452278 let mut body = Vec::new();
@@ -2155,7 +2288,8 @@ impl Parser {
21552288 matched_body = body;
21562289 }
21572290 } else if trimmed == "@default" || trimmed.starts_with("@default ") {
2158− let default_rest = trimmed.strip_prefix("@default").unwrap_or("").trim();
2291+ let default_rest =
2292+ trimmed.strip_prefix("@default").unwrap_or("").trim();
21592293 si += 1;
21602294 let mut body = Vec::new();
21612295 while si < switch_lines.len() && switch_lines[si].indent > case_indent {
@@ -2164,7 +2298,8 @@ impl Parser {
21642298 }
21652299 if matched_attrs.is_none() {
21662300 if !default_rest.is_empty() && default_rest.starts_with('[') {
2167− let (attrs, _) = parse_attr_brackets(default_rest, line_num, ctx)?;
2301+ let (attrs, _) =
2302+ parse_attr_brackets(default_rest, line_num, ctx)?;
21682303 matched_attrs = Some(attrs);
21692304 }
21702305 matched_body = body;
@@ -2188,13 +2323,19 @@ impl Parser {
21882323 // Parse matched body if any
21892324 if !matched_body.is_empty() {
21902325 let min_indent = matched_body.iter().map(|l| l.indent).min().unwrap_or(0);
2191− let adjusted: Vec<Line> = matched_body.iter().map(|l| Line {
2192− indent: l.indent - min_indent,
2193− content: l.content.clone(),
2194− line_num: l.line_num,
2195− }).collect();
2326+ let adjusted: Vec<Line> = matched_body
2327+ .iter()
2328+ .map(|l| Line {
2329+ indent: l.indent - min_indent,
2330+ content: l.content.clone(),
2331+ line_num: l.line_num,
2332+ })
2333+ .collect();
21962334 let saved_vars = ctx.variables.clone();
2197− let mut body_parser = Parser { lines: adjusted, pos: 0 };
2335+ let mut body_parser = Parser {
2336+ lines: adjusted,
2337+ pos: 0,
2338+ };
21982339 let nodes = body_parser.parse_children(0, ctx);
21992340 ctx.variables = saved_vars;
22002341 return Ok(Some(nodes));
@@ -2245,17 +2386,30 @@ impl Parser {
22452386 eprintln!("log: line {}: ${} = \"{}\" ({})", line_num, name, val, kind);
22462387 } else if ctx.functions.contains_key(name) {
22472388 let def = &ctx.functions[name];
2248− let params: Vec<String> = def.params.iter().map(|p| format!("${}", p)).collect();
2249− eprintln!("log: line {}: @{} ({}) — {} line(s)", line_num, name, params.join(", "), def.body_lines.len());
2389+ let params: Vec<String> =
2390+ def.params.iter().map(|p| format!("${}", p)).collect();
2391+ eprintln!(
2392+ "log: line {}: @{} ({}) — {} line(s)",
2393+ line_num,
2394+ name,
2395+ params.join(", "),
2396+ def.body_lines.len()
2397+ );
22502398 } else if ctx.defines.contains_key(name) {
22512399 let attrs = &ctx.defines[name];
2252− let attr_strs: Vec<String> = attrs.iter().map(|a| {
2253− match &a.value {
2400+ let attr_strs: Vec<String> = attrs
2401+ .iter()
2402+ .map(|a| match &a.value {
22542403 Some(v) => format!("{} {}", a.key, v),
22552404 None => a.key.clone(),
2256− }
2257− }).collect();
2258− eprintln!("log: line {}: ${} = [{}]", line_num, name, attr_strs.join(", "));
2405+ })
2406+ .collect();
2407+ eprintln!(
2408+ "log: line {}: ${} = [{}]",
2409+ line_num,
2410+ name,
2411+ attr_strs.join(", ")
2412+ );
22592413 } else {
22602414 eprintln!("log: line {}: ${} is undefined", line_num, name);
22612415 }
@@ -2388,7 +2542,7 @@ impl Parser {
23882542 let trimmed = s.trim();
23892543 // Check for locale sub-header: e.g., "en:" or "fr:" or "es:"
23902544 if trimmed.ends_with(':') && trimmed.len() <= 10 && !trimmed.contains(' ') {
2391− current_locale = trimmed[..trimmed.len()-1].to_string();
2545+ current_locale = trimmed[..trimmed.len() - 1].to_string();
23922546 first_locale_indent = Some(self.lines[self.pos].indent);
23932547 self.pos += 1;
23942548 continue;
@@ -2426,12 +2580,13 @@ impl Parser {
24262580 // Peek at the next line to get the function name
24272581 if self.pos < self.lines.len()
24282582 && let LineContent::Normal(ref s) = self.lines[self.pos].content
2429− && let Some(fn_rest) = s.trim().strip_prefix("@fn ") {
2430− let parts: Vec<&str> = fn_rest.split_whitespace().collect();
2431− if let Some(&fn_name) = parts.first() {
2432− ctx.deprecated_fns.insert(fn_name.to_string(), message);
2433− }
2434− }
2583+ && let Some(fn_rest) = s.trim().strip_prefix("@fn ")
2584+ {
2585+ let parts: Vec<&str> = fn_rest.split_whitespace().collect();
2586+ if let Some(&fn_name) = parts.first() {
2587+ ctx.deprecated_fns.insert(fn_name.to_string(), message);
2588+ }
2589+ }
24352590 return Ok(None);
24362591 }
24372592
@@ -2489,7 +2644,9 @@ impl Parser {
24892644 let slot_name = slot_name.trim().to_string();
24902645 self.pos += 1;
24912646 let mut slot_lines = Vec::new();
2492− while self.pos < self.lines.len() && self.lines[self.pos].indent > line_indent {
2647+ while self.pos < self.lines.len()
2648+ && self.lines[self.pos].indent > line_indent
2649+ {
24932650 slot_lines.push(self.lines[self.pos].clone());
24942651 self.pos += 1;
24952652 }
@@ -2507,12 +2664,18 @@ impl Parser {
25072664 continue;
25082665 }
25092666 let min_indent = lines.iter().map(|l| l.indent).min().unwrap_or(0);
2510− let adjusted: Vec<Line> = lines.iter().map(|l| Line {
2511− indent: l.indent - min_indent,
2512− content: l.content.clone(),
2513− line_num: l.line_num,
2514− }).collect();
2515− let mut slot_parser = Parser { lines: adjusted, pos: 0 };
2667+ let adjusted: Vec<Line> = lines
2668+ .iter()
2669+ .map(|l| Line {
2670+ indent: l.indent - min_indent,
2671+ content: l.content.clone(),
2672+ line_num: l.line_num,
2673+ })
2674+ .collect();
2675+ let mut slot_parser = Parser {
2676+ lines: adjusted,
2677+ pos: 0,
2678+ };
25162679 let nodes = slot_parser.parse_children(0, ctx);
25172680 slot_nodes.insert(name.clone(), nodes);
25182681 }
@@ -2524,7 +2687,10 @@ impl Parser {
25242687 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
25252688
25262689 let extends_lines = preprocess(&extends_text);
2527− let mut extends_parser = Parser { lines: extends_lines, pos: 0 };
2690+ let mut extends_parser = Parser {
2691+ lines: extends_lines,
2692+ pos: 0,
2693+ };
25282694 let layout_nodes = extends_parser.parse_children(0, ctx);
25292695
25302696 ctx.base_path = saved_base;
@@ -2586,7 +2752,10 @@ impl Parser {
25862752 ctx.base_path = resolved.parent().map(|p| p.to_path_buf());
25872753
25882754 let layout_lines = preprocess(&layout_text);
2589− let mut layout_parser = Parser { lines: layout_lines, pos: 0 };
2755+ let mut layout_parser = Parser {
2756+ lines: layout_lines,
2757+ pos: 0,
2758+ };
25902759 let layout_nodes = layout_parser.parse_children(0, ctx);
25912760
25922761 ctx.base_path = saved_base;
@@ -2594,7 +2763,8 @@ impl Parser {
25942763
25952764 // Replace @children in layout with the caller's body content
25962765 let empty_slots: HashMap<String, Vec<Node>> = HashMap::new();
2597− let result_nodes = replace_children_and_slots(layout_nodes, &caller_children, &empty_slots);
2766+ let result_nodes =
2767+ replace_children_and_slots(layout_nodes, &caller_children, &empty_slots);
25982768 return Ok(Some(result_nodes));
25992769 }
26002770
@@ -2628,12 +2798,13 @@ impl Parser {
26282798 let mut style_indent = 0;
26292799 while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
26302800 if let LineContent::Normal(ref s) = self.lines[self.pos].content
2631− && s.trim() == "@style" {
2632− in_style = true;
2633− style_indent = self.lines[self.pos].indent;
2634− self.pos += 1;
2635− continue;
2636− }
2801+ && s.trim() == "@style"
2802+ {
2803+ in_style = true;
2804+ style_indent = self.lines[self.pos].indent;
2805+ self.pos += 1;
2806+ continue;
2807+ }
26372808 if in_style && self.lines[self.pos].indent > style_indent {
26382809 style_lines.push(self.lines[self.pos].clone());
26392810 } else {
@@ -2662,9 +2833,19 @@ impl Parser {
26622833 }
26632834
26642835 ctx.fn_lines.entry(name.clone()).or_insert(line_num);
2665− ctx.functions.insert(name.clone(), FnDef { params, defaults, body_lines });
2836+ ctx.functions.insert(
2837+ name.clone(),
2838+ FnDef {
2839+ params,
2840+ defaults,
2841+ body_lines,
2842+ },
2843+ );
26662844 // Mark that this function should wrap output with a scoped class
2667− ctx.variables.insert(format!("__component_scope_{}", name), format!("hl-{}", name));
2845+ ctx.variables.insert(
2846+ format!("__component_scope_{}", name),
2847+ format!("hl-{}", name),
2848+ );
26682849 return Ok(None);
26692850 }
26702851
@@ -2699,7 +2880,14 @@ impl Parser {
26992880 }
27002881
27012882 ctx.fn_lines.entry(name.clone()).or_insert(line_num);
2702− ctx.functions.insert(name, FnDef { params, defaults, body_lines });
2883+ ctx.functions.insert(
2884+ name,
2885+ FnDef {
2886+ params,
2887+ defaults,
2888+ body_lines,
2889+ },
2890+ );
27032891 return Ok(None);
27042892 }
27052893
@@ -2719,8 +2907,7 @@ impl Parser {
27192907 source_line: Some(content.clone()),
27202908 });
27212909 }
2722− let nodes =
2723− self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
2910+ let nodes = self.expand_fn_call(name, &content, current_indent, line_num, ctx)?;
27242911 return Ok(Some(nodes));
27252912 }
27262913 }
@@ -2793,10 +2980,14 @@ impl Parser {
27932980 for child in all_caller_children {
27942981 if let Node::Element(ref elem) = child
27952982 && let ElementKind::Slot(ref slot_name) = elem.kind
2796− && !slot_name.is_empty() {
2797− slot_contents.entry(slot_name.clone()).or_default().extend(elem.children.clone());
2798− continue;
2799− }
2983+ && !slot_name.is_empty()
2984+ {
2985+ slot_contents
2986+ .entry(slot_name.clone())
2987+ .or_default()
2988+ .extend(elem.children.clone());
2989+ continue;
2990+ }
28002991 caller_children.push(child);
28012992 }
28022993
@@ -2842,14 +3033,18 @@ impl Parser {
28423033 ctx.fn_call_stack.pop();
28433034
28443035 // Replace @children with caller's children and @slot with slot content
2845− let mut result_nodes = replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
3036+ let mut result_nodes =
3037+ replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
28463038
28473039 // If this is a @component, wrap output in a scoped container
28483040 let scope_key = format!("__component_scope_{}", name);
28493041 if let Some(scope_class) = ctx.variables.get(&scope_key).cloned() {
28503042 let wrapper = Element {
28513043 kind: ElementKind::El,
2852− attrs: vec![Attribute { key: "class".to_string(), value: Some(scope_class) }],
3044+ attrs: vec![Attribute {
3045+ key: "class".to_string(),
3046+ value: Some(scope_class),
3047+ }],
28533048 argument: None,
28543049 children: result_nodes,
28553050 line_num,
@@ -2913,7 +3108,8 @@ fn replace_children_and_slots(
29133108 result.extend(caller_children.iter().cloned());
29143109 }
29153110 }
2916− Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) => {
3111+ Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) =>
3112+ {
29173113 if let ElementKind::Slot(ref name) = elem.kind {
29183114 if let Some(content) = slot_contents.get(name) {
29193115 result.extend(content.iter().cloned());
@@ -2925,7 +3121,8 @@ fn replace_children_and_slots(
29253121 }
29263122 }
29273123 Node::Element(mut elem) => {
2928− elem.children = replace_children_and_slots(elem.children, caller_children, slot_contents);
3124+ elem.children =
3125+ replace_children_and_slots(elem.children, caller_children, slot_contents);
29293126 result.push(Node::Element(elem));
29303127 }
29313128 other => result.push(other),
@@ -2968,7 +3165,8 @@ fn parse_single_element(
29683165 Err(mut e) => {
29693166 // Also suggest user-defined functions
29703167 if let Some(fn_suggestion) = suggest_fn_name(kind_str, ctx) {
2971− e.message = format!("{}, or did you mean @{}?", e.message, fn_suggestion);
3168+ e.message =
3169+ format!("{}, or did you mean @{}?", e.message, fn_suggestion);
29723170 }
29733171 return Err(e);
29743172 }
@@ -3036,35 +3234,138 @@ fn parse_single_element(
30363234}
30373235
30383236const KNOWN_ELEMENTS: &[&str] = &[
3039− "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
3040− "input", "button", "select", "textarea", "option", "label", "slot",
3041− "nav", "header", "footer", "main", "section", "article", "aside",
3042− "list", "item", "li",
3043− "table", "thead", "tbody", "tr", "td", "th",
3044− "video", "audio",
3045− "form", "details", "summary", "blockquote", "cite", "code", "pre", "hr", "divider",
3046− "figure", "figcaption", "progress", "meter",
3237+ "row",
3238+ "column",
3239+ "col",
3240+ "el",
3241+ "text",
3242+ "paragraph",
3243+ "p",
3244+ "image",
3245+ "img",
3246+ "link",
3247+ "children",
3248+ "input",
3249+ "button",
3250+ "select",
3251+ "textarea",
3252+ "option",
3253+ "label",
3254+ "slot",
3255+ "nav",
3256+ "header",
3257+ "footer",
3258+ "main",
3259+ "section",
3260+ "article",
3261+ "aside",
3262+ "list",
3263+ "item",
3264+ "li",
3265+ "table",
3266+ "thead",
3267+ "tbody",
3268+ "tr",
3269+ "td",
3270+ "th",
3271+ "video",
3272+ "audio",
3273+ "form",
3274+ "details",
3275+ "summary",
3276+ "blockquote",
3277+ "cite",
3278+ "code",
3279+ "pre",
3280+ "hr",
3281+ "divider",
3282+ "figure",
3283+ "figcaption",
3284+ "progress",
3285+ "meter",
30473286 "fragment",
3048− "btn", "ul", "dialog", "dl", "dt", "dd", "fieldset", "legend",
3049− "picture", "source", "time", "mark", "kbd", "abbr", "datalist",
3050− "iframe", "output", "canvas",
3051− "grid", "stack", "spacer", "badge", "tooltip",
3052− "avatar", "carousel", "chip", "tag",
3053− "script", "noscript", "address", "search", "breadcrumb",
3287+ "btn",
3288+ "ul",
3289+ "dialog",
3290+ "dl",
3291+ "dt",
3292+ "dd",
3293+ "fieldset",
3294+ "legend",
3295+ "picture",
3296+ "source",
3297+ "time",
3298+ "mark",
3299+ "kbd",
3300+ "abbr",
3301+ "datalist",
3302+ "iframe",
3303+ "output",
3304+ "canvas",
3305+ "grid",
3306+ "stack",
3307+ "spacer",
3308+ "badge",
3309+ "tooltip",
3310+ "avatar",
3311+ "carousel",
3312+ "chip",
3313+ "tag",
3314+ "script",
3315+ "noscript",
3316+ "address",
3317+ "search",
3318+ "breadcrumb",
30543319];
30553320
30563321const KNOWN_DIRECTIVES: &[&str] = &[
3057− "page", "let", "define", "fn", "include", "import", "raw", "keyframes",
3058− "if", "else", "each", "meta", "head", "style",
3059− "match", "case", "default", "warn", "debug",
3060− "unless", "og", "breakpoint", "lang", "favicon",
3061− "use", "theme", "deprecated", "extends",
3062− "canonical", "base", "font-face", "json-ld",
3063− "mixin", "assert",
3064− "for", "component", "switch",
3322+ "page",
3323+ "let",
3324+ "define",
3325+ "fn",
3326+ "include",
3327+ "import",
3328+ "raw",
3329+ "keyframes",
3330+ "if",
3331+ "else",
3332+ "each",
3333+ "meta",
3334+ "head",
3335+ "style",
3336+ "match",
3337+ "case",
3338+ "default",
3339+ "warn",
3340+ "debug",
3341+ "unless",
3342+ "og",
3343+ "breakpoint",
3344+ "lang",
3345+ "favicon",
3346+ "use",
3347+ "theme",
3348+ "deprecated",
3349+ "extends",
3350+ "canonical",
3351+ "base",
3352+ "font-face",
3353+ "json-ld",
3354+ "mixin",
3355+ "assert",
3356+ "for",
3357+ "component",
3358+ "switch",
30653359 "log",
3066− "markdown", "repeat", "with", "layout", "collection",
3067− "manifest", "scope", "starting-style", "translations",
3360+ "markdown",
3361+ "repeat",
3362+ "with",
3363+ "layout",
3364+ "collection",
3365+ "manifest",
3366+ "scope",
3367+ "starting-style",
3368+ "translations",
30683369];
30693370
30703371fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
@@ -3168,7 +3469,12 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
31683469fn format_include_chain(stack: &[PathBuf]) -> String {
31693470 stack
31703471 .iter()
3171− .map(|p| p.file_name().unwrap_or_default().to_string_lossy().to_string())
3472+ .map(|p| {
3473+ p.file_name()
3474+ .unwrap_or_default()
3475+ .to_string_lossy()
3476+ .to_string()
3477+ })
31723478 .collect::<Vec<_>>()
31733479 .join(" → ")
31743480}
@@ -3194,9 +3500,7 @@ fn levenshtein_bounded(a: &[char], b: &[char], cutoff: usize) -> usize {
31943500 let mut row_min = curr[0];
31953501 for (i, &ac) in a.iter().enumerate() {
31963502 let cost = if ac == bc { 0 } else { 1 };
3197− curr[i + 1] = (prev[i + 1] + 1)
3198− .min(curr[i] + 1)
3199− .min(prev[i] + cost);
3503+ curr[i + 1] = (prev[i + 1] + 1).min(curr[i] + 1).min(prev[i] + cost);
32003504 row_min = row_min.min(curr[i + 1]);
32013505 }
32023506 if row_min > cutoff {
@@ -3272,7 +3576,11 @@ fn suggest_var_name(input: &str, vars: &HashMap<String, String>) -> Option<Strin
32723576}
32733577
32743578/// Check for undefined `$var` references and return "did you mean?" diagnostics.
3275−fn check_undefined_vars(input: &str, vars: &HashMap<String, String>, line_num: usize) -> Vec<Diagnostic> {
3579+fn check_undefined_vars(
3580+ input: &str,
3581+ vars: &HashMap<String, String>,
3582+ line_num: usize,
3583+) -> Vec<Diagnostic> {
32763584 let mut warnings = Vec::new();
32773585 if !input.contains('$') {
32783586 return warnings;
@@ -3288,7 +3596,10 @@ fn check_undefined_vars(input: &str, vars: &HashMap<String, String>, line_num: u
32883596 let start = i + 1;
32893597 let mut end = start;
32903598 while end < chars.len()
3291− && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_' || chars[end] == '.')
3599+ && (chars[end].is_alphanumeric()
3600+ || chars[end] == '-'
3601+ || chars[end] == '_'
3602+ || chars[end] == '.')
32923603 {
32933604 end += 1;
32943605 }
@@ -3296,16 +3607,21 @@ fn check_undefined_vars(input: &str, vars: &HashMap<String, String>, line_num: u
32963607 end -= 1;
32973608 }
32983609 let name: String = chars[start..end].iter().collect();
3299− if !name.is_empty() && !vars.contains_key(&name)
3300− && let Some(closest) = suggest_var_name(&name, vars) {
3301− warnings.push(Diagnostic {
3302− line: line_num,
3303− column: Some(col),
3304− message: format!("undefined variable '${}', did you mean '${}'?", name, closest),
3305− severity: Severity::Warning,
3306− source_line: Some(input.to_string()),
3307− });
3308− }
3610+ if !name.is_empty()
3611+ && !vars.contains_key(&name)
3612+ && let Some(closest) = suggest_var_name(&name, vars)
3613+ {
3614+ warnings.push(Diagnostic {
3615+ line: line_num,
3616+ column: Some(col),
3617+ message: format!(
3618+ "undefined variable '${}', did you mean '${}'?",
3619+ name, closest
3620+ ),
3621+ severity: Severity::Warning,
3622+ source_line: Some(input.to_string()),
3623+ });
3624+ }
33093625 i = end;
33103626 } else {
33113627 i += 1;
@@ -3320,126 +3636,306 @@ fn check_undefined_vars(input: &str, vars: &HashMap<String, String>, line_num: u
33203636
33213637const KNOWN_ATTRS: &[&str] = &[
33223638 // Layout
3323− "spacing", "gap", "padding", "padding-x", "padding-y",
3324− "width", "height", "min-width", "max-width", "min-height", "max-height",
3325− "center-x", "center-y", "align-left", "align-right", "align-top", "align-bottom",
3639+ "spacing",
3640+ "gap",
3641+ "padding",
3642+ "padding-x",
3643+ "padding-y",
3644+ "width",
3645+ "height",
3646+ "min-width",
3647+ "max-width",
3648+ "min-height",
3649+ "max-height",
3650+ "center-x",
3651+ "center-y",
3652+ "align-left",
3653+ "align-right",
3654+ "align-top",
3655+ "align-bottom",
33263656 // Style
3327− "background", "color", "border", "border-top", "border-bottom", "border-left", "border-right",
3328− "rounded", "bold", "italic", "underline",
3329− "size", "font", "transition", "cursor", "opacity",
3330− "text-align", "line-height", "letter-spacing", "text-transform", "white-space",
3331− "overflow", "position", "top", "right", "bottom", "left", "z-index", "shadow",
3332− "wrap", "gap-x", "gap-y",
3657+ "background",
3658+ "color",
3659+ "border",
3660+ "border-top",
3661+ "border-bottom",
3662+ "border-left",
3663+ "border-right",
3664+ "rounded",
3665+ "bold",
3666+ "italic",
3667+ "underline",
3668+ "size",
3669+ "font",
3670+ "transition",
3671+ "cursor",
3672+ "opacity",
3673+ "text-align",
3674+ "line-height",
3675+ "letter-spacing",
3676+ "text-transform",
3677+ "white-space",
3678+ "overflow",
3679+ "position",
3680+ "top",
3681+ "right",
3682+ "bottom",
3683+ "left",
3684+ "z-index",
3685+ "shadow",
3686+ "wrap",
3687+ "gap-x",
3688+ "gap-y",
33333689 // Display & visibility
3334− "display", "visibility",
3690+ "display",
3691+ "visibility",
33353692 // Transform & filters
3336− "transform", "backdrop-filter",
3693+ "transform",
3694+ "backdrop-filter",
33373695 // Grid
3338− "grid", "grid-cols", "grid-rows", "col-span", "row-span",
3696+ "grid",
3697+ "grid-cols",
3698+ "grid-rows",
3699+ "col-span",
3700+ "row-span",
33393701 // Container queries
3340− "container", "container-name", "container-type",
3702+ "container",
3703+ "container-name",
3704+ "container-type",
33413705 // Identity
3342− "id", "class",
3706+ "id",
3707+ "class",
33433708 // Animation
33443709 "animation",
33453710 // Form
3346− "type", "placeholder", "name", "value", "disabled", "required", "checked",
3347− "for", "action", "method", "autocomplete",
3348− "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
3711+ "type",
3712+ "placeholder",
3713+ "name",
3714+ "value",
3715+ "disabled",
3716+ "required",
3717+ "checked",
3718+ "for",
3719+ "action",
3720+ "method",
3721+ "autocomplete",
3722+ "min",
3723+ "max",
3724+ "step",
3725+ "pattern",
3726+ "maxlength",
3727+ "rows",
3728+ "cols",
3729+ "multiple",
33493730 // Accessibility
3350− "alt", "role", "tabindex", "title", "autofocus",
3731+ "alt",
3732+ "role",
3733+ "tabindex",
3734+ "title",
3735+ "autofocus",
33513736 // CSS: aspect-ratio, outline, logical properties, scroll-snap
3352− "aspect-ratio", "outline",
3353− "padding-inline", "padding-block", "margin-inline", "margin-block",
3354− "padding-inline-start", "padding-inline-end", "padding-block-start", "padding-block-end",
3355− "margin-inline-start", "margin-inline-end", "margin-block-start", "margin-block-end",
3356− "inset-inline", "inset-block", "inset-inline-start", "inset-inline-end", "inset-block-start", "inset-block-end",
3357− "border-inline", "border-block", "border-inline-start", "border-inline-end", "border-block-start", "border-block-end",
3358− "border-start-start-radius", "border-start-end-radius", "border-end-start-radius", "border-end-end-radius",
3359− "scroll-margin-inline", "scroll-margin-block", "scroll-padding-inline", "scroll-padding-block",
3360− "inline-size", "block-size", "min-inline-size", "max-inline-size", "min-block-size", "max-block-size",
3361− "scroll-snap-type", "scroll-snap-align",
3737+ "aspect-ratio",
3738+ "outline",
3739+ "padding-inline",
3740+ "padding-block",
3741+ "margin-inline",
3742+ "margin-block",
3743+ "padding-inline-start",
3744+ "padding-inline-end",
3745+ "padding-block-start",
3746+ "padding-block-end",
3747+ "margin-inline-start",
3748+ "margin-inline-end",
3749+ "margin-block-start",
3750+ "margin-block-end",
3751+ "inset-inline",
3752+ "inset-block",
3753+ "inset-inline-start",
3754+ "inset-inline-end",
3755+ "inset-block-start",
3756+ "inset-block-end",
3757+ "border-inline",
3758+ "border-block",
3759+ "border-inline-start",
3760+ "border-inline-end",
3761+ "border-block-start",
3762+ "border-block-end",
3763+ "border-start-start-radius",
3764+ "border-start-end-radius",
3765+ "border-end-start-radius",
3766+ "border-end-end-radius",
3767+ "scroll-margin-inline",
3768+ "scroll-margin-block",
3769+ "scroll-padding-inline",
3770+ "scroll-padding-block",
3771+ "inline-size",
3772+ "block-size",
3773+ "min-inline-size",
3774+ "max-inline-size",
3775+ "min-block-size",
3776+ "max-block-size",
3777+ "scroll-snap-type",
3778+ "scroll-snap-align",
33623779 // Media attributes
3363− "controls", "autoplay", "loop", "muted", "poster", "preload",
3364− "loading", "decoding",
3780+ "controls",
3781+ "autoplay",
3782+ "loop",
3783+ "muted",
3784+ "poster",
3785+ "preload",
3786+ "loading",
3787+ "decoding",
33653788 // List
33663789 "ordered",
33673790 // Media src (explicit attribute form)
33683791 "src",
33693792 // New elements
3370− "open", "novalidate", "low", "high", "optimum",
3371− "colspan", "rowspan", "scope",
3793+ "open",
3794+ "novalidate",
3795+ "low",
3796+ "high",
3797+ "optimum",
3798+ "colspan",
3799+ "rowspan",
3800+ "scope",
33723801 // Margin
3373− "margin", "margin-x", "margin-y",
3802+ "margin",
3803+ "margin-x",
3804+ "margin-y",
33743805 // Filter & object
3375− "filter", "object-fit", "object-position",
3806+ "filter",
3807+ "object-fit",
3808+ "object-position",
33763809 // Text extras
3377− "text-shadow", "text-overflow",
3810+ "text-shadow",
3811+ "text-overflow",
33783812 // Interaction
3379− "pointer-events", "user-select",
3813+ "pointer-events",
3814+ "user-select",
33803815 // Flexbox/grid alignment
3381− "justify-content", "align-items",
3816+ "justify-content",
3817+ "align-items",
33823818 // Flex item
33833819 "order",
33843820 // Background extras
3385− "background-size", "background-position", "background-repeat",
3821+ "background-size",
3822+ "background-position",
3823+ "background-repeat",
33863824 // Text wrapping
3387− "word-break", "overflow-wrap",
3825+ "word-break",
3826+ "overflow-wrap",
33883827 // Asset inlining
33893828 "inline",
33903829 // Hidden
33913830 "hidden",
33923831 // Overflow directional
3393− "overflow-x", "overflow-y",
3832+ "overflow-x",
3833+ "overflow-y",
33943834 // Inset shorthand
33953835 "inset",
33963836 // Modern form theming
3397− "accent-color", "caret-color",
3837+ "accent-color",
3838+ "caret-color",
33983839 // Color scheme & appearance
3399− "color-scheme", "appearance",
3840+ "color-scheme",
3841+ "appearance",
34003842 // List styling
34013843 "list-style",
34023844 // Table styling
3403− "border-collapse", "border-spacing",
3845+ "border-collapse",
3846+ "border-spacing",
34043847 // Text decoration variants
3405− "text-decoration", "text-decoration-color", "text-decoration-thickness", "text-decoration-style",
3848+ "text-decoration",
3849+ "text-decoration-color",
3850+ "text-decoration-thickness",
3851+ "text-decoration-style",
34063852 "text-underline-offset",
3407− "column-width", "column-rule",
3853+ "column-width",
3854+ "column-rule",
34083855 // Grid/flex placement
3409− "place-items", "place-self",
3856+ "place-items",
3857+ "place-self",
34103858 // Scroll behavior
34113859 "scroll-behavior",
34123860 // Resize
34133861 "resize",
34143862 // New CSS properties
3415− "clip-path", "mix-blend-mode", "background-blend-mode", "writing-mode",
3416− "column-count", "column-gap", "text-indent", "hyphens",
3417− "flex-grow", "flex-shrink", "flex-basis", "isolation",
3418− "place-content", "background-image", "datetime", "direction",
3863+ "clip-path",
3864+ "mix-blend-mode",
3865+ "background-blend-mode",
3866+ "writing-mode",
3867+ "column-count",
3868+ "column-gap",
3869+ "text-indent",
3870+ "hyphens",
3871+ "flex-grow",
3872+ "flex-shrink",
3873+ "flex-basis",
3874+ "isolation",
3875+ "place-content",
3876+ "background-image",
3877+ "datetime",
3878+ "direction",
34193879 // New CSS properties (batch 2)
3420− "font-weight", "font-style", "text-wrap", "will-change", "touch-action",
3421− "vertical-align", "contain", "content-visibility",
3422− "scroll-margin", "scroll-margin-top", "scroll-margin-bottom", "scroll-margin-left", "scroll-margin-right",
3423− "scroll-padding", "scroll-padding-top", "scroll-padding-bottom", "scroll-padding-left", "scroll-padding-right",
3880+ "font-weight",
3881+ "font-style",
3882+ "text-wrap",
3883+ "will-change",
3884+ "touch-action",
3885+ "vertical-align",
3886+ "contain",
3887+ "content-visibility",
3888+ "scroll-margin",
3889+ "scroll-margin-top",
3890+ "scroll-margin-bottom",
3891+ "scroll-margin-left",
3892+ "scroll-margin-right",
3893+ "scroll-padding",
3894+ "scroll-padding-top",
3895+ "scroll-padding-bottom",
3896+ "scroll-padding-left",
3897+ "scroll-padding-right",
34243898 // Iframe/output attrs
3425− "sandbox", "allow", "allowfullscreen", "referrerpolicy",
3426− "formaction", "formmethod", "formtarget", "target",
3899+ "sandbox",
3900+ "allow",
3901+ "allowfullscreen",
3902+ "referrerpolicy",
3903+ "formaction",
3904+ "formmethod",
3905+ "formtarget",
3906+ "target",
34273907 // Popover API
3428− "popover", "popovertarget", "popovertargetaction",
3908+ "popover",
3909+ "popovertarget",
3910+ "popovertargetaction",
34293911 // Modern form/input hints
3430− "inputmode", "enterkeyhint",
3912+ "inputmode",
3913+ "enterkeyhint",
34313914 // Performance hints
3432− "fetchpriority", "blocking",
3915+ "fetchpriority",
3916+ "blocking",
34333917 // Global attrs
3434− "translate", "spellcheck",
3918+ "translate",
3919+ "spellcheck",
34353920 // Script attributes
3436− "defer", "async", "crossorigin", "integrity", "nomodule",
3921+ "defer",
3922+ "async",
3923+ "crossorigin",
3924+ "integrity",
3925+ "nomodule",
34373926 // Pseudo-element content
34383927 "content",
34393928 // CSS shorthands
3440− "truncate", "line-clamp", "blur", "backdrop-blur", "no-scrollbar", "skeleton", "gradient",
3929+ "truncate",
3930+ "line-clamp",
3931+ "blur",
3932+ "backdrop-blur",
3933+ "no-scrollbar",
3934+ "skeleton",
3935+ "gradient",
34413936 // Grid areas
3442− "grid-template-areas", "grid-area",
3937+ "grid-template-areas",
3938+ "grid-area",
34433939 // View transitions
34443940 "view-transition-name",
34453941 // Animate shorthand
@@ -3447,13 +3943,20 @@ const KNOWN_ATTRS: &[&str] = &[
34473943 // Critical CSS hint
34483944 "critical",
34493945 // CSS subgrid
3450− "grid-template-columns", "grid-template-rows",
3946+ "grid-template-columns",
3947+ "grid-template-rows",
34513948 // Scroll-driven animations
3452− "animation-timeline", "animation-range",
3453− "view-timeline-name", "view-timeline-axis",
3454− "scroll-timeline-name", "scroll-timeline-axis",
3949+ "animation-timeline",
3950+ "animation-range",
3951+ "view-timeline-name",
3952+ "view-timeline-axis",
3953+ "scroll-timeline-name",
3954+ "scroll-timeline-axis",
34553955 // Anchor positioning
3456− "anchor-name", "position-anchor", "position-area", "inset-area",
3956+ "anchor-name",
3957+ "position-anchor",
3958+ "position-area",
3959+ "inset-area",
34573960 // Drop caps
34583961 "initial-letter",
34593962 // Responsive images
@@ -3462,15 +3965,29 @@ const KNOWN_ATTRS: &[&str] = &[
34623965
34633966/// Attributes that expect purely numeric values (px-based) or values with CSS units.
34643967const NUMERIC_ATTRS: &[&str] = &[
3465− "spacing", "gap", "padding", "padding-x", "padding-y",
3466− "min-width", "max-width", "min-height", "max-height",
3467− "rounded", "size", "gap-x", "gap-y",
3468− "top", "right", "bottom", "left", "letter-spacing",
3968+ "spacing",
3969+ "gap",
3970+ "padding",
3971+ "padding-x",
3972+ "padding-y",
3973+ "min-width",
3974+ "max-width",
3975+ "min-height",
3976+ "max-height",
3977+ "rounded",
3978+ "size",
3979+ "gap-x",
3980+ "gap-y",
3981+ "top",
3982+ "right",
3983+ "bottom",
3984+ "left",
3985+ "letter-spacing",
34693986];
34703987
34713988const CSS_UNIT_SUFFIXES: &[&str] = &[
3472− "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh",
3473− "ch", "ex", "cm", "mm", "in", "pt", "pc", "fr",
3989+ "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh", "ch", "ex", "cm", "mm",
3990+ "in", "pt", "pc", "fr",
34743991];
34753992
34763993fn has_css_unit(value: &str) -> bool {
@@ -3557,9 +4074,20 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
35574074 }
35584075 } else if base_key == "display" {
35594076 const DISPLAY_VALUES: &[&str] = &[
3560− "none", "block", "inline", "inline-block", "flex", "inline-flex",
3561− "grid", "inline-grid", "table", "table-row", "table-cell",
3562− "contents", "flow-root", "list-item",
4077+ "none",
4078+ "block",
4079+ "inline",
4080+ "inline-block",
4081+ "flex",
4082+ "inline-flex",
4083+ "grid",
4084+ "inline-grid",
4085+ "table",
4086+ "table-row",
4087+ "table-cell",
4088+ "contents",
4089+ "flow-root",
4090+ "list-item",
35634091 ];
35644092 if !DISPLAY_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
35654093 let suggestion = suggest_closest(val, DISPLAY_VALUES);
@@ -3568,14 +4096,15 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
35684096 None => format!("unknown display value '{}'", val),
35694097 };
35704098 ctx.diagnostics.push(Diagnostic {
3571− line: line_num, column: None, message: msg,
3572− severity: Severity::Warning, source_line: None,
4099+ line: line_num,
4100+ column: None,
4101+ message: msg,
4102+ severity: Severity::Warning,
4103+ source_line: None,
35734104 });
35744105 }
35754106 } else if base_key == "position" {
3576− const POSITION_VALUES: &[&str] = &[
3577− "static", "relative", "absolute", "fixed", "sticky",
3578− ];
4107+ const POSITION_VALUES: &[&str] = &["static", "relative", "absolute", "fixed", "sticky"];
35794108 if !POSITION_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
35804109 let suggestion = suggest_closest(val, POSITION_VALUES);
35814110 let msg = match suggestion {
@@ -3583,14 +4112,15 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
35834112 None => format!("unknown position value '{}'", val),
35844113 };
35854114 ctx.diagnostics.push(Diagnostic {
3586− line: line_num, column: None, message: msg,
3587− severity: Severity::Warning, source_line: None,
4115+ line: line_num,
4116+ column: None,
4117+ message: msg,
4118+ severity: Severity::Warning,
4119+ source_line: None,
35884120 });
35894121 }
35904122 } else if base_key == "overflow" || base_key == "overflow-x" || base_key == "overflow-y" {
3591− const OVERFLOW_VALUES: &[&str] = &[
3592− "visible", "hidden", "scroll", "auto", "clip",
3593− ];
4123+ const OVERFLOW_VALUES: &[&str] = &["visible", "hidden", "scroll", "auto", "clip"];
35944124 if !OVERFLOW_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
35954125 let suggestion = suggest_closest(val, OVERFLOW_VALUES);
35964126 let msg = match suggestion {
@@ -3598,14 +4128,16 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
35984128 None => format!("unknown overflow value '{}'", val),
35994129 };
36004130 ctx.diagnostics.push(Diagnostic {
3601− line: line_num, column: None, message: msg,
3602− severity: Severity::Warning, source_line: None,
4131+ line: line_num,
4132+ column: None,
4133+ message: msg,
4134+ severity: Severity::Warning,
4135+ source_line: None,
36034136 });
36044137 }
36054138 } else if base_key == "text-align" {
3606− const TEXT_ALIGN_VALUES: &[&str] = &[
3607− "left", "right", "center", "justify", "start", "end",
3608− ];
4139+ const TEXT_ALIGN_VALUES: &[&str] =
4140+ &["left", "right", "center", "justify", "start", "end"];
36094141 if !TEXT_ALIGN_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
36104142 let suggestion = suggest_closest(val, TEXT_ALIGN_VALUES);
36114143 let msg = match suggestion {
@@ -3613,62 +4145,161 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
36134145 None => format!("unknown text-align value '{}'", val),
36144146 };
36154147 ctx.diagnostics.push(Diagnostic {
3616− line: line_num, column: None, message: msg,
3617− severity: Severity::Warning, source_line: None,
4148+ line: line_num,
4149+ column: None,
4150+ message: msg,
4151+ severity: Severity::Warning,
4152+ source_line: None,
36184153 });
36194154 }
36204155 } else if base_key == "cursor" {
36214156 const CURSOR_VALUES: &[&str] = &[
3622− "auto", "default", "none", "pointer", "wait", "text", "move",
3623− "not-allowed", "crosshair", "grab", "grabbing", "help", "progress",
3624− "col-resize", "row-resize", "n-resize", "s-resize", "e-resize", "w-resize",
3625− "zoom-in", "zoom-out", "context-menu", "cell", "copy", "alias", "no-drop",
4157+ "auto",
4158+ "default",
4159+ "none",
4160+ "pointer",
4161+ "wait",
4162+ "text",
4163+ "move",
4164+ "not-allowed",
4165+ "crosshair",
4166+ "grab",
4167+ "grabbing",
4168+ "help",
4169+ "progress",
4170+ "col-resize",
4171+ "row-resize",
4172+ "n-resize",
4173+ "s-resize",
4174+ "e-resize",
4175+ "w-resize",
4176+ "zoom-in",
4177+ "zoom-out",
4178+ "context-menu",
4179+ "cell",
4180+ "copy",
4181+ "alias",
4182+ "no-drop",
36264183 ];
3627− if !CURSOR_VALUES.contains(&val.as_str()) && !val.starts_with("url(") && !val.starts_with("var(") {
4184+ if !CURSOR_VALUES.contains(&val.as_str())
4185+ && !val.starts_with("url(")
4186+ && !val.starts_with("var(")
4187+ {
36284188 let suggestion = suggest_closest(val, CURSOR_VALUES);
36294189 let msg = match suggestion {
36304190 Some(s) => format!("unknown cursor value '{}', did you mean '{}'?", val, s),
36314191 None => format!("unknown cursor value '{}'", val),
36324192 };
36334193 ctx.diagnostics.push(Diagnostic {
3634− line: line_num, column: None, message: msg,
3635− severity: Severity::Warning, source_line: None,
4194+ line: line_num,
4195+ column: None,
4196+ message: msg,
4197+ severity: Severity::Warning,
4198+ source_line: None,
36364199 });
36374200 }
36384201 } else if base_key == "font-weight" {
36394202 const WEIGHT_VALUES: &[&str] = &[
3640− "normal", "bold", "bolder", "lighter",
3641− "100", "200", "300", "400", "500", "600", "700", "800", "900",
4203+ "normal", "bold", "bolder", "lighter", "100", "200", "300", "400", "500", "600",
4204+ "700", "800", "900",
36424205 ];
36434206 if !WEIGHT_VALUES.contains(&val.as_str()) && !val.starts_with("var(") {
36444207 ctx.diagnostics.push(Diagnostic {
3645− line: line_num, column: None,
3646− message: format!("'font-weight' expects a weight keyword or number 100-900, got '{}'", val),
3647− severity: Severity::Warning, source_line: None,
4208+ line: line_num,
4209+ column: None,
4210+ message: format!(
4211+ "'font-weight' expects a weight keyword or number 100-900, got '{}'",
4212+ val
4213+ ),
4214+ severity: Severity::Warning,
4215+ source_line: None,
36484216 });
36494217 }
36504218 } else if base_key == "color" || base_key == "background" {
36514219 // Validate named CSS colors (only if not hex, rgb, hsl, var, etc.)
3652− if !val.starts_with('#') && !val.starts_with("rgb") && !val.starts_with("hsl")
3653− && !val.starts_with("var(") && !val.starts_with("linear-gradient")
3654− && !val.starts_with("radial-gradient") && !val.starts_with("conic-gradient")
3655− && !val.starts_with("oklch") && !val.starts_with("oklab")
3656− && !val.starts_with("color(") && !val.starts_with("light-dark(")
4220+ if !val.starts_with('#')
4221+ && !val.starts_with("rgb")
4222+ && !val.starts_with("hsl")
4223+ && !val.starts_with("var(")
4224+ && !val.starts_with("linear-gradient")
4225+ && !val.starts_with("radial-gradient")
4226+ && !val.starts_with("conic-gradient")
4227+ && !val.starts_with("oklch")
4228+ && !val.starts_with("oklab")
4229+ && !val.starts_with("color(")
4230+ && !val.starts_with("light-dark(")
36574231 && !val.contains("url(")
3658− && !val.contains(' ') // skip shorthand multi-value
4232+ && !val.contains(' ')
4233+ // skip shorthand multi-value
36594234 {
36604235 const NAMED_COLORS: &[&str] = &[
3661− "transparent", "currentcolor", "inherit", "initial", "unset",
3662− "black", "white", "red", "green", "blue", "yellow", "orange", "purple",
3663− "pink", "brown", "gray", "grey", "cyan", "magenta", "lime", "olive",
3664− "navy", "teal", "aqua", "fuchsia", "maroon", "silver",
3665− "coral", "salmon", "tomato", "crimson", "firebrick", "darkred",
3666− "indigo", "violet", "plum", "orchid", "thistle", "lavender",
3667− "gold", "khaki", "wheat", "tan", "sienna", "chocolate", "peru",
3668− "beige", "ivory", "linen", "snow", "seashell", "mintcream",
3669− "skyblue", "steelblue", "royalblue", "dodgerblue", "cornflowerblue",
3670− "slategray", "slategrey", "dimgray", "dimgrey", "lightgray", "lightgrey",
3671− "darkgray", "darkgrey", "gainsboro", "whitesmoke",
4236+ "transparent",
4237+ "currentcolor",
4238+ "inherit",
4239+ "initial",
4240+ "unset",
4241+ "black",
4242+ "white",
4243+ "red",
4244+ "green",
4245+ "blue",
4246+ "yellow",
4247+ "orange",
4248+ "purple",
4249+ "pink",
4250+ "brown",
4251+ "gray",
4252+ "grey",
4253+ "cyan",
4254+ "magenta",
4255+ "lime",
4256+ "olive",
4257+ "navy",
4258+ "teal",
4259+ "aqua",
4260+ "fuchsia",
4261+ "maroon",
4262+ "silver",
4263+ "coral",
4264+ "salmon",
4265+ "tomato",
4266+ "crimson",
4267+ "firebrick",
4268+ "darkred",
4269+ "indigo",
4270+ "violet",
4271+ "plum",
4272+ "orchid",
4273+ "thistle",
4274+ "lavender",
4275+ "gold",
4276+ "khaki",
4277+ "wheat",
4278+ "tan",
4279+ "sienna",
4280+ "chocolate",
4281+ "peru",
4282+ "beige",
4283+ "ivory",
4284+ "linen",
4285+ "snow",
4286+ "seashell",
4287+ "mintcream",
4288+ "skyblue",
4289+ "steelblue",
4290+ "royalblue",
4291+ "dodgerblue",
4292+ "cornflowerblue",
4293+ "slategray",
4294+ "slategrey",
4295+ "dimgray",
4296+ "dimgrey",
4297+ "lightgray",
4298+ "lightgrey",
4299+ "darkgray",
4300+ "darkgrey",
4301+ "gainsboro",
4302+ "whitesmoke",
36724303 ];
36734304 if !NAMED_COLORS.contains(&val.to_lowercase().as_str()) {
36744305 let lower = val.to_lowercase();
@@ -3678,8 +4309,11 @@ fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext
36784309 None => format!("unknown color '{}'", val),
36794310 };
36804311 ctx.diagnostics.push(Diagnostic {
3681− line: line_num, column: None, message: msg,
3682− severity: Severity::Warning, source_line: None,
4312+ line: line_num,
4313+ column: None,
4314+ message: msg,
4315+ severity: Severity::Warning,
4316+ source_line: None,
36834317 });
36844318 }
36854319 }
@@ -3748,7 +4382,12 @@ fn is_valid_hex_color(s: &str) -> bool {
37484382 matches!(hex.len(), 3 | 4 | 6 | 8) && hex.chars().all(|c| c.is_ascii_hexdigit())
37494383}
37504384
3751−fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validate: bool) -> Vec<Attribute> {
4385+fn parse_attr_list(
4386+ input: &str,
4387+ line_num: usize,
4388+ ctx: &mut ParseContext,
4389+ validate: bool,
4390+) -> Vec<Attribute> {
37524391 let mut attrs = Vec::new();
37534392 let mut seen_keys: Vec<String> = Vec::new();
37544393
@@ -3859,15 +4498,17 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
38594498 // Color validation for hex colors
38604499 if matches!(strip_all_prefixes(&attr.key), "background" | "color")
38614500 && let Some(ref val) = attr.value
3862− && val.starts_with('#') && !is_valid_hex_color(val) {
3863− ctx.diagnostics.push(Diagnostic {
3864− line: line_num,
3865− column: None,
3866− message: format!("invalid hex color '{}'", val),
3867− severity: Severity::Warning,
3868− source_line: None,
3869− });
3870− }
4501+ && val.starts_with('#')
4502+ && !is_valid_hex_color(val)
4503+ {
4504+ ctx.diagnostics.push(Diagnostic {
4505+ line: line_num,
4506+ column: None,
4507+ message: format!("invalid hex color '{}'", val),
4508+ severity: Severity::Warning,
4509+ source_line: None,
4510+ });
4511+ }
38714512 }
38724513
38734514 // Warn on unknown attributes
@@ -3880,7 +4521,10 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
38804521 let suggestion = suggest_closest(base_key, KNOWN_ATTRS);
38814522 let msg = match suggestion {
38824523 Some(closest) => {
3883− format!("unknown attribute '{}', did you mean '{}'?", attr.key, closest)
4524+ format!(
4525+ "unknown attribute '{}', did you mean '{}'?",
4526+ attr.key, closest
4527+ )
38844528 }
38854529 None => format!("unknown attribute '{}'", attr.key),
38864530 };
@@ -3977,10 +4621,7 @@ fn parse_text_segments(input: &str, ctx: &mut ParseContext) -> Vec<TextSegment>
39774621 let mut i = 0;
39784622
39794623 while i < chars.len() {
3980− if chars[i] == '{'
3981− && i + 1 < chars.len()
3982− && chars[i + 1] == '@'
3983− {
4624+ if chars[i] == '{' && i + 1 < chars.len() && chars[i + 1] == '@' {
39844625 // Flush accumulated plain text
39854626 if !current_text.is_empty() {
39864627 segments.push(TextSegment::Plain(substitute_vars(
@@ -4163,15 +4804,19 @@ fn evaluate_arithmetic(input: &str) -> String {
41634804 if input.contains(" ~ ") {
41644805 let parts: Vec<&str> = input.split(" ~ ").collect();
41654806 if parts.len() >= 2 {
4166− return parts.iter().map(|p| {
4167− let t = p.trim();
4168− // Strip quotes from string literals
4169− if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
4170− &t[1..t.len()-1]
4171− } else {
4172− t
4173− }
4174− }).collect::<Vec<_>>().join("");
4807+ return parts
4808+ .iter()
4809+ .map(|p| {
4810+ let t = p.trim();
4811+ // Strip quotes from string literals
4812+ if t.len() >= 2 && t.starts_with('"') && t.ends_with('"') {
4813+ &t[1..t.len() - 1]
4814+ } else {
4815+ t
4816+ }
4817+ })
4818+ .collect::<Vec<_>>()
4819+ .join("");
41754820 }
41764821 }
41774822
@@ -4187,7 +4832,13 @@ fn evaluate_arithmetic(input: &str) -> String {
41874832 '+' => l + r,
41884833 '-' => l - r,
41894834 '*' => l * r,
4190− '/' => if r != 0.0 { l / r } else { return input.to_string() },
4835+ '/' => {
4836+ if r != 0.0 {
4837+ l / r
4838+ } else {
4839+ return input.to_string();
4840+ }
4841+ }
41914842 _ => return input.to_string(),
41924843 };
41934844 if result == result.floor() && result.abs() < i64::MAX as f64 {
@@ -4249,23 +4900,26 @@ fn strip_all_prefixes(key: &str) -> &str {
42494900 })
42504901 .unwrap_or(key);
42514902 // Strip responsive prefixes
4252− let key = key.strip_prefix("sm:")
4903+ let key = key
4904+ .strip_prefix("sm:")
42534905 .or_else(|| key.strip_prefix("md:"))
42544906 .or_else(|| key.strip_prefix("lg:"))
42554907 .or_else(|| key.strip_prefix("xl:"))
42564908 .or_else(|| key.strip_prefix("2xl:"))
42574909 .unwrap_or(key);
42584910 // Strip media prefixes
4259− let key = key.strip_prefix("dark:")
4911+ let key = key
4912+ .strip_prefix("dark:")
42604913 .or_else(|| key.strip_prefix("print:"))
42614914 .unwrap_or(key);
4262− let key = key.strip_prefix("motion-safe:")
4915+ let key = key
4916+ .strip_prefix("motion-safe:")
42634917 .or_else(|| key.strip_prefix("motion-reduce:"))
42644918 .or_else(|| key.strip_prefix("landscape:"))
42654919 .or_else(|| key.strip_prefix("portrait:"))
42664920 .unwrap_or(key);
42674921 // Strip container query prefixes (cq-sm:, cq-md:, etc.)
4268−
4922+
42694923 (key.strip_prefix("cq-sm:")
42704924 .or_else(|| key.strip_prefix("cq-md:"))
42714925 .or_else(|| key.strip_prefix("cq-lg:"))
@@ -4276,9 +4930,17 @@ fn strip_all_prefixes(key: &str) -> &str {
42764930
42774931/// Attributes that only make sense on container elements (@row, @column, @el).
42784932const CONTAINER_ONLY_ATTRS: &[&str] = &[
4279− "spacing", "gap", "gap-x", "gap-y", "wrap",
4280− "grid", "grid-cols", "grid-rows",
4281− "container", "container-name", "container-type",
4933+ "spacing",
4934+ "gap",
4935+ "gap-x",
4936+ "gap-y",
4937+ "wrap",
4938+ "grid",
4939+ "grid-cols",
4940+ "grid-rows",
4941+ "container",
4942+ "container-name",
4943+ "container-type",
42824944];
42834945
42844946fn element_kind_name(kind: &ElementKind) -> &'static str {
@@ -4362,18 +5024,39 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
43625024}
43635025
43645026fn is_container(kind: &ElementKind) -> bool {
4365− matches!(kind,
4366− ElementKind::Row | ElementKind::Column | ElementKind::El
4367− | ElementKind::Nav | ElementKind::Header | ElementKind::Footer
4368− | ElementKind::Main | ElementKind::Section | ElementKind::Article
4369− | ElementKind::Aside | ElementKind::List | ElementKind::ListItem
4370− | ElementKind::Form | ElementKind::Details | ElementKind::Figure
4371− | ElementKind::Blockquote
4372− | ElementKind::Dialog | ElementKind::DefinitionList | ElementKind::DefinitionTerm
4373− | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
4374− | ElementKind::Iframe | ElementKind::Canvas
4375− | ElementKind::Grid | ElementKind::Stack | ElementKind::Carousel
4376− | ElementKind::Noscript | ElementKind::Address | ElementKind::Search | ElementKind::Breadcrumb
5027+ matches!(
5028+ kind,
5029+ ElementKind::Row
5030+ | ElementKind::Column
5031+ | ElementKind::El
5032+ | ElementKind::Nav
5033+ | ElementKind::Header
5034+ | ElementKind::Footer
5035+ | ElementKind::Main
5036+ | ElementKind::Section
5037+ | ElementKind::Article
5038+ | ElementKind::Aside
5039+ | ElementKind::List
5040+ | ElementKind::ListItem
5041+ | ElementKind::Form
5042+ | ElementKind::Details
5043+ | ElementKind::Figure
5044+ | ElementKind::Blockquote
5045+ | ElementKind::Dialog
5046+ | ElementKind::DefinitionList
5047+ | ElementKind::DefinitionTerm
5048+ | ElementKind::DefinitionDescription
5049+ | ElementKind::Fieldset
5050+ | ElementKind::Datalist
5051+ | ElementKind::Iframe
5052+ | ElementKind::Canvas
5053+ | ElementKind::Grid
5054+ | ElementKind::Stack
5055+ | ElementKind::Carousel
5056+ | ElementKind::Noscript
5057+ | ElementKind::Address
5058+ | ElementKind::Search
5059+ | ElementKind::Breadcrumb
43775060 )
43785061}
43795062
@@ -4386,29 +5069,32 @@ fn validate_tree(
43865069 if let Node::Element(elem) = node {
43875070 for attr in &elem.attrs {
43885071 let base = strip_all_prefixes(&attr.key);
4389− if base == "width" && attr.value.as_deref() == Some("fill")
4390− && !matches!(parent_kind, Some(ElementKind::Row)) {
4391− diagnostics.push(Diagnostic {
4392− line: elem.line_num,
4393− column: None,
4394− message: "'width fill' works best inside @row; using 100% as fallback"
4395− .to_string(),
4396− severity: Severity::Warning,
4397− source_line: None,
4398− });
4399− }
4400− if base == "height" && attr.value.as_deref() == Some("fill")
4401− && !matches!(parent_kind, Some(ElementKind::Column)) {
4402− diagnostics.push(Diagnostic {
4403− line: elem.line_num,
4404− column: None,
4405− message:
4406− "'height fill' works best inside @column; using 100% as fallback"
4407− .to_string(),
4408− severity: Severity::Warning,
4409− source_line: None,
4410− });
4411− }
5072+ if base == "width"
5073+ && attr.value.as_deref() == Some("fill")
5074+ && !matches!(parent_kind, Some(ElementKind::Row))
5075+ {
5076+ diagnostics.push(Diagnostic {
5077+ line: elem.line_num,
5078+ column: None,
5079+ message: "'width fill' works best inside @row; using 100% as fallback"
5080+ .to_string(),
5081+ severity: Severity::Warning,
5082+ source_line: None,
5083+ });
5084+ }
5085+ if base == "height"
5086+ && attr.value.as_deref() == Some("fill")
5087+ && !matches!(parent_kind, Some(ElementKind::Column))
5088+ {
5089+ diagnostics.push(Diagnostic {
5090+ line: elem.line_num,
5091+ column: None,
5092+ message: "'height fill' works best inside @column; using 100% as fallback"
5093+ .to_string(),
5094+ severity: Severity::Warning,
5095+ source_line: None,
5096+ });
5097+ }
44125098
44135099 // Container-only attributes on non-container elements
44145100 if CONTAINER_ONLY_ATTRS.contains(&base) && !is_container(&elem.kind) {
@@ -4417,7 +5103,8 @@ fn validate_tree(
44175103 column: None,
44185104 message: format!(
44195105 "'{}' has no effect on {} (only works on @row, @column, @el)",
4420− base, element_kind_name(&elem.kind)
5106+ base,
5107+ element_kind_name(&elem.kind)
44215108 ),
44225109 severity: Severity::Warning,
44235110 source_line: None,
@@ -4455,15 +5142,15 @@ fn validate_tree(
44555142 }
44565143
44575144 // 'rows'/'cols' only on @textarea
4458− if (base == "rows" || base == "cols")
4459− && !matches!(elem.kind, ElementKind::Textarea)
5145+ if (base == "rows" || base == "cols") && !matches!(elem.kind, ElementKind::Textarea)
44605146 {
44615147 diagnostics.push(Diagnostic {
44625148 line: elem.line_num,
44635149 column: None,
44645150 message: format!(
44655151 "'{}' has no effect on {} (only works on @textarea)",
4466− base, element_kind_name(&elem.kind)
5152+ base,
5153+ element_kind_name(&elem.kind)
44675154 ),
44685155 severity: Severity::Warning,
44695156 source_line: None,
@@ -4485,15 +5172,18 @@ fn validate_tree(
44855172 }
44865173
44875174 // Media-specific attributes only on @video/@audio
4488− if matches!(base, "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload")
4489− && !matches!(elem.kind, ElementKind::Video | ElementKind::Audio)
5175+ if matches!(
5176+ base,
5177+ "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload"
5178+ ) && !matches!(elem.kind, ElementKind::Video | ElementKind::Audio)
44905179 {
44915180 diagnostics.push(Diagnostic {
44925181 line: elem.line_num,
44935182 column: None,
44945183 message: format!(
44955184 "'{}' has no effect on {} (only works on @video, @audio)",
4496− base, element_kind_name(&elem.kind)
5185+ base,
5186+ element_kind_name(&elem.kind)
44975187 ),
44985188 severity: Severity::Warning,
44995189 source_line: None,
@@ -4501,34 +5191,41 @@ fn validate_tree(
45015191 }
45025192 }
45035193 // Missing alt text on @image
4504− if matches!(elem.kind, ElementKind::Image)
4505− && !elem.attrs.iter().any(|a| a.key == "alt") {
4506− diagnostics.push(Diagnostic {
4507− line: elem.line_num,
4508− column: None,
4509− message: "@image missing 'alt' attribute (accessibility)".to_string(),
4510− severity: Severity::Warning,
4511− source_line: None,
4512− });
4513− }
5194+ if matches!(elem.kind, ElementKind::Image) && !elem.attrs.iter().any(|a| a.key == "alt")
5195+ {
5196+ diagnostics.push(Diagnostic {
5197+ line: elem.line_num,
5198+ column: None,
5199+ message: "@image missing 'alt' attribute (accessibility)".to_string(),
5200+ severity: Severity::Warning,
5201+ source_line: None,
5202+ });
5203+ }
45145204 if matches!(elem.kind, ElementKind::Input)
4515− && !elem.attrs.iter().any(|a| a.key == "type") {
4516− diagnostics.push(Diagnostic {
4517− line: elem.line_num,
4518− column: None,
4519− message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
4520− severity: Severity::Warning,
4521− source_line: None,
4522− });
4523− }
5205+ && !elem.attrs.iter().any(|a| a.key == "type")
5206+ {
5207+ diagnostics.push(Diagnostic {
5208+ line: elem.line_num,
5209+ column: None,
5210+ message: "@input missing 'type' attribute (defaults to 'text')".to_string(),
5211+ severity: Severity::Warning,
5212+ source_line: None,
5213+ });
5214+ }
45245215 if matches!(elem.kind, ElementKind::Link) {
45255216 // For @link, argument is the URL, not text content
45265217 let has_text = !elem.children.is_empty();
4527− if !has_text && !elem.attrs.iter().any(|a| a.key == "aria-label" || a.key == "title") {
5218+ if !has_text
5219+ && !elem
5220+ .attrs
5221+ .iter()
5222+ .any(|a| a.key == "aria-label" || a.key == "title")
5223+ {
45285224 diagnostics.push(Diagnostic {
45295225 line: elem.line_num,
45305226 column: None,
4531− message: "@link has no visible text or aria-label (accessibility)".to_string(),
5227+ message: "@link has no visible text or aria-label (accessibility)"
5228+ .to_string(),
45325229 severity: Severity::Warning,
45335230 source_line: None,
45345231 });
@@ -4537,17 +5234,22 @@ fn validate_tree(
45375234
45385235 // Contrast ratio check for hex color pairs
45395236 {
4540− let bg_color = elem.attrs.iter()
5237+ let bg_color = elem
5238+ .attrs
5239+ .iter()
45415240 .find(|a| strip_all_prefixes(&a.key) == "background")
45425241 .and_then(|a| a.value.as_deref());
4543− let fg_color = elem.attrs.iter()
5242+ let fg_color = elem
5243+ .attrs
5244+ .iter()
45445245 .find(|a| strip_all_prefixes(&a.key) == "color")
45455246 .and_then(|a| a.value.as_deref());
45465247 if let (Some(bg), Some(fg)) = (bg_color, fg_color)
4547− && let (Some(bg_rgb), Some(fg_rgb)) = (parse_hex_rgb(bg), parse_hex_rgb(fg)) {
4548− let ratio = contrast_ratio(bg_rgb, fg_rgb);
4549− if ratio < 4.5 {
4550− diagnostics.push(Diagnostic {
5248+ && let (Some(bg_rgb), Some(fg_rgb)) = (parse_hex_rgb(bg), parse_hex_rgb(fg))
5249+ {
5250+ let ratio = contrast_ratio(bg_rgb, fg_rgb);
5251+ if ratio < 4.5 {
5252+ diagnostics.push(Diagnostic {
45515253 line: elem.line_num,
45525254 column: None,
45535255 message: format!(
@@ -4557,14 +5259,20 @@ fn validate_tree(
45575259 severity: Severity::Warning,
45585260 source_line: None,
45595261 });
4560− }
45615262 }
5263+ }
45625264 }
45635265
45645266 // @form inputs should have associated @label
4565− if matches!(elem.kind, ElementKind::Input | ElementKind::Select | ElementKind::Textarea) {
5267+ if matches!(
5268+ elem.kind,
5269+ ElementKind::Input | ElementKind::Select | ElementKind::Textarea
5270+ ) {
45665271 let has_id = elem.attrs.iter().any(|a| a.key == "id");
4567− let has_aria_label = elem.attrs.iter().any(|a| a.key == "aria-label" || a.key == "aria-labelledby");
5272+ let has_aria_label = elem
5273+ .attrs
5274+ .iter()
5275+ .any(|a| a.key == "aria-label" || a.key == "aria-labelledby");
45685276 let has_title = elem.attrs.iter().any(|a| a.key == "title");
45695277 let in_label = matches!(parent_kind, Some(ElementKind::Label));
45705278 if !has_id && !has_aria_label && !has_title && !in_label {
@@ -4583,15 +5291,16 @@ fn validate_tree(
45835291
45845292 // @iframe should have title attribute
45855293 if matches!(elem.kind, ElementKind::Iframe)
4586− && !elem.attrs.iter().any(|a| a.key == "title") {
4587− diagnostics.push(Diagnostic {
4588− line: elem.line_num,
4589− column: None,
4590− message: "@iframe missing 'title' attribute (accessibility)".to_string(),
4591− severity: Severity::Warning,
4592− source_line: None,
4593− });
4594− }
5294+ && !elem.attrs.iter().any(|a| a.key == "title")
5295+ {
5296+ diagnostics.push(Diagnostic {
5297+ line: elem.line_num,
5298+ column: None,
5299+ message: "@iframe missing 'title' attribute (accessibility)".to_string(),
5300+ severity: Severity::Warning,
5301+ source_line: None,
5302+ });
5303+ }
45955304
45965305 // @button should have accessible text
45975306 if matches!(elem.kind, ElementKind::Button) {
@@ -4601,7 +5310,8 @@ fn validate_tree(
46015310 diagnostics.push(Diagnostic {
46025311 line: elem.line_num,
46035312 column: None,
4604− message: "@button has no text content or aria-label (accessibility)".to_string(),
5313+ message: "@button has no text content or aria-label (accessibility)"
5314+ .to_string(),
46055315 severity: Severity::Warning,
46065316 source_line: None,
46075317 });
@@ -4610,15 +5320,20 @@ fn validate_tree(
46105320
46115321 // @video should have captions or aria-label
46125322 if matches!(elem.kind, ElementKind::Video) {
4613− let has_aria = elem.attrs.iter().any(|a| a.key == "aria-label" || a.key == "aria-describedby");
4614− let has_track = elem.children.iter().any(|c| {
4615− matches!(c, Node::Element(e) if e.kind == ElementKind::Source)
4616− });
5323+ let has_aria = elem
5324+ .attrs
5325+ .iter()
5326+ .any(|a| a.key == "aria-label" || a.key == "aria-describedby");
5327+ let has_track = elem
5328+ .children
5329+ .iter()
5330+ .any(|c| matches!(c, Node::Element(e) if e.kind == ElementKind::Source));
46175331 if !has_aria && !has_track {
46185332 diagnostics.push(Diagnostic {
46195333 line: elem.line_num,
46205334 column: None,
4621− message: "@video should have aria-label or captions for accessibility".to_string(),
5335+ message: "@video should have aria-label or captions for accessibility"
5336+ .to_string(),
46225337 severity: Severity::Warning,
46235338 source_line: None,
46245339 });
@@ -4628,16 +5343,17 @@ fn validate_tree(
46285343 // Tabindex > 0 is an anti-pattern
46295344 if let Some(tabindex_attr) = elem.attrs.iter().find(|a| a.key == "tabindex")
46305345 && let Some(ref val) = tabindex_attr.value
4631− && let Ok(n) = val.parse::<i32>()
4632− && n > 0 {
4633− diagnostics.push(Diagnostic {
5346+ && let Ok(n) = val.parse::<i32>()
5347+ && n > 0
5348+ {
5349+ diagnostics.push(Diagnostic {
46345350 line: elem.line_num,
46355351 column: None,
46365352 message: format!("tabindex {} is positive — avoid positive tabindex values as they disrupt natural tab order", n),
46375353 severity: Severity::Warning,
46385354 source_line: None,
46395355 });
4640− }
5356+ }
46415357
46425358 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
46435359 }
@@ -4666,7 +5382,11 @@ fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
46665382fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
46675383 fn linearize(c: u8) -> f64 {
46685384 let s = c as f64 / 255.0;
4669− if s <= 0.03928 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
5385+ if s <= 0.03928 {
5386+ s / 12.92
5387+ } else {
5388+ ((s + 0.055) / 1.055).powf(2.4)
5389+ }
46705390 }
46715391 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
46725392}
@@ -4722,7 +5442,10 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
47225442 let start = i + 1;
47235443 let mut end = start;
47245444 while end < chars.len()
4725− && (chars[end].is_alphanumeric() || chars[end] == '-' || chars[end] == '_' || chars[end] == '.')
5445+ && (chars[end].is_alphanumeric()
5446+ || chars[end] == '-'
5447+ || chars[end] == '_'
5448+ || chars[end] == '.')
47265449 {
47275450 end += 1;
47285451 }
@@ -4814,14 +5537,12 @@ fn parse_keyframe_line(line: &str) -> Option<String> {
48145537}
48155538
48165539/// Replace @slot placeholders in the base layout with content from @extends
4817−fn replace_extends_slots(
4818− nodes: Vec<Node>,
4819− slot_nodes: &HashMap<String, Vec<Node>>,
4820−) -> Vec<Node> {
5540+fn replace_extends_slots(nodes: Vec<Node>, slot_nodes: &HashMap<String, Vec<Node>>) -> Vec<Node> {
48215541 let mut result = Vec::new();
48225542 for node in nodes {
48235543 match node {
4824− Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) => {
5544+ Node::Element(elem) if matches!(&elem.kind, ElementKind::Slot(name) if !name.is_empty()) =>
5545+ {
48255546 if let ElementKind::Slot(ref name) = elem.kind {
48265547 if let Some(content) = slot_nodes.get(name) {
48275548 result.extend(content.iter().cloned());
@@ -4844,9 +5565,10 @@ fn replace_extends_slots(
48445565fn apply_filter(value: &str, filter: &str) -> String {
48455566 if let Some(arg) = filter.strip_prefix("truncate:") {
48465567 if let Ok(n) = arg.parse::<usize>()
4847− && value.len() > n {
4848− return format!("{}...", &value[..n]);
4849− }
5568+ && value.len() > n
5569+ {
5570+ return format!("{}...", &value[..n]);
5571+ }
48505572 return value.to_string();
48515573 }
48525574 if let Some(rest) = filter.strip_prefix("replace:") {
@@ -4864,26 +5586,29 @@ fn apply_filter(value: &str, filter: &str) -> String {
48645586 // Color functions: lighten:N, darken:N, alpha:N, mix:COLOR:N
48655587 if let Some(arg) = filter.strip_prefix("lighten:") {
48665588 if let Ok(amount) = arg.parse::<f64>()
4867− && let Some(rgb) = parse_hex_rgb(value) {
4868− let (r, g, b) = lighten_color(rgb, amount / 100.0);
4869− return format!("#{:02x}{:02x}{:02x}", r, g, b);
4870− }
5589+ && let Some(rgb) = parse_hex_rgb(value)
5590+ {
5591+ let (r, g, b) = lighten_color(rgb, amount / 100.0);
5592+ return format!("#{:02x}{:02x}{:02x}", r, g, b);
5593+ }
48715594 return value.to_string();
48725595 }
48735596 if let Some(arg) = filter.strip_prefix("darken:") {
48745597 if let Ok(amount) = arg.parse::<f64>()
4875− && let Some(rgb) = parse_hex_rgb(value) {
4876− let (r, g, b) = darken_color(rgb, amount / 100.0);
4877− return format!("#{:02x}{:02x}{:02x}", r, g, b);
4878− }
5598+ && let Some(rgb) = parse_hex_rgb(value)
5599+ {
5600+ let (r, g, b) = darken_color(rgb, amount / 100.0);
5601+ return format!("#{:02x}{:02x}{:02x}", r, g, b);
5602+ }
48795603 return value.to_string();
48805604 }
48815605 if let Some(arg) = filter.strip_prefix("alpha:") {
48825606 if let Ok(a) = arg.parse::<f64>()
4883− && let Some((r, g, b)) = parse_hex_rgb(value) {
4884− let a8 = (a.clamp(0.0, 1.0) * 255.0) as u8;
4885− return format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a8);
4886− }
5607+ && let Some((r, g, b)) = parse_hex_rgb(value)
5608+ {
5609+ let a8 = (a.clamp(0.0, 1.0) * 255.0) as u8;
5610+ return format!("#{:02x}{:02x}{:02x}{:02x}", r, g, b, a8);
5611+ }
48875612 return value.to_string();
48885613 }
48895614 if let Some(arg) = filter.strip_prefix("mix:") {
@@ -4894,10 +5619,11 @@ fn apply_filter(value: &str, filter: &str) -> String {
48945619 parse_hex_rgb(value),
48955620 parse_hex_rgb(parts[0]),
48965621 parts[1].parse::<f64>(),
4897− ) {
4898− let (r, g, b) = mix_colors(c1, c2, pct / 100.0);
4899− return format!("#{:02x}{:02x}{:02x}", r, g, b);
4900− }
5622+ )
5623+ {
5624+ let (r, g, b) = mix_colors(c1, c2, pct / 100.0);
5625+ return format!("#{:02x}{:02x}{:02x}", r, g, b);
5626+ }
49015627 return value.to_string();
49025628 }
49035629 match filter {
@@ -5046,20 +5772,34 @@ fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
50465772 fn probe(chars: &[char], pos: usize, deepest: &mut usize) {
50475773 let mut p = pos;
50485774 while p < chars.len() {
5049− if p > *deepest { *deepest = p; }
5775+ if p > *deepest {
5776+ *deepest = p;
5777+ }
50505778 match chars[p] {
5051− '{' | '[' => { p += 1; probe(chars, p, deepest); return; }
5779+ '{' | '[' => {
5780+ p += 1;
5781+ probe(chars, p, deepest);
5782+ return;
5783+ }
50525784 '"' => {
50535785 p += 1;
50545786 while p < chars.len() && chars[p] != '"' {
5055− if chars[p] == '\\' { p += 1; }
5787+ if chars[p] == '\\' {
5788+ p += 1;
5789+ }
5790+ p += 1;
5791+ }
5792+ if p < chars.len() {
50565793 p += 1;
50575794 }
5058− if p < chars.len() { p += 1; }
5059− if p > *deepest { *deepest = p; }
5795+ if p > *deepest {
5796+ *deepest = p;
5797+ }
50605798 return;
50615799 }
5062− _ => { p += 1; }
5800+ _ => {
5801+ p += 1;
5802+ }
50635803 }
50645804 }
50655805 }
@@ -5067,9 +5807,19 @@ fn parse_json_with_error(input: &str) -> Result<JsonValue, String> {
50675807 // Convert char position to line:col
50685808 let prefix: String = chars[..deepest.min(chars.len())].iter().collect();
50695809 let line = prefix.chars().filter(|&c| c == '\n').count() + 1;
5070− let col = prefix.rfind('\n').map_or(prefix.len(), |p| prefix.len() - p - 1) + 1;
5071− let context: String = chars[deepest.saturating_sub(20)..deepest.min(chars.len())].iter().collect();
5072− Err(format!("at line {}:{} near \"{}\"", line, col, context.trim()))
5810+ let col = prefix
5811+ .rfind('\n')
5812+ .map_or(prefix.len(), |p| prefix.len() - p - 1)
5813+ + 1;
5814+ let context: String = chars[deepest.saturating_sub(20)..deepest.min(chars.len())]
5815+ .iter()
5816+ .collect();
5817+ Err(format!(
5818+ "at line {}:{} near \"{}\"",
5819+ line,
5820+ col,
5821+ context.trim()
5822+ ))
50735823 }
50745824 }
50755825}
@@ -5112,8 +5862,19 @@ fn parse_json_value(chars: &[char], mut pos: usize) -> Option<(JsonValue, usize)
51125862 if chars[pos] == '-' {
51135863 pos += 1;
51145864 }
5115− while pos < chars.len() && (chars[pos].is_ascii_digit() || chars[pos] == '.' || chars[pos] == 'e' || chars[pos] == 'E' || chars[pos] == '+' || chars[pos] == '-') {
5116− if (chars[pos] == '+' || chars[pos] == '-') && pos > start + 1 && chars[pos - 1] != 'e' && chars[pos - 1] != 'E' {
5865+ while pos < chars.len()
5866+ && (chars[pos].is_ascii_digit()
5867+ || chars[pos] == '.'
5868+ || chars[pos] == 'e'
5869+ || chars[pos] == 'E'
5870+ || chars[pos] == '+'
5871+ || chars[pos] == '-')
5872+ {
5873+ if (chars[pos] == '+' || chars[pos] == '-')
5874+ && pos > start + 1
5875+ && chars[pos - 1] != 'e'
5876+ && chars[pos - 1] != 'E'
5877+ {
51175878 break;
51185879 }
51195880 pos += 1;
@@ -5327,8 +6088,7 @@ fn fetch_url_blocking(url: &str) -> Result<String, String> {
53276088 }
53286089
53296090 let addr = format!("{}:{}", host, port);
5330− let mut stream =
5331− TcpStream::connect(&addr).map_err(|e| format!("connection failed: {}", e))?;
6091+ let mut stream = TcpStream::connect(&addr).map_err(|e| format!("connection failed: {}", e))?;
53326092 stream
53336093 .set_read_timeout(Some(std::time::Duration::from_secs(10)))
53346094 .ok();
@@ -5354,15 +6114,19 @@ fn fetch_url_blocking(url: &str) -> Result<String, String> {
53546114
53556115 // Check status code
53566116 if let Some(first_line) = headers.lines().next()
5357− && let Some(code_str) = first_line.split_whitespace().nth(1) {
5358− let code: u16 = code_str.parse().unwrap_or(0);
5359− if code >= 400 {
5360− return Err(format!("HTTP {}", code));
5361− }
6117+ && let Some(code_str) = first_line.split_whitespace().nth(1)
6118+ {
6119+ let code: u16 = code_str.parse().unwrap_or(0);
6120+ if code >= 400 {
6121+ return Err(format!("HTTP {}", code));
53626122 }
6123+ }
53636124
53646125 // Handle chunked transfer encoding
5365− if headers.to_lowercase().contains("transfer-encoding: chunked") {
6126+ if headers
6127+ .to_lowercase()
6128+ .contains("transfer-encoding: chunked")
6129+ {
53666130 return Ok(decode_chunked(body));
53676131 }
53686132
@@ -5380,9 +6144,7 @@ fn decode_chunked(body: &str) -> String {
53806144 if rest_trimmed.is_empty() {
53816145 break;
53826146 }
5383− let size_end = rest_trimmed
5384− .find("\r\n")
5385− .unwrap_or(rest_trimmed.len());
6147+ let size_end = rest_trimmed.find("\r\n").unwrap_or(rest_trimmed.len());
53866148 let size_str = &rest_trimmed[..size_end];
53876149 let size = usize::from_str_radix(size_str.trim(), 16).unwrap_or(0);
53886150 if size == 0 {
@@ -5507,11 +6269,20 @@ fn markdown_to_html(lines: &[String]) -> String {
55076269 // skip
55086270 }
55096271 let heading_level = trimmed.bytes().take_while(|&b| b == b'#').count();
5510− if (1..=6).contains(&heading_level) && trimmed.as_bytes().get(heading_level) == Some(&b' ') {
6272+ if (1..=6).contains(&heading_level) && trimmed.as_bytes().get(heading_level) == Some(&b' ')
6273+ {
55116274 flush_para(&mut para_buf, &mut html);
5512− if in_list { html.push_str("</ul>\n"); in_list = false; }
6275+ if in_list {
6276+ html.push_str("</ul>\n");
6277+ in_list = false;
6278+ }
55136279 let text = &trimmed[heading_level + 1..];
5514− html.push_str(&format!("<h{}>{}</h{}>\n", heading_level, md_inline(text), heading_level));
6280+ html.push_str(&format!(
6281+ "<h{}>{}</h{}>\n",
6282+ heading_level,
6283+ md_inline(text),
6284+ heading_level
6285+ ));
55156286 continue;
55166287 }
55176288
@@ -5528,18 +6299,29 @@ fn markdown_to_html(lines: &[String]) -> String {
55286299
55296300 // Ordered list items
55306301 if let Some(dot_pos) = trimmed.find(". ")
5531− && dot_pos <= 3 && trimmed[..dot_pos].chars().all(|c| c.is_ascii_digit()) {
5532− flush_para(&mut para_buf, &mut html);
5533− if in_list { html.push_str("</ul>\n"); in_list = false; }
5534− // We use <ol> but just emit <li> for simplicity
5535− html.push_str(&format!("<li>{}</li>\n", md_inline(&trimmed[dot_pos + 2..])));
5536− continue;
6302+ && dot_pos <= 3
6303+ && trimmed[..dot_pos].chars().all(|c| c.is_ascii_digit())
6304+ {
6305+ flush_para(&mut para_buf, &mut html);
6306+ if in_list {
6307+ html.push_str("</ul>\n");
6308+ in_list = false;
55376309 }
6310+ // We use <ol> but just emit <li> for simplicity
6311+ html.push_str(&format!(
6312+ "<li>{}</li>\n",
6313+ md_inline(&trimmed[dot_pos + 2..])
6314+ ));
6315+ continue;
6316+ }
55386317
55396318 // Horizontal rule
55406319 if trimmed == "---" || trimmed == "***" || trimmed == "___" {
55416320 flush_para(&mut para_buf, &mut html);
5542− if in_list { html.push_str("</ul>\n"); in_list = false; }
6321+ if in_list {
6322+ html.push_str("</ul>\n");
6323+ in_list = false;
6324+ }
55436325 html.push_str("<hr>\n");
55446326 continue;
55456327 }
@@ -5547,7 +6329,10 @@ fn markdown_to_html(lines: &[String]) -> String {
55476329 // Blockquote
55486330 if let Some(quote_content) = trimmed.strip_prefix("> ") {
55496331 flush_para(&mut para_buf, &mut html);
5550− html.push_str(&format!("<blockquote><p>{}</p></blockquote>\n", md_inline(quote_content)));
6332+ html.push_str(&format!(
6333+ "<blockquote><p>{}</p></blockquote>\n",
6334+ md_inline(quote_content)
6335+ ));
55516336 continue;
55526337 }
55536338
@@ -5559,7 +6344,9 @@ fn markdown_to_html(lines: &[String]) -> String {
55596344 }
55606345
55616346 // Flush remaining
5562− if in_list { html.push_str("</ul>\n"); }
6347+ if in_list {
6348+ html.push_str("</ul>\n");
6349+ }
55636350 flush_para(&mut para_buf, &mut html);
55646351
55656352 html
@@ -5572,10 +6359,13 @@ fn md_inline(text: &str) -> String {
55726359
55736360 while i < chars.len() {
55746361 // Bold: **text** or __text__
5575− if i + 1 < chars.len() && ((chars[i] == '*' && chars[i+1] == '*') || (chars[i] == '_' && chars[i+1] == '_')) {
6362+ if i + 1 < chars.len()
6363+ && ((chars[i] == '*' && chars[i + 1] == '*')
6364+ || (chars[i] == '_' && chars[i + 1] == '_'))
6365+ {
55766366 let marker = chars[i];
55776367 if let Some(end) = find_closing_double(&chars, i + 2, marker) {
5578− let inner: String = chars[i+2..end].iter().collect();
6368+ let inner: String = chars[i + 2..end].iter().collect();
55796369 result.push_str("<strong>");
55806370 result.push_str(&md_inline(&inner));
55816371 result.push_str("</strong>");
@@ -5584,10 +6374,10 @@ fn md_inline(text: &str) -> String {
55846374 }
55856375 }
55866376 // Italic: *text* or _text_
5587− if (chars[i] == '*' || chars[i] == '_') && i + 1 < chars.len() && chars[i+1] != chars[i] {
6377+ if (chars[i] == '*' || chars[i] == '_') && i + 1 < chars.len() && chars[i + 1] != chars[i] {
55886378 let marker = chars[i];
55896379 if let Some(end) = find_closing_single(&chars, i + 1, marker) {
5590− let inner: String = chars[i+1..end].iter().collect();
6380+ let inner: String = chars[i + 1..end].iter().collect();
55916381 result.push_str("<em>");
55926382 result.push_str(&md_inline(&inner));
55936383 result.push_str("</em>");
@@ -5597,27 +6387,35 @@ fn md_inline(text: &str) -> String {
55976387 }
55986388 // Inline code: `text`
55996389 if chars[i] == '`'
5600− && let Some(end) = chars[i+1..].iter().position(|&c| c == '`') {
5601− let inner: String = chars[i+1..i+1+end].iter().collect();
5602− result.push_str("<code>");
5603− result.push_str(&html_escape_md(&inner));
5604− result.push_str("</code>");
5605− i = i + 2 + end;
5606− continue;
5607− }
6390+ && let Some(end) = chars[i + 1..].iter().position(|&c| c == '`')
6391+ {
6392+ let inner: String = chars[i + 1..i + 1 + end].iter().collect();
6393+ result.push_str("<code>");
6394+ result.push_str(&html_escape_md(&inner));
6395+ result.push_str("</code>");
6396+ i = i + 2 + end;
6397+ continue;
6398+ }
56086399 // Links: [text](url)
56096400 if chars[i] == '['
5610− && let Some(close_bracket) = chars[i+1..].iter().position(|&c| c == ']') {
5611− let after = i + 1 + close_bracket + 1;
5612− if after < chars.len() && chars[after] == '('
5613− && let Some(close_paren) = chars[after+1..].iter().position(|&c| c == ')') {
5614− let text: String = chars[i+1..i+1+close_bracket].iter().collect();
5615− let url: String = chars[after+1..after+1+close_paren].iter().collect();
5616− result.push_str(&format!("<a href=\"{}\">{}</a>", html_escape_md(&url), md_inline(&text)));
5617− i = after + 2 + close_paren;
5618− continue;
5619− }
6401+ && let Some(close_bracket) = chars[i + 1..].iter().position(|&c| c == ']')
6402+ {
6403+ let after = i + 1 + close_bracket + 1;
6404+ if after < chars.len()
6405+ && chars[after] == '('
6406+ && let Some(close_paren) = chars[after + 1..].iter().position(|&c| c == ')')
6407+ {
6408+ let text: String = chars[i + 1..i + 1 + close_bracket].iter().collect();
6409+ let url: String = chars[after + 1..after + 1 + close_paren].iter().collect();
6410+ result.push_str(&format!(
6411+ "<a href=\"{}\">{}</a>",
6412+ html_escape_md(&url),
6413+ md_inline(&text)
6414+ ));
6415+ i = after + 2 + close_paren;
6416+ continue;
56206417 }
6418+ }
56216419 result.push(chars[i]);
56226420 i += 1;
56236421 }
@@ -5628,7 +6426,7 @@ fn md_inline(text: &str) -> String {
56286426fn find_closing_double(chars: &[char], start: usize, marker: char) -> Option<usize> {
56296427 let mut i = start;
56306428 while i + 1 < chars.len() {
5631− if chars[i] == marker && chars[i+1] == marker {
6429+ if chars[i] == marker && chars[i + 1] == marker {
56326430 return Some(i);
56336431 }
56346432 i += 1;
@@ -5672,12 +6470,11 @@ fn glob_match(pattern: &str, text: &str) -> bool {
56726470
56736471fn html_escape_md(s: &str) -> String {
56746472 s.replace('&', "&amp;")
5675− .replace('<', "&lt;")
5676− .replace('>', "&gt;")
5677− .replace('"', "&quot;")
6473+ .replace('<', "&lt;")
6474+ .replace('>', "&gt;")
6475+ .replace('"', "&quot;")
56786476}
56796477
5680−
56816478#[cfg(test)]
56826479mod tests {
56836480 use super::*;
@@ -5714,7 +6511,10 @@ mod tests {
57146511 #[test]
57156512 fn let_variable_substitution() {
57166513 let r = parse_ok("@let color red\n@text [color $color] hi\n");
5717− assert_eq!(r.document.variables.get("color").map(|s| s.as_str()), Some("red"));
6514+ assert_eq!(
6515+ r.document.variables.get("color").map(|s| s.as_str()),
6516+ Some("red")
6517+ );
57186518 }
57196519
57206520 #[test]
@@ -5752,7 +6552,12 @@ mod tests {
57526552 #[test]
57536553 fn severity_has_all_variants() {
57546554 // This compiles only if Severity has Error, Warning, Info, Help.
5755− let all = [Severity::Error, Severity::Warning, Severity::Info, Severity::Help];
6555+ let all = [
6556+ Severity::Error,
6557+ Severity::Warning,
6558+ Severity::Info,
6559+ Severity::Help,
6560+ ];
57566561 assert_eq!(all.len(), 4);
57576562 }
57586563
src/bin/htmlang_lsp/analysis.rs+525−312
@@ -21,7 +21,11 @@ pub(crate) fn document_symbols(text: &str) -> Vec<SymbolInformation> {
2121 let parts: Vec<&str> = rest.split_whitespace().collect();
2222 if let Some(name) = parts.first() {
2323 let params = parts[1..].join(" ");
24− let detail = if params.is_empty() { None } else { Some(format!("({})", params)) };
24+ let detail = if params.is_empty() {
25+ None
26+ } else {
27+ Some(format!("({})", params))
28+ };
2529 symbols.push(SymbolInformation {
2630 name: format!("@{}", name),
2731 kind: SymbolKind::FUNCTION,
@@ -29,7 +33,10 @@ pub(crate) fn document_symbols(text: &str) -> Vec<SymbolInformation> {
2933 deprecated: None,
3034 location: Location {
3135 uri: Url::parse("file:///").unwrap(), // replaced by caller
32− range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
36+ range: Range::new(
37+ Position::new(line_num, 0),
38+ Position::new(line_num, line.len() as u32),
39+ ),
3340 },
3441 container_name: detail,
3542 });
@@ -38,36 +45,44 @@ pub(crate) fn document_symbols(text: &str) -> Vec<SymbolInformation> {
3845
3946 // @let definitions
4047 if let Some(rest) = trimmed.strip_prefix("@let ")
41− && let Some((name, value)) = rest.trim().split_once(' ') {
42− symbols.push(SymbolInformation {
43− name: format!("${}", name),
44− kind: SymbolKind::VARIABLE,
45− tags: None,
46− deprecated: None,
47− location: Location {
48− uri: Url::parse("file:///").unwrap(),
49− range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
50− },
51− container_name: Some(format!("= {}", value.trim())),
52− });
53− }
48+ && let Some((name, value)) = rest.trim().split_once(' ')
49+ {
50+ symbols.push(SymbolInformation {
51+ name: format!("${}", name),
52+ kind: SymbolKind::VARIABLE,
53+ tags: None,
54+ deprecated: None,
55+ location: Location {
56+ uri: Url::parse("file:///").unwrap(),
57+ range: Range::new(
58+ Position::new(line_num, 0),
59+ Position::new(line_num, line.len() as u32),
60+ ),
61+ },
62+ container_name: Some(format!("= {}", value.trim())),
63+ });
64+ }
5465
5566 // @define definitions
5667 if let Some(rest) = trimmed.strip_prefix("@define ")
57− && let Some(bracket) = rest.find('[') {
58− let name = rest[..bracket].trim();
59− symbols.push(SymbolInformation {
60− name: format!("${}", name),
61− kind: SymbolKind::CONSTANT,
62− tags: None,
63− deprecated: None,
64− location: Location {
65− uri: Url::parse("file:///").unwrap(),
66− range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
67− },
68− container_name: Some("attribute bundle".to_string()),
69− });
70− }
68+ && let Some(bracket) = rest.find('[')
69+ {
70+ let name = rest[..bracket].trim();
71+ symbols.push(SymbolInformation {
72+ name: format!("${}", name),
73+ kind: SymbolKind::CONSTANT,
74+ tags: None,
75+ deprecated: None,
76+ location: Location {
77+ uri: Url::parse("file:///").unwrap(),
78+ range: Range::new(
79+ Position::new(line_num, 0),
80+ Position::new(line_num, line.len() as u32),
81+ ),
82+ },
83+ container_name: Some("attribute bundle".to_string()),
84+ });
85+ }
7186
7287 // @keyframes definitions
7388 if let Some(rest) = trimmed.strip_prefix("@keyframes ") {
@@ -80,7 +95,10 @@ pub(crate) fn document_symbols(text: &str) -> Vec<SymbolInformation> {
8095 deprecated: None,
8196 location: Location {
8297 uri: Url::parse("file:///").unwrap(),
83− range: Range::new(Position::new(line_num, 0), Position::new(line_num, line.len() as u32)),
98+ range: Range::new(
99+ Position::new(line_num, 0),
100+ Position::new(line_num, line.len() as u32),
101+ ),
84102 },
85103 container_name: Some("animation".to_string()),
86104 });
@@ -166,7 +184,8 @@ pub(crate) fn code_actions(
166184 if let Some(source_line) = lines.get(line) {
167185 let trimmed = source_line.trim_start();
168186 if trimmed.starts_with("@let ") {
169− let var_name = trimmed.strip_prefix("@let ")
187+ let var_name = trimmed
188+ .strip_prefix("@let ")
170189 .and_then(|r| r.split_whitespace().next())
171190 .unwrap_or("?");
172191 let edit = TextEdit {
@@ -199,10 +218,13 @@ pub(crate) fn code_actions(
199218 if let Some(source_line) = lines.get(line) {
200219 let trimmed = source_line.trim_start();
201220 if trimmed.starts_with("@define ") {
202− let def_name = trimmed.strip_prefix("@define ")
221+ let def_name = trimmed
222+ .strip_prefix("@define ")
203223 .and_then(|r| {
204224 let r = r.trim();
205− r.find('[').map(|b| r[..b].trim()).or_else(|| r.split_whitespace().next())
225+ r.find('[')
226+ .map(|b| r[..b].trim())
227+ .or_else(|| r.split_whitespace().next())
206228 })
207229 .unwrap_or("?");
208230 let edit = TextEdit {
@@ -235,7 +257,8 @@ pub(crate) fn code_actions(
235257 if let Some(source_line) = lines.get(line) {
236258 let trimmed = source_line.trim_start();
237259 if trimmed.starts_with("@fn ") {
238− let fn_name = trimmed.strip_prefix("@fn ")
260+ let fn_name = trimmed
261+ .strip_prefix("@fn ")
239262 .and_then(|r| r.split_whitespace().next())
240263 .unwrap_or("?");
241264 // Find the end of the function body (indented lines below)
@@ -285,10 +308,13 @@ pub(crate) fn code_actions(
285308 if let Some(source_line) = lines.get(line) {
286309 let trimmed = source_line.trim_start();
287310 if trimmed.starts_with("@mixin ") {
288− let mixin_name = trimmed.strip_prefix("@mixin ")
311+ let mixin_name = trimmed
312+ .strip_prefix("@mixin ")
289313 .and_then(|r| {
290314 let r = r.trim();
291− r.find('[').map(|b| r[..b].trim()).or_else(|| r.split_whitespace().next())
315+ r.find('[')
316+ .map(|b| r[..b].trim())
317+ .or_else(|| r.split_whitespace().next())
292318 })
293319 .unwrap_or("?");
294320 let edit = TextEdit {
@@ -395,28 +421,29 @@ pub(crate) fn code_actions(
395421 ..Default::default()
396422 }));
397423 } else if source_line.contains("@input")
398− && let Some(pos) = source_line.find("@input") {
399− let after = pos + "@input".len();
400− let edit = TextEdit {
401− range: Range::new(
402− Position::new(diag.range.start.line, after as u32),
403− Position::new(diag.range.start.line, after as u32),
404− ),
405− new_text: " [type text]".into(),
406− };
407− let mut changes = HashMap::new();
408− changes.insert(uri.clone(), vec![edit]);
409− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
410− title: "Add type=\"text\" attribute".into(),
411− kind: Some(CodeActionKind::QUICKFIX),
412− diagnostics: Some(vec![diag.clone()]),
413− edit: Some(WorkspaceEdit {
414− changes: Some(changes),
415− ..Default::default()
416− }),
424+ && let Some(pos) = source_line.find("@input")
425+ {
426+ let after = pos + "@input".len();
427+ let edit = TextEdit {
428+ range: Range::new(
429+ Position::new(diag.range.start.line, after as u32),
430+ Position::new(diag.range.start.line, after as u32),
431+ ),
432+ new_text: " [type text]".into(),
433+ };
434+ let mut changes = HashMap::new();
435+ changes.insert(uri.clone(), vec![edit]);
436+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
437+ title: "Add type=\"text\" attribute".into(),
438+ kind: Some(CodeActionKind::QUICKFIX),
439+ diagnostics: Some(vec![diag.clone()]),
440+ edit: Some(WorkspaceEdit {
441+ changes: Some(changes),
417442 ..Default::default()
418− }));
419− }
443+ }),
444+ ..Default::default()
445+ }));
446+ }
420447 }
421448 }
422449
@@ -436,106 +463,118 @@ pub(crate) fn code_actions(
436463 if msg.contains("unknown element @")
437464 && let Some(fn_name) = extract_between(msg, "unknown element @", ",")
438465 .or_else(|| extract_between(msg, "unknown element @", ""))
466+ {
467+ let fn_name = fn_name.trim();
468+ if !fn_name.is_empty()
469+ && let Ok(file_path) = uri.to_file_path()
470+ && let Some(dir) = file_path.parent()
439471 {
440− let fn_name = fn_name.trim();
441− if !fn_name.is_empty()
442− && let Ok(file_path) = uri.to_file_path()
443− && let Some(dir) = file_path.parent() {
444− // Search current dir and subdirs for .hl files defining this function
445− let mut search_dirs = vec![dir.to_path_buf()];
446− // Also search parent dir's subdirs (for project-wide imports)
447− if let Some(parent) = dir.parent()
448− && let Ok(entries) = std::fs::read_dir(parent) {
449− for entry in entries.flatten() {
450− let p = entry.path();
451− if p.is_dir() && p != dir {
452− search_dirs.push(p);
453− }
472+ // Search current dir and subdirs for .hl files defining this function
473+ let mut search_dirs = vec![dir.to_path_buf()];
474+ // Also search parent dir's subdirs (for project-wide imports)
475+ if let Some(parent) = dir.parent()
476+ && let Ok(entries) = std::fs::read_dir(parent)
477+ {
478+ for entry in entries.flatten() {
479+ let p = entry.path();
480+ if p.is_dir() && p != dir {
481+ search_dirs.push(p);
482+ }
483+ }
484+ }
485+ for search_dir in &search_dirs {
486+ if let Ok(entries) = std::fs::read_dir(search_dir) {
487+ for entry in entries.flatten() {
488+ let path = entry.path();
489+ if path.extension().and_then(|e| e.to_str()) != Some("hl") {
490+ continue;
491+ }
492+ if path == file_path {
493+ continue;
494+ }
495+ if let Ok(content) = std::fs::read_to_string(&path) {
496+ let defines_fn = content.lines().any(|l| {
497+ let t = l.trim();
498+ if let Some(rest) = t.strip_prefix("@fn ") {
499+ rest.split_whitespace().next() == Some(fn_name)
500+ } else {
501+ false
454502 }
455− }
456− for search_dir in &search_dirs {
457− if let Ok(entries) = std::fs::read_dir(search_dir) {
458− for entry in entries.flatten() {
459− let path = entry.path();
460− if path.extension().and_then(|e| e.to_str()) != Some("hl") {
461− continue;
462− }
463− if path == file_path {
464− continue;
465− }
466− if let Ok(content) = std::fs::read_to_string(&path) {
467− let defines_fn = content.lines().any(|l| {
468− let t = l.trim();
469− if let Some(rest) = t.strip_prefix("@fn ") {
470− rest.split_whitespace().next() == Some(fn_name)
471− } else {
472− false
473− }
474− });
475− if defines_fn {
476− // Compute relative path from current file's dir
477− let rel = path.strip_prefix(dir)
478− .map(|p| p.display().to_string())
479− .unwrap_or_else(|_| {
480− path.file_name().and_then(|n| n.to_str()).unwrap_or("").to_string()
481− });
482− let already_imported = text.lines().any(|l| {
483− let t = l.trim();
484− t == format!("@import {}", rel)
485− || t == format!("@include {}", rel)
486− });
487− if !already_imported {
488− // Offer @import (all definitions)
489− let import_line = format!("@import {}\n", rel);
490− let edit = TextEdit {
491− range: Range::new(
492− Position::new(0, 0),
493− Position::new(0, 0),
494− ),
495− new_text: import_line,
496− };
497− let mut changes = HashMap::new();
498− changes.insert(uri.clone(), vec![edit]);
499− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
500− title: format!("Add '@import {}' for @{}", rel, fn_name),
501− kind: Some(CodeActionKind::QUICKFIX),
502− diagnostics: Some(vec![diag.clone()]),
503− edit: Some(WorkspaceEdit {
504− changes: Some(changes),
505− ..Default::default()
506− }),
507− ..Default::default()
508− }));
509−
510− // Also offer @use (selective import)
511− let use_line = format!("@use \"{}\" {}\n", rel, fn_name);
512− let use_edit = TextEdit {
513− range: Range::new(
514− Position::new(0, 0),
515− Position::new(0, 0),
516− ),
517− new_text: use_line,
518− };
519− let mut use_changes = HashMap::new();
520− use_changes.insert(uri.clone(), vec![use_edit]);
521− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
522− title: format!("Add '@use \"{}\" {}' (selective)", rel, fn_name),
523− kind: Some(CodeActionKind::QUICKFIX),
524− diagnostics: Some(vec![diag.clone()]),
525− edit: Some(WorkspaceEdit {
526− changes: Some(use_changes),
527− ..Default::default()
528− }),
529− ..Default::default()
530− }));
531− }
532− }
533− }
503+ });
504+ if defines_fn {
505+ // Compute relative path from current file's dir
506+ let rel = path
507+ .strip_prefix(dir)
508+ .map(|p| p.display().to_string())
509+ .unwrap_or_else(|_| {
510+ path.file_name()
511+ .and_then(|n| n.to_str())
512+ .unwrap_or("")
513+ .to_string()
514+ });
515+ let already_imported = text.lines().any(|l| {
516+ let t = l.trim();
517+ t == format!("@import {}", rel)
518+ || t == format!("@include {}", rel)
519+ });
520+ if !already_imported {
521+ // Offer @import (all definitions)
522+ let import_line = format!("@import {}\n", rel);
523+ let edit = TextEdit {
524+ range: Range::new(
525+ Position::new(0, 0),
526+ Position::new(0, 0),
527+ ),
528+ new_text: import_line,
529+ };
530+ let mut changes = HashMap::new();
531+ changes.insert(uri.clone(), vec![edit]);
532+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
533+ title: format!(
534+ "Add '@import {}' for @{}",
535+ rel, fn_name
536+ ),
537+ kind: Some(CodeActionKind::QUICKFIX),
538+ diagnostics: Some(vec![diag.clone()]),
539+ edit: Some(WorkspaceEdit {
540+ changes: Some(changes),
541+ ..Default::default()
542+ }),
543+ ..Default::default()
544+ }));
545+
546+ // Also offer @use (selective import)
547+ let use_line = format!("@use \"{}\" {}\n", rel, fn_name);
548+ let use_edit = TextEdit {
549+ range: Range::new(
550+ Position::new(0, 0),
551+ Position::new(0, 0),
552+ ),
553+ new_text: use_line,
554+ };
555+ let mut use_changes = HashMap::new();
556+ use_changes.insert(uri.clone(), vec![use_edit]);
557+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
558+ title: format!(
559+ "Add '@use \"{}\" {}' (selective)",
560+ rel, fn_name
561+ ),
562+ kind: Some(CodeActionKind::QUICKFIX),
563+ diagnostics: Some(vec![diag.clone()]),
564+ edit: Some(WorkspaceEdit {
565+ changes: Some(use_changes),
566+ ..Default::default()
567+ }),
568+ ..Default::default()
569+ }));
534570 }
535571 }
536572 }
537573 }
574+ }
575+ }
538576 }
577+ }
539578 }
540579
541580 // Refactoring: extract selection to @fn
@@ -548,19 +587,25 @@ pub(crate) fn code_actions(
548587 let selected: Vec<&str> = lines[start_line..=end_line].to_vec();
549588 if !selected.is_empty() {
550589 // Determine the minimum indentation of selected lines (ignoring blank lines)
551− let min_indent = selected.iter()
590+ let min_indent = selected
591+ .iter()
552592 .filter(|l| !l.trim().is_empty())
553593 .map(|l| l.len() - l.trim_start().len())
554594 .min()
555595 .unwrap_or(0);
556596
557597 // Build the function body with two-space indentation relative to @fn
558− let fn_body: String = selected.iter()
598+ let fn_body: String = selected
599+ .iter()
559600 .map(|l| {
560601 if l.trim().is_empty() {
561602 String::from("\n")
562603 } else {
563− let stripped = if l.len() > min_indent { &l[min_indent..] } else { l.trim_start() };
604+ let stripped = if l.len() > min_indent {
605+ &l[min_indent..]
606+ } else {
607+ l.trim_start()
608+ };
564609 format!(" {}\n", stripped)
565610 }
566611 })
@@ -606,49 +651,50 @@ pub(crate) fn code_actions(
606651 if line_idx < lines.len() {
607652 let line = lines[line_idx];
608653 if let Some(bracket_start) = line.find('[')
609− && let Some(bracket_end) = line[bracket_start..].find(']') {
610− let attrs_str = &line[bracket_start + 1..bracket_start + bracket_end];
611− // Only offer if there are at least 2 attributes
612− let attr_count = attrs_str.split(',').count();
613− if attr_count >= 2 {
614− let define_name = "extracted-style";
615− let define_line = format!("@define {} [{}]\n", define_name, attrs_str.trim());
616− let indent = " ".repeat(line.len() - line.trim_start().len());
617−
618− // Replace [attrs] with [$extracted-style]
619− let new_line = format!(
620− "{}{}[${}]{}",
621− indent,
622− &line.trim_start()[..line.trim_start().find('[').unwrap_or(0)],
623− define_name,
624− &line[bracket_start + bracket_end + 1..]
625− );
626−
627− let replace_edit = TextEdit {
628− range: Range::new(
629− Position::new(line_idx as u32, 0),
630− Position::new(line_idx as u32, line.len() as u32),
631− ),
632− new_text: new_line,
633− };
634− let insert_edit = TextEdit {
635− range: Range::new(Position::new(0, 0), Position::new(0, 0)),
636− new_text: define_line,
637− };
638− let mut changes = HashMap::new();
639− changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
640− actions.push(CodeActionOrCommand::CodeAction(CodeAction {
641− title: "Extract to @define".into(),
642− kind: Some(CodeActionKind::REFACTOR_EXTRACT),
643− diagnostics: None,
644− edit: Some(WorkspaceEdit {
645− changes: Some(changes),
646− ..Default::default()
647− }),
654+ && let Some(bracket_end) = line[bracket_start..].find(']')
655+ {
656+ let attrs_str = &line[bracket_start + 1..bracket_start + bracket_end];
657+ // Only offer if there are at least 2 attributes
658+ let attr_count = attrs_str.split(',').count();
659+ if attr_count >= 2 {
660+ let define_name = "extracted-style";
661+ let define_line = format!("@define {} [{}]\n", define_name, attrs_str.trim());
662+ let indent = " ".repeat(line.len() - line.trim_start().len());
663+
664+ // Replace [attrs] with [$extracted-style]
665+ let new_line = format!(
666+ "{}{}[${}]{}",
667+ indent,
668+ &line.trim_start()[..line.trim_start().find('[').unwrap_or(0)],
669+ define_name,
670+ &line[bracket_start + bracket_end + 1..]
671+ );
672+
673+ let replace_edit = TextEdit {
674+ range: Range::new(
675+ Position::new(line_idx as u32, 0),
676+ Position::new(line_idx as u32, line.len() as u32),
677+ ),
678+ new_text: new_line,
679+ };
680+ let insert_edit = TextEdit {
681+ range: Range::new(Position::new(0, 0), Position::new(0, 0)),
682+ new_text: define_line,
683+ };
684+ let mut changes = HashMap::new();
685+ changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
686+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
687+ title: "Extract to @define".into(),
688+ kind: Some(CodeActionKind::REFACTOR_EXTRACT),
689+ diagnostics: None,
690+ edit: Some(WorkspaceEdit {
691+ changes: Some(changes),
648692 ..Default::default()
649− }));
650− }
693+ }),
694+ ..Default::default()
695+ }));
651696 }
697+ }
652698 }
653699 }
654700
@@ -702,20 +748,21 @@ pub(crate) fn find_colors(text: &str) -> Vec<ColorInformation> {
702748 let hex = &line[hex_start..hex_end];
703749 let len = hex.len();
704750 if (len == 3 || len == 6 || len == 8)
705− && let Some((r, g, b, a)) = parse_hex_color(hex) {
706− colors.push(ColorInformation {
707− range: Range::new(
708− Position::new(line_idx as u32, abs_pos as u32),
709− Position::new(line_idx as u32, hex_end as u32),
710− ),
711− color: Color {
712− red: r as f32 / 255.0,
713− green: g as f32 / 255.0,
714− blue: b as f32 / 255.0,
715− alpha: a as f32 / 255.0,
716− },
717− });
718− }
751+ && let Some((r, g, b, a)) = parse_hex_color(hex)
752+ {
753+ colors.push(ColorInformation {
754+ range: Range::new(
755+ Position::new(line_idx as u32, abs_pos as u32),
756+ Position::new(line_idx as u32, hex_end as u32),
757+ ),
758+ color: Color {
759+ red: r as f32 / 255.0,
760+ green: g as f32 / 255.0,
761+ blue: b as f32 / 255.0,
762+ alpha: a as f32 / 255.0,
763+ },
764+ });
765+ }
719766 start = hex_end;
720767 }
721768 // Detect named CSS colors
@@ -751,43 +798,92 @@ pub(crate) fn find_colors(text: &str) -> Vec<ColorInformation> {
751798
752799/// Named CSS colors: (name, r, g, b)
753800const NAMED_CSS_COLORS: &[(&str, u8, u8, u8)] = &[
754− ("red", 255, 0, 0), ("green", 0, 128, 0), ("blue", 0, 0, 255),
755− ("white", 255, 255, 255), ("black", 0, 0, 0),
756− ("orange", 255, 165, 0), ("yellow", 255, 255, 0), ("purple", 128, 0, 128),
757− ("pink", 255, 192, 203), ("gray", 128, 128, 128), ("grey", 128, 128, 128),
758− ("navy", 0, 0, 128), ("teal", 0, 128, 128), ("maroon", 128, 0, 0),
759− ("aqua", 0, 255, 255), ("cyan", 0, 255, 255), ("fuchsia", 255, 0, 255),
760− ("magenta", 255, 0, 255), ("lime", 0, 255, 0), ("olive", 128, 128, 0),
761− ("silver", 192, 192, 192), ("coral", 255, 127, 80), ("salmon", 250, 128, 114),
762− ("tomato", 255, 99, 71), ("gold", 255, 215, 0), ("khaki", 240, 230, 140),
763− ("violet", 238, 130, 238), ("indigo", 75, 0, 130), ("crimson", 220, 20, 60),
764− ("turquoise", 64, 224, 208), ("plum", 221, 160, 221), ("orchid", 218, 112, 214),
765− ("sienna", 160, 82, 45), ("tan", 210, 180, 140), ("peru", 205, 133, 63),
766− ("chocolate", 210, 105, 30), ("firebrick", 178, 34, 34),
767− ("darkred", 139, 0, 0), ("darkgreen", 0, 100, 0), ("darkblue", 0, 0, 139),
768− ("darkgray", 169, 169, 169), ("darkgrey", 169, 169, 169),
769− ("lightgray", 211, 211, 211), ("lightgrey", 211, 211, 211),
770− ("lightblue", 173, 216, 230), ("lightgreen", 144, 238, 144),
771− ("lightyellow", 255, 255, 224), ("lightcoral", 240, 128, 128),
772− ("lightpink", 255, 182, 193), ("lightsalmon", 255, 160, 122),
773− ("steelblue", 70, 130, 180), ("royalblue", 65, 105, 225),
774− ("dodgerblue", 30, 144, 255), ("deepskyblue", 0, 191, 255),
775− ("cornflowerblue", 100, 149, 237), ("midnightblue", 25, 25, 112),
776− ("slateblue", 106, 90, 205), ("mediumblue", 0, 0, 205),
777− ("springgreen", 0, 255, 127), ("limegreen", 50, 205, 50),
778− ("forestgreen", 34, 139, 34), ("seagreen", 46, 139, 87),
779− ("darkslategray", 47, 79, 79), ("darkslategrey", 47, 79, 79),
780− ("cadetblue", 95, 158, 160), ("mediumaquamarine", 102, 205, 170),
781− ("darkorange", 255, 140, 0), ("orangered", 255, 69, 0),
782− ("deeppink", 255, 20, 147), ("hotpink", 255, 105, 180),
783− ("mediumvioletred", 199, 21, 133), ("palevioletred", 219, 112, 147),
784− ("sandybrown", 244, 164, 96), ("goldenrod", 218, 165, 32),
785− ("darkgoldenrod", 184, 134, 11), ("saddlebrown", 139, 69, 19),
786− ("wheat", 245, 222, 179), ("beige", 245, 245, 220),
787− ("linen", 250, 240, 230), ("ivory", 255, 255, 240),
788− ("snow", 255, 250, 250), ("honeydew", 240, 255, 240),
789− ("azure", 240, 255, 255), ("lavender", 230, 230, 250),
790− ("mistyrose", 255, 228, 225), ("seashell", 255, 245, 238),
801+ ("red", 255, 0, 0),
802+ ("green", 0, 128, 0),
803+ ("blue", 0, 0, 255),
804+ ("white", 255, 255, 255),
805+ ("black", 0, 0, 0),
806+ ("orange", 255, 165, 0),
807+ ("yellow", 255, 255, 0),
808+ ("purple", 128, 0, 128),
809+ ("pink", 255, 192, 203),
810+ ("gray", 128, 128, 128),
811+ ("grey", 128, 128, 128),
812+ ("navy", 0, 0, 128),
813+ ("teal", 0, 128, 128),
814+ ("maroon", 128, 0, 0),
815+ ("aqua", 0, 255, 255),
816+ ("cyan", 0, 255, 255),
817+ ("fuchsia", 255, 0, 255),
818+ ("magenta", 255, 0, 255),
819+ ("lime", 0, 255, 0),
820+ ("olive", 128, 128, 0),
821+ ("silver", 192, 192, 192),
822+ ("coral", 255, 127, 80),
823+ ("salmon", 250, 128, 114),
824+ ("tomato", 255, 99, 71),
825+ ("gold", 255, 215, 0),
826+ ("khaki", 240, 230, 140),
827+ ("violet", 238, 130, 238),
828+ ("indigo", 75, 0, 130),
829+ ("crimson", 220, 20, 60),
830+ ("turquoise", 64, 224, 208),
831+ ("plum", 221, 160, 221),
832+ ("orchid", 218, 112, 214),
833+ ("sienna", 160, 82, 45),
834+ ("tan", 210, 180, 140),
835+ ("peru", 205, 133, 63),
836+ ("chocolate", 210, 105, 30),
837+ ("firebrick", 178, 34, 34),
838+ ("darkred", 139, 0, 0),
839+ ("darkgreen", 0, 100, 0),
840+ ("darkblue", 0, 0, 139),
841+ ("darkgray", 169, 169, 169),
842+ ("darkgrey", 169, 169, 169),
843+ ("lightgray", 211, 211, 211),
844+ ("lightgrey", 211, 211, 211),
845+ ("lightblue", 173, 216, 230),
846+ ("lightgreen", 144, 238, 144),
847+ ("lightyellow", 255, 255, 224),
848+ ("lightcoral", 240, 128, 128),
849+ ("lightpink", 255, 182, 193),
850+ ("lightsalmon", 255, 160, 122),
851+ ("steelblue", 70, 130, 180),
852+ ("royalblue", 65, 105, 225),
853+ ("dodgerblue", 30, 144, 255),
854+ ("deepskyblue", 0, 191, 255),
855+ ("cornflowerblue", 100, 149, 237),
856+ ("midnightblue", 25, 25, 112),
857+ ("slateblue", 106, 90, 205),
858+ ("mediumblue", 0, 0, 205),
859+ ("springgreen", 0, 255, 127),
860+ ("limegreen", 50, 205, 50),
861+ ("forestgreen", 34, 139, 34),
862+ ("seagreen", 46, 139, 87),
863+ ("darkslategray", 47, 79, 79),
864+ ("darkslategrey", 47, 79, 79),
865+ ("cadetblue", 95, 158, 160),
866+ ("mediumaquamarine", 102, 205, 170),
867+ ("darkorange", 255, 140, 0),
868+ ("orangered", 255, 69, 0),
869+ ("deeppink", 255, 20, 147),
870+ ("hotpink", 255, 105, 180),
871+ ("mediumvioletred", 199, 21, 133),
872+ ("palevioletred", 219, 112, 147),
873+ ("sandybrown", 244, 164, 96),
874+ ("goldenrod", 218, 165, 32),
875+ ("darkgoldenrod", 184, 134, 11),
876+ ("saddlebrown", 139, 69, 19),
877+ ("wheat", 245, 222, 179),
878+ ("beige", 245, 245, 220),
879+ ("linen", 250, 240, 230),
880+ ("ivory", 255, 255, 240),
881+ ("snow", 255, 250, 250),
882+ ("honeydew", 240, 255, 240),
883+ ("azure", 240, 255, 255),
884+ ("lavender", 230, 230, 250),
885+ ("mistyrose", 255, 228, 225),
886+ ("seashell", 255, 245, 238),
791887];
792888
793889fn parse_hex_color(hex: &str) -> Option<(u8, u8, u8, u8)> {
@@ -903,33 +999,37 @@ pub(crate) fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
903999 let mut unused_vars: std::collections::HashSet<String> = std::collections::HashSet::new();
9041000 for d in &result.diagnostics {
9051001 if d.message.contains("unused variable '$")
906− && let Some(start) = d.message.find("'$") {
907− let rest = &d.message[start + 2..];
908− if let Some(end) = rest.find('\'') {
909− unused_vars.insert(rest[..end].to_string());
910− }
1002+ && let Some(start) = d.message.find("'$")
1003+ {
1004+ let rest = &d.message[start + 2..];
1005+ if let Some(end) = rest.find('\'') {
1006+ unused_vars.insert(rest[..end].to_string());
9111007 }
1008+ }
9121009 if d.message.contains("unused function '@")
913− && let Some(start) = d.message.find("'@") {
914− let rest = &d.message[start + 2..];
915− if let Some(end) = rest.find('\'') {
916− unused_vars.insert(format!("@{}", &rest[..end]));
917− }
1010+ && let Some(start) = d.message.find("'@")
1011+ {
1012+ let rest = &d.message[start + 2..];
1013+ if let Some(end) = rest.find('\'') {
1014+ unused_vars.insert(format!("@{}", &rest[..end]));
9181015 }
1016+ }
9191017 if d.message.contains("unused define '$")
920− && let Some(start) = d.message.find("'$") {
921− let rest = &d.message[start + 2..];
922− if let Some(end) = rest.find('\'') {
923− unused_vars.insert(rest[..end].to_string());
924− }
1018+ && let Some(start) = d.message.find("'$")
1019+ {
1020+ let rest = &d.message[start + 2..];
1021+ if let Some(end) = rest.find('\'') {
1022+ unused_vars.insert(rest[..end].to_string());
9251023 }
1024+ }
9261025 if d.message.contains("unused mixin '")
927− && let Some(start) = d.message.find("unused mixin '") {
928− let rest = &d.message[start + 14..];
929− if let Some(end) = rest.find('\'') {
930− unused_vars.insert(rest[..end].to_string());
931− }
1026+ && let Some(start) = d.message.find("unused mixin '")
1027+ {
1028+ let rest = &d.message[start + 14..];
1029+ if let Some(end) = rest.find('\'') {
1030+ unused_vars.insert(rest[..end].to_string());
9321031 }
1032+ }
9331033 }
9341034
9351035 for (line_idx, line) in text.lines().enumerate() {
@@ -939,7 +1039,16 @@ pub(crate) fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
9391039 // Detect comments
9401040 if trimmed.starts_with("--") {
9411041 let col = (line.len() - trimmed.len()) as u32;
942− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, col, trimmed.len() as u32, 4, 0);
1042+ push_token(
1043+ &mut tokens,
1044+ &mut prev_line,
1045+ &mut prev_start,
1046+ line_num,
1047+ col,
1048+ trimmed.len() as u32,
1049+ 4,
1050+ 0,
1051+ );
9431052 continue;
9441053 }
9451054
@@ -950,41 +1059,79 @@ pub(crate) fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
9501059 if bytes[i] == b'@' {
9511060 let start = i;
9521061 i += 1;
953− while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_') {
1062+ while i < bytes.len()
1063+ && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
1064+ {
9541065 i += 1;
9551066 }
9561067 let word = &line[start..i];
9571068 let token_type = match word {
9581069 "@page" | "@let" | "@define" | "@fn" | "@if" | "@else" | "@each"
9591070 | "@include" | "@import" | "@meta" | "@head" | "@style" | "@keyframes"
960− | "@match" | "@case" | "@default" | "@slot" | "@children"
961− | "@warn" | "@debug" | "@lang" | "@favicon" | "@fragment"
962− | "@unless" | "@og" | "@breakpoint"
963− | "@canonical" | "@base" | "@font-face" | "@json-ld"
964− | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends"
965− | "@use" | "@data" | "@env" | "@fetch" | "@svg" | "@css-property" => 0, // keyword
1071+ | "@match" | "@case" | "@default" | "@slot" | "@children" | "@warn"
1072+ | "@debug" | "@lang" | "@favicon" | "@fragment" | "@unless" | "@og"
1073+ | "@breakpoint" | "@canonical" | "@base" | "@font-face" | "@json-ld"
1074+ | "@mixin" | "@assert" | "@theme" | "@deprecated" | "@extends" | "@use"
1075+ | "@data" | "@env" | "@fetch" | "@svg" | "@css-property" => 0, // keyword
9661076 _ => {
9671077 // Check if it's a user function call (starts with @ but not a builtin element)
9681078 if is_builtin_element(word) { 0 } else { 2 } // function
9691079 }
9701080 };
9711081 // Mark unused @fn definitions with deprecated modifier (dimmed)
972− let modifier = if (trimmed.starts_with("@fn ") || trimmed.starts_with("@define ") || trimmed.starts_with("@mixin ")) && word != "@fn" && word != "@define" && word != "@mixin" {
1082+ let modifier = if (trimmed.starts_with("@fn ")
1083+ || trimmed.starts_with("@define ")
1084+ || trimmed.starts_with("@mixin "))
1085+ && word != "@fn"
1086+ && word != "@define"
1087+ && word != "@mixin"
1088+ {
9731089 let name_part = &word[1..]; // strip @
974− if unused_vars.contains(&format!("@{}", name_part)) { 1 } else { 0 }
975− } else { 0 };
976− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, token_type, modifier);
1090+ if unused_vars.contains(&format!("@{}", name_part)) {
1091+ 1
1092+ } else {
1093+ 0
1094+ }
1095+ } else {
1096+ 0
1097+ };
1098+ push_token(
1099+ &mut tokens,
1100+ &mut prev_line,
1101+ &mut prev_start,
1102+ line_num,
1103+ start as u32,
1104+ (i - start) as u32,
1105+ token_type,
1106+ modifier,
1107+ );
9771108 } else if bytes[i] == b'$' {
9781109 let start = i;
9791110 i += 1;
980− while i < bytes.len() && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_') {
1111+ while i < bytes.len()
1112+ && (bytes[i].is_ascii_alphanumeric() || bytes[i] == b'-' || bytes[i] == b'_')
1113+ {
9811114 i += 1;
9821115 }
9831116 if i > start + 1 {
9841117 // Check if this is an unused variable definition on a @let line
9851118 let var_name = &line[start + 1..i];
986− let modifier = if trimmed.starts_with("@let ") && unused_vars.contains(var_name) { 1 } else { 0 };
987− push_token(&mut tokens, &mut prev_line, &mut prev_start, line_num, start as u32, (i - start) as u32, 1, modifier); // variable
1119+ let modifier = if trimmed.starts_with("@let ") && unused_vars.contains(var_name)
1120+ {
1121+ 1
1122+ } else {
1123+ 0
1124+ };
1125+ push_token(
1126+ &mut tokens,
1127+ &mut prev_line,
1128+ &mut prev_start,
1129+ line_num,
1130+ start as u32,
1131+ (i - start) as u32,
1132+ 1,
1133+ modifier,
1134+ ); // variable
9881135 }
9891136 } else {
9901137 i += 1;
@@ -995,18 +1142,77 @@ pub(crate) fn semantic_tokens(text: &str) -> Vec<SemanticToken> {
9951142}
9961143
9971144fn is_builtin_element(word: &str) -> bool {
998− matches!(word,
999− "@row" | "@column" | "@col" | "@el" | "@text" | "@paragraph" | "@p"
1000− | "@image" | "@img" | "@link" | "@input" | "@button" | "@btn"
1001− | "@select" | "@textarea" | "@option" | "@opt" | "@label" | "@raw"
1002− | "@nav" | "@header" | "@footer" | "@main" | "@section" | "@article" | "@aside"
1003− | "@list" | "@item" | "@li" | "@table" | "@thead" | "@tbody" | "@tr" | "@td" | "@th"
1004− | "@video" | "@audio" | "@form" | "@details" | "@summary"
1005− | "@blockquote" | "@cite" | "@code" | "@pre" | "@hr" | "@divider"
1006− | "@figure" | "@figcaption" | "@progress" | "@meter" | "@fragment"
1007− | "@dialog" | "@dl" | "@dt" | "@dd" | "@fieldset" | "@legend"
1008− | "@picture" | "@source" | "@time" | "@mark" | "@kbd" | "@abbr" | "@datalist"
1009− | "@script" | "@noscript" | "@address" | "@search" | "@breadcrumb"
1145+ matches!(
1146+ word,
1147+ "@row"
1148+ | "@column"
1149+ | "@col"
1150+ | "@el"
1151+ | "@text"
1152+ | "@paragraph"
1153+ | "@p"
1154+ | "@image"
1155+ | "@img"
1156+ | "@link"
1157+ | "@input"
1158+ | "@button"
1159+ | "@btn"
1160+ | "@select"
1161+ | "@textarea"
1162+ | "@option"
1163+ | "@opt"
1164+ | "@label"
1165+ | "@raw"
1166+ | "@nav"
1167+ | "@header"
1168+ | "@footer"
1169+ | "@main"
1170+ | "@section"
1171+ | "@article"
1172+ | "@aside"
1173+ | "@list"
1174+ | "@item"
1175+ | "@li"
1176+ | "@table"
1177+ | "@thead"
1178+ | "@tbody"
1179+ | "@tr"
1180+ | "@td"
1181+ | "@th"
1182+ | "@video"
1183+ | "@audio"
1184+ | "@form"
1185+ | "@details"
1186+ | "@summary"
1187+ | "@blockquote"
1188+ | "@cite"
1189+ | "@code"
1190+ | "@pre"
1191+ | "@hr"
1192+ | "@divider"
1193+ | "@figure"
1194+ | "@figcaption"
1195+ | "@progress"
1196+ | "@meter"
1197+ | "@fragment"
1198+ | "@dialog"
1199+ | "@dl"
1200+ | "@dt"
1201+ | "@dd"
1202+ | "@fieldset"
1203+ | "@legend"
1204+ | "@picture"
1205+ | "@source"
1206+ | "@time"
1207+ | "@mark"
1208+ | "@kbd"
1209+ | "@abbr"
1210+ | "@datalist"
1211+ | "@script"
1212+ | "@noscript"
1213+ | "@address"
1214+ | "@search"
1215+ | "@breadcrumb"
10101216 )
10111217}
10121218
@@ -1048,9 +1254,10 @@ pub(crate) fn inlay_hints(text: &str) -> Vec<InlayHint> {
10481254 for line in text.lines() {
10491255 let trimmed = line.trim();
10501256 if let Some(rest) = trimmed.strip_prefix("@let ")
1051− && let Some((name, value)) = rest.trim().split_once(' ') {
1052− vars.insert(name, value.trim());
1053− }
1257+ && let Some((name, value)) = rest.trim().split_once(' ')
1258+ {
1259+ vars.insert(name, value.trim());
1260+ }
10541261 }
10551262
10561263 let mut hints = Vec::new();
@@ -1112,7 +1319,8 @@ pub(crate) fn get_signature_help(text: &str, position: Position) -> Option<Signa
11121319
11131320 // Extract the function name
11141321 let after_at = &trimmed[1..];
1115− let name_end = after_at.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
1322+ let name_end = after_at
1323+ .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
11161324 .unwrap_or(after_at.len());
11171325 let fn_name = &after_at[..name_end];
11181326
@@ -1130,7 +1338,8 @@ pub(crate) fn get_signature_help(text: &str, position: Position) -> Option<Signa
11301338 let t = line_text.trim_start();
11311339 if let Some(rest) = t.strip_prefix("@fn ") {
11321340 let rest = rest.trim();
1133− let def_name_end = rest.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
1341+ let def_name_end = rest
1342+ .find(|c: char| !c.is_alphanumeric() && c != '_' && c != '-')
11341343 .unwrap_or(rest.len());
11351344 let def_name = &rest[..def_name_end];
11361345 if def_name != fn_name {
@@ -1146,18 +1355,21 @@ pub(crate) fn get_signature_help(text: &str, position: Position) -> Option<Signa
11461355 return None;
11471356 }
11481357
1149− let param_labels: Vec<ParameterInformation> = params.iter().map(|p| {
1150− let name = p.trim_start_matches('$');
1151− let (label, doc) = if let Some((n, default)) = name.split_once('=') {
1152− (n.to_string(), Some(format!("Default: {}", default)))
1153− } else {
1154− (name.to_string(), None)
1155− };
1156− ParameterInformation {
1157− label: ParameterLabel::Simple(label),
1158− documentation: doc.map(Documentation::String),
1159− }
1160− }).collect();
1358+ let param_labels: Vec<ParameterInformation> = params
1359+ .iter()
1360+ .map(|p| {
1361+ let name = p.trim_start_matches('$');
1362+ let (label, doc) = if let Some((n, default)) = name.split_once('=') {
1363+ (n.to_string(), Some(format!("Default: {}", default)))
1364+ } else {
1365+ (name.to_string(), None)
1366+ };
1367+ ParameterInformation {
1368+ label: ParameterLabel::Simple(label),
1369+ documentation: doc.map(Documentation::String),
1370+ }
1371+ })
1372+ .collect();
11611373
11621374 let sig_label = format!("@{} {}", fn_name, params.join(" "));
11631375
@@ -1175,9 +1387,10 @@ pub(crate) fn get_signature_help(text: &str, position: Position) -> Option<Signa
11751387 return Some(SignatureHelp {
11761388 signatures: vec![SignatureInformation {
11771389 label: sig_label,
1178− documentation: Some(Documentation::String(
1179− format!("Defined at line {}", line_idx + 1),
1180− )),
1390+ documentation: Some(Documentation::String(format!(
1391+ "Defined at line {}",
1392+ line_idx + 1
1393+ ))),
11811394 parameters: Some(param_labels),
11821395 active_parameter: Some(active_param),
11831396 }],
src/bin/htmlang_lsp/completion.rs+581−188
@@ -11,10 +11,7 @@ pub(crate) fn completions(text: &str, position: Position) -> Vec<CompletionItem>
1111 let before = &line[..col];
1212
1313 let word_start = find_word_start(before);
14− let edit_range = Range::new(
15− Position::new(position.line, word_start as u32),
16− position,
17− );
14+ let edit_range = Range::new(Position::new(position.line, word_start as u32), position);
1815
1916 // Inside attribute brackets?
2017 if in_brackets(before) {
@@ -28,7 +25,34 @@ pub(crate) fn completions(text: &str, position: Position) -> Vec<CompletionItem>
2825 // State prefix (hover:, active:, focus:) or media prefix (dark:, print:)
2926 if let Some(colon) = current_word.find(':') {
3027 let prefix = &current_word[..colon];
31− if matches!(prefix, "hover" | "active" | "focus" | "focus-visible" | "focus-within" | "disabled" | "checked" | "placeholder" | "first" | "last" | "odd" | "even" | "before" | "after" | "dark" | "print" | "sm" | "md" | "lg" | "xl" | "2xl" | "motion-safe" | "motion-reduce" | "landscape" | "portrait") {
28+ if matches!(
29+ prefix,
30+ "hover"
31+ | "active"
32+ | "focus"
33+ | "focus-visible"
34+ | "focus-within"
35+ | "disabled"
36+ | "checked"
37+ | "placeholder"
38+ | "first"
39+ | "last"
40+ | "odd"
41+ | "even"
42+ | "before"
43+ | "after"
44+ | "dark"
45+ | "print"
46+ | "sm"
47+ | "md"
48+ | "lg"
49+ | "xl"
50+ | "2xl"
51+ | "motion-safe"
52+ | "motion-reduce"
53+ | "landscape"
54+ | "portrait"
55+ ) {
3256 return state_attr_completions(prefix, edit_range);
3357 }
3458 }
@@ -182,7 +206,10 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
182206 ("@legend", "Legend for @fieldset (legend)"),
183207 // Picture/responsive images
184208 ("@picture", "Responsive image container (picture)"),
185− ("@source", "Media source for @picture/@video/@audio (source)"),
209+ (
210+ "@source",
211+ "Media source for @picture/@video/@audio (source)",
212+ ),
186213 // Inline semantics
187214 ("@time", "Date/time element (time)"),
188215 ("@mark", "Highlighted/marked text (mark)"),
@@ -195,7 +222,10 @@ fn element_completions(range: Range) -> Vec<CompletionItem> {
195222 ("@output", "Form calculation result (output)"),
196223 ("@canvas", "Drawing surface for scripts (canvas)"),
197224 ("@script", "Inline script element (script)"),
198− ("@noscript", "Fallback content when scripts disabled (noscript)"),
225+ (
226+ "@noscript",
227+ "Fallback content when scripts disabled (noscript)",
228+ ),
199229 ("@address", "Contact information (address)"),
200230 ("@search", "Search section (search)"),
201231 ("@breadcrumb", "Breadcrumb navigation (nav with aria)"),
@@ -210,14 +240,30 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
210240 ("@page", "Set HTML page title", "@page "),
211241 ("@let", "Define a variable", "@let "),
212242 ("@define", "Define an attribute bundle", "@define "),
213− ("@fn", "Define a reusable function (supports $param=default)", "@fn "),
243+ (
244+ "@fn",
245+ "Define a reusable function (supports $param=default)",
246+ "@fn ",
247+ ),
214248 ("@keyframes", "Define a CSS animation", "@keyframes "),
215249 ("@if", "Conditional rendering", "@if "),
216250 ("@else if", "Else-if branch", "@else if "),
217251 ("@else", "Else branch", "@else"),
218− ("@each", "Loop over values (@each $var, $i in list)", "@each "),
219− ("@include", "Include another .hl file (DOM + definitions)", "@include "),
220− ("@import", "Import definitions only (no DOM nodes)", "@import "),
252+ (
253+ "@each",
254+ "Loop over values (@each $var, $i in list)",
255+ "@each ",
256+ ),
257+ (
258+ "@include",
259+ "Include another .hl file (DOM + definitions)",
260+ "@include ",
261+ ),
262+ (
263+ "@import",
264+ "Import definitions only (no DOM nodes)",
265+ "@import ",
266+ ),
221267 ("@meta", "Add a <meta> tag to <head>", "@meta "),
222268 ("@head", "Add raw content to <head>", "@head"),
223269 ("@style", "Add raw CSS to stylesheet", "@style"),
@@ -226,32 +272,86 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
226272 ("@case", "Match case (inside @match)", "@case "),
227273 ("@default", "Default case (inside @match)", "@default"),
228274 ("@warn", "Emit a compile-time warning", "@warn "),
229− ("@debug", "Print debug message during compilation", "@debug "),
230− ("@lang", "Set document language (html lang attribute)", "@lang "),
231− ("@favicon", "Set favicon (inlined as base64 data URI)", "@favicon "),
232− ("@unless", "Inverse conditional (renders when false)", "@unless "),
275+ (
276+ "@debug",
277+ "Print debug message during compilation",
278+ "@debug ",
279+ ),
280+ (
281+ "@lang",
282+ "Set document language (html lang attribute)",
283+ "@lang ",
284+ ),
285+ (
286+ "@favicon",
287+ "Set favicon (inlined as base64 data URI)",
288+ "@favicon ",
289+ ),
290+ (
291+ "@unless",
292+ "Inverse conditional (renders when false)",
293+ "@unless ",
294+ ),
233295 ("@og", "Add Open Graph meta tag", "@og "),
234− ("@breakpoint", "Define custom responsive breakpoint", "@breakpoint "),
235− ("@theme", "Define design tokens (colors, spacing, fonts)", "@theme"),
296+ (
297+ "@breakpoint",
298+ "Define custom responsive breakpoint",
299+ "@breakpoint ",
300+ ),
301+ (
302+ "@theme",
303+ "Define design tokens (colors, spacing, fonts)",
304+ "@theme",
305+ ),
236306 ("@deprecated", "Mark next @fn as deprecated", "@deprecated "),
237− ("@extends", "Inherit a layout template and fill @slot blocks", "@extends "),
238− ("@use", "Selective import of definitions from a file", "@use "),
239− ("@canonical", "Set canonical URL for the page", "@canonical "),
307+ (
308+ "@extends",
309+ "Inherit a layout template and fill @slot blocks",
310+ "@extends ",
311+ ),
312+ (
313+ "@use",
314+ "Selective import of definitions from a file",
315+ "@use ",
316+ ),
317+ (
318+ "@canonical",
319+ "Set canonical URL for the page",
320+ "@canonical ",
321+ ),
240322 ("@base", "Set base URL for relative links", "@base "),
241323 ("@font-face", "Define a custom font face", "@font-face"),
242− ("@json-ld", "Add JSON-LD structured data to head", "@json-ld"),
243− ("@mixin", "Define a composable style group (use with ...$name)", "@mixin "),
244− ("@assert", "Compile-time assertion for variable values", "@assert "),
245− ("@data", "Load JSON data file into template variables", "@data "),
324+ (
325+ "@json-ld",
326+ "Add JSON-LD structured data to head",
327+ "@json-ld",
328+ ),
329+ (
330+ "@mixin",
331+ "Define a composable style group (use with ...$name)",
332+ "@mixin ",
333+ ),
334+ (
335+ "@assert",
336+ "Compile-time assertion for variable values",
337+ "@assert ",
338+ ),
339+ (
340+ "@data",
341+ "Load JSON data file into template variables",
342+ "@data ",
343+ ),
246344 ("@env", "Access compile-time environment variable", "@env "),
247345 ("@fetch", "Fetch data from URL at compile time", "@fetch "),
248346 ("@svg", "Inline SVG file with optional attributes", "@svg "),
249− ("@css-property", "Define typed CSS custom property (@property)", "@css-property "),
347+ (
348+ "@css-property",
349+ "Define typed CSS custom property (@property)",
350+ "@css-property ",
351+ ),
250352 ]
251353 .iter()
252− .map(|(name, detail, insert)| {
253− item(name, CompletionItemKind::SNIPPET, detail, insert, range)
254− })
354+ .map(|(name, detail, insert)| item(name, CompletionItemKind::SNIPPET, detail, insert, range))
255355 .collect()
256356}
257357
@@ -387,7 +487,10 @@ pub(crate) fn path_completions(uri: &Url, position: Position) -> Vec<CompletionI
387487 };
388488
389489 let col = position.character;
390− let edit_range = Range::new(Position::new(position.line, col), Position::new(position.line, col));
490+ let edit_range = Range::new(
491+ Position::new(position.line, col),
492+ Position::new(position.line, col),
493+ );
391494
392495 let entries = match std::fs::read_dir(dir) {
393496 Ok(e) => e,
@@ -398,28 +501,33 @@ pub(crate) fn path_completions(uri: &Url, position: Position) -> Vec<CompletionI
398501 for entry in entries.flatten() {
399502 let path = entry.path();
400503 if path.extension().and_then(|e| e.to_str()) == Some("hl")
401− && let Some(name) = path.file_name().and_then(|n| n.to_str()) {
402− // Skip the current file itself
403− if Some(name) == file_path.file_name().and_then(|n| n.to_str()) {
404− continue;
405− }
406− items.push(CompletionItem {
407− label: name.to_string(),
408− kind: Some(CompletionItemKind::FILE),
409− detail: Some("htmlang file".to_string()),
410− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
411− range: edit_range,
412− new_text: name.to_string(),
413− })),
414− ..Default::default()
415− });
504+ && let Some(name) = path.file_name().and_then(|n| n.to_str())
505+ {
506+ // Skip the current file itself
507+ if Some(name) == file_path.file_name().and_then(|n| n.to_str()) {
508+ continue;
416509 }
510+ items.push(CompletionItem {
511+ label: name.to_string(),
512+ kind: Some(CompletionItemKind::FILE),
513+ detail: Some("htmlang file".to_string()),
514+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
515+ range: edit_range,
516+ new_text: name.to_string(),
517+ })),
518+ ..Default::default()
519+ });
520+ }
417521 }
418522 items
419523}
420524
421525/// Suggest exported @fn and @define names from a file referenced in @use
422−pub(crate) fn use_symbol_completions(uri: &Url, line: &str, position: Position) -> Vec<CompletionItem> {
526+pub(crate) fn use_symbol_completions(
527+ uri: &Url,
528+ line: &str,
529+ position: Position,
530+) -> Vec<CompletionItem> {
423531 let file_path = match uri.to_file_path() {
424532 Ok(p) => p,
425533 Err(_) => return vec![],
@@ -449,7 +557,10 @@ pub(crate) fn use_symbol_completions(uri: &Url, line: &str, position: Position)
449557 };
450558
451559 let col = position.character;
452− let edit_range = Range::new(Position::new(position.line, col), Position::new(position.line, col));
560+ let edit_range = Range::new(
561+ Position::new(position.line, col),
562+ Position::new(position.line, col),
563+ );
453564
454565 let mut items = Vec::new();
455566
@@ -489,18 +600,19 @@ pub(crate) fn use_symbol_completions(uri: &Url, line: &str, position: Position)
489600 });
490601 }
491602 } else if let Some(rest) = trimmed.strip_prefix("@component ")
492− && let Some(name) = rest.split_whitespace().next() {
493− items.push(CompletionItem {
494− label: name.to_string(),
495− kind: Some(CompletionItemKind::CLASS),
496− detail: Some(format!("@component {} (from {})", name, filename)),
497− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
498− range: edit_range,
499− new_text: name.to_string(),
500− })),
501− ..Default::default()
502− });
503− }
603+ && let Some(name) = rest.split_whitespace().next()
604+ {
605+ items.push(CompletionItem {
606+ label: name.to_string(),
607+ kind: Some(CompletionItemKind::CLASS),
608+ detail: Some(format!("@component {} (from {})", name, filename)),
609+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
610+ range: edit_range,
611+ new_text: name.to_string(),
612+ })),
613+ ..Default::default()
614+ });
615+ }
504616 }
505617
506618 items
@@ -511,7 +623,11 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
511623 // Layout
512624 ("spacing", "Gap between children (supports CSS units)", true),
513625 ("gap", "Gap between children (alias for spacing)", true),
514− ("padding", "Inner padding (1/2/3/4 values, supports CSS units)", true),
626+ (
627+ "padding",
628+ "Inner padding (1/2/3/4 values, supports CSS units)",
629+ true,
630+ ),
515631 ("padding-x", "Horizontal padding", true),
516632 ("padding-y", "Vertical padding", true),
517633 // Sizing
@@ -546,25 +662,49 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
546662 ("cursor", "CSS cursor type", true),
547663 ("opacity", "Opacity (0-1)", true),
548664 // Typography
549− ("text-align", "Text alignment (left/center/right/justify)", true),
665+ (
666+ "text-align",
667+ "Text alignment (left/center/right/justify)",
668+ true,
669+ ),
550670 ("line-height", "Line height (unitless or px)", true),
551671 ("letter-spacing", "Letter spacing", true),
552− ("text-transform", "Text transform (uppercase/lowercase/capitalize)", true),
553− ("white-space", "White-space behavior (nowrap/pre/normal)", true),
672+ (
673+ "text-transform",
674+ "Text transform (uppercase/lowercase/capitalize)",
675+ true,
676+ ),
677+ (
678+ "white-space",
679+ "White-space behavior (nowrap/pre/normal)",
680+ true,
681+ ),
554682 // Overflow & positioning
555683 ("overflow", "Overflow behavior (hidden/scroll/auto)", true),
556− ("position", "Position type (relative/absolute/fixed/sticky)", true),
684+ (
685+ "position",
686+ "Position type (relative/absolute/fixed/sticky)",
687+ true,
688+ ),
557689 ("top", "Top offset (for positioned elements)", true),
558690 ("right", "Right offset (for positioned elements)", true),
559691 ("bottom", "Bottom offset (for positioned elements)", true),
560692 ("left", "Left offset (for positioned elements)", true),
561693 ("z-index", "Stack order (integer)", true),
562694 // Display & visibility
563− ("display", "Display mode (none/block/inline/flex/grid)", true),
695+ (
696+ "display",
697+ "Display mode (none/block/inline/flex/grid)",
698+ true,
699+ ),
564700 ("visibility", "Visibility (visible/hidden)", true),
565701 // Transform & filters
566702 ("transform", "CSS transform (e.g., rotate(45deg))", true),
567− ("backdrop-filter", "Backdrop filter (e.g., blur(10px))", true),
703+ (
704+ "backdrop-filter",
705+ "Backdrop filter (e.g., blur(10px))",
706+ true,
707+ ),
568708 // Effects
569709 ("shadow", "Box shadow (CSS value)", true),
570710 // Flow
@@ -579,8 +719,16 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
579719 ("row-span", "Span rows in grid", true),
580720 // Container queries
581721 ("container", "Enable container queries (inline-size)", false),
582− ("container-name", "Container name for @container queries", true),
583− ("container-type", "Container type (inline-size/size/normal)", true),
722+ (
723+ "container-name",
724+ "Container name for @container queries",
725+ true,
726+ ),
727+ (
728+ "container-type",
729+ "Container type (inline-size/size/normal)",
730+ true,
731+ ),
584732 // Identity
585733 ("id", "HTML id attribute", true),
586734 ("class", "HTML class attribute", true),
@@ -612,14 +760,30 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
612760 // CSS: aspect-ratio, outline, logical properties, scroll-snap
613761 ("aspect-ratio", "CSS aspect ratio (e.g., 16/9, 1)", true),
614762 ("outline", "Outline (width [color])", true),
615− ("padding-inline", "Inline (horizontal) padding for i18n", true),
763+ (
764+ "padding-inline",
765+ "Inline (horizontal) padding for i18n",
766+ true,
767+ ),
616768 ("padding-block", "Block (vertical) padding for i18n", true),
617769 ("margin-inline", "Inline (horizontal) margin for i18n", true),
618770 ("margin-block", "Block (vertical) margin for i18n", true),
619− ("scroll-snap-type", "Scroll snap behavior (x/y mandatory/proximity)", true),
620− ("scroll-snap-align", "Snap alignment (start/center/end)", true),
771+ (
772+ "scroll-snap-type",
773+ "Scroll snap behavior (x/y mandatory/proximity)",
774+ true,
775+ ),
776+ (
777+ "scroll-snap-align",
778+ "Snap alignment (start/center/end)",
779+ true,
780+ ),
621781 // Media attributes
622− ("controls", "Show media controls (for @video, @audio)", false),
782+ (
783+ "controls",
784+ "Show media controls (for @video, @audio)",
785+ false,
786+ ),
623787 ("autoplay", "Auto-play media", false),
624788 ("loop", "Loop media playback", false),
625789 ("muted", "Mute media", false),
@@ -636,8 +800,16 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
636800 ("margin-x", "Horizontal margin", true),
637801 ("margin-y", "Vertical margin", true),
638802 // Filter & object
639− ("filter", "CSS filter (blur, brightness, grayscale, etc.)", true),
640− ("object-fit", "Object fit for images (cover/contain/fill)", true),
803+ (
804+ "filter",
805+ "CSS filter (blur, brightness, grayscale, etc.)",
806+ true,
807+ ),
808+ (
809+ "object-fit",
810+ "Object fit for images (cover/contain/fill)",
811+ true,
812+ ),
641813 ("object-position", "Object position within container", true),
642814 // Text extras
643815 ("text-shadow", "Text shadow (CSS value)", true),
@@ -646,16 +818,40 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
646818 ("pointer-events", "Pointer events (none/auto)", true),
647819 ("user-select", "User selection (none/text/all)", true),
648820 // Flexbox/grid alignment
649− ("justify-content", "Main axis alignment (center/space-between/etc.)", true),
650− ("align-items", "Cross axis alignment (center/baseline/etc.)", true),
821+ (
822+ "justify-content",
823+ "Main axis alignment (center/space-between/etc.)",
824+ true,
825+ ),
826+ (
827+ "align-items",
828+ "Cross axis alignment (center/baseline/etc.)",
829+ true,
830+ ),
651831 // Flex item
652832 ("order", "Flex/grid item order", true),
653833 // Background extras
654− ("background-size", "Background size (cover/contain/auto)", true),
655− ("background-position", "Background position (center/top/etc.)", true),
656− ("background-repeat", "Background repeat (no-repeat/repeat/etc.)", true),
834+ (
835+ "background-size",
836+ "Background size (cover/contain/auto)",
837+ true,
838+ ),
839+ (
840+ "background-position",
841+ "Background position (center/top/etc.)",
842+ true,
843+ ),
844+ (
845+ "background-repeat",
846+ "Background repeat (no-repeat/repeat/etc.)",
847+ true,
848+ ),
657849 // Text wrapping
658− ("word-break", "Word break behavior (break-all/keep-all)", true),
850+ (
851+ "word-break",
852+ "Word break behavior (break-all/keep-all)",
853+ true,
854+ ),
659855 ("overflow-wrap", "Overflow wrap (break-word/anywhere)", true),
660856 // New element attrs
661857 ("open", "Details initially open", false),
@@ -665,47 +861,119 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
665861 ("optimum", "Meter optimum value", true),
666862 ("colspan", "Table cell column span", true),
667863 ("rowspan", "Table cell row span", true),
668− ("scope", "Table header scope (col/row/colgroup/rowgroup)", true),
864+ (
865+ "scope",
866+ "Table header scope (col/row/colgroup/rowgroup)",
867+ true,
868+ ),
669869 ("inline", "Inline SVG images into output", false),
670870 // Hidden
671871 ("hidden", "Hide element (display:none)", false),
672872 // Overflow directional
673− ("overflow-x", "Horizontal overflow (hidden/scroll/auto)", true),
873+ (
874+ "overflow-x",
875+ "Horizontal overflow (hidden/scroll/auto)",
876+ true,
877+ ),
674878 ("overflow-y", "Vertical overflow (hidden/scroll/auto)", true),
675879 // Inset
676880 ("inset", "Shorthand for top/right/bottom/left", true),
677881 // Modern form theming
678882 ("accent-color", "Accent color for form controls", true),
679883 ("caret-color", "Text cursor color", true),
680− ("color-scheme", "Color scheme preference (light/dark/light dark)", true),
681− ("appearance", "Form element appearance (none to reset)", true),
884+ (
885+ "color-scheme",
886+ "Color scheme preference (light/dark/light dark)",
887+ true,
888+ ),
889+ (
890+ "appearance",
891+ "Form element appearance (none to reset)",
892+ true,
893+ ),
682894 // Popover API
683− ("popover", "Make element a popover (HTML Popover API)", false),
895+ (
896+ "popover",
897+ "Make element a popover (HTML Popover API)",
898+ false,
899+ ),
684900 ("popovertarget", "ID of popover to toggle", true),
685− ("popovertargetaction", "Popover action (toggle/show/hide)", true),
901+ (
902+ "popovertargetaction",
903+ "Popover action (toggle/show/hide)",
904+ true,
905+ ),
686906 // Input hints
687− ("inputmode", "Virtual keyboard type (numeric/email/search/tel/url)", true),
688− ("enterkeyhint", "Enter key label (done/go/next/search/send)", true),
689− ("fetchpriority", "Resource fetch priority (high/low/auto)", true),
690− ("translate", "Whether element should be translated (yes/no)", true),
907+ (
908+ "inputmode",
909+ "Virtual keyboard type (numeric/email/search/tel/url)",
910+ true,
911+ ),
912+ (
913+ "enterkeyhint",
914+ "Enter key label (done/go/next/search/send)",
915+ true,
916+ ),
917+ (
918+ "fetchpriority",
919+ "Resource fetch priority (high/low/auto)",
920+ true,
921+ ),
922+ (
923+ "translate",
924+ "Whether element should be translated (yes/no)",
925+ true,
926+ ),
691927 ("spellcheck", "Spell check mode (true/false)", true),
692928 // List styling
693− ("list-style", "List style type (disc/circle/square/none)", true),
929+ (
930+ "list-style",
931+ "List style type (disc/circle/square/none)",
932+ true,
933+ ),
694934 // Table styling
695− ("border-collapse", "Border collapse mode (collapse/separate)", true),
935+ (
936+ "border-collapse",
937+ "Border collapse mode (collapse/separate)",
938+ true,
939+ ),
696940 ("border-spacing", "Spacing between table cell borders", true),
697941 // Text decoration
698− ("text-decoration", "Text decoration (underline/overline/line-through)", true),
942+ (
943+ "text-decoration",
944+ "Text decoration (underline/overline/line-through)",
945+ true,
946+ ),
699947 ("text-decoration-color", "Text decoration color", true),
700− ("text-decoration-thickness", "Text decoration thickness", true),
701− ("text-decoration-style", "Text decoration style (solid/dashed/dotted/wavy)", true),
948+ (
949+ "text-decoration-thickness",
950+ "Text decoration thickness",
951+ true,
952+ ),
953+ (
954+ "text-decoration-style",
955+ "Text decoration style (solid/dashed/dotted/wavy)",
956+ true,
957+ ),
702958 // Grid/flex placement
703− ("place-items", "Shorthand for align-items + justify-items", true),
704− ("place-self", "Shorthand for align-self + justify-self", true),
959+ (
960+ "place-items",
961+ "Shorthand for align-items + justify-items",
962+ true,
963+ ),
964+ (
965+ "place-self",
966+ "Shorthand for align-self + justify-self",
967+ true,
968+ ),
705969 // Scroll behavior
706970 ("scroll-behavior", "Scroll behavior (smooth/auto)", true),
707971 // Resize
708− ("resize", "Resize behavior (none/both/horizontal/vertical)", true),
972+ (
973+ "resize",
974+ "Resize behavior (none/both/horizontal/vertical)",
975+ true,
976+ ),
709977 // State prefixes
710978 ("hover:", "Style on hover", false),
711979 ("active:", "Style on active/click", false),
@@ -730,7 +998,11 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
730998 ("2xl:", "Style at 1536px+ (2x extra large)", false),
731999 // Motion prefixes
7321000 ("motion-safe:", "Style when motion is allowed", false),
733− ("motion-reduce:", "Style when reduced motion preferred", false),
1001+ (
1002+ "motion-reduce:",
1003+ "Style when reduced motion preferred",
1004+ false,
1005+ ),
7341006 // Orientation prefixes
7351007 ("landscape:", "Style in landscape orientation", false),
7361008 ("portrait:", "Style in portrait orientation", false),
@@ -739,12 +1011,24 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
7391011 ("print:", "Style for print media", false),
7401012 // Clipping & blending
7411013 ("clip-path", "Clip path (circle, polygon, etc.)", true),
742− ("mix-blend-mode", "Blend mode (multiply, screen, overlay, etc.)", true),
1014+ (
1015+ "mix-blend-mode",
1016+ "Blend mode (multiply, screen, overlay, etc.)",
1017+ true,
1018+ ),
7431019 ("background-blend-mode", "Background blend mode", true),
7441020 // Writing mode
745− ("writing-mode", "Writing mode (horizontal-tb, vertical-rl, etc.)", true),
1021+ (
1022+ "writing-mode",
1023+ "Writing mode (horizontal-tb, vertical-rl, etc.)",
1024+ true,
1025+ ),
7461026 // Multi-column layout
747− ("column-count", "Number of columns in multi-column layout", true),
1027+ (
1028+ "column-count",
1029+ "Number of columns in multi-column layout",
1030+ true,
1031+ ),
7481032 ("column-gap", "Gap between columns", true),
7491033 // Text
7501034 ("text-indent", "First-line text indentation", true),
@@ -756,35 +1040,91 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
7561040 // Stacking context
7571041 ("isolation", "Create stacking context (isolate/auto)", true),
7581042 // Grid/flex placement
759− ("place-content", "Shorthand for align-content + justify-content", true),
1043+ (
1044+ "place-content",
1045+ "Shorthand for align-content + justify-content",
1046+ true,
1047+ ),
7601048 // Background image
761− ("background-image", "Background image (url or gradient)", true),
1049+ (
1050+ "background-image",
1051+ "Background image (url or gradient)",
1052+ true,
1053+ ),
7621054 // New CSS properties
763− ("font-weight", "Font weight (100-900, bold, lighter, bolder)", true),
1055+ (
1056+ "font-weight",
1057+ "Font weight (100-900, bold, lighter, bolder)",
1058+ true,
1059+ ),
7641060 ("font-style", "Font style (normal/italic/oblique)", true),
7651061 ("text-wrap", "Text wrapping (balance/pretty/nowrap)", true),
766− ("will-change", "Performance hint for animations (transform, opacity)", true),
767− ("touch-action", "Touch behavior (none/pan-x/pan-y/manipulation)", true),
768− ("vertical-align", "Vertical alignment (middle/top/bottom/baseline)", true),
769− ("contain", "CSS containment (layout/paint/content/strict)", true),
770− ("content-visibility", "Content visibility (auto/visible/hidden)", true),
771− ("scroll-margin", "Scroll margin (for scroll-snap and anchor offsets)", true),
1062+ (
1063+ "will-change",
1064+ "Performance hint for animations (transform, opacity)",
1065+ true,
1066+ ),
1067+ (
1068+ "touch-action",
1069+ "Touch behavior (none/pan-x/pan-y/manipulation)",
1070+ true,
1071+ ),
1072+ (
1073+ "vertical-align",
1074+ "Vertical alignment (middle/top/bottom/baseline)",
1075+ true,
1076+ ),
1077+ (
1078+ "contain",
1079+ "CSS containment (layout/paint/content/strict)",
1080+ true,
1081+ ),
1082+ (
1083+ "content-visibility",
1084+ "Content visibility (auto/visible/hidden)",
1085+ true,
1086+ ),
1087+ (
1088+ "scroll-margin",
1089+ "Scroll margin (for scroll-snap and anchor offsets)",
1090+ true,
1091+ ),
7721092 ("scroll-margin-top", "Scroll margin top", true),
773− ("scroll-padding", "Scroll padding (for scroll-snap containers)", true),
1093+ (
1094+ "scroll-padding",
1095+ "Scroll padding (for scroll-snap containers)",
1096+ true,
1097+ ),
7741098 ("scroll-padding-top", "Scroll padding top", true),
7751099 // Pseudo-element content
776− ("content", "Content for ::before/::after (use with before:/after: prefix)", true),
1100+ (
1101+ "content",
1102+ "Content for ::before/::after (use with before:/after: prefix)",
1103+ true,
1104+ ),
7771105 // Iframe/form/link attributes
7781106 ("sandbox", "Iframe sandbox restrictions", true),
7791107 ("allow", "Iframe permissions policy", true),
7801108 ("allowfullscreen", "Allow iframe fullscreen", false),
781− ("target", "Link/form target (_blank/_self/_parent/_top)", true),
1109+ (
1110+ "target",
1111+ "Link/form target (_blank/_self/_parent/_top)",
1112+ true,
1113+ ),
7821114 // CSS shorthands
783− ("truncate", "Truncate text with ellipsis (single line)", false),
1115+ (
1116+ "truncate",
1117+ "Truncate text with ellipsis (single line)",
1118+ false,
1119+ ),
7841120 ("line-clamp", "Clamp text to N lines with ellipsis", true),
7851121 ("blur", "Apply blur filter (px)", true),
7861122 ("backdrop-blur", "Apply backdrop blur filter (px)", true),
787− ("no-scrollbar", "Hide scrollbar while keeping overflow", false),
1123+ (
1124+ "no-scrollbar",
1125+ "Hide scrollbar while keeping overflow",
1126+ false,
1127+ ),
7881128 ("skeleton", "Add shimmer loading skeleton animation", false),
7891129 ("gradient", "Linear gradient (color1 color2 [angle])", true),
7901130 // Direction
@@ -798,26 +1138,58 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
7981138 ("after:", "Style ::after pseudo-element", false),
7991139 ("selection:", "Style text selection", false),
8001140 // Grid areas
801− ("grid-template-areas", "Define named grid areas (quoted string)", true),
1141+ (
1142+ "grid-template-areas",
1143+ "Define named grid areas (quoted string)",
1144+ true,
1145+ ),
8021146 ("grid-area", "Place element in a named grid area", true),
8031147 // View transitions
804− ("view-transition-name", "Assign a view transition name", true),
1148+ (
1149+ "view-transition-name",
1150+ "Assign a view transition name",
1151+ true,
1152+ ),
8051153 // Animate shorthand
806− ("animate", "Animation shorthand (name duration [timing])", true),
1154+ (
1155+ "animate",
1156+ "Animation shorthand (name duration [timing])",
1157+ true,
1158+ ),
8071159 // Has pseudo-selector
808− ("has(", "Style when element has matching children :has()", false),
1160+ (
1161+ "has(",
1162+ "Style when element has matching children :has()",
1163+ false,
1164+ ),
8091165 // Critical CSS hint
8101166 ("critical", "Mark as above-fold critical CSS", false),
8111167 // New pseudo-state prefixes
8121168 ("visited:", "Style visited links", false),
8131169 ("empty:", "Style when element has no children", false),
814− ("target:", "Style when element is the URL fragment target", false),
1170+ (
1171+ "target:",
1172+ "Style when element is the URL fragment target",
1173+ false,
1174+ ),
8151175 ("valid:", "Style when form element is valid", false),
8161176 ("invalid:", "Style when form element is invalid", false),
8171177 // New CSS properties
818− ("text-underline-offset", "Offset of text underline from its default position", true),
819− ("column-width", "Ideal width of columns in multi-column layout", true),
820− ("column-rule", "Rule between columns (width style color)", true),
1178+ (
1179+ "text-underline-offset",
1180+ "Offset of text underline from its default position",
1181+ true,
1182+ ),
1183+ (
1184+ "column-width",
1185+ "Ideal width of columns in multi-column layout",
1186+ true,
1187+ ),
1188+ (
1189+ "column-rule",
1190+ "Rule between columns (width style color)",
1191+ true,
1192+ ),
8211193 ]
8221194 .iter()
8231195 .map(|(name, detail, takes_value)| {
@@ -901,9 +1273,17 @@ fn color_value_completions(before: &str, range: Range) -> Option<Vec<CompletionI
9011273
9021274 if !matches!(
9031275 base_attr,
904− "background" | "color" | "border" | "border-top" | "border-bottom"
905− | "border-left" | "border-right" | "accent-color" | "caret-color"
906− | "text-decoration-color" | "outline"
1276+ "background"
1277+ | "color"
1278+ | "border"
1279+ | "border-top"
1280+ | "border-bottom"
1281+ | "border-left"
1282+ | "border-right"
1283+ | "accent-color"
1284+ | "caret-color"
1285+ | "text-decoration-color"
1286+ | "outline"
9071287 ) {
9081288 return None;
9091289 }
@@ -915,56 +1295,63 @@ fn color_value_completions(before: &str, range: Range) -> Option<Vec<CompletionI
9151295 }
9161296
9171297 let colors: &[(&str, &str, &str)] = &[
918− ("white", "#ffffff", "White"),
919− ("black", "#000000", "Black"),
920− ("red", "#ef4444", "Red"),
921− ("orange", "#f97316", "Orange"),
922− ("yellow", "#eab308", "Yellow"),
923− ("green", "#22c55e", "Green"),
924− ("blue", "#3b82f6", "Blue"),
925− ("indigo", "#6366f1", "Indigo"),
926− ("purple", "#a855f7", "Purple"),
927− ("pink", "#ec4899", "Pink"),
928− ("gray", "#6b7280", "Gray"),
929− ("slate", "#64748b", "Slate"),
930− ("zinc", "#71717a", "Zinc"),
931− ("neutral", "#737373", "Neutral"),
932− ("stone", "#78716c", "Stone"),
933− ("amber", "#f59e0b", "Amber"),
934− ("lime", "#84cc16", "Lime"),
935− ("emerald", "#10b981", "Emerald"),
936− ("teal", "#14b8a6", "Teal"),
937− ("cyan", "#06b6d4", "Cyan"),
938− ("sky", "#0ea5e9", "Sky"),
939− ("violet", "#8b5cf6", "Violet"),
940− ("fuchsia", "#d946ef", "Fuchsia"),
941− ("rose", "#f43f5e", "Rose"),
1298+ ("white", "#ffffff", "White"),
1299+ ("black", "#000000", "Black"),
1300+ ("red", "#ef4444", "Red"),
1301+ ("orange", "#f97316", "Orange"),
1302+ ("yellow", "#eab308", "Yellow"),
1303+ ("green", "#22c55e", "Green"),
1304+ ("blue", "#3b82f6", "Blue"),
1305+ ("indigo", "#6366f1", "Indigo"),
1306+ ("purple", "#a855f7", "Purple"),
1307+ ("pink", "#ec4899", "Pink"),
1308+ ("gray", "#6b7280", "Gray"),
1309+ ("slate", "#64748b", "Slate"),
1310+ ("zinc", "#71717a", "Zinc"),
1311+ ("neutral", "#737373", "Neutral"),
1312+ ("stone", "#78716c", "Stone"),
1313+ ("amber", "#f59e0b", "Amber"),
1314+ ("lime", "#84cc16", "Lime"),
1315+ ("emerald", "#10b981", "Emerald"),
1316+ ("teal", "#14b8a6", "Teal"),
1317+ ("cyan", "#06b6d4", "Cyan"),
1318+ ("sky", "#0ea5e9", "Sky"),
1319+ ("violet", "#8b5cf6", "Violet"),
1320+ ("fuchsia", "#d946ef", "Fuchsia"),
1321+ ("rose", "#f43f5e", "Rose"),
9421322 ("transparent", "transparent", "Transparent"),
943− ("currentColor", "currentColor", "Inherit from parent text color"),
1323+ (
1324+ "currentColor",
1325+ "currentColor",
1326+ "Inherit from parent text color",
1327+ ),
9441328 ];
9451329
946− let items: Vec<CompletionItem> = colors.iter().map(|(label, value, detail)| {
947− let doc = if value.starts_with('#') {
948− format!("{} (`{}`)", detail, value)
949− } else {
950− detail.to_string()
951− };
952− CompletionItem {
953− label: label.to_string(),
954− kind: Some(CompletionItemKind::COLOR),
955− detail: Some(doc),
956− text_edit: Some(CompletionTextEdit::Edit(TextEdit {
957− range,
958− new_text: value.to_string(),
959− })),
960− documentation: if value.starts_with('#') {
961− Some(Documentation::String(value.to_string()))
1330+ let items: Vec<CompletionItem> = colors
1331+ .iter()
1332+ .map(|(label, value, detail)| {
1333+ let doc = if value.starts_with('#') {
1334+ format!("{} (`{}`)", detail, value)
9621335 } else {
963− None
964− },
965− ..Default::default()
966− }
967− }).collect();
1336+ detail.to_string()
1337+ };
1338+ CompletionItem {
1339+ label: label.to_string(),
1340+ kind: Some(CompletionItemKind::COLOR),
1341+ detail: Some(doc),
1342+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
1343+ range,
1344+ new_text: value.to_string(),
1345+ })),
1346+ documentation: if value.starts_with('#') {
1347+ Some(Documentation::String(value.to_string()))
1348+ } else {
1349+ None
1350+ },
1351+ ..Default::default()
1352+ }
1353+ })
1354+ .collect();
9681355
9691356 Some(items)
9701357}
@@ -985,16 +1372,17 @@ fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
9851372 ));
9861373 }
9871374 } else if let Some(rest) = trimmed.strip_prefix("@define ")
988− && let Some(bracket) = rest.find('[') {
989− let name = rest[..bracket].trim();
990− items.push(item(
991− &format!("${}", name),
992− CompletionItemKind::CONSTANT,
993− "Attribute bundle",
994− &format!("${}", name),
995− range,
996− ));
997− }
1375+ && let Some(bracket) = rest.find('[')
1376+ {
1377+ let name = rest[..bracket].trim();
1378+ items.push(item(
1379+ &format!("${}", name),
1380+ CompletionItemKind::CONSTANT,
1381+ "Attribute bundle",
1382+ &format!("${}", name),
1383+ range,
1384+ ));
1385+ }
9981386 }
9991387
10001388 items
@@ -1008,7 +1396,8 @@ fn function_completions(text: &str, range: Range) -> Vec<CompletionItem> {
10081396 if let Some(rest) = trimmed.strip_prefix("@fn ") {
10091397 let parts: Vec<&str> = rest.split_whitespace().collect();
10101398 if let Some(name) = parts.first() {
1011− let params: Vec<&str> = parts[1..].iter()
1399+ let params: Vec<&str> = parts[1..]
1400+ .iter()
10121401 .map(|p| p.strip_prefix('$').unwrap_or(p))
10131402 .collect();
10141403 let detail = if params.is_empty() {
@@ -1020,11 +1409,15 @@ fn function_completions(text: &str, range: Range) -> Vec<CompletionItem> {
10201409 let insert_text = if params.is_empty() {
10211410 format!("@{}", name)
10221411 } else {
1023− let param_snippets: Vec<String> = params.iter().enumerate().map(|(i, p)| {
1024− let p_name = p.split('=').next().unwrap_or(p);
1025− let default = p.split('=').nth(1).unwrap_or(p_name);
1026− format!("{} ${{{}:{}}}", p_name, i + 1, default)
1027− }).collect();
1412+ let param_snippets: Vec<String> = params
1413+ .iter()
1414+ .enumerate()
1415+ .map(|(i, p)| {
1416+ let p_name = p.split('=').next().unwrap_or(p);
1417+ let default = p.split('=').nth(1).unwrap_or(p_name);
1418+ format!("{} ${{{}:{}}}", p_name, i + 1, default)
1419+ })
1420+ .collect();
10281421 format!("@{} [{}]", name, param_snippets.join(", "))
10291422 };
10301423 let mut ci = CompletionItem {
src/bin/htmlang_lsp/hover.rs+703−244
@@ -48,9 +48,10 @@ fn hover_variable(text: &str, name: &str) -> Option<String> {
4848 let trimmed = line.trim();
4949 if let Some(rest) = trimmed.strip_prefix("@let ")
5050 && let Some((n, v)) = rest.trim().split_once(' ')
51− && n == name {
52− return Some(format!("**${}** = `{}`", name, v.trim()));
53− }
51+ && n == name
52+ {
53+ return Some(format!("**${}** = `{}`", name, v.trim()));
54+ }
5455 }
5556
5657 for line in text.lines() {
@@ -58,12 +59,13 @@ fn hover_variable(text: &str, name: &str) -> Option<String> {
5859 if let Some(rest) = trimmed.strip_prefix("@define ") {
5960 let rest = rest.trim();
6061 if let Some(bracket) = rest.find('[')
61− && rest[..bracket].trim() == name {
62− return Some(format!(
63− "**${}** \u{2014} Attribute bundle\n\n`{}`",
64− name, trimmed
65− ));
66− }
62+ && rest[..bracket].trim() == name
63+ {
64+ return Some(format!(
65+ "**${}** \u{2014} Attribute bundle\n\n`{}`",
66+ name, trimmed
67+ ));
68+ }
6769 }
6870 }
6971
@@ -123,14 +125,17 @@ fn hover_user_fn(text: &str, name: &str) -> Option<String> {
123125 let params_str = if params.is_empty() {
124126 String::new()
125127 } else {
126− let formatted: Vec<String> = params.iter().map(|p| {
127− if p.contains('=') {
128− let (name, default) = p.split_once('=').unwrap();
129− format!("{} (default: {})", name, default)
130− } else {
131− p.to_string()
132− }
133− }).collect();
128+ let formatted: Vec<String> = params
129+ .iter()
130+ .map(|p| {
131+ if p.contains('=') {
132+ let (name, default) = p.split_once('=').unwrap();
133+ format!("{} (default: {})", name, default)
134+ } else {
135+ p.to_string()
136+ }
137+ })
138+ .collect();
134139 format!("\n\nParameters: {}", formatted.join(", "))
135140 };
136141
@@ -208,122 +213,308 @@ fn hover_builtin(word: &str) -> Option<String> {
208213
209214 let doc = match base {
210215 // Elements
211− "@row" => "**@row** \u{2014} Horizontal layout\n\nRenders as `<div>` with `display: flex; flex-direction: row`.\n\nChildren are laid out left-to-right.",
212− "@column" | "@col" => "**@column** \u{2014} Vertical layout\n\nRenders as `<div>` with `display: flex; flex-direction: column`.\n\nChildren are laid out top-to-bottom.",
213− "@el" => "**@el** \u{2014} Generic container\n\nRenders as `<div>` with column flex layout.",
214− "@text" => "**@text** \u{2014} Inline text\n\nRenders as `<span>`.\n\nUsage: `@text [bold, size 24] Hello world`",
215− "@paragraph" | "@p" => "**@paragraph** \u{2014} Text block\n\nRenders as `<p>`.\n\nSupports inline elements: `{@text [bold] word}`",
216− "@image" | "@img" => "**@image** \u{2014} Image\n\nRenders as `<img>`.\n\nUsage: `@image [width 200] https://example.com/photo.jpg`",
217− "@link" => "**@link** \u{2014} Hyperlink\n\nRenders as `<a>`.\n\nUsage: `@link [color blue] https://example.com Link text`",
218− "@raw" => "**@raw** \u{2014} Raw HTML\n\nPasses content through without processing.\n\nUsage: `@raw \"\"\"<div>custom html</div>\"\"\"`",
219− "@page" => "**@page** \u{2014} Page title\n\nSets the HTML `<title>` and wraps output in a full document.\n\nUsage: `@page My Page Title`",
220− "@let" => "**@let** \u{2014} Variable\n\nDefines a variable for `$name` substitution.\n\nUsage: `@let primary #3b82f6`",
221− "@define" => "**@define** \u{2014} Attribute bundle\n\nDefines a reusable set of attributes.\n\nUsage: `@define card-style [padding 20, rounded 8]`\n\nApply with `$card-style` in attribute lists.",
222− "@fn" => "**@fn** \u{2014} Function\n\nDefines a reusable component.\n\n```\n@fn card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```",
223− "@keyframes" => "**@keyframes** \u{2014} CSS Animation\n\nDefines keyframes for CSS animations.\n\n```\n@keyframes fade-in\n from{opacity:0}to{opacity:1}\n```\n\nUse with `animation` attribute: `[animation fade-in 0.3s ease]`",
224− "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
225− "@input" => "**@input** \u{2014} Form input\n\nRenders as self-closing `<input>`.\n\nUsage: `@input [type text, placeholder Name, name user]`",
226− "@button" | "@btn" => "**@button** \u{2014} Button\n\nRenders as `<button>`.\n\nUsage: `@button [type submit] Click me`",
227− "@select" => "**@select** \u{2014} Select dropdown\n\nRenders as `<select>`. Use `@option` children.\n\nUsage: `@select [name color]`",
228− "@textarea" => "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`",
229− "@option" | "@opt" => "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`",
230− "@label" => "**@label** \u{2014} Form label\n\nRenders as `<label>`.\n\nUsage: `@label [for email] Email Address`",
231− "@if" => "**@if** \u{2014} Conditional\n\nConditionally includes children at compile time.\n\n```\n@if $theme == dark\n @el [background #333]\n@else if $theme == light\n @el [background white]\n@else\n @el [background gray]\n```",
232− "@each" => "**@each** \u{2014} Loop\n\nRepeat children for each item in a comma-separated list.\nOptional index variable.\n\n```\n@each $color, $i in red,green,blue\n @text $i: $color\n```",
233− "@include" => "**@include** \u{2014} Include file\n\nIncludes another .hl file (DOM nodes + definitions).\n\nUsage: `@include header.hl`",
234− "@import" => "**@import** \u{2014} Import definitions\n\nImports `@let`, `@define`, `@fn` from another .hl file without emitting DOM nodes.\n\nUsage: `@import theme.hl`",
235− "@meta" => "**@meta** \u{2014} Meta tag\n\nAdds a `<meta>` tag to `<head>`.\n\nUsage: `@meta description A portfolio site`",
236− "@head" => "**@head** \u{2014} Head content\n\nAdds raw content to `<head>` (fonts, icons, etc.).\n\n```\n@head\n <link rel=\"icon\" href=\"favicon.ico\">\n```",
237− "@style" => "**@style** \u{2014} Custom CSS\n\nAdds raw CSS to the stylesheet.\n\n```\n@style\n .custom { border: 1px solid red; }\n @container sidebar (min-width: 400px) { ... }\n```",
238− "@slot" => "**@slot** \u{2014} Named slot\n\nDefines a named insertion point inside `@fn`. Callers fill it with `@slot name` + children.\n\n```\n@fn layout\n @slot header\n @children\n @slot footer\n```",
216+ "@row" => {
217+ "**@row** \u{2014} Horizontal layout\n\nRenders as `<div>` with `display: flex; flex-direction: row`.\n\nChildren are laid out left-to-right."
218+ }
219+ "@column" | "@col" => {
220+ "**@column** \u{2014} Vertical layout\n\nRenders as `<div>` with `display: flex; flex-direction: column`.\n\nChildren are laid out top-to-bottom."
221+ }
222+ "@el" => {
223+ "**@el** \u{2014} Generic container\n\nRenders as `<div>` with column flex layout."
224+ }
225+ "@text" => {
226+ "**@text** \u{2014} Inline text\n\nRenders as `<span>`.\n\nUsage: `@text [bold, size 24] Hello world`"
227+ }
228+ "@paragraph" | "@p" => {
229+ "**@paragraph** \u{2014} Text block\n\nRenders as `<p>`.\n\nSupports inline elements: `{@text [bold] word}`"
230+ }
231+ "@image" | "@img" => {
232+ "**@image** \u{2014} Image\n\nRenders as `<img>`.\n\nUsage: `@image [width 200] https://example.com/photo.jpg`"
233+ }
234+ "@link" => {
235+ "**@link** \u{2014} Hyperlink\n\nRenders as `<a>`.\n\nUsage: `@link [color blue] https://example.com Link text`"
236+ }
237+ "@raw" => {
238+ "**@raw** \u{2014} Raw HTML\n\nPasses content through without processing.\n\nUsage: `@raw \"\"\"<div>custom html</div>\"\"\"`"
239+ }
240+ "@page" => {
241+ "**@page** \u{2014} Page title\n\nSets the HTML `<title>` and wraps output in a full document.\n\nUsage: `@page My Page Title`"
242+ }
243+ "@let" => {
244+ "**@let** \u{2014} Variable\n\nDefines a variable for `$name` substitution.\n\nUsage: `@let primary #3b82f6`"
245+ }
246+ "@define" => {
247+ "**@define** \u{2014} Attribute bundle\n\nDefines a reusable set of attributes.\n\nUsage: `@define card-style [padding 20, rounded 8]`\n\nApply with `$card-style` in attribute lists."
248+ }
249+ "@fn" => {
250+ "**@fn** \u{2014} Function\n\nDefines a reusable component.\n\n```\n@fn card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```"
251+ }
252+ "@keyframes" => {
253+ "**@keyframes** \u{2014} CSS Animation\n\nDefines keyframes for CSS animations.\n\n```\n@keyframes fade-in\n from{opacity:0}to{opacity:1}\n```\n\nUse with `animation` attribute: `[animation fade-in 0.3s ease]`"
254+ }
255+ "@children" => {
256+ "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children."
257+ }
258+ "@input" => {
259+ "**@input** \u{2014} Form input\n\nRenders as self-closing `<input>`.\n\nUsage: `@input [type text, placeholder Name, name user]`"
260+ }
261+ "@button" | "@btn" => {
262+ "**@button** \u{2014} Button\n\nRenders as `<button>`.\n\nUsage: `@button [type submit] Click me`"
263+ }
264+ "@select" => {
265+ "**@select** \u{2014} Select dropdown\n\nRenders as `<select>`. Use `@option` children.\n\nUsage: `@select [name color]`"
266+ }
267+ "@textarea" => {
268+ "**@textarea** \u{2014} Multi-line text input\n\nRenders as `<textarea>`.\n\nUsage: `@textarea [name bio, rows 4] Default text`"
269+ }
270+ "@option" | "@opt" => {
271+ "**@option** \u{2014} Select option\n\nRenders as `<option>`.\n\nUsage: `@option [value red] Red`"
272+ }
273+ "@label" => {
274+ "**@label** \u{2014} Form label\n\nRenders as `<label>`.\n\nUsage: `@label [for email] Email Address`"
275+ }
276+ "@if" => {
277+ "**@if** \u{2014} Conditional\n\nConditionally includes children at compile time.\n\n```\n@if $theme == dark\n @el [background #333]\n@else if $theme == light\n @el [background white]\n@else\n @el [background gray]\n```"
278+ }
279+ "@each" => {
280+ "**@each** \u{2014} Loop\n\nRepeat children for each item in a comma-separated list.\nOptional index variable.\n\n```\n@each $color, $i in red,green,blue\n @text $i: $color\n```"
281+ }
282+ "@include" => {
283+ "**@include** \u{2014} Include file\n\nIncludes another .hl file (DOM nodes + definitions).\n\nUsage: `@include header.hl`"
284+ }
285+ "@import" => {
286+ "**@import** \u{2014} Import definitions\n\nImports `@let`, `@define`, `@fn` from another .hl file without emitting DOM nodes.\n\nUsage: `@import theme.hl`"
287+ }
288+ "@meta" => {
289+ "**@meta** \u{2014} Meta tag\n\nAdds a `<meta>` tag to `<head>`.\n\nUsage: `@meta description A portfolio site`"
290+ }
291+ "@head" => {
292+ "**@head** \u{2014} Head content\n\nAdds raw content to `<head>` (fonts, icons, etc.).\n\n```\n@head\n <link rel=\"icon\" href=\"favicon.ico\">\n```"
293+ }
294+ "@style" => {
295+ "**@style** \u{2014} Custom CSS\n\nAdds raw CSS to the stylesheet.\n\n```\n@style\n .custom { border: 1px solid red; }\n @container sidebar (min-width: 400px) { ... }\n```"
296+ }
297+ "@slot" => {
298+ "**@slot** \u{2014} Named slot\n\nDefines a named insertion point inside `@fn`. Callers fill it with `@slot name` + children.\n\n```\n@fn layout\n @slot header\n @children\n @slot footer\n```"
299+ }
239300 // Semantic elements
240− "@nav" => "**@nav** \u{2014} Navigation\n\nRenders as `<nav>`. Semantic landmark for navigation links.",
241− "@header" => "**@header** \u{2014} Header\n\nRenders as `<header>`. Page or section header.",
242− "@footer" => "**@footer** \u{2014} Footer\n\nRenders as `<footer>`. Page or section footer.",
243− "@main" => "**@main** \u{2014} Main content\n\nRenders as `<main>`. Primary content of the page.",
244− "@section" => "**@section** \u{2014} Section\n\nRenders as `<section>`. Thematic grouping of content.",
245− "@article" => "**@article** \u{2014} Article\n\nRenders as `<article>`. Self-contained, independently distributable content.",
246− "@aside" => "**@aside** \u{2014} Aside\n\nRenders as `<aside>`. Content tangentially related to surrounding content.",
301+ "@nav" => {
302+ "**@nav** \u{2014} Navigation\n\nRenders as `<nav>`. Semantic landmark for navigation links."
303+ }
304+ "@header" => {
305+ "**@header** \u{2014} Header\n\nRenders as `<header>`. Page or section header."
306+ }
307+ "@footer" => {
308+ "**@footer** \u{2014} Footer\n\nRenders as `<footer>`. Page or section footer."
309+ }
310+ "@main" => {
311+ "**@main** \u{2014} Main content\n\nRenders as `<main>`. Primary content of the page."
312+ }
313+ "@section" => {
314+ "**@section** \u{2014} Section\n\nRenders as `<section>`. Thematic grouping of content."
315+ }
316+ "@article" => {
317+ "**@article** \u{2014} Article\n\nRenders as `<article>`. Self-contained, independently distributable content."
318+ }
319+ "@aside" => {
320+ "**@aside** \u{2014} Aside\n\nRenders as `<aside>`. Content tangentially related to surrounding content."
321+ }
247322 // List elements
248− "@list" => "**@list** \u{2014} List\n\nRenders as `<ul>` (or `<ol>` with `[ordered]`).\n\nUsage:\n```\n@list [ordered]\n @item First\n @item Second\n```",
323+ "@list" => {
324+ "**@list** \u{2014} List\n\nRenders as `<ul>` (or `<ol>` with `[ordered]`).\n\nUsage:\n```\n@list [ordered]\n @item First\n @item Second\n```"
325+ }
249326 "@item" | "@li" => "**@item** \u{2014} List item\n\nRenders as `<li>`. Use inside `@list`.",
250327 // Table elements
251− "@table" => "**@table** \u{2014} Table\n\nRenders as `<table>`.\n\n```\n@table\n @thead\n @tr\n @th Name\n @th Age\n @tbody\n @tr\n @td Alice\n @td 30\n```",
328+ "@table" => {
329+ "**@table** \u{2014} Table\n\nRenders as `<table>`.\n\n```\n@table\n @thead\n @tr\n @th Name\n @th Age\n @tbody\n @tr\n @td Alice\n @td 30\n```"
330+ }
252331 "@thead" => "**@thead** \u{2014} Table head\n\nRenders as `<thead>`. Groups header rows.",
253332 "@tbody" => "**@tbody** \u{2014} Table body\n\nRenders as `<tbody>`. Groups body rows.",
254333 "@tr" => "**@tr** \u{2014} Table row\n\nRenders as `<tr>`.",
255334 "@td" => "**@td** \u{2014} Table cell\n\nRenders as `<td>`. Regular table data cell.",
256− "@th" => "**@th** \u{2014} Table header cell\n\nRenders as `<th>`. Header cell (typically bold/centered).",
335+ "@th" => {
336+ "**@th** \u{2014} Table header cell\n\nRenders as `<th>`. Header cell (typically bold/centered)."
337+ }
257338 // Media elements
258− "@video" => "**@video** \u{2014} Video\n\nRenders as `<video>`.\n\nUsage: `@video [controls] demo.mp4`",
259− "@audio" => "**@audio** \u{2014} Audio\n\nRenders as `<audio>`.\n\nUsage: `@audio [controls] song.mp3`",
339+ "@video" => {
340+ "**@video** \u{2014} Video\n\nRenders as `<video>`.\n\nUsage: `@video [controls] demo.mp4`"
341+ }
342+ "@audio" => {
343+ "**@audio** \u{2014} Audio\n\nRenders as `<audio>`.\n\nUsage: `@audio [controls] song.mp3`"
344+ }
260345 // Additional elements
261− "@form" => "**@form** \u{2014} Form\n\nRenders as `<form>`. Container for form elements.\n\nUsage: `@form [method post] /submit`",
262− "@details" => "**@details** \u{2014} Disclosure\n\nRenders as `<details>`. Use `[open]` for initially expanded.\n\nContains `@summary` for the toggle label.",
263− "@summary" => "**@summary** \u{2014} Summary\n\nRenders as `<summary>`. Toggle label inside `@details`.\n\nUsage: `@summary Click to expand`",
264− "@blockquote" => "**@blockquote** \u{2014} Block quotation\n\nRenders as `<blockquote>`. Semantic quotation container.",
265− "@cite" => "**@cite** \u{2014} Citation\n\nRenders as `<cite>`. Source or reference for a quotation.\n\nUsage: `@cite The Great Gatsby`",
266− "@code" => "**@code** \u{2014} Code\n\nRenders as `<code>` with monospace font.\n\nUsage: `@code console.log(\"hello\")`",
267− "@pre" => "**@pre** \u{2014} Preformatted\n\nRenders as `<pre>` with preserved whitespace and monospace font.",
268− "@hr" | "@divider" => "**@hr** \u{2014} Horizontal Rule\n\nRenders as self-closing `<hr>`. Visual divider.\n\nUsage: `@hr [border-top 1 #ccc]`",
269− "@figure" => "**@figure** \u{2014} Figure\n\nRenders as `<figure>`. Container for media with optional `@figcaption`.\n\n```\n@figure\n @image photo.jpg\n @figcaption A nice photo\n```",
270− "@figcaption" => "**@figcaption** \u{2014} Figure caption\n\nRenders as `<figcaption>`. Caption text inside `@figure`.",
271− "@progress" => "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`",
272− "@meter" => "**@meter** \u{2014} Meter\n\nRenders as `<meter>`. Gauge for scalar measurement.\n\nUsage: `@meter [value 0.7, min 0, max 1, low 0.3, high 0.8]`",
273− "@fragment" => "**@fragment** \u{2014} Fragment\n\nGroups children without emitting a wrapper element. Renders children directly in the parent.",
346+ "@form" => {
347+ "**@form** \u{2014} Form\n\nRenders as `<form>`. Container for form elements.\n\nUsage: `@form [method post] /submit`"
348+ }
349+ "@details" => {
350+ "**@details** \u{2014} Disclosure\n\nRenders as `<details>`. Use `[open]` for initially expanded.\n\nContains `@summary` for the toggle label."
351+ }
352+ "@summary" => {
353+ "**@summary** \u{2014} Summary\n\nRenders as `<summary>`. Toggle label inside `@details`.\n\nUsage: `@summary Click to expand`"
354+ }
355+ "@blockquote" => {
356+ "**@blockquote** \u{2014} Block quotation\n\nRenders as `<blockquote>`. Semantic quotation container."
357+ }
358+ "@cite" => {
359+ "**@cite** \u{2014} Citation\n\nRenders as `<cite>`. Source or reference for a quotation.\n\nUsage: `@cite The Great Gatsby`"
360+ }
361+ "@code" => {
362+ "**@code** \u{2014} Code\n\nRenders as `<code>` with monospace font.\n\nUsage: `@code console.log(\"hello\")`"
363+ }
364+ "@pre" => {
365+ "**@pre** \u{2014} Preformatted\n\nRenders as `<pre>` with preserved whitespace and monospace font."
366+ }
367+ "@hr" | "@divider" => {
368+ "**@hr** \u{2014} Horizontal Rule\n\nRenders as self-closing `<hr>`. Visual divider.\n\nUsage: `@hr [border-top 1 #ccc]`"
369+ }
370+ "@figure" => {
371+ "**@figure** \u{2014} Figure\n\nRenders as `<figure>`. Container for media with optional `@figcaption`.\n\n```\n@figure\n @image photo.jpg\n @figcaption A nice photo\n```"
372+ }
373+ "@figcaption" => {
374+ "**@figcaption** \u{2014} Figure caption\n\nRenders as `<figcaption>`. Caption text inside `@figure`."
375+ }
376+ "@progress" => {
377+ "**@progress** \u{2014} Progress bar\n\nRenders as `<progress>`.\n\nUsage: `@progress [value 70, max 100]`"
378+ }
379+ "@meter" => {
380+ "**@meter** \u{2014} Meter\n\nRenders as `<meter>`. Gauge for scalar measurement.\n\nUsage: `@meter [value 0.7, min 0, max 1, low 0.3, high 0.8]`"
381+ }
382+ "@fragment" => {
383+ "**@fragment** \u{2014} Fragment\n\nGroups children without emitting a wrapper element. Renders children directly in the parent."
384+ }
274385 // New elements
275− "@dialog" => "**@dialog** \u{2014} Dialog\n\nRenders as `<dialog>`. Modal or non-modal dialog box.\n\nUsage: `@dialog [open] Dialog content`",
276− "@dl" => "**@dl** \u{2014} Description list\n\nRenders as `<dl>`. Contains `@dt` and `@dd` pairs.",
277− "@dt" => "**@dt** \u{2014} Description term\n\nRenders as `<dt>`. Term in a `@dl` description list.",
278− "@dd" => "**@dd** \u{2014} Description details\n\nRenders as `<dd>`. Details for a `@dt` term.",
279− "@fieldset" => "**@fieldset** \u{2014} Fieldset\n\nRenders as `<fieldset>`. Groups related form elements.\n\nUse `@legend` for a caption.",
280− "@legend" => "**@legend** \u{2014} Legend\n\nRenders as `<legend>`. Caption for a `@fieldset`.",
281− "@picture" => "**@picture** \u{2014} Picture\n\nRenders as `<picture>`. Container for responsive image sources.\n\nUse `@source` children for different media queries.",
282− "@source" => "**@source** \u{2014} Source\n\nRenders as `<source>`. Media source for `@picture`, `@video`, or `@audio`.\n\nUsage: `@source [src image.webp, type image/webp]`",
283− "@time" => "**@time** \u{2014} Time\n\nRenders as `<time>`. Machine-readable date/time.\n\nUsage: `@time [datetime 2024-01-15] January 15`",
386+ "@dialog" => {
387+ "**@dialog** \u{2014} Dialog\n\nRenders as `<dialog>`. Modal or non-modal dialog box.\n\nUsage: `@dialog [open] Dialog content`"
388+ }
389+ "@dl" => {
390+ "**@dl** \u{2014} Description list\n\nRenders as `<dl>`. Contains `@dt` and `@dd` pairs."
391+ }
392+ "@dt" => {
393+ "**@dt** \u{2014} Description term\n\nRenders as `<dt>`. Term in a `@dl` description list."
394+ }
395+ "@dd" => {
396+ "**@dd** \u{2014} Description details\n\nRenders as `<dd>`. Details for a `@dt` term."
397+ }
398+ "@fieldset" => {
399+ "**@fieldset** \u{2014} Fieldset\n\nRenders as `<fieldset>`. Groups related form elements.\n\nUse `@legend` for a caption."
400+ }
401+ "@legend" => {
402+ "**@legend** \u{2014} Legend\n\nRenders as `<legend>`. Caption for a `@fieldset`."
403+ }
404+ "@picture" => {
405+ "**@picture** \u{2014} Picture\n\nRenders as `<picture>`. Container for responsive image sources.\n\nUse `@source` children for different media queries."
406+ }
407+ "@source" => {
408+ "**@source** \u{2014} Source\n\nRenders as `<source>`. Media source for `@picture`, `@video`, or `@audio`.\n\nUsage: `@source [src image.webp, type image/webp]`"
409+ }
410+ "@time" => {
411+ "**@time** \u{2014} Time\n\nRenders as `<time>`. Machine-readable date/time.\n\nUsage: `@time [datetime 2024-01-15] January 15`"
412+ }
284413 "@mark" => "**@mark** \u{2014} Mark\n\nRenders as `<mark>`. Highlighted or marked text.",
285− "@kbd" => "**@kbd** \u{2014} Keyboard input\n\nRenders as `<kbd>`. Represents keyboard input.\n\nUsage: `@kbd Ctrl+C`",
286− "@abbr" => "**@abbr** \u{2014} Abbreviation\n\nRenders as `<abbr>`. Abbreviation with optional title.\n\nUsage: `@abbr [title Hypertext Markup Language] HTML`",
287− "@datalist" => "**@datalist** \u{2014} Datalist\n\nRenders as `<datalist>`. Provides predefined options for `@input`.\n\nUsage: `@datalist [id colors]`",
414+ "@kbd" => {
415+ "**@kbd** \u{2014} Keyboard input\n\nRenders as `<kbd>`. Represents keyboard input.\n\nUsage: `@kbd Ctrl+C`"
416+ }
417+ "@abbr" => {
418+ "**@abbr** \u{2014} Abbreviation\n\nRenders as `<abbr>`. Abbreviation with optional title.\n\nUsage: `@abbr [title Hypertext Markup Language] HTML`"
419+ }
420+ "@datalist" => {
421+ "**@datalist** \u{2014} Datalist\n\nRenders as `<datalist>`. Provides predefined options for `@input`.\n\nUsage: `@datalist [id colors]`"
422+ }
288423 // Directives
289− "@match" => "**@match** \u{2014} Pattern matching\n\nMatch a value against cases.\n\n```\n@match $theme\n @case dark\n @el [background #333]\n @case light\n @el [background white]\n @default\n @el [background gray]\n```",
290− "@case" => "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value.",
291− "@default" => "**@default** \u{2014} Default case\n\nFallback case inside `@match` when no other case matches.",
292− "@warn" => "**@warn** \u{2014} Compile warning\n\nEmit a custom warning during compilation.\n\nUsage: `@warn This value is deprecated`",
293− "@debug" => "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`",
294− "@lang" => "**@lang** \u{2014} Document language\n\nSets the `lang` attribute on the `<html>` element.\n\nUsage: `@lang en`",
295− "@favicon" => "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`",
296− "@unless" => "**@unless** \u{2014} Inverse conditional\n\nRenders children when the condition is false (opposite of `@if`).\n\nUsage: `@unless $debug`",
297− "@og" => "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`",
298− "@breakpoint" => "**@breakpoint** \u{2014} Custom breakpoint\n\nDefines a custom responsive breakpoint.\n\nUsage: `@breakpoint tablet 600`",
299− "@theme" => "**@theme** \u{2014} Design tokens\n\nDefines centralized design tokens (colors, spacing, fonts).\nEach token becomes both a `$variable` and a `--css-custom-property`.\n\n```\n@theme\n primary #3b82f6\n spacing-md 16\n font-body system-ui, sans-serif\n```",
300− "@deprecated" => "**@deprecated** `<message>`\n\nMarks the next `@fn` as deprecated. Callers get a compile-time warning.\n\n```\n@deprecated Use @new-card instead\n@fn old-card $title\n ...\n```",
301− "@extends" => "**@extends** `<file.hl>`\n\nInherit a layout template. Fill named `@slot` blocks.\n\n```\n@extends layout.hl\n@slot content\n My page content\n@slot sidebar\n Sidebar content\n```",
302− "@use" => "**@use** `<file.hl> name1, name2`\n\nSelective import: only imports named `@fn`/`@define` definitions.\n\n```\n@use components.hl card, button\n```",
303− "@canonical" => "**@canonical** `<url>`\n\nSets the canonical URL for the page. Adds `<link rel=\"canonical\">` to `<head>`.\n\nUsage: `@canonical https://example.com/page`",
304− "@base" => "**@base** `<url>`\n\nSets the base URL for all relative URLs in the document. Adds `<base>` to `<head>`.\n\nUsage: `@base https://example.com/`",
305− "@font-face" => "**@font-face** \u{2014} Custom font\n\nDefines a custom font face. Generates a CSS `@font-face` rule.\n\n```\n@font-face\n family Inter\n src url(/fonts/Inter.woff2)\n weight 400 700\n```",
306− "@json-ld" => "**@json-ld** \u{2014} Structured data\n\nAdds JSON-LD structured data to `<head>` as `<script type=\"application/ld+json\">`.\n\n```\n@json-ld\n type Organization\n name My Company\n url https://example.com\n```",
424+ "@match" => {
425+ "**@match** \u{2014} Pattern matching\n\nMatch a value against cases.\n\n```\n@match $theme\n @case dark\n @el [background #333]\n @case light\n @el [background white]\n @default\n @el [background gray]\n```"
426+ }
427+ "@case" => {
428+ "**@case** \u{2014} Match case\n\nA case inside `@match`. Matches when the value equals the case value."
429+ }
430+ "@default" => {
431+ "**@default** \u{2014} Default case\n\nFallback case inside `@match` when no other case matches."
432+ }
433+ "@warn" => {
434+ "**@warn** \u{2014} Compile warning\n\nEmit a custom warning during compilation.\n\nUsage: `@warn This value is deprecated`"
435+ }
436+ "@debug" => {
437+ "**@debug** \u{2014} Debug message\n\nPrint a debug message to stderr during compilation.\n\nUsage: `@debug Theme is $theme`"
438+ }
439+ "@lang" => {
440+ "**@lang** \u{2014} Document language\n\nSets the `lang` attribute on the `<html>` element.\n\nUsage: `@lang en`"
441+ }
442+ "@favicon" => {
443+ "**@favicon** \u{2014} Favicon\n\nInlines a favicon as a base64 data URI in the `<head>`.\n\nUsage: `@favicon favicon.png`"
444+ }
445+ "@unless" => {
446+ "**@unless** \u{2014} Inverse conditional\n\nRenders children when the condition is false (opposite of `@if`).\n\nUsage: `@unless $debug`"
447+ }
448+ "@og" => {
449+ "**@og** \u{2014} Open Graph meta tag\n\nAdds an Open Graph `<meta>` tag to `<head>`.\n\nUsage: `@og title My Page Title`"
450+ }
451+ "@breakpoint" => {
452+ "**@breakpoint** \u{2014} Custom breakpoint\n\nDefines a custom responsive breakpoint.\n\nUsage: `@breakpoint tablet 600`"
453+ }
454+ "@theme" => {
455+ "**@theme** \u{2014} Design tokens\n\nDefines centralized design tokens (colors, spacing, fonts).\nEach token becomes both a `$variable` and a `--css-custom-property`.\n\n```\n@theme\n primary #3b82f6\n spacing-md 16\n font-body system-ui, sans-serif\n```"
456+ }
457+ "@deprecated" => {
458+ "**@deprecated** `<message>`\n\nMarks the next `@fn` as deprecated. Callers get a compile-time warning.\n\n```\n@deprecated Use @new-card instead\n@fn old-card $title\n ...\n```"
459+ }
460+ "@extends" => {
461+ "**@extends** `<file.hl>`\n\nInherit a layout template. Fill named `@slot` blocks.\n\n```\n@extends layout.hl\n@slot content\n My page content\n@slot sidebar\n Sidebar content\n```"
462+ }
463+ "@use" => {
464+ "**@use** `<file.hl> name1, name2`\n\nSelective import: only imports named `@fn`/`@define` definitions.\n\n```\n@use components.hl card, button\n```"
465+ }
466+ "@canonical" => {
467+ "**@canonical** `<url>`\n\nSets the canonical URL for the page. Adds `<link rel=\"canonical\">` to `<head>`.\n\nUsage: `@canonical https://example.com/page`"
468+ }
469+ "@base" => {
470+ "**@base** `<url>`\n\nSets the base URL for all relative URLs in the document. Adds `<base>` to `<head>`.\n\nUsage: `@base https://example.com/`"
471+ }
472+ "@font-face" => {
473+ "**@font-face** \u{2014} Custom font\n\nDefines a custom font face. Generates a CSS `@font-face` rule.\n\n```\n@font-face\n family Inter\n src url(/fonts/Inter.woff2)\n weight 400 700\n```"
474+ }
475+ "@json-ld" => {
476+ "**@json-ld** \u{2014} Structured data\n\nAdds JSON-LD structured data to `<head>` as `<script type=\"application/ld+json\">`.\n\n```\n@json-ld\n type Organization\n name My Company\n url https://example.com\n```"
477+ }
307478 // Attributes
308− "spacing" | "gap" => "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`.",
309− "padding" => "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values.",
310− "padding-x" => "**padding-x** `<value>`\n\nHorizontal padding (left + right). Supports CSS units.",
311− "padding-y" => "**padding-y** `<value>`\n\nVertical padding (top + bottom). Supports CSS units.",
312− "width" => "**width** `<value>` | `fill` | `shrink`\n\n- Number/unit: fixed width (e.g., `300`, `50%`, `80ch`)\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
313− "height" => "**height** `<value>` | `fill` | `shrink`\n\n- Number/unit: fixed height (e.g., `300`, `100vh`)\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking",
479+ "spacing" | "gap" => {
480+ "**spacing** `<value>`\n\nGap between children. Supports CSS units (px, rem, em, %).\nMaps to CSS `gap`."
481+ }
482+ "padding" => {
483+ "**padding** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nInner padding. Supports CSS units. Accepts 1\u{2013}4 values."
484+ }
485+ "padding-x" => {
486+ "**padding-x** `<value>`\n\nHorizontal padding (left + right). Supports CSS units."
487+ }
488+ "padding-y" => {
489+ "**padding-y** `<value>`\n\nVertical padding (top + bottom). Supports CSS units."
490+ }
491+ "width" => {
492+ "**width** `<value>` | `fill` | `shrink`\n\n- Number/unit: fixed width (e.g., `300`, `50%`, `80ch`)\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking"
493+ }
494+ "height" => {
495+ "**height** `<value>` | `fill` | `shrink`\n\n- Number/unit: fixed height (e.g., `300`, `100vh`)\n- `fill`: expand to fill parent\n- `shrink`: prevent flex shrinking"
496+ }
314497 "min-width" => "**min-width** `<value>` \u{2014} Minimum width. Supports CSS units.",
315498 "max-width" => "**max-width** `<value>` \u{2014} Maximum width. Supports CSS units.",
316499 "min-height" => "**min-height** `<value>` \u{2014} Minimum height. Supports CSS units.",
317500 "max-height" => "**max-height** `<value>` \u{2014} Maximum height. Supports CSS units.",
318− "center-x" => "**center-x**\n\nCenter horizontally.\n\nIn column parent: `align-self: center`\nOtherwise: auto margins.",
319− "center-y" => "**center-y**\n\nCenter vertically.\n\nIn row parent: `align-self: center`\nOtherwise: auto margins.",
501+ "center-x" => {
502+ "**center-x**\n\nCenter horizontally.\n\nIn column parent: `align-self: center`\nOtherwise: auto margins."
503+ }
504+ "center-y" => {
505+ "**center-y**\n\nCenter vertically.\n\nIn row parent: `align-self: center`\nOtherwise: auto margins."
506+ }
320507 "align-left" => "**align-left** \u{2014} Align to the left edge.",
321508 "align-right" => "**align-right** \u{2014} Align to the right edge.",
322509 "align-top" => "**align-top** \u{2014} Align to the top edge.",
323510 "align-bottom" => "**align-bottom** \u{2014} Align to the bottom edge.",
324− "background" => "**background** `<color>` \u{2014} Background color or CSS background value.",
511+ "background" => {
512+ "**background** `<color>` \u{2014} Background color or CSS background value."
513+ }
325514 "color" => "**color** `<color>` \u{2014} Text color.",
326− "border" => "**border** `<width> [color]`\n\nBorder. Width in pixels, color defaults to `currentColor`.",
515+ "border" => {
516+ "**border** `<width> [color]`\n\nBorder. Width in pixels, color defaults to `currentColor`."
517+ }
327518 "border-top" => "**border-top** `<width> [color]` \u{2014} Top border.",
328519 "border-bottom" => "**border-bottom** `<width> [color]` \u{2014} Bottom border.",
329520 "border-left" => "**border-left** `<width> [color]` \u{2014} Left border.",
@@ -334,42 +525,84 @@ fn hover_builtin(word: &str) -> Option<String> {
334525 "underline" => "**underline** \u{2014} Underlined text.",
335526 "size" => "**size** `<value>` \u{2014} Font size in pixels.",
336527 "font" => "**font** `<family>` \u{2014} Font family.",
337− "transition" => "**transition** `<value>` \u{2014} CSS transition (e.g., `all 0.15s ease`).",
528+ "transition" => {
529+ "**transition** `<value>` \u{2014} CSS transition (e.g., `all 0.15s ease`)."
530+ }
338531 "cursor" => "**cursor** `<value>` \u{2014} CSS cursor (e.g., `pointer`).",
339532 "opacity" => "**opacity** `<value>` \u{2014} Opacity from 0 to 1.",
340− "text-align" => "**text-align** `<value>` \u{2014} Text alignment (`left`, `center`, `right`, `justify`).",
341− "line-height" => "**line-height** `<value>` \u{2014} Line height. Unitless (e.g., `1.5`) or pixels.",
342− "overflow" => "**overflow** `<value>` \u{2014} Overflow behavior (`hidden`, `scroll`, `auto`, `visible`).",
343− "position" => "**position** `<value>` \u{2014} Position type (`relative`, `absolute`, `fixed`, `sticky`).",
533+ "text-align" => {
534+ "**text-align** `<value>` \u{2014} Text alignment (`left`, `center`, `right`, `justify`)."
535+ }
536+ "line-height" => {
537+ "**line-height** `<value>` \u{2014} Line height. Unitless (e.g., `1.5`) or pixels."
538+ }
539+ "overflow" => {
540+ "**overflow** `<value>` \u{2014} Overflow behavior (`hidden`, `scroll`, `auto`, `visible`)."
541+ }
542+ "position" => {
543+ "**position** `<value>` \u{2014} Position type (`relative`, `absolute`, `fixed`, `sticky`)."
544+ }
344545 "top" => "**top** `<value>` \u{2014} Top offset for positioned elements.",
345546 "right" => "**right** `<value>` \u{2014} Right offset for positioned elements.",
346547 "bottom" => "**bottom** `<value>` \u{2014} Bottom offset for positioned elements.",
347548 "left" => "**left** `<value>` \u{2014} Left offset for positioned elements.",
348549 "z-index" => "**z-index** `<value>` \u{2014} Stack order (integer).",
349− "display" => "**display** `<value>` \u{2014} Display mode (`none`, `block`, `inline`, `flex`, `grid`).",
550+ "display" => {
551+ "**display** `<value>` \u{2014} Display mode (`none`, `block`, `inline`, `flex`, `grid`)."
552+ }
350553 "visibility" => "**visibility** `<value>` \u{2014} Visibility (`visible`, `hidden`).",
351− "transform" => "**transform** `<value>` \u{2014} CSS transform (e.g., `rotate(45deg)`, `scale(1.5)`).",
352− "backdrop-filter" => "**backdrop-filter** `<value>` \u{2014} Backdrop filter (e.g., `blur(10px)`).",
353− "letter-spacing" => "**letter-spacing** `<value>` \u{2014} Letter spacing. Supports CSS units.",
354− "text-transform" => "**text-transform** `<value>` \u{2014} Text transform (`uppercase`, `lowercase`, `capitalize`).",
355− "white-space" => "**white-space** `<value>` \u{2014} White-space behavior (`nowrap`, `pre`, `normal`).",
554+ "transform" => {
555+ "**transform** `<value>` \u{2014} CSS transform (e.g., `rotate(45deg)`, `scale(1.5)`)."
556+ }
557+ "backdrop-filter" => {
558+ "**backdrop-filter** `<value>` \u{2014} Backdrop filter (e.g., `blur(10px)`)."
559+ }
560+ "letter-spacing" => {
561+ "**letter-spacing** `<value>` \u{2014} Letter spacing. Supports CSS units."
562+ }
563+ "text-transform" => {
564+ "**text-transform** `<value>` \u{2014} Text transform (`uppercase`, `lowercase`, `capitalize`)."
565+ }
566+ "white-space" => {
567+ "**white-space** `<value>` \u{2014} White-space behavior (`nowrap`, `pre`, `normal`)."
568+ }
356569 "grid" => "**grid** \u{2014} Enable CSS grid layout on this element.",
357− "grid-cols" => "**grid-cols** `<value>` \u{2014} Grid template columns. Number for equal columns, or CSS value.",
358− "grid-rows" => "**grid-rows** `<value>` \u{2014} Grid template rows. Number for equal rows, or CSS value.",
570+ "grid-cols" => {
571+ "**grid-cols** `<value>` \u{2014} Grid template columns. Number for equal columns, or CSS value."
572+ }
573+ "grid-rows" => {
574+ "**grid-rows** `<value>` \u{2014} Grid template rows. Number for equal rows, or CSS value."
575+ }
359576 "col-span" => "**col-span** `<value>` \u{2014} Span N columns in a grid.",
360577 "row-span" => "**row-span** `<value>` \u{2014} Span N rows in a grid.",
361− "shadow" => "**shadow** `<value>` \u{2014} Box shadow. Raw CSS value (e.g., `0 2px 4px rgba(0,0,0,0.1)`).",
362− "gap-x" => "**gap-x** `<value>` \u{2014} Horizontal gap between children in pixels. Maps to `column-gap`.",
363− "gap-y" => "**gap-y** `<value>` \u{2014} Vertical gap between children in pixels. Maps to `row-gap`.",
578+ "shadow" => {
579+ "**shadow** `<value>` \u{2014} Box shadow. Raw CSS value (e.g., `0 2px 4px rgba(0,0,0,0.1)`)."
580+ }
581+ "gap-x" => {
582+ "**gap-x** `<value>` \u{2014} Horizontal gap between children in pixels. Maps to `column-gap`."
583+ }
584+ "gap-y" => {
585+ "**gap-y** `<value>` \u{2014} Vertical gap between children in pixels. Maps to `row-gap`."
586+ }
364587 "wrap" => "**wrap** \u{2014} Enable flex-wrap for children.",
365588 "id" => "**id** `<value>` \u{2014} HTML id attribute.",
366589 "class" => "**class** `<value>` \u{2014} HTML class attribute.",
367− "animation" => "**animation** `<value>` \u{2014} CSS animation shorthand (e.g., `fade-in 0.3s ease`).\n\nDefine animations with `@keyframes`.",
368− "container" => "**container** \u{2014} Enable container queries (`container-type: inline-size`).",
369− "container-name" => "**container-name** `<value>` \u{2014} Name this container for `@container` queries.",
370− "container-type" => "**container-type** `<value>` \u{2014} Container type (`inline-size`, `size`, `normal`).",
590+ "animation" => {
591+ "**animation** `<value>` \u{2014} CSS animation shorthand (e.g., `fade-in 0.3s ease`).\n\nDefine animations with `@keyframes`."
592+ }
593+ "container" => {
594+ "**container** \u{2014} Enable container queries (`container-type: inline-size`)."
595+ }
596+ "container-name" => {
597+ "**container-name** `<value>` \u{2014} Name this container for `@container` queries."
598+ }
599+ "container-type" => {
600+ "**container-type** `<value>` \u{2014} Container type (`inline-size`, `size`, `normal`)."
601+ }
371602 // Form attributes
372− "type" => "**type** `<value>` \u{2014} Input type (`text`, `email`, `password`, `submit`, etc.).",
603+ "type" => {
604+ "**type** `<value>` \u{2014} Input type (`text`, `email`, `password`, `submit`, etc.)."
605+ }
373606 "placeholder" => "**placeholder** `<value>` \u{2014} Placeholder text for inputs.",
374607 "name" => "**name** `<value>` \u{2014} Form field name.",
375608 "value" => "**value** `<value>` \u{2014} Form field value.",
@@ -383,47 +616,101 @@ fn hover_builtin(word: &str) -> Option<String> {
383616 // Accessibility
384617 "alt" => "**alt** `<value>` \u{2014} Alternative text for images.",
385618 "role" => "**role** `<value>` \u{2014} ARIA role (e.g., `navigation`, `banner`, `main`).",
386− "tabindex" => "**tabindex** `<value>` \u{2014} Tab order. `0` = natural order, `-1` = skip.",
619+ "tabindex" => {
620+ "**tabindex** `<value>` \u{2014} Tab order. `0` = natural order, `-1` = skip."
621+ }
387622 "title" => "**title** `<value>` \u{2014} Tooltip text.",
388623 // New CSS attributes
389− "aspect-ratio" => "**aspect-ratio** `<value>` \u{2014} CSS aspect ratio (e.g., `16/9`, `1`, `4/3`).",
390− "outline" => "**outline** `<width> [color]` \u{2014} Outline (like border but doesn't affect layout).",
391− "padding-inline" => "**padding-inline** `<value>` \u{2014} Horizontal padding (logical property, i18n-aware).",
392− "padding-block" => "**padding-block** `<value>` \u{2014} Vertical padding (logical property, i18n-aware).",
393− "margin-inline" => "**margin-inline** `<value>` \u{2014} Horizontal margin (logical property, i18n-aware).",
394− "margin-block" => "**margin-block** `<value>` \u{2014} Vertical margin (logical property, i18n-aware).",
395− "scroll-snap-type" => "**scroll-snap-type** `<value>` \u{2014} Scroll snap type (`x mandatory`, `y proximity`).",
396− "scroll-snap-align" => "**scroll-snap-align** `<value>` \u{2014} Scroll snap alignment (`start`, `center`, `end`).",
624+ "aspect-ratio" => {
625+ "**aspect-ratio** `<value>` \u{2014} CSS aspect ratio (e.g., `16/9`, `1`, `4/3`)."
626+ }
627+ "outline" => {
628+ "**outline** `<width> [color]` \u{2014} Outline (like border but doesn't affect layout)."
629+ }
630+ "padding-inline" => {
631+ "**padding-inline** `<value>` \u{2014} Horizontal padding (logical property, i18n-aware)."
632+ }
633+ "padding-block" => {
634+ "**padding-block** `<value>` \u{2014} Vertical padding (logical property, i18n-aware)."
635+ }
636+ "margin-inline" => {
637+ "**margin-inline** `<value>` \u{2014} Horizontal margin (logical property, i18n-aware)."
638+ }
639+ "margin-block" => {
640+ "**margin-block** `<value>` \u{2014} Vertical margin (logical property, i18n-aware)."
641+ }
642+ "scroll-snap-type" => {
643+ "**scroll-snap-type** `<value>` \u{2014} Scroll snap type (`x mandatory`, `y proximity`)."
644+ }
645+ "scroll-snap-align" => {
646+ "**scroll-snap-align** `<value>` \u{2014} Scroll snap alignment (`start`, `center`, `end`)."
647+ }
397648 // Media/image attributes
398− "loading" => "**loading** `<value>` \u{2014} Loading behavior for images (`lazy`, `eager`).",
399− "decoding" => "**decoding** `<value>` \u{2014} Image decoding mode (`async`, `sync`, `auto`).",
649+ "loading" => {
650+ "**loading** `<value>` \u{2014} Loading behavior for images (`lazy`, `eager`)."
651+ }
652+ "decoding" => {
653+ "**decoding** `<value>` \u{2014} Image decoding mode (`async`, `sync`, `auto`)."
654+ }
400655 "controls" => "**controls** \u{2014} Show media controls (for @video, @audio).",
401656 "autoplay" => "**autoplay** \u{2014} Auto-play media.",
402657 "loop" => "**loop** \u{2014} Loop media playback.",
403658 "muted" => "**muted** \u{2014} Mute media.",
404659 "poster" => "**poster** `<url>` \u{2014} Poster image for video.",
405− "preload" => "**preload** `<value>` \u{2014} Media preload hint (`auto`, `metadata`, `none`).",
660+ "preload" => {
661+ "**preload** `<value>` \u{2014} Media preload hint (`auto`, `metadata`, `none`)."
662+ }
406663 "ordered" => "**ordered** \u{2014} Use ordered list (`<ol>` instead of `<ul>`).",
407664 "src" => "**src** `<url>` \u{2014} Source URL for media elements.",
408665 // New CSS attributes
409− "margin" => "**margin** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nOuter margin. Supports CSS units. Accepts 1\u{2013}4 values.",
666+ "margin" => {
667+ "**margin** `<value>` | `<y> <x>` | `<t> <h> <b>` | `<t> <r> <b> <l>`\n\nOuter margin. Supports CSS units. Accepts 1\u{2013}4 values."
668+ }
410669 "margin-x" => "**margin-x** `<value>` \u{2014} Horizontal margin (left + right).",
411670 "margin-y" => "**margin-y** `<value>` \u{2014} Vertical margin (top + bottom).",
412− "filter" => "**filter** `<value>` \u{2014} CSS filter (e.g., `blur(5px)`, `brightness(1.2)`, `grayscale(1)`).",
413− "object-fit" => "**object-fit** `<value>` \u{2014} How content fits its container (`cover`, `contain`, `fill`, `none`, `scale-down`).",
414− "object-position" => "**object-position** `<value>` \u{2014} Position of content within container (e.g., `center`, `top left`).",
415− "text-shadow" => "**text-shadow** `<value>` \u{2014} Text shadow. Raw CSS value (e.g., `1px 1px 2px rgba(0,0,0,0.3)`).",
416− "text-overflow" => "**text-overflow** `<value>` \u{2014} Text overflow behavior (`ellipsis`, `clip`). Combine with `white-space nowrap` and `overflow hidden`.",
417− "pointer-events" => "**pointer-events** `<value>` \u{2014} Pointer event behavior (`none`, `auto`).",
418− "user-select" => "**user-select** `<value>` \u{2014} Text selection behavior (`none`, `text`, `all`, `auto`).",
419− "justify-content" => "**justify-content** `<value>` \u{2014} Main axis alignment (`center`, `space-between`, `space-around`, `flex-start`, `flex-end`).",
420− "align-items" => "**align-items** `<value>` \u{2014} Cross axis alignment (`center`, `flex-start`, `flex-end`, `stretch`, `baseline`).",
671+ "filter" => {
672+ "**filter** `<value>` \u{2014} CSS filter (e.g., `blur(5px)`, `brightness(1.2)`, `grayscale(1)`)."
673+ }
674+ "object-fit" => {
675+ "**object-fit** `<value>` \u{2014} How content fits its container (`cover`, `contain`, `fill`, `none`, `scale-down`)."
676+ }
677+ "object-position" => {
678+ "**object-position** `<value>` \u{2014} Position of content within container (e.g., `center`, `top left`)."
679+ }
680+ "text-shadow" => {
681+ "**text-shadow** `<value>` \u{2014} Text shadow. Raw CSS value (e.g., `1px 1px 2px rgba(0,0,0,0.3)`)."
682+ }
683+ "text-overflow" => {
684+ "**text-overflow** `<value>` \u{2014} Text overflow behavior (`ellipsis`, `clip`). Combine with `white-space nowrap` and `overflow hidden`."
685+ }
686+ "pointer-events" => {
687+ "**pointer-events** `<value>` \u{2014} Pointer event behavior (`none`, `auto`)."
688+ }
689+ "user-select" => {
690+ "**user-select** `<value>` \u{2014} Text selection behavior (`none`, `text`, `all`, `auto`)."
691+ }
692+ "justify-content" => {
693+ "**justify-content** `<value>` \u{2014} Main axis alignment (`center`, `space-between`, `space-around`, `flex-start`, `flex-end`)."
694+ }
695+ "align-items" => {
696+ "**align-items** `<value>` \u{2014} Cross axis alignment (`center`, `flex-start`, `flex-end`, `stretch`, `baseline`)."
697+ }
421698 "order" => "**order** `<value>` \u{2014} Flex/grid item order (integer).",
422− "background-size" => "**background-size** `<value>` \u{2014} Background size (`cover`, `contain`, `auto`, or dimensions).",
423− "background-position" => "**background-position** `<value>` \u{2014} Background position (`center`, `top`, `bottom left`, etc.).",
424− "background-repeat" => "**background-repeat** `<value>` \u{2014} Background repeat (`no-repeat`, `repeat`, `repeat-x`, `repeat-y`).",
425− "word-break" => "**word-break** `<value>` \u{2014} Word breaking behavior (`break-all`, `keep-all`, `normal`).",
426− "overflow-wrap" => "**overflow-wrap** `<value>` \u{2014} Overflow wrapping (`break-word`, `anywhere`, `normal`).",
699+ "background-size" => {
700+ "**background-size** `<value>` \u{2014} Background size (`cover`, `contain`, `auto`, or dimensions)."
701+ }
702+ "background-position" => {
703+ "**background-position** `<value>` \u{2014} Background position (`center`, `top`, `bottom left`, etc.)."
704+ }
705+ "background-repeat" => {
706+ "**background-repeat** `<value>` \u{2014} Background repeat (`no-repeat`, `repeat`, `repeat-x`, `repeat-y`)."
707+ }
708+ "word-break" => {
709+ "**word-break** `<value>` \u{2014} Word breaking behavior (`break-all`, `keep-all`, `normal`)."
710+ }
711+ "overflow-wrap" => {
712+ "**overflow-wrap** `<value>` \u{2014} Overflow wrapping (`break-word`, `anywhere`, `normal`)."
713+ }
427714 // New element attributes
428715 "open" => "**open** \u{2014} Initially expand `@details` element.",
429716 "novalidate" => "**novalidate** \u{2014} Disable form validation.",
@@ -432,110 +719,282 @@ fn hover_builtin(word: &str) -> Option<String> {
432719 "optimum" => "**optimum** `<value>` \u{2014} Optimum value for `@meter`.",
433720 "colspan" => "**colspan** `<value>` \u{2014} Number of columns a cell spans.",
434721 "rowspan" => "**rowspan** `<value>` \u{2014} Number of rows a cell spans.",
435− "scope" => "**scope** `<value>` \u{2014} Header scope (`col`, `row`, `colgroup`, `rowgroup`).",
722+ "scope" => {
723+ "**scope** `<value>` \u{2014} Header scope (`col`, `row`, `colgroup`, `rowgroup`)."
724+ }
436725 "inline" => "**inline** \u{2014} Inline SVG image content into the HTML output.",
437− "color-scheme" => "**color-scheme** `<value>` \u{2014} Color scheme preference (`light`, `dark`, `light dark`).",
438− "appearance" => "**appearance** `<value>` \u{2014} Form element appearance (`none` to remove native styling).",
439− "popover" => "**popover** \u{2014} Make element a popover (HTML Popover API). Shown/hidden declaratively.",
726+ "color-scheme" => {
727+ "**color-scheme** `<value>` \u{2014} Color scheme preference (`light`, `dark`, `light dark`)."
728+ }
729+ "appearance" => {
730+ "**appearance** `<value>` \u{2014} Form element appearance (`none` to remove native styling)."
731+ }
732+ "popover" => {
733+ "**popover** \u{2014} Make element a popover (HTML Popover API). Shown/hidden declaratively."
734+ }
440735 "popovertarget" => "**popovertarget** `<id>` \u{2014} ID of the popover element to toggle.",
441− "popovertargetaction" => "**popovertargetaction** `<value>` \u{2014} Popover action (`toggle`, `show`, `hide`).",
442− "inputmode" => "**inputmode** `<value>` \u{2014} Virtual keyboard type (`numeric`, `email`, `search`, `tel`, `url`).",
443− "enterkeyhint" => "**enterkeyhint** `<value>` \u{2014} Enter key label (`done`, `go`, `next`, `search`, `send`).",
444− "fetchpriority" => "**fetchpriority** `<value>` \u{2014} Resource fetch priority (`high`, `low`, `auto`).",
445− "translate" => "**translate** `<value>` \u{2014} Whether element should be translated (`yes`, `no`).",
736+ "popovertargetaction" => {
737+ "**popovertargetaction** `<value>` \u{2014} Popover action (`toggle`, `show`, `hide`)."
738+ }
739+ "inputmode" => {
740+ "**inputmode** `<value>` \u{2014} Virtual keyboard type (`numeric`, `email`, `search`, `tel`, `url`)."
741+ }
742+ "enterkeyhint" => {
743+ "**enterkeyhint** `<value>` \u{2014} Enter key label (`done`, `go`, `next`, `search`, `send`)."
744+ }
745+ "fetchpriority" => {
746+ "**fetchpriority** `<value>` \u{2014} Resource fetch priority (`high`, `low`, `auto`)."
747+ }
748+ "translate" => {
749+ "**translate** `<value>` \u{2014} Whether element should be translated (`yes`, `no`)."
750+ }
446751 "spellcheck" => "**spellcheck** `<value>` \u{2014} Spell check mode (`true`, `false`).",
447752 "hidden" => "**hidden** \u{2014} Hide element (`display: none`).",
448− "overflow-x" => "**overflow-x** `<value>` \u{2014} Horizontal overflow (`hidden`, `scroll`, `auto`, `visible`).",
449− "overflow-y" => "**overflow-y** `<value>` \u{2014} Vertical overflow (`hidden`, `scroll`, `auto`, `visible`).",
450− "inset" => "**inset** `<value>` \u{2014} Shorthand for `top`, `right`, `bottom`, `left`. Maps to CSS `inset`.",
451− "accent-color" => "**accent-color** `<color>` \u{2014} Accent color for form controls (checkboxes, radios, range).",
753+ "overflow-x" => {
754+ "**overflow-x** `<value>` \u{2014} Horizontal overflow (`hidden`, `scroll`, `auto`, `visible`)."
755+ }
756+ "overflow-y" => {
757+ "**overflow-y** `<value>` \u{2014} Vertical overflow (`hidden`, `scroll`, `auto`, `visible`)."
758+ }
759+ "inset" => {
760+ "**inset** `<value>` \u{2014} Shorthand for `top`, `right`, `bottom`, `left`. Maps to CSS `inset`."
761+ }
762+ "accent-color" => {
763+ "**accent-color** `<color>` \u{2014} Accent color for form controls (checkboxes, radios, range)."
764+ }
452765 "caret-color" => "**caret-color** `<color>` \u{2014} Color of the text input cursor.",
453− "list-style" => "**list-style** `<value>` \u{2014} List style type (`disc`, `circle`, `square`, `decimal`, `none`).",
454− "border-collapse" => "**border-collapse** `<value>` \u{2014} Table border model (`collapse`, `separate`).",
455− "border-spacing" => "**border-spacing** `<value>` \u{2014} Spacing between table cell borders (when `border-collapse: separate`).",
456− "text-decoration" => "**text-decoration** `<value>` \u{2014} Text decoration (`underline`, `overline`, `line-through`, `none`).",
457− "text-decoration-color" => "**text-decoration-color** `<color>` \u{2014} Color of text decoration.",
458− "text-decoration-thickness" => "**text-decoration-thickness** `<value>` \u{2014} Thickness of text decoration.",
459− "text-decoration-style" => "**text-decoration-style** `<value>` \u{2014} Style of text decoration (`solid`, `dashed`, `dotted`, `wavy`, `double`).",
460− "place-items" => "**place-items** `<value>` \u{2014} Shorthand for `align-items` and `justify-items`.",
461− "place-self" => "**place-self** `<value>` \u{2014} Shorthand for `align-self` and `justify-self`.",
462− "scroll-behavior" => "**scroll-behavior** `<value>` \u{2014} Scroll behavior (`smooth`, `auto`).",
463− "resize" => "**resize** `<value>` \u{2014} Resize behavior (`none`, `both`, `horizontal`, `vertical`).",
766+ "list-style" => {
767+ "**list-style** `<value>` \u{2014} List style type (`disc`, `circle`, `square`, `decimal`, `none`)."
768+ }
769+ "border-collapse" => {
770+ "**border-collapse** `<value>` \u{2014} Table border model (`collapse`, `separate`)."
771+ }
772+ "border-spacing" => {
773+ "**border-spacing** `<value>` \u{2014} Spacing between table cell borders (when `border-collapse: separate`)."
774+ }
775+ "text-decoration" => {
776+ "**text-decoration** `<value>` \u{2014} Text decoration (`underline`, `overline`, `line-through`, `none`)."
777+ }
778+ "text-decoration-color" => {
779+ "**text-decoration-color** `<color>` \u{2014} Color of text decoration."
780+ }
781+ "text-decoration-thickness" => {
782+ "**text-decoration-thickness** `<value>` \u{2014} Thickness of text decoration."
783+ }
784+ "text-decoration-style" => {
785+ "**text-decoration-style** `<value>` \u{2014} Style of text decoration (`solid`, `dashed`, `dotted`, `wavy`, `double`)."
786+ }
787+ "place-items" => {
788+ "**place-items** `<value>` \u{2014} Shorthand for `align-items` and `justify-items`."
789+ }
790+ "place-self" => {
791+ "**place-self** `<value>` \u{2014} Shorthand for `align-self` and `justify-self`."
792+ }
793+ "scroll-behavior" => {
794+ "**scroll-behavior** `<value>` \u{2014} Scroll behavior (`smooth`, `auto`)."
795+ }
796+ "resize" => {
797+ "**resize** `<value>` \u{2014} Resize behavior (`none`, `both`, `horizontal`, `vertical`)."
798+ }
464799 // New CSS attributes
465− "clip-path" => "**clip-path** `<value>` \u{2014} Clip path (`circle()`, `polygon()`, `inset()`, `url()`).",
466− "mix-blend-mode" => "**mix-blend-mode** `<value>` \u{2014} Blend mode (`multiply`, `screen`, `overlay`, `darken`, `lighten`).",
467− "background-blend-mode" => "**background-blend-mode** `<value>` \u{2014} Background blend mode for layered backgrounds.",
468− "writing-mode" => "**writing-mode** `<value>` \u{2014} Writing direction (`horizontal-tb`, `vertical-rl`, `vertical-lr`).",
469− "column-count" => "**column-count** `<value>` \u{2014} Number of columns in multi-column layout.",
470− "column-gap" => "**column-gap** `<value>` \u{2014} Gap between columns. Supports CSS units.",
471− "text-indent" => "**text-indent** `<value>` \u{2014} Indentation of the first line of text.",
472− "hyphens" => "**hyphens** `<value>` \u{2014} Hyphenation behavior (`none`, `manual`, `auto`).",
473− "flex-grow" => "**flex-grow** `<value>` \u{2014} Flex grow factor (number). Controls how much an item grows.",
474− "flex-shrink" => "**flex-shrink** `<value>` \u{2014} Flex shrink factor (number). Controls how much an item shrinks.",
475− "flex-basis" => "**flex-basis** `<value>` \u{2014} Initial main size of a flex item (e.g., `200px`, `auto`, `0`).",
476− "isolation" => "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`).",
477− "place-content" => "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`.",
478− "background-image" => "**background-image** `<value>` \u{2014} Background image (`url()` or gradient function).",
800+ "clip-path" => {
801+ "**clip-path** `<value>` \u{2014} Clip path (`circle()`, `polygon()`, `inset()`, `url()`)."
802+ }
803+ "mix-blend-mode" => {
804+ "**mix-blend-mode** `<value>` \u{2014} Blend mode (`multiply`, `screen`, `overlay`, `darken`, `lighten`)."
805+ }
806+ "background-blend-mode" => {
807+ "**background-blend-mode** `<value>` \u{2014} Background blend mode for layered backgrounds."
808+ }
809+ "writing-mode" => {
810+ "**writing-mode** `<value>` \u{2014} Writing direction (`horizontal-tb`, `vertical-rl`, `vertical-lr`)."
811+ }
812+ "column-count" => {
813+ "**column-count** `<value>` \u{2014} Number of columns in multi-column layout."
814+ }
815+ "column-gap" => {
816+ "**column-gap** `<value>` \u{2014} Gap between columns. Supports CSS units."
817+ }
818+ "text-indent" => {
819+ "**text-indent** `<value>` \u{2014} Indentation of the first line of text."
820+ }
821+ "hyphens" => {
822+ "**hyphens** `<value>` \u{2014} Hyphenation behavior (`none`, `manual`, `auto`)."
823+ }
824+ "flex-grow" => {
825+ "**flex-grow** `<value>` \u{2014} Flex grow factor (number). Controls how much an item grows."
826+ }
827+ "flex-shrink" => {
828+ "**flex-shrink** `<value>` \u{2014} Flex shrink factor (number). Controls how much an item shrinks."
829+ }
830+ "flex-basis" => {
831+ "**flex-basis** `<value>` \u{2014} Initial main size of a flex item (e.g., `200px`, `auto`, `0`)."
832+ }
833+ "isolation" => {
834+ "**isolation** `<value>` \u{2014} Creates a new stacking context (`isolate`, `auto`)."
835+ }
836+ "place-content" => {
837+ "**place-content** `<value>` \u{2014} Shorthand for `align-content` and `justify-content`."
838+ }
839+ "background-image" => {
840+ "**background-image** `<value>` \u{2014} Background image (`url()` or gradient function)."
841+ }
479842 // New CSS properties (batch 2)
480− "font-weight" => "**font-weight** `<value>` \u{2014} Font weight (`100`\u{2013}`900`, `bold`, `lighter`, `bolder`). More precise than `bold`.",
481− "font-style" => "**font-style** `<value>` \u{2014} Font style (`normal`, `italic`, `oblique`). More precise than `italic`.",
482− "text-wrap" => "**text-wrap** `<value>` \u{2014} Text wrapping behavior (`balance`, `pretty`, `nowrap`, `wrap`). `balance` is great for headings.",
483− "will-change" => "**will-change** `<value>` \u{2014} Performance hint for upcoming changes (`transform`, `opacity`, `scroll-position`).",
484− "touch-action" => "**touch-action** `<value>` \u{2014} Touch behavior for mobile (`none`, `pan-x`, `pan-y`, `manipulation`, `auto`).",
485− "vertical-align" => "**vertical-align** `<value>` \u{2014} Vertical alignment for inline elements (`middle`, `top`, `bottom`, `baseline`, `text-top`).",
486− "contain" => "**contain** `<value>` \u{2014} CSS containment for performance (`layout`, `paint`, `content`, `strict`, `size`).",
487− "content-visibility" => "**content-visibility** `<value>` \u{2014} Content rendering optimization (`auto`, `visible`, `hidden`). `auto` enables lazy rendering.",
488− "scroll-margin" => "**scroll-margin** `<value>` \u{2014} Scroll margin (for scroll-snap and anchor link offsets). Supports CSS units.",
489− "scroll-padding" => "**scroll-padding** `<value>` \u{2014} Scroll padding for scroll-snap containers. Supports CSS units.",
490− "content" => "**content** `<value>` \u{2014} CSS content property. Use with `before:` or `after:` prefix.\n\nExample: `before:content \u{2192}, before:color red`",
843+ "font-weight" => {
844+ "**font-weight** `<value>` \u{2014} Font weight (`100`\u{2013}`900`, `bold`, `lighter`, `bolder`). More precise than `bold`."
845+ }
846+ "font-style" => {
847+ "**font-style** `<value>` \u{2014} Font style (`normal`, `italic`, `oblique`). More precise than `italic`."
848+ }
849+ "text-wrap" => {
850+ "**text-wrap** `<value>` \u{2014} Text wrapping behavior (`balance`, `pretty`, `nowrap`, `wrap`). `balance` is great for headings."
851+ }
852+ "will-change" => {
853+ "**will-change** `<value>` \u{2014} Performance hint for upcoming changes (`transform`, `opacity`, `scroll-position`)."
854+ }
855+ "touch-action" => {
856+ "**touch-action** `<value>` \u{2014} Touch behavior for mobile (`none`, `pan-x`, `pan-y`, `manipulation`, `auto`)."
857+ }
858+ "vertical-align" => {
859+ "**vertical-align** `<value>` \u{2014} Vertical alignment for inline elements (`middle`, `top`, `bottom`, `baseline`, `text-top`)."
860+ }
861+ "contain" => {
862+ "**contain** `<value>` \u{2014} CSS containment for performance (`layout`, `paint`, `content`, `strict`, `size`)."
863+ }
864+ "content-visibility" => {
865+ "**content-visibility** `<value>` \u{2014} Content rendering optimization (`auto`, `visible`, `hidden`). `auto` enables lazy rendering."
866+ }
867+ "scroll-margin" => {
868+ "**scroll-margin** `<value>` \u{2014} Scroll margin (for scroll-snap and anchor link offsets). Supports CSS units."
869+ }
870+ "scroll-padding" => {
871+ "**scroll-padding** `<value>` \u{2014} Scroll padding for scroll-snap containers. Supports CSS units."
872+ }
873+ "content" => {
874+ "**content** `<value>` \u{2014} CSS content property. Use with `before:` or `after:` prefix.\n\nExample: `before:content \u{2192}, before:color red`"
875+ }
491876 // New elements
492− "@iframe" => "**@iframe** \u{2014} Embedded page\n\nRenders as `<iframe>`.\n\nUsage: `@iframe [width fill, height 400] https://example.com`\n\nAttributes: `sandbox`, `allow`, `allowfullscreen`",
493− "@output" => "**@output** \u{2014} Form output\n\nRenders as `<output>`. Displays calculation results in forms.\n\nUsage: `@output [for a b] Result`",
494− "@canvas" => "**@canvas** \u{2014} Drawing surface\n\nRenders as `<canvas>`. Use with `@raw` JavaScript for drawing.\n\nUsage: `@canvas [width 400, height 300, id myCanvas]`",
495− "@script" => "**@script** \u{2014} Script\n\nRenders as `<script>`. Inline JavaScript.\n\nUsage: `@script console.log(\"hello\")`",
496− "@noscript" => "**@noscript** \u{2014} NoScript fallback\n\nRenders as `<noscript>`. Shown when JavaScript is disabled.\n\nUsage: `@noscript Please enable JavaScript.`",
497− "@address" => "**@address** \u{2014} Address\n\nRenders as `<address>`. Contact information for the nearest `@article` or `@body`.\n\nUsage: `@address hello@example.com`",
498− "@search" => "**@search** \u{2014} Search\n\nRenders as `<search>`. Semantic container for search functionality.\n\nUsage:\n```\n@search\n @input [type search, placeholder Search...]\n @button Search\n```",
499− "@breadcrumb" => "**@breadcrumb** \u{2014} Breadcrumb\n\nRenders as `<nav aria-label=\"Breadcrumb\">`. Semantic breadcrumb navigation.\n\nUsage:\n```\n@breadcrumb\n @link / Home\n @link /docs Docs\n @text Current Page\n```",
877+ "@iframe" => {
878+ "**@iframe** \u{2014} Embedded page\n\nRenders as `<iframe>`.\n\nUsage: `@iframe [width fill, height 400] https://example.com`\n\nAttributes: `sandbox`, `allow`, `allowfullscreen`"
879+ }
880+ "@output" => {
881+ "**@output** \u{2014} Form output\n\nRenders as `<output>`. Displays calculation results in forms.\n\nUsage: `@output [for a b] Result`"
882+ }
883+ "@canvas" => {
884+ "**@canvas** \u{2014} Drawing surface\n\nRenders as `<canvas>`. Use with `@raw` JavaScript for drawing.\n\nUsage: `@canvas [width 400, height 300, id myCanvas]`"
885+ }
886+ "@script" => {
887+ "**@script** \u{2014} Script\n\nRenders as `<script>`. Inline JavaScript.\n\nUsage: `@script console.log(\"hello\")`"
888+ }
889+ "@noscript" => {
890+ "**@noscript** \u{2014} NoScript fallback\n\nRenders as `<noscript>`. Shown when JavaScript is disabled.\n\nUsage: `@noscript Please enable JavaScript.`"
891+ }
892+ "@address" => {
893+ "**@address** \u{2014} Address\n\nRenders as `<address>`. Contact information for the nearest `@article` or `@body`.\n\nUsage: `@address hello@example.com`"
894+ }
895+ "@search" => {
896+ "**@search** \u{2014} Search\n\nRenders as `<search>`. Semantic container for search functionality.\n\nUsage:\n```\n@search\n @input [type search, placeholder Search...]\n @button Search\n```"
897+ }
898+ "@breadcrumb" => {
899+ "**@breadcrumb** \u{2014} Breadcrumb\n\nRenders as `<nav aria-label=\"Breadcrumb\">`. Semantic breadcrumb navigation.\n\nUsage:\n```\n@breadcrumb\n @link / Home\n @link /docs Docs\n @text Current Page\n```"
900+ }
500901 // Convenience elements
501− "@grid" => "**@grid** \u{2014} CSS Grid container\n\nRenders as `<div>` with `display: grid`.\n\nUsage: `@grid [grid-cols 3, gap 20]`",
502− "@stack" => "**@stack** \u{2014} Stack container\n\nRenders as `<div>` with `position: relative`. Children can be absolutely positioned on top of each other.",
503− "@spacer" => "**@spacer** \u{2014} Flexible spacer\n\nRenders as `<div>` with `flex: 1`. Pushes siblings apart in flex containers.",
504− "@badge" => "**@badge** \u{2014} Badge\n\nRenders as `<span>` with pill shape, centered text.\n\nUsage: `@badge [background #3b82f6, color white] NEW`",
505− "@tooltip" => "**@tooltip** \u{2014} Tooltip\n\nRenders as `<span>` with `title` attribute for native tooltips.\n\nUsage: `@tooltip [cursor help] Hover me`",
506− "@avatar" => "**@avatar** \u{2014} Avatar\n\nRenders as `<div>` with circular shape, centered content, `overflow: hidden`.\n\nUsage:\n```\n@avatar [width 48, height 48, background #e5e7eb]\n @image [object-fit cover] photo.jpg\n```",
507− "@carousel" => "**@carousel** \u{2014} Horizontal carousel\n\nRenders as `<div>` with horizontal scroll-snap.\nChildren auto-receive `scroll-snap-align: start` and `flex-shrink: 0`.\n\nUsage:\n```\n@carousel [gap 16]\n @el [width 300] Slide 1\n @el [width 300] Slide 2\n```",
508− "@chip" => "**@chip** \u{2014} Chip / pill\n\nRenders as `<span>` with rounded borders, inline-flex layout.\n\nUsage: `@chip [background #e5e7eb] Category`",
509− "@tag" => "**@tag** \u{2014} Tag label\n\nRenders as `<span>` with subtle rounded rectangle, bold small text.\n\nUsage: `@tag [background #dbeafe, color #1e40af] v2.0`",
902+ "@grid" => {
903+ "**@grid** \u{2014} CSS Grid container\n\nRenders as `<div>` with `display: grid`.\n\nUsage: `@grid [grid-cols 3, gap 20]`"
904+ }
905+ "@stack" => {
906+ "**@stack** \u{2014} Stack container\n\nRenders as `<div>` with `position: relative`. Children can be absolutely positioned on top of each other."
907+ }
908+ "@spacer" => {
909+ "**@spacer** \u{2014} Flexible spacer\n\nRenders as `<div>` with `flex: 1`. Pushes siblings apart in flex containers."
910+ }
911+ "@badge" => {
912+ "**@badge** \u{2014} Badge\n\nRenders as `<span>` with pill shape, centered text.\n\nUsage: `@badge [background #3b82f6, color white] NEW`"
913+ }
914+ "@tooltip" => {
915+ "**@tooltip** \u{2014} Tooltip\n\nRenders as `<span>` with `title` attribute for native tooltips.\n\nUsage: `@tooltip [cursor help] Hover me`"
916+ }
917+ "@avatar" => {
918+ "**@avatar** \u{2014} Avatar\n\nRenders as `<div>` with circular shape, centered content, `overflow: hidden`.\n\nUsage:\n```\n@avatar [width 48, height 48, background #e5e7eb]\n @image [object-fit cover] photo.jpg\n```"
919+ }
920+ "@carousel" => {
921+ "**@carousel** \u{2014} Horizontal carousel\n\nRenders as `<div>` with horizontal scroll-snap.\nChildren auto-receive `scroll-snap-align: start` and `flex-shrink: 0`.\n\nUsage:\n```\n@carousel [gap 16]\n @el [width 300] Slide 1\n @el [width 300] Slide 2\n```"
922+ }
923+ "@chip" => {
924+ "**@chip** \u{2014} Chip / pill\n\nRenders as `<span>` with rounded borders, inline-flex layout.\n\nUsage: `@chip [background #e5e7eb] Category`"
925+ }
926+ "@tag" => {
927+ "**@tag** \u{2014} Tag label\n\nRenders as `<span>` with subtle rounded rectangle, bold small text.\n\nUsage: `@tag [background #dbeafe, color #1e40af] v2.0`"
928+ }
510929 // CSS shorthands
511− "truncate" => "**truncate** \u{2014} Truncate with ellipsis\n\nShorthand for: `overflow: hidden; text-overflow: ellipsis; white-space: nowrap`\n\nUsage: `@text [max-width 200, truncate] Long text here...`",
512− "line-clamp" => "**line-clamp** `<N>` \u{2014} Multi-line truncation\n\nClamps text to N lines with ellipsis.\n\nUsage: `@paragraph [line-clamp 3] Long paragraph...`",
513− "blur" => "**blur** `<value>` \u{2014} Apply blur filter\n\nShorthand for `filter: blur(Npx)`.\n\nUsage: `[blur 4]` \u{2192} `filter: blur(4px)`",
514− "backdrop-blur" => "**backdrop-blur** `<value>` \u{2014} Apply backdrop blur\n\nShorthand for `backdrop-filter: blur(Npx)`.\n\nUsage: `[backdrop-blur 10]` \u{2192} `backdrop-filter: blur(10px)`",
515− "no-scrollbar" => "**no-scrollbar** \u{2014} Hide scrollbar\n\nHides scrollbar while keeping overflow scrollable.\n\nSets `scrollbar-width: none` and `::-webkit-scrollbar { display: none }`.",
516− "skeleton" => "**skeleton** \u{2014} Loading skeleton\n\nAdds a shimmer animation for loading placeholders.\n\nUsage: `@el [width fill, height 20, rounded 4, skeleton]`",
517− "gradient" => "**gradient** `<from> <to> [angle]` \u{2014} Linear gradient\n\nShorthand for `background: linear-gradient(...)`.\n\nUsage:\n- `[gradient #fff #000]` \u{2192} top-to-bottom\n- `[gradient #fff #000 45deg]` \u{2192} 45\u{00b0} angle",
930+ "truncate" => {
931+ "**truncate** \u{2014} Truncate with ellipsis\n\nShorthand for: `overflow: hidden; text-overflow: ellipsis; white-space: nowrap`\n\nUsage: `@text [max-width 200, truncate] Long text here...`"
932+ }
933+ "line-clamp" => {
934+ "**line-clamp** `<N>` \u{2014} Multi-line truncation\n\nClamps text to N lines with ellipsis.\n\nUsage: `@paragraph [line-clamp 3] Long paragraph...`"
935+ }
936+ "blur" => {
937+ "**blur** `<value>` \u{2014} Apply blur filter\n\nShorthand for `filter: blur(Npx)`.\n\nUsage: `[blur 4]` \u{2192} `filter: blur(4px)`"
938+ }
939+ "backdrop-blur" => {
940+ "**backdrop-blur** `<value>` \u{2014} Apply backdrop blur\n\nShorthand for `backdrop-filter: blur(Npx)`.\n\nUsage: `[backdrop-blur 10]` \u{2192} `backdrop-filter: blur(10px)`"
941+ }
942+ "no-scrollbar" => {
943+ "**no-scrollbar** \u{2014} Hide scrollbar\n\nHides scrollbar while keeping overflow scrollable.\n\nSets `scrollbar-width: none` and `::-webkit-scrollbar { display: none }`."
944+ }
945+ "skeleton" => {
946+ "**skeleton** \u{2014} Loading skeleton\n\nAdds a shimmer animation for loading placeholders.\n\nUsage: `@el [width fill, height 20, rounded 4, skeleton]`"
947+ }
948+ "gradient" => {
949+ "**gradient** `<from> <to> [angle]` \u{2014} Linear gradient\n\nShorthand for `background: linear-gradient(...)`.\n\nUsage:\n- `[gradient #fff #000]` \u{2192} top-to-bottom\n- `[gradient #fff #000 45deg]` \u{2192} 45\u{00b0} angle"
950+ }
518951 "direction" => "**direction** `<value>` \u{2014} Text direction (`ltr`, `rtl`).",
519952 // Grid areas
520− "grid-template-areas" => "**grid-template-areas** `<value>` \u{2014} Define named grid areas.\n\nUsage: `[grid, grid-template-areas \"header header\" \"sidebar main\"]`\n\nChildren use `grid-area` to place themselves.",
521− "grid-area" => "**grid-area** `<name>` \u{2014} Place element in a named grid area.\n\nUsage: `@el [grid-area header] ...`\n\nRequires parent with `grid-template-areas`.",
953+ "grid-template-areas" => {
954+ "**grid-template-areas** `<value>` \u{2014} Define named grid areas.\n\nUsage: `[grid, grid-template-areas \"header header\" \"sidebar main\"]`\n\nChildren use `grid-area` to place themselves."
955+ }
956+ "grid-area" => {
957+ "**grid-area** `<name>` \u{2014} Place element in a named grid area.\n\nUsage: `@el [grid-area header] ...`\n\nRequires parent with `grid-template-areas`."
958+ }
522959 // View transitions
523− "view-transition-name" => "**view-transition-name** `<name>` \u{2014} Assign a View Transition API name.\n\nEnables smooth transitions between pages.\n\nUsage: `@el [view-transition-name hero]`",
960+ "view-transition-name" => {
961+ "**view-transition-name** `<name>` \u{2014} Assign a View Transition API name.\n\nEnables smooth transitions between pages.\n\nUsage: `@el [view-transition-name hero]`"
962+ }
524963 // Animate shorthand
525− "animate" => "**animate** `<name> <duration> [timing]` \u{2014} Animation shorthand.\n\nAlias for the CSS `animation` property.\n\nUsage: `@el [animate fade-in 0.3s ease]`\n\nRequires a matching `@keyframes fade-in` definition.",
964+ "animate" => {
965+ "**animate** `<name> <duration> [timing]` \u{2014} Animation shorthand.\n\nAlias for the CSS `animation` property.\n\nUsage: `@el [animate fade-in 0.3s ease]`\n\nRequires a matching `@keyframes fade-in` definition."
966+ }
526967 // Has pseudo-selector
527− "has(" => "**has(selector):** \u{2014} Parent selector pseudo-class.\n\nStyle an element based on its children.\n\nUsage: `@el [has(.active):background blue, has(img):padding 0]`\n\nGenerates CSS `:has()` selector.",
968+ "has(" => {
969+ "**has(selector):** \u{2014} Parent selector pseudo-class.\n\nStyle an element based on its children.\n\nUsage: `@el [has(.active):background blue, has(img):padding 0]`\n\nGenerates CSS `:has()` selector."
970+ }
528971 // Critical
529− "critical" => "**critical** \u{2014} Mark element as above-fold.\n\nHint for build tools to prioritize this element's CSS.",
972+ "critical" => {
973+ "**critical** \u{2014} Mark element as above-fold.\n\nHint for build tools to prioritize this element's CSS."
974+ }
530975 // New CSS properties
531− "text-underline-offset" => "**text-underline-offset** `<value>` \u{2014} Offset of text underline from its default position. Supports CSS units.\n\nUsage: `[underline, text-underline-offset 4]`",
532− "column-width" => "**column-width** `<value>` \u{2014} Ideal column width in multi-column layout. Browser determines column count.\n\nUsage: `[column-width 200]`",
533− "column-rule" => "**column-rule** `<value>` \u{2014} Rule between columns (shorthand for width, style, color).\n\nUsage: `[column-rule 1px solid #ccc]`",
976+ "text-underline-offset" => {
977+ "**text-underline-offset** `<value>` \u{2014} Offset of text underline from its default position. Supports CSS units.\n\nUsage: `[underline, text-underline-offset 4]`"
978+ }
979+ "column-width" => {
980+ "**column-width** `<value>` \u{2014} Ideal column width in multi-column layout. Browser determines column count.\n\nUsage: `[column-width 200]`"
981+ }
982+ "column-rule" => {
983+ "**column-rule** `<value>` \u{2014} Rule between columns (shorthand for width, style, color).\n\nUsage: `[column-rule 1px solid #ccc]`"
984+ }
534985 // Variable filters
535− "\\$|uppercase" => "**|uppercase** \u{2014} Convert variable to UPPERCASE.\n\nUsage: `$name|uppercase`",
536− "\\$|lowercase" => "**|lowercase** \u{2014} Convert variable to lowercase.\n\nUsage: `$name|lowercase`",
537− "\\$|capitalize" => "**|capitalize** \u{2014} Capitalize first letter.\n\nUsage: `$name|capitalize`",
538− "\\$|trim" => "**|trim** \u{2014} Strip leading/trailing whitespace.\n\nUsage: `$name|trim`",
986+ "\\$|uppercase" => {
987+ "**|uppercase** \u{2014} Convert variable to UPPERCASE.\n\nUsage: `$name|uppercase`"
988+ }
989+ "\\$|lowercase" => {
990+ "**|lowercase** \u{2014} Convert variable to lowercase.\n\nUsage: `$name|lowercase`"
991+ }
992+ "\\$|capitalize" => {
993+ "**|capitalize** \u{2014} Capitalize first letter.\n\nUsage: `$name|capitalize`"
994+ }
995+ "\\$|trim" => {
996+ "**|trim** \u{2014} Strip leading/trailing whitespace.\n\nUsage: `$name|trim`"
997+ }
539998 "\\$|length" => "**|length** \u{2014} Get string length.\n\nUsage: `$name|length`",
540999 "\\$|reverse" => "**|reverse** \u{2014} Reverse string.\n\nUsage: `$name|reverse`",
5411000 _ => return None,
src/bin/htmlang_lsp/main.rs+94−95
@@ -48,7 +48,8 @@ impl Backend {
4848 let col_end = if d.column.is_some() {
4949 // Highlight a reasonable span from the column
5050 let lines_vec: Vec<&str> = text.lines().collect();
51− lines_vec.get(line as usize)
51+ lines_vec
52+ .get(line as usize)
5253 .map(|l| l.len() as u32)
5354 .unwrap_or(col_start + 1)
5455 } else {
@@ -79,12 +80,7 @@ impl LanguageServer for Backend {
7980 TextDocumentSyncKind::FULL,
8081 )),
8182 completion_provider: Some(CompletionOptions {
82− trigger_characters: Some(vec![
83− "@".into(),
84− "$".into(),
85− "[".into(),
86− ",".into(),
87− ]),
83+ trigger_characters: Some(vec!["@".into(), "$".into(), "[".into(), ",".into()]),
8884 ..Default::default()
8985 }),
9086 hover_provider: Some(HoverProviderCapability::Simple(true)),
@@ -106,30 +102,32 @@ impl LanguageServer for Backend {
106102 color_provider: Some(ColorProviderCapability::Simple(true)),
107103 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
108104 semantic_tokens_provider: Some(
109− SemanticTokensServerCapabilities::SemanticTokensOptions(SemanticTokensOptions {
110− legend: SemanticTokensLegend {
111− token_types: vec![
112− SemanticTokenType::KEYWORD,
113− SemanticTokenType::VARIABLE,
114− SemanticTokenType::FUNCTION,
115− SemanticTokenType::STRING,
116− SemanticTokenType::COMMENT,
117− SemanticTokenType::PROPERTY,
118− ],
119− token_modifiers: vec![
120− SemanticTokenModifier::new("deprecated"), // bit 0 = 1 -> dimmed/strikethrough
121− ],
105+ SemanticTokensServerCapabilities::SemanticTokensOptions(
106+ SemanticTokensOptions {
107+ legend: SemanticTokensLegend {
108+ token_types: vec![
109+ SemanticTokenType::KEYWORD,
110+ SemanticTokenType::VARIABLE,
111+ SemanticTokenType::FUNCTION,
112+ SemanticTokenType::STRING,
113+ SemanticTokenType::COMMENT,
114+ SemanticTokenType::PROPERTY,
115+ ],
116+ token_modifiers: vec![
117+ SemanticTokenModifier::new("deprecated"), // bit 0 = 1 -> dimmed/strikethrough
118+ ],
119+ },
120+ full: Some(SemanticTokensFullOptions::Bool(true)),
121+ range: None,
122+ ..Default::default()
122123 },
123− full: Some(SemanticTokensFullOptions::Bool(true)),
124− range: None,
125− ..Default::default()
126− }),
124+ ),
127125 ),
128126 inlay_hint_provider: Some(OneOf::Left(true)),
129127 workspace_symbol_provider: Some(OneOf::Left(true)),
130− linked_editing_range_provider: Some(
131− LinkedEditingRangeServerCapabilities::Simple(true),
132− ),
128+ linked_editing_range_provider: Some(LinkedEditingRangeServerCapabilities::Simple(
129+ true,
130+ )),
133131 document_formatting_provider: Some(OneOf::Left(true)),
134132 document_range_formatting_provider: Some(OneOf::Left(true)),
135133 references_provider: Some(OneOf::Left(true)),
@@ -178,10 +176,7 @@ impl LanguageServer for Backend {
178176 .await;
179177 }
180178
181− async fn completion(
182− &self,
183− params: CompletionParams,
184− ) -> Result<Option<CompletionResponse>> {
179+ async fn completion(&self, params: CompletionParams) -> Result<Option<CompletionResponse>> {
185180 let uri = &params.text_document_position.text_document.uri;
186181 let pos = params.text_document_position.position;
187182 let docs = self.documents.read().await;
@@ -195,8 +190,10 @@ impl LanguageServer for Backend {
195190 let lines: Vec<&str> = text.lines().collect();
196191 if let Some(line) = lines.get(pos.line as usize) {
197192 let trimmed = line.trim_start();
198− if trimmed.starts_with("@include ") || trimmed.starts_with("@import ")
199− || trimmed.starts_with("@extends ") {
193+ if trimmed.starts_with("@include ")
194+ || trimmed.starts_with("@import ")
195+ || trimmed.starts_with("@extends ")
196+ {
200197 let items = path_completions(uri, pos);
201198 if !items.is_empty() {
202199 return Ok(Some(CompletionResponse::Array(items)));
@@ -245,7 +242,11 @@ impl LanguageServer for Backend {
245242 &self,
246243 params: GotoDefinitionParams,
247244 ) -> Result<Option<GotoDefinitionResponse>> {
248− let uri = params.text_document_position_params.text_document.uri.clone();
245+ let uri = params
246+ .text_document_position_params
247+ .text_document
248+ .uri
249+ .clone();
249250 let pos = params.text_document_position_params.position;
250251 let docs = self.documents.read().await;
251252 let text = match docs.get(&uri) {
@@ -307,10 +308,7 @@ impl LanguageServer for Backend {
307308 })
308309 }
309310
310− async fn code_action(
311− &self,
312− params: CodeActionParams,
313− ) -> Result<Option<CodeActionResponse>> {
311+ async fn code_action(&self, params: CodeActionParams) -> Result<Option<CodeActionResponse>> {
314312 let uri = params.text_document.uri.clone();
315313 let docs = self.documents.read().await;
316314 let text = match docs.get(&uri) {
@@ -327,10 +325,7 @@ impl LanguageServer for Backend {
327325 })
328326 }
329327
330− async fn document_color(
331− &self,
332− params: DocumentColorParams,
333− ) -> Result<Vec<ColorInformation>> {
328+ async fn document_color(&self, params: DocumentColorParams) -> Result<Vec<ColorInformation>> {
334329 let uri = &params.text_document.uri;
335330 let docs = self.documents.read().await;
336331 let text = match docs.get(uri) {
@@ -365,10 +360,7 @@ impl LanguageServer for Backend {
365360 }])
366361 }
367362
368− async fn folding_range(
369− &self,
370− params: FoldingRangeParams,
371− ) -> Result<Option<Vec<FoldingRange>>> {
363+ async fn folding_range(&self, params: FoldingRangeParams) -> Result<Option<Vec<FoldingRange>>> {
372364 let uri = &params.text_document.uri;
373365 let docs = self.documents.read().await;
374366 let text = match docs.get(uri) {
@@ -377,7 +369,11 @@ impl LanguageServer for Backend {
377369 };
378370 drop(docs);
379371 let ranges = folding_ranges(&text);
380− Ok(if ranges.is_empty() { None } else { Some(ranges) })
372+ Ok(if ranges.is_empty() {
373+ None
374+ } else {
375+ Some(ranges)
376+ })
381377 }
382378
383379 async fn semantic_tokens_full(
@@ -398,10 +394,7 @@ impl LanguageServer for Backend {
398394 })))
399395 }
400396
401− async fn inlay_hint(
402− &self,
403− params: InlayHintParams,
404− ) -> Result<Option<Vec<InlayHint>>> {
397+ async fn inlay_hint(&self, params: InlayHintParams) -> Result<Option<Vec<InlayHint>>> {
405398 let uri = &params.text_document.uri;
406399 let docs = self.documents.read().await;
407400 let text = match docs.get(uri) {
@@ -454,7 +447,9 @@ impl LanguageServer for Backend {
454447 let max_files = 500; // safety cap for huge workspaces
455448 let mut scanned = 0usize;
456449 while let Some(dir) = stack.pop() {
457− let Ok(entries) = std::fs::read_dir(&dir) else { continue };
450+ let Ok(entries) = std::fs::read_dir(&dir) else {
451+ continue;
452+ };
458453 for entry in entries.flatten() {
459454 if scanned >= max_files {
460455 break;
@@ -466,24 +461,25 @@ impl LanguageServer for Backend {
466461 || name == "target"
467462 || name == "node_modules"
468463 || name == "dist")
469− {
470− continue;
471− }
464+ {
465+ continue;
466+ }
472467 if path.is_dir() {
473468 stack.push(path);
474469 continue;
475470 }
476− if path.extension().is_some_and(|e| e == "hl")
477− && !covered_files.contains(&path)
471+ if path.extension().is_some_and(|e| e == "hl") && !covered_files.contains(&path)
478472 {
479473 scanned += 1;
480− let Ok(text) = std::fs::read_to_string(&path) else { continue };
481− let Ok(file_uri) = Url::from_file_path(&path) else { continue };
474+ let Ok(text) = std::fs::read_to_string(&path) else {
475+ continue;
476+ };
477+ let Ok(file_uri) = Url::from_file_path(&path) else {
478+ continue;
479+ };
482480 for mut sym in document_symbols(&text) {
483481 sym.location.uri = file_uri.clone();
484− if query.is_empty()
485− || sym.name.to_lowercase().contains(&query)
486− {
482+ if query.is_empty() || sym.name.to_lowercase().contains(&query) {
487483 all_symbols.push(sym);
488484 }
489485 }
@@ -514,10 +510,7 @@ impl LanguageServer for Backend {
514510 Ok(linked_editing_ranges(&text, pos))
515511 }
516512
517− async fn formatting(
518− &self,
519− params: DocumentFormattingParams,
520− ) -> Result<Option<Vec<TextEdit>>> {
513+ async fn formatting(&self, params: DocumentFormattingParams) -> Result<Option<Vec<TextEdit>>> {
521514 let uri = &params.text_document.uri;
522515 let docs = self.documents.read().await;
523516 let text = match docs.get(uri) {
@@ -576,10 +569,7 @@ impl LanguageServer for Backend {
576569 }]))
577570 }
578571
579− async fn document_link(
580− &self,
581− params: DocumentLinkParams,
582− ) -> Result<Option<Vec<DocumentLink>>> {
572+ async fn document_link(&self, params: DocumentLinkParams) -> Result<Option<Vec<DocumentLink>>> {
583573 let uri = params.text_document.uri.clone();
584574 let docs = self.documents.read().await;
585575 let text = match docs.get(&uri) {
@@ -658,10 +648,7 @@ impl LanguageServer for Backend {
658648 Ok(if links.is_empty() { None } else { Some(links) })
659649 }
660650
661− async fn code_lens(
662− &self,
663− params: CodeLensParams,
664− ) -> Result<Option<Vec<CodeLens>>> {
651+ async fn code_lens(&self, params: CodeLensParams) -> Result<Option<Vec<CodeLens>>> {
665652 let uri = params.text_document.uri.clone();
666653 let docs = self.documents.read().await;
667654 let text = match docs.get(&uri) {
@@ -676,26 +663,43 @@ impl LanguageServer for Backend {
676663 let lines: Vec<&str> = text.lines().collect();
677664
678665 #[derive(Clone)]
679− struct Def { line: u32, name: String, kind: &'static str }
666+ struct Def {
667+ line: u32,
668+ name: String,
669+ kind: &'static str,
670+ }
680671 let mut defs: Vec<Def> = Vec::new();
681672
682673 for (i, line) in lines.iter().enumerate() {
683674 let trimmed = line.trim_start();
684675 if let Some(rest) = trimmed.strip_prefix("@fn ") {
685676 if let Some(n) = rest.split_whitespace().next() {
686− defs.push(Def { line: i as u32, name: n.to_string(), kind: "fn" });
677+ defs.push(Def {
678+ line: i as u32,
679+ name: n.to_string(),
680+ kind: "fn",
681+ });
687682 }
688683 } else if let Some(rest) = trimmed.strip_prefix("@let ") {
689684 if let Some((n, _)) = rest.trim().split_once(' ') {
690− defs.push(Def { line: i as u32, name: n.to_string(), kind: "let" });
685+ defs.push(Def {
686+ line: i as u32,
687+ name: n.to_string(),
688+ kind: "let",
689+ });
691690 }
692691 } else if let Some(rest) = trimmed.strip_prefix("@define ")
693− && let Some(bracket) = rest.find('[') {
694− let n = rest[..bracket].trim();
695− if !n.is_empty() {
696− defs.push(Def { line: i as u32, name: n.to_string(), kind: "define" });
697− }
692+ && let Some(bracket) = rest.find('[')
693+ {
694+ let n = rest[..bracket].trim();
695+ if !n.is_empty() {
696+ defs.push(Def {
697+ line: i as u32,
698+ name: n.to_string(),
699+ kind: "define",
700+ });
698701 }
702+ }
699703 }
700704
701705 let mut lenses = Vec::with_capacity(defs.len());
@@ -734,10 +738,7 @@ impl LanguageServer for Backend {
734738 format!("{} references", count)
735739 };
736740 lenses.push(CodeLens {
737− range: Range::new(
738− Position::new(def.line, 0),
739− Position::new(def.line, 0),
740− ),
741+ range: Range::new(Position::new(def.line, 0), Position::new(def.line, 0)),
741742 // Non-executable lens: clients display the title without an
742743 // associated command action. Leaving `command` as None yields an
743744 // informational lens in most editors.
@@ -750,13 +751,14 @@ impl LanguageServer for Backend {
750751 });
751752 }
752753
753− Ok(if lenses.is_empty() { None } else { Some(lenses) })
754+ Ok(if lenses.is_empty() {
755+ None
756+ } else {
757+ Some(lenses)
758+ })
754759 }
755760
756− async fn references(
757− &self,
758− params: ReferenceParams,
759− ) -> Result<Option<Vec<Location>>> {
761+ async fn references(&self, params: ReferenceParams) -> Result<Option<Vec<Location>>> {
760762 let uri = params.text_document_position.text_document.uri.clone();
761763 let pos = params.text_document_position.position;
762764 let docs = self.documents.read().await;
@@ -769,10 +771,7 @@ impl LanguageServer for Backend {
769771 Ok(if refs.is_empty() { None } else { Some(refs) })
770772 }
771773
772− async fn signature_help(
773− &self,
774− params: SignatureHelpParams,
775− ) -> Result<Option<SignatureHelp>> {
774+ async fn signature_help(&self, params: SignatureHelpParams) -> Result<Option<SignatureHelp>> {
776775 let uri = &params.text_document_position_params.text_document.uri;
777776 let pos = params.text_document_position_params.position;
778777 let docs = self.documents.read().await;
src/bin/htmlang_lsp/navigation.rs+72−48
@@ -8,7 +8,11 @@ use crate::hover::{is_word_byte, word_at};
88// Go to definition
99// ---------------------------------------------------------------------------
1010
11−pub(crate) fn definition_at(text: &str, position: Position, uri: &Url) -> Option<GotoDefinitionResponse> {
11+pub(crate) fn definition_at(
12+ text: &str,
13+ position: Position,
14+ uri: &Url,
15+) -> Option<GotoDefinitionResponse> {
1216 let lines: Vec<&str> = text.lines().collect();
1317 let line = lines.get(position.line as usize)?;
1418
@@ -62,10 +66,11 @@ pub(crate) fn find_definition(text: &str, name: &str) -> Option<(u32, u32, u32)>
6266
6367 if let Some(rest) = trimmed.strip_prefix("@let ")
6468 && let Some((n, _)) = rest.trim().split_once(' ')
65− && n == name {
66− let col = offset + "@let ".len() as u32;
67− return Some((i as u32, col, n.len() as u32));
68− }
69+ && n == name
70+ {
71+ let col = offset + "@let ".len() as u32;
72+ return Some((i as u32, col, n.len() as u32));
73+ }
6974
7075 if let Some(rest) = trimmed.strip_prefix("@define ") {
7176 let rest = rest.trim();
@@ -152,7 +157,12 @@ pub(crate) fn prepare_rename_at(text: &str, position: Position) -> Option<Prepar
152157 )))
153158}
154159
155−pub(crate) fn rename_at(text: &str, position: Position, new_name: &str, uri: &Url) -> Option<WorkspaceEdit> {
160+pub(crate) fn rename_at(
161+ text: &str,
162+ position: Position,
163+ new_name: &str,
164+ uri: &Url,
165+) -> Option<WorkspaceEdit> {
156166 let lines: Vec<&str> = text.lines().collect();
157167 let line = lines.get(position.line as usize)?;
158168 let col = (position.character as usize).min(line.len());
@@ -177,16 +187,17 @@ pub(crate) fn rename_at(text: &str, position: Position, new_name: &str, uri: &Ur
177187 // Rename @let definition
178188 if let Some(rest) = trimmed.strip_prefix("@let ")
179189 && let Some((n, _)) = rest.trim().split_once(' ')
180− && n == name
181− && let Some(pos) = line.find(n) {
182− edits.push(TextEdit {
183− range: Range::new(
184− Position::new(line_num, pos as u32),
185− Position::new(line_num, (pos + n.len()) as u32),
186− ),
187− new_text: new_base.to_string(),
188− });
189− }
190+ && n == name
191+ && let Some(pos) = line.find(n)
192+ {
193+ edits.push(TextEdit {
194+ range: Range::new(
195+ Position::new(line_num, pos as u32),
196+ Position::new(line_num, (pos + n.len()) as u32),
197+ ),
198+ new_text: new_base.to_string(),
199+ });
200+ }
190201
191202 // Rename @define definition
192203 if let Some(rest) = trimmed.strip_prefix("@define ") {
@@ -194,16 +205,17 @@ pub(crate) fn rename_at(text: &str, position: Position, new_name: &str, uri: &Ur
194205 if let Some(bracket) = rest_trimmed.find('[') {
195206 let n = rest_trimmed[..bracket].trim();
196207 if n == name
197− && let Some(pos) = line.find(n) {
198− edits.push(TextEdit {
199− range: Range::new(
200− Position::new(line_num, pos as u32),
201− Position::new(line_num, (pos + n.len()) as u32),
202− ),
203− new_text: new_base.to_string(),
204− });
205− continue; // Don't also match $ references on this line
206− }
208+ && let Some(pos) = line.find(n)
209+ {
210+ edits.push(TextEdit {
211+ range: Range::new(
212+ Position::new(line_num, pos as u32),
213+ Position::new(line_num, (pos + n.len()) as u32),
214+ ),
215+ new_text: new_base.to_string(),
216+ });
217+ continue; // Don't also match $ references on this line
218+ }
207219 }
208220 }
209221
@@ -213,16 +225,17 @@ pub(crate) fn rename_at(text: &str, position: Position, new_name: &str, uri: &Ur
213225 for param in &parts[1..] {
214226 let p = param.strip_prefix('$').unwrap_or(param);
215227 if p == name
216− && let Some(pos) = line.find(param) {
217− let prefix = if param.starts_with('$') { "$" } else { "" };
218− edits.push(TextEdit {
219− range: Range::new(
220− Position::new(line_num, pos as u32),
221− Position::new(line_num, (pos + param.len()) as u32),
222− ),
223− new_text: format!("{}{}", prefix, new_base),
224− });
225− }
228+ && let Some(pos) = line.find(param)
229+ {
230+ let prefix = if param.starts_with('$') { "$" } else { "" };
231+ edits.push(TextEdit {
232+ range: Range::new(
233+ Position::new(line_num, pos as u32),
234+ Position::new(line_num, (pos + param.len()) as u32),
235+ ),
236+ new_text: format!("{}{}", prefix, new_base),
237+ });
238+ }
226239 }
227240 }
228241
@@ -254,16 +267,17 @@ pub(crate) fn rename_at(text: &str, position: Position, new_name: &str, uri: &Ur
254267 if let Some(rest) = trimmed.strip_prefix("@fn ") {
255268 let parts: Vec<&str> = rest.split_whitespace().collect();
256269 if parts.first() == Some(&name)
257− && let Some(pos) = line.find(&format!("@fn {}", name)) {
258− let start = pos + 4; // skip "@fn "
259− edits.push(TextEdit {
260− range: Range::new(
261− Position::new(line_num, start as u32),
262− Position::new(line_num, (start + name.len()) as u32),
263− ),
264− new_text: new_base.to_string(),
265− });
266− }
270+ && let Some(pos) = line.find(&format!("@fn {}", name))
271+ {
272+ let start = pos + 4; // skip "@fn "
273+ edits.push(TextEdit {
274+ range: Range::new(
275+ Position::new(line_num, start as u32),
276+ Position::new(line_num, (start + name.len()) as u32),
277+ ),
278+ new_text: new_base.to_string(),
279+ });
280+ }
267281 }
268282
269283 // Rename @name function calls
@@ -322,11 +336,21 @@ pub(crate) fn linked_editing_ranges(text: &str, position: Position) -> Option<Li
322336 // Find the $variable at the cursor
323337 let bytes = line.as_bytes();
324338 let mut start = col;
325− while start > 0 && (bytes[start - 1].is_ascii_alphanumeric() || bytes[start - 1] == b'$' || bytes[start - 1] == b'-' || bytes[start - 1] == b'_') {
339+ while start > 0
340+ && (bytes[start - 1].is_ascii_alphanumeric()
341+ || bytes[start - 1] == b'$'
342+ || bytes[start - 1] == b'-'
343+ || bytes[start - 1] == b'_')
344+ {
326345 start -= 1;
327346 }
328347 let mut end = col;
329− while end < bytes.len() && (bytes[end].is_ascii_alphanumeric() || bytes[end] == b'$' || bytes[end] == b'-' || bytes[end] == b'_') {
348+ while end < bytes.len()
349+ && (bytes[end].is_ascii_alphanumeric()
350+ || bytes[end] == b'$'
351+ || bytes[end] == b'-'
352+ || bytes[end] == b'_')
353+ {
330354 end += 1;
331355 }
332356 if start == end {
src/cli.rs+36−10
@@ -87,7 +87,11 @@ Examples:
8787
8888pub fn run_lsp() {
8989 let self_exe = env::current_exe().ok();
90− let lsp_name = if cfg!(windows) { "htmlang-lsp.exe" } else { "htmlang-lsp" };
90+ let lsp_name = if cfg!(windows) {
91+ "htmlang-lsp.exe"
92+ } else {
93+ "htmlang-lsp"
94+ };
9195
9296 let lsp_path = self_exe
9397 .as_ref()
@@ -113,7 +117,9 @@ pub fn run_lsp() {
113117 }
114118 Err(_) => {
115119 eprintln!("error: could not find htmlang-lsp binary");
116− eprintln!("hint: ensure htmlang-lsp is in the same directory as htmlang, or in your PATH");
120+ eprintln!(
121+ "hint: ensure htmlang-lsp is in the same directory as htmlang, or in your PATH"
122+ );
117123 process::exit(1);
118124 }
119125 }
@@ -132,7 +138,8 @@ pub fn print_shell_completions(shell: &str) {
132138}
133139
134140fn print_bash_completions() {
135− println!(r#"_htmlang() {{
141+ println!(
142+ r#"_htmlang() {{
136143 local cur prev commands
137144 cur="${{COMP_WORDS[COMP_CWORD]}}"
138145 prev="${{COMP_WORDS[COMP_CWORD-1]}}"
@@ -159,11 +166,13 @@ fn print_bash_completions() {
159166 ;;
160167 esac
161168}}
162−complete -F _htmlang htmlang"#);
169+complete -F _htmlang htmlang"#
170+ );
163171}
164172
165173fn print_zsh_completions() {
166− println!(r#"#compdef htmlang
174+ println!(
175+ r#"#compdef htmlang
167176
168177_htmlang() {{
169178 local -a commands=(
@@ -235,7 +244,8 @@ _htmlang() {{
235244 fi
236245}}
237246
238−_htmlang "$@""#);
247+_htmlang "$@""#
248+ );
239249}
240250
241251fn print_fish_completions() {
@@ -277,7 +287,10 @@ fn print_fish_completions() {
277287 ];
278288
279289 for (cmd, desc) in &commands {
280− println!("complete -c htmlang -n '__fish_use_subcommand' -a '{}' -d '{}'", cmd, desc);
290+ println!(
291+ "complete -c htmlang -n '__fish_use_subcommand' -a '{}' -d '{}'",
292+ cmd, desc
293+ );
281294 }
282295
283296 let flags = [
@@ -287,7 +300,11 @@ fn print_fish_completions() {
287300 ("-c", "--check", "Check only"),
288301 ("", "--compat", "Add vendor prefixes"),
289302 ("", "--strict", "Treat warnings as errors"),
290− ("", "--partial", "Output HTML fragment without document wrapper"),
303+ (
304+ "",
305+ "--partial",
306+ "Output HTML fragment without document wrapper",
307+ ),
291308 ("", "--open", "Open browser"),
292309 ("", "--minify", "Minify output"),
293310 ("-o", "--output", "Output path"),
@@ -298,9 +315,18 @@ fn print_fish_completions() {
298315
299316 for (short, long, desc) in &flags {
300317 if short.is_empty() {
301− println!("complete -c htmlang -l '{}' -d '{}'", long.trim_start_matches('-'), desc);
318+ println!(
319+ "complete -c htmlang -l '{}' -d '{}'",
320+ long.trim_start_matches('-'),
321+ desc
322+ );
302323 } else {
303− println!("complete -c htmlang -s '{}' -l '{}' -d '{}'", short.trim_start_matches('-'), long.trim_start_matches('-'), desc);
324+ println!(
325+ "complete -c htmlang -s '{}' -l '{}' -d '{}'",
326+ short.trim_start_matches('-'),
327+ long.trim_start_matches('-'),
328+ desc
329+ );
304330 }
305331 }
306332
src/convert.rs+80−79
@@ -29,8 +29,8 @@ struct HtmlParser<'a> {
2929
3030/// Tags that are self-closing in HTML (void elements).
3131const VOID_TAGS: &[&str] = &[
32− "area", "base", "br", "col", "embed", "hr", "img", "input", "link",
33− "meta", "param", "source", "track", "wbr",
32+ "area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "param", "source",
33+ "track", "wbr",
3434];
3535
3636impl<'a> HtmlParser<'a> {
@@ -87,21 +87,22 @@ impl<'a> HtmlParser<'a> {
8787 }
8888 // Check for closing tag
8989 if let Some(stop) = stop_tag
90− && self.starts_with("</") {
91− let saved = self.pos;
92− self.pos += 2;
93− let tag = self.parse_tag_name();
94− if tag.eq_ignore_ascii_case(stop) {
95− // Consume the rest of the closing tag
96− self.consume_until(">");
97− break;
98− }
99− // Not our closing tag, restore and let it be handled
100− self.pos = saved;
101− // This closing tag doesn't match; break out to let the
102− // parent handle it.
90+ && self.starts_with("</")
91+ {
92+ let saved = self.pos;
93+ self.pos += 2;
94+ let tag = self.parse_tag_name();
95+ if tag.eq_ignore_ascii_case(stop) {
96+ // Consume the rest of the closing tag
97+ self.consume_until(">");
10398 break;
10499 }
100+ // Not our closing tag, restore and let it be handled
101+ self.pos = saved;
102+ // This closing tag doesn't match; break out to let the
103+ // parent handle it.
104+ break;
105+ }
105106
106107 if self.starts_with("<!--") {
107108 self.pos += 4;
@@ -391,28 +392,28 @@ fn css_to_hl_attrs(styles: &[CssDecl]) -> (Vec<HlAttr>, Vec<CssDecl>) {
391392 // Check for padding-x pattern
392393 if key == "padding-left"
393394 && let Some(&pr) = style_map.get("padding-right")
394− && pr == val {
395− attrs.push(("padding-x".into(), Some(strip_px(val).into())));
396− continue;
397− }
398− if key == "padding-right"
399− && style_map.get("padding-left") == Some(&val.as_str()) {
400− // Already emitted as padding-x
401− continue;
402− }
395+ && pr == val
396+ {
397+ attrs.push(("padding-x".into(), Some(strip_px(val).into())));
398+ continue;
399+ }
400+ if key == "padding-right" && style_map.get("padding-left") == Some(&val.as_str()) {
401+ // Already emitted as padding-x
402+ continue;
403+ }
403404 leftover.push((key.clone(), val.clone()));
404405 }
405406 "padding-top" | "padding-bottom" => {
406407 if key == "padding-top"
407408 && let Some(&pb) = style_map.get("padding-bottom")
408− && pb == val {
409− attrs.push(("padding-y".into(), Some(strip_px(val).into())));
410− continue;
411− }
412− if key == "padding-bottom"
413− && style_map.get("padding-top") == Some(&val.as_str()) {
414− continue;
415− }
409+ && pb == val
410+ {
411+ attrs.push(("padding-y".into(), Some(strip_px(val).into())));
412+ continue;
413+ }
414+ if key == "padding-bottom" && style_map.get("padding-top") == Some(&val.as_str()) {
415+ continue;
416+ }
416417 leftover.push((key.clone(), val.clone()));
417418 }
418419 "padding-inline" => {
@@ -428,27 +429,27 @@ fn css_to_hl_attrs(styles: &[CssDecl]) -> (Vec<HlAttr>, Vec<CssDecl>) {
428429 "margin-left" | "margin-right" => {
429430 if key == "margin-left"
430431 && let Some(&mr) = style_map.get("margin-right")
431− && mr == val {
432− attrs.push(("margin-x".into(), Some(strip_px(val).into())));
433− continue;
434− }
435− if key == "margin-right"
436− && style_map.get("margin-left") == Some(&val.as_str()) {
437− continue;
438− }
432+ && mr == val
433+ {
434+ attrs.push(("margin-x".into(), Some(strip_px(val).into())));
435+ continue;
436+ }
437+ if key == "margin-right" && style_map.get("margin-left") == Some(&val.as_str()) {
438+ continue;
439+ }
439440 leftover.push((key.clone(), val.clone()));
440441 }
441442 "margin-top" | "margin-bottom" => {
442443 if key == "margin-top"
443444 && let Some(&mb) = style_map.get("margin-bottom")
444− && mb == val {
445− attrs.push(("margin-y".into(), Some(strip_px(val).into())));
446− continue;
447− }
448− if key == "margin-bottom"
449− && style_map.get("margin-top") == Some(&val.as_str()) {
450− continue;
451− }
445+ && mb == val
446+ {
447+ attrs.push(("margin-y".into(), Some(strip_px(val).into())));
448+ continue;
449+ }
450+ if key == "margin-bottom" && style_map.get("margin-top") == Some(&val.as_str()) {
451+ continue;
452+ }
452453 leftover.push((key.clone(), val.clone()));
453454 }
454455 "margin-inline" => {
@@ -668,12 +669,11 @@ fn unwrap_boilerplate(nodes: &[HtmlNode]) -> Vec<&HtmlNode> {
668669
669670 // If there's a single <html> element, unwrap it
670671 if meaningful.len() == 1
671− && let HtmlNode::Element {
672− tag, children, ..
673− } = meaningful[0]
674− && tag == "html" {
675− return unwrap_html_children(children);
676− }
672+ && let HtmlNode::Element { tag, children, .. } = meaningful[0]
673+ && tag == "html"
674+ {
675+ return unwrap_html_children(children);
676+ }
677677
678678 // Otherwise, check if there are any html/head/body tags mixed in
679679 let mut result = Vec::new();
@@ -719,9 +719,7 @@ fn unwrap_html_children(children: &[HtmlNode]) -> Vec<&HtmlNode> {
719719 // Skip <head> entirely
720720 let _ = children;
721721 }
722− HtmlNode::Element {
723− tag, children, ..
724− } if tag == "body" => {
722+ HtmlNode::Element { tag, children, .. } if tag == "body" => {
725723 for body_child in children {
726724 match body_child {
727725 HtmlNode::Text(t) if t.trim().is_empty() => {}
@@ -939,21 +937,21 @@ fn emit_element(
939937 // Alt text for images
940938 if tag == "img"
941939 && let Some(alt_text) = &alt
942− && !alt_text.is_empty() {
943− hl_attrs.push(("alt".into(), Some(alt_text.clone())));
944− }
940+ && !alt_text.is_empty()
941+ {
942+ hl_attrs.push(("alt".into(), Some(alt_text.clone())));
943+ }
945944
946945 // Passthrough HTML attributes that htmlang supports
947946 for (key, val) in attrs {
948947 match key.as_str() {
949948 "style" | "class" | "id" | "href" | "src" | "alt" => {}
950− "type" | "name" | "value" | "placeholder" | "disabled" | "required"
951− | "checked" | "readonly" | "maxlength" | "min" | "max" | "step"
952− | "action" | "method" | "target" | "rel" | "title" | "role"
953− | "aria-label" | "aria-hidden" | "tabindex" | "for" | "open"
954− | "autoplay" | "controls" | "loop" | "muted" | "preload"
955− | "width" | "height" | "loading" | "decoding" | "srcset" | "sizes"
956− | "media" | "datetime" | "cite" | "download" => {
949+ "type" | "name" | "value" | "placeholder" | "disabled" | "required" | "checked"
950+ | "readonly" | "maxlength" | "min" | "max" | "step" | "action" | "method"
951+ | "target" | "rel" | "title" | "role" | "aria-label" | "aria-hidden" | "tabindex"
952+ | "for" | "open" | "autoplay" | "controls" | "loop" | "muted" | "preload" | "width"
953+ | "height" | "loading" | "decoding" | "srcset" | "sizes" | "media" | "datetime"
954+ | "cite" | "download" => {
957955 if val.is_empty() {
958956 hl_attrs.push((key.clone(), None));
959957 } else {
@@ -982,10 +980,11 @@ fn emit_element(
982980 out.push_str(url);
983981 }
984982 } else if tag == "img"
985− && let Some(src_val) = &src {
986− out.push(' ');
987− out.push_str(src_val);
988− }
983+ && let Some(src_val) = &src
984+ {
985+ out.push(' ');
986+ out.push_str(src_val);
987+ }
989988
990989 // Attributes block
991990 if !hl_attrs.is_empty() {
@@ -1027,13 +1026,14 @@ fn emit_element(
10271026 // Alt text for images
10281027 if tag == "img"
10291028 && let Some(alt_text) = &alt
1030− && !alt_text.is_empty() {
1031− // Alt is emitted as an attribute already? No -- let's add it.
1032− // Actually we should add it to hl_attrs above. But since we
1033− // already flushed the line, add a comment.
1034− // Re-check: the image alt is typically an attribute.
1035− // We'll add it inline. Let's fix this by checking above.
1036− }
1029+ && !alt_text.is_empty()
1030+ {
1031+ // Alt is emitted as an attribute already? No -- let's add it.
1032+ // Actually we should add it to hl_attrs above. But since we
1033+ // already flushed the line, add a comment.
1034+ // Re-check: the image alt is typically an attribute.
1035+ // We'll add it inline. Let's fix this by checking above.
1036+ }
10371037
10381038 // Emit children
10391039 for child in children {
@@ -1154,7 +1154,8 @@ mod tests {
11541154
11551155 #[test]
11561156 fn simple_div() {
1157− let html = r#"<div style="padding: 20px; background-color: #fff; border-radius: 8px">Hello</div>"#;
1157+ let html =
1158+ r#"<div style="padding: 20px; background-color: #fff; border-radius: 8px">Hello</div>"#;
11581159 let result = convert(html);
11591160 assert!(result.contains("@el [padding 20, background #fff, rounded 8]"));
11601161 assert!(result.contains("Hello"));
src/fmt.rs+83−53
@@ -40,46 +40,67 @@ fn attr_category(key: &str) -> u8 {
4040
4141 match base {
4242 // Layout (parent)
43− "spacing" | "gap" | "gap-x" | "gap-y" | "wrap"
44− | "grid" | "grid-cols" | "grid-rows"
43+ "spacing" | "gap" | "gap-x" | "gap-y" | "wrap" | "grid" | "grid-cols" | "grid-rows"
4544 | "column-count" | "column-gap" | "column-width" | "column-rule" => 0,
4645 // Sizing
4746 "width" | "height" | "min-width" | "max-width" | "min-height" | "max-height"
4847 | "flex-grow" | "flex-shrink" | "flex-basis" => 1,
4948 // Padding & margin
50− "padding" | "padding-x" | "padding-y" | "padding-inline" | "padding-block"
51− | "margin" | "margin-x" | "margin-y" | "margin-inline" | "margin-block" => 2,
49+ "padding" | "padding-x" | "padding-y" | "padding-inline" | "padding-block" | "margin"
50+ | "margin-x" | "margin-y" | "margin-inline" | "margin-block" => 2,
5251 // Alignment
53− "center-x" | "center-y" | "align-left" | "align-right"
54− | "align-top" | "align-bottom" | "justify-content" | "align-items"
55− | "place-items" | "place-self" | "place-content" => 3,
52+ "center-x" | "center-y" | "align-left" | "align-right" | "align-top" | "align-bottom"
53+ | "justify-content" | "align-items" | "place-items" | "place-self" | "place-content" => 3,
5654 // Positioning
5755 "position" | "top" | "right" | "bottom" | "left" | "z-index" | "order" | "inset"
58− | "col-span" | "row-span" | "display" | "visibility"
59− | "overflow" | "overflow-x" | "overflow-y" | "hidden" => 4,
56+ | "col-span" | "row-span" | "display" | "visibility" | "overflow" | "overflow-x"
57+ | "overflow-y" | "hidden" => 4,
6058 // Visual style
61− "background" | "background-size" | "background-position" | "background-repeat"
62− | "color" | "opacity" | "accent-color" | "caret-color"
63− | "background-image" | "background-blend-mode" | "mix-blend-mode" | "isolation" => 5,
59+ "background"
60+ | "background-size"
61+ | "background-position"
62+ | "background-repeat"
63+ | "color"
64+ | "opacity"
65+ | "accent-color"
66+ | "caret-color"
67+ | "background-image"
68+ | "background-blend-mode"
69+ | "mix-blend-mode"
70+ | "isolation" => 5,
6471 // Border & shape
65− "border" | "border-top" | "border-bottom" | "border-left" | "border-right"
66− | "rounded" | "outline" | "shadow" | "text-shadow"
67− | "border-collapse" | "border-spacing" => 6,
72+ "border" | "border-top" | "border-bottom" | "border-left" | "border-right" | "rounded"
73+ | "outline" | "shadow" | "text-shadow" | "border-collapse" | "border-spacing" => 6,
6874 // Typography
69− "bold" | "italic" | "underline" | "size" | "font"
70− | "text-align" | "line-height" | "letter-spacing" | "text-transform"
71− | "white-space" | "text-overflow" | "word-break" | "overflow-wrap"
72− | "text-decoration" | "text-decoration-color" | "text-decoration-thickness"
73− | "text-decoration-style" | "text-underline-offset" | "list-style"
74− | "text-indent" | "hyphens" | "writing-mode" => 7,
75+ "bold"
76+ | "italic"
77+ | "underline"
78+ | "size"
79+ | "font"
80+ | "text-align"
81+ | "line-height"
82+ | "letter-spacing"
83+ | "text-transform"
84+ | "white-space"
85+ | "text-overflow"
86+ | "word-break"
87+ | "overflow-wrap"
88+ | "text-decoration"
89+ | "text-decoration-color"
90+ | "text-decoration-thickness"
91+ | "text-decoration-style"
92+ | "text-underline-offset"
93+ | "list-style"
94+ | "text-indent"
95+ | "hyphens"
96+ | "writing-mode" => 7,
7597 // Effects & interaction
76− "transform" | "transition" | "animation" | "cursor" | "backdrop-filter"
77− | "filter" | "pointer-events" | "user-select" | "aspect-ratio"
78− | "object-fit" | "object-position" | "resize"
79− | "clip-path" => 8,
98+ "transform" | "transition" | "animation" | "cursor" | "backdrop-filter" | "filter"
99+ | "pointer-events" | "user-select" | "aspect-ratio" | "object-fit" | "object-position"
100+ | "resize" | "clip-path" => 8,
80101 // Container queries & scroll
81− "container" | "container-name" | "container-type"
82− | "scroll-snap-type" | "scroll-snap-align" | "scroll-behavior" => 9,
102+ "container" | "container-name" | "container-type" | "scroll-snap-type"
103+ | "scroll-snap-align" | "scroll-behavior" => 9,
83104 // Identity
84105 "id" | "class" => 10,
85106 // HTML passthrough / form / accessibility
@@ -95,7 +116,8 @@ fn sort_attrs(attrs_str: &str) -> String {
95116 let key = part.split_whitespace().next().unwrap_or("");
96117 attr_category(key)
97118 });
98− parts.iter()
119+ parts
120+ .iter()
99121 .map(|p| p.trim())
100122 .filter(|p| !p.is_empty())
101123 .collect::<Vec<_>>()
@@ -221,15 +243,12 @@ pub fn format(input: &str) -> String {
221243 let indented_len = level * 2 + formatted.len();
222244 // Stay wrapped if the original was wrapped (hysteresis).
223245 let should_wrap = (indented_len > MAX_LINE_WIDTH)
224− || (bracket_was_wrapped
225− && attrs_count > 1
226− && indented_len > WRAP_MIN_WIDTH);
227− if should_wrap
228− && let Some(wrapped) = wrap_attrs(&formatted, level) {
229− output.push_str(&wrapped);
230− bracket_was_wrapped = false;
231− continue;
232− }
246+ || (bracket_was_wrapped && attrs_count > 1 && indented_len > WRAP_MIN_WIDTH);
247+ if should_wrap && let Some(wrapped) = wrap_attrs(&formatted, level) {
248+ output.push_str(&wrapped);
249+ bracket_was_wrapped = false;
250+ continue;
251+ }
233252 output.push_str(&" ".repeat(level));
234253 output.push_str(&formatted);
235254 if let Some(cmt) = pending_comment.take() {
@@ -274,21 +293,23 @@ pub fn format(input: &str) -> String {
274293
275294 let indented_len = level * 2 + formatted.len();
276295 // Single-line emission — only wrap when we strictly exceed the ceiling.
277− if indented_len > MAX_LINE_WIDTH && formatted.contains('[')
278− && let Some(mut wrapped) = wrap_attrs(&formatted, level) {
279− if let Some(cmt) = &trailing {
280− // Reattach comment on the final `]` line.
281− if wrapped.ends_with('\n') {
282− wrapped.pop();
283− }
284− wrapped.push(' ');
285− wrapped.push_str(cmt);
286− wrapped.push('\n');
296+ if indented_len > MAX_LINE_WIDTH
297+ && formatted.contains('[')
298+ && let Some(mut wrapped) = wrap_attrs(&formatted, level)
299+ {
300+ if let Some(cmt) = &trailing {
301+ // Reattach comment on the final `]` line.
302+ if wrapped.ends_with('\n') {
303+ wrapped.pop();
287304 }
288− output.push_str(&wrapped);
289− indent_stack.push(raw_indent);
290− continue;
305+ wrapped.push(' ');
306+ wrapped.push_str(cmt);
307+ wrapped.push('\n');
291308 }
309+ output.push_str(&wrapped);
310+ indent_stack.push(raw_indent);
311+ continue;
312+ }
292313 let _ = attrs_count;
293314
294315 output.push_str(&" ".repeat(level));
@@ -350,7 +371,9 @@ fn format_line_with_brackets(line: &str) -> String {
350371}
351372
352373fn count_attrs(line: &str) -> usize {
353− let Some(start) = line.find('[') else { return 0 };
374+ let Some(start) = line.find('[') else {
375+ return 0;
376+ };
354377 let Some(end) = line.rfind(']') else { return 0 };
355378 if end <= start {
356379 return 0;
@@ -395,7 +418,11 @@ fn wrap_attrs(line: &str, indent_level: usize) -> Option<String> {
395418 let attrs_inner = &line[bracket_start + 1..bracket_end];
396419 let after = &line[bracket_end + 1..];
397420
398− let parts: Vec<&str> = attrs_inner.split(',').map(|s| s.trim()).filter(|s| !s.is_empty()).collect();
421+ let parts: Vec<&str> = attrs_inner
422+ .split(',')
423+ .map(|s| s.trim())
424+ .filter(|s| !s.is_empty())
425+ .collect();
399426 if parts.len() <= 1 {
400427 return None;
401428 }
@@ -444,7 +471,10 @@ mod tests {
444471 fn preserves_trailing_comment() {
445472 let src = "@text [bold] hello -- greeting\n";
446473 let out = format(src);
447− assert!(out.contains("-- greeting"), "trailing comment should survive format: {out}");
474+ assert!(
475+ out.contains("-- greeting"),
476+ "trailing comment should survive format: {out}"
477+ );
448478 }
449479
450480 #[test]
src/main.rs+970−499
@@ -90,11 +90,10 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
9090 }
9191 }
9292
93− let has_errors = result
94− .diagnostics
95− .iter()
96− .any(|d| d.severity == htmlang::parser::Severity::Error
97− || (cfg.strict && d.severity == htmlang::parser::Severity::Warning));
93+ let has_errors = result.diagnostics.iter().any(|d| {
94+ d.severity == htmlang::parser::Severity::Error
95+ || (cfg.strict && d.severity == htmlang::parser::Severity::Warning)
96+ });
9897
9998 let out_path = match cfg.output_path {
10099 Some(p) => PathBuf::from(p),
@@ -126,7 +125,10 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
126125 let map_path = out_path.with_extension("html.map");
127126 let source_map = htmlang::codegen::generate_source_map(
128127 &result.document,
129− &Path::new(input_path).file_name().unwrap_or_default().to_string_lossy(),
128+ &Path::new(input_path)
129+ .file_name()
130+ .unwrap_or_default()
131+ .to_string_lossy(),
130132 );
131133 let _ = fs::write(&map_path, &source_map);
132134 }
@@ -137,7 +139,10 @@ fn compile(input_path: &str, cfg: &CompileConfig) -> (bool, Vec<PathBuf>) {
137139}
138140
139141fn json_escape_string(s: &str) -> String {
140− let escaped = s.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n");
142+ let escaped = s
143+ .replace('\\', "\\\\")
144+ .replace('"', "\\\"")
145+ .replace('\n', "\\n");
141146 format!("\"{}\"", escaped)
142147}
143148
@@ -147,7 +152,8 @@ fn json_array(items: impl Iterator<Item = String>) -> String {
147152}
148153
149154fn json_object(fields: &[(&str, String)]) -> String {
150− let inner: Vec<String> = fields.iter()
155+ let inner: Vec<String> = fields
156+ .iter()
151157 .map(|(k, v)| format!("{}:{}", json_escape_string(k), v))
152158 .collect();
153159 format!("{{{}}}", inner.join(","))
@@ -219,33 +225,34 @@ fn bundle_assets(html: &str, base_dir: &Path) -> String {
219225 {
220226 let asset_path = base_dir.join(path_str);
221227 if asset_path.exists()
222− && let Ok(mut file) = std::fs::File::open(&asset_path) {
223− let mut buf = Vec::new();
224− if file.read_to_end(&mut buf).is_ok() {
225− let mime = match asset_path.extension().and_then(|e| e.to_str()) {
226− Some("png") => "image/png",
227− Some("jpg") | Some("jpeg") => "image/jpeg",
228− Some("gif") => "image/gif",
229− Some("svg") => "image/svg+xml",
230− Some("webp") => "image/webp",
231− Some("ico") => "image/x-icon",
232− Some("woff2") => "font/woff2",
233− Some("woff") => "font/woff",
234− Some("ttf") => "font/ttf",
235− Some("otf") => "font/otf",
236− Some("avif") => "image/avif",
237− _ => "application/octet-stream",
238− };
239− use std::fmt::Write as FmtWrite;
240− let mut b64 = String::new();
241− // Simple base64 encoding
242− let encoded = base64_encode(&buf);
243− let _ = write!(b64, "data:{};base64,{}", mime, encoded);
244− output.push_str(&b64);
245− remaining = &remaining[end..];
246− continue;
247− }
228+ && let Ok(mut file) = std::fs::File::open(&asset_path)
229+ {
230+ let mut buf = Vec::new();
231+ if file.read_to_end(&mut buf).is_ok() {
232+ let mime = match asset_path.extension().and_then(|e| e.to_str()) {
233+ Some("png") => "image/png",
234+ Some("jpg") | Some("jpeg") => "image/jpeg",
235+ Some("gif") => "image/gif",
236+ Some("svg") => "image/svg+xml",
237+ Some("webp") => "image/webp",
238+ Some("ico") => "image/x-icon",
239+ Some("woff2") => "font/woff2",
240+ Some("woff") => "font/woff",
241+ Some("ttf") => "font/ttf",
242+ Some("otf") => "font/otf",
243+ Some("avif") => "image/avif",
244+ _ => "application/octet-stream",
245+ };
246+ use std::fmt::Write as FmtWrite;
247+ let mut b64 = String::new();
248+ // Simple base64 encoding
249+ let encoded = base64_encode(&buf);
250+ let _ = write!(b64, "data:{};base64,{}", mime, encoded);
251+ output.push_str(&b64);
252+ remaining = &remaining[end..];
253+ continue;
248254 }
255+ }
249256 }
250257 // If we couldn't inline, keep original path
251258 output.push_str(&remaining[..end]);
@@ -311,7 +318,8 @@ fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &st
311318 let mut errors = String::new();
312319 for d in diagnostics {
313320 let prefix = severity_label(d.severity);
314− let escaped = d.message
321+ let escaped = d
322+ .message
315323 .replace('&', "&amp;")
316324 .replace('<', "&lt;")
317325 .replace('>', "&gt;");
@@ -324,7 +332,10 @@ fn generate_error_overlay(diagnostics: &[htmlang::parser::Diagnostic], file: &st
324332 prefix, prefix, location, escaped
325333 ));
326334 if let Some(ref src) = d.source_line {
327− let src_esc = src.replace('&', "&amp;").replace('<', "&lt;").replace('>', "&gt;");
335+ let src_esc = src
336+ .replace('&', "&amp;")
337+ .replace('<', "&lt;")
338+ .replace('>', "&gt;");
328339 errors.push_str(&format!("<pre class=\"src\">{}</pre>", src_esc));
329340 if let Some(col) = d.column {
330341 // Render a caret indicator underneath the source line.
@@ -362,10 +373,11 @@ h1{{color:#ff6b6b;margin-bottom:1rem;font-size:1.5rem}}
362373fn init_project(dir: &str, template_name: Option<&str>) {
363374 let dir = Path::new(dir);
364375 if dir.to_str() != Some(".")
365− && let Err(e) = fs::create_dir_all(dir) {
366− eprintln!("error: cannot create directory '{}': {}", dir.display(), e);
367− process::exit(1);
368− }
376+ && let Err(e) = fs::create_dir_all(dir)
377+ {
378+ eprintln!("error: cannot create directory '{}': {}", dir.display(), e);
379+ process::exit(1);
380+ }
369381
370382 let index_path = dir.join("index.hl");
371383 if index_path.exists() {
@@ -374,7 +386,8 @@ fn init_project(dir: &str, template_name: Option<&str>) {
374386 }
375387
376388 let template = match template_name {
377− Some("blog") => r#"@page My Blog
389+ Some("blog") => {
390+ r#"@page My Blog
378391@let primary #3b82f6
379392@let bg-dark #1a1a2e
380393
@@ -398,8 +411,10 @@ fn init_project(dir: &str, template_name: Option<&str>) {
398411
399412 @footer [padding-top 20, border-top 1 #eee]
400413 @text [color #888, size 14, text-align center] Built with htmlang
401−"#,
402− Some("docs") => r#"@page Documentation
414+"#
415+ }
416+ Some("docs") => {
417+ r#"@page Documentation
403418@let primary #3b82f6
404419@let sidebar-width 250
405420
@@ -424,8 +439,10 @@ fn init_project(dir: &str, template_name: Option<&str>) {
424439 @text [bold, size 24] Usage
425440 @paragraph [line-height 1.8]
426441 Import and use the library in your project.
427−"#,
428− Some("portfolio") => r#"@page Portfolio
442+"#
443+ }
444+ Some("portfolio") => {
445+ r#"@page Portfolio
429446@let primary #3b82f6
430447@let accent #8b5cf6
431448
@@ -460,8 +477,10 @@ fn init_project(dir: &str, template_name: Option<&str>) {
460477
461478 @footer [padding 30 40, background #1a1a2e, color white, text-align center]
462479 @text [size 14] Built with htmlang
463−"#,
464− _ => r#"@page My Site
480+"#
481+ }
482+ _ => {
483+ r#"@page My Site
465484@let primary #3b82f6
466485
467486@column [max-width 800, center-x, padding 40, spacing 20]
@@ -474,7 +493,8 @@ fn init_project(dir: &str, template_name: Option<&str>) {
474493 @row [spacing 10]
475494 @el [padding 12 24, background $primary, rounded 8, cursor pointer, hover:background #2563eb, transition all 0.15s ease] > @link https://github.com/nicholasgasior/htmlang
476495 @text [color white, bold] Documentation
477−"#,
496+"#
497+ }
478498 };
479499
480500 match fs::write(&index_path, template) {
@@ -532,10 +552,15 @@ fn generate_sitemap(dir: &str, base_url: &str) {
532552 eprintln!("no .hl files found in {}", dir.display());
533553 process::exit(1);
534554 }
535− let mut xml = String::from("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n");
555+ let mut xml = String::from(
556+ "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<urlset xmlns=\"http://www.sitemaps.org/schemas/sitemap/0.9\">\n",
557+ );
536558 for file in &hl_files {
537559 let rel = file.strip_prefix(dir).unwrap_or(file);
538− let url_path = rel.with_extension("html").to_string_lossy().replace('\\', "/");
560+ let url_path = rel
561+ .with_extension("html")
562+ .to_string_lossy()
563+ .replace('\\', "/");
539564 let url = if url_path == "index.html" {
540565 format!("{}/", base_url.trim_end_matches('/'))
541566 } else {
@@ -543,10 +568,15 @@ fn generate_sitemap(dir: &str, base_url: &str) {
543568 };
544569
545570 // Get file modification time for <lastmod>
546− let lastmod = file.metadata().ok()
571+ let lastmod = file
572+ .metadata()
573+ .ok()
547574 .and_then(|m| m.modified().ok())
548575 .map(|t| {
549− let secs = t.duration_since(std::time::UNIX_EPOCH).unwrap_or_default().as_secs();
576+ let secs = t
577+ .duration_since(std::time::UNIX_EPOCH)
578+ .unwrap_or_default()
579+ .as_secs();
550580 let days = secs / 86400;
551581 // Simple date calculation from epoch days
552582 let (y, m, d) = epoch_days_to_date(days);
@@ -621,14 +651,21 @@ fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings:
621651 if let htmlang::ast::Node::Element(elem) = node {
622652 // Deeply nested elements (>10 levels)
623653 if depth > 10 {
624− warnings.push(format!("{}:{}:lint: deeply nested element ({} levels) — consider simplifying", path, elem.line_num, depth));
654+ warnings.push(format!(
655+ "{}:{}:lint: deeply nested element ({} levels) — consider simplifying",
656+ path, elem.line_num, depth
657+ ));
625658 }
626659
627660 // @image without alt
628661 if elem.kind == htmlang::ast::ElementKind::Image
629− && !elem.attrs.iter().any(|a| a.key == "alt") {
630− warnings.push(format!("{}:{}:lint: @image missing 'alt' attribute (accessibility)", path, elem.line_num));
631− }
662+ && !elem.attrs.iter().any(|a| a.key == "alt")
663+ {
664+ warnings.push(format!(
665+ "{}:{}:lint: @image missing 'alt' attribute (accessibility)",
666+ path, elem.line_num
667+ ));
668+ }
632669
633670 // @link without content or aria-label
634671 if elem.kind == htmlang::ast::ElementKind::Link {
@@ -636,21 +673,35 @@ fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings:
636673 let has_children = !elem.children.is_empty();
637674 let has_arg_text = elem.argument.as_ref().is_some_and(|_| false);
638675 if !has_aria && !has_children && !has_arg_text {
639− warnings.push(format!("{}:{}:lint: @link has no visible text or aria-label (accessibility)", path, elem.line_num));
676+ warnings.push(format!(
677+ "{}:{}:lint: @link has no visible text or aria-label (accessibility)",
678+ path, elem.line_num
679+ ));
640680 }
641681 }
642682
643683 // @input without type
644684 if elem.kind == htmlang::ast::ElementKind::Input
645− && !elem.attrs.iter().any(|a| a.key == "type") {
646− warnings.push(format!("{}:{}:lint: @input missing 'type' attribute", path, elem.line_num));
647− }
685+ && !elem.attrs.iter().any(|a| a.key == "type")
686+ {
687+ warnings.push(format!(
688+ "{}:{}:lint: @input missing 'type' attribute",
689+ path, elem.line_num
690+ ));
691+ }
648692
649693 // Empty containers (no children, no text)
650− if matches!(elem.kind,
651− htmlang::ast::ElementKind::Row | htmlang::ast::ElementKind::Column | htmlang::ast::ElementKind::El
652− ) && elem.children.is_empty() {
653− warnings.push(format!("{}:{}:lint: empty container (@{}) has no children", path, elem.line_num,
694+ if matches!(
695+ elem.kind,
696+ htmlang::ast::ElementKind::Row
697+ | htmlang::ast::ElementKind::Column
698+ | htmlang::ast::ElementKind::El
699+ ) && elem.children.is_empty()
700+ {
701+ warnings.push(format!(
702+ "{}:{}:lint: empty container (@{}) has no children",
703+ path,
704+ elem.line_num,
654705 match elem.kind {
655706 htmlang::ast::ElementKind::Row => "row",
656707 htmlang::ast::ElementKind::Column => "column",
@@ -661,9 +712,13 @@ fn lint_nodes(nodes: &[htmlang::ast::Node], path: &str, depth: usize, warnings:
661712
662713 // @button without type
663714 if elem.kind == htmlang::ast::ElementKind::Button
664− && !elem.attrs.iter().any(|a| a.key == "type") {
665− warnings.push(format!("{}:{}:lint: @button missing 'type' attribute (defaults to submit)", path, elem.line_num));
666− }
715+ && !elem.attrs.iter().any(|a| a.key == "type")
716+ {
717+ warnings.push(format!(
718+ "{}:{}:lint: @button missing 'type' attribute (defaults to submit)",
719+ path, elem.line_num
720+ ));
721+ }
667722
668723 lint_nodes(&elem.children, path, depth + 1, warnings);
669724 }
@@ -685,7 +740,12 @@ fn stats_file(path: &str) {
685740 let mut element_count = 0;
686741 let mut colors = std::collections::HashSet::new();
687742 let mut fonts = std::collections::HashSet::new();
688− count_elements(&result.document.nodes, &mut element_count, &mut colors, &mut fonts);
743+ count_elements(
744+ &result.document.nodes,
745+ &mut element_count,
746+ &mut colors,
747+ &mut fonts,
748+ );
689749
690750 // Count CSS rules (approximate from generated style block)
691751 let css_rules = html.matches('{').count().saturating_sub(1); // subtract the html/head/body structure
@@ -694,7 +754,11 @@ fn stats_file(path: &str) {
694754 let output_bytes = html.len();
695755
696756 eprintln!("--- {} ---", path);
697− eprintln!(" source size: {} bytes ({} lines)", source_bytes, input.lines().count());
757+ eprintln!(
758+ " source size: {} bytes ({} lines)",
759+ source_bytes,
760+ input.lines().count()
761+ );
698762 eprintln!(" output size: {} bytes", output_bytes);
699763 eprintln!(" elements: {}", element_count);
700764 eprintln!(" CSS rules: ~{}", css_rules);
@@ -702,18 +766,47 @@ fn stats_file(path: &str) {
702766 if !colors.is_empty() {
703767 let mut sorted: Vec<_> = colors.iter().collect();
704768 sorted.sort();
705− eprintln!(" {}", sorted.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
769+ eprintln!(
770+ " {}",
771+ sorted
772+ .iter()
773+ .map(|s| s.as_str())
774+ .collect::<Vec<_>>()
775+ .join(", ")
776+ );
706777 }
707778 eprintln!(" unique fonts: {}", fonts.len());
708779 if !fonts.is_empty() {
709780 let mut sorted: Vec<_> = fonts.iter().collect();
710781 sorted.sort();
711− eprintln!(" {}", sorted.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(", "));
782+ eprintln!(
783+ " {}",
784+ sorted
785+ .iter()
786+ .map(|s| s.as_str())
787+ .collect::<Vec<_>>()
788+ .join(", ")
789+ );
712790 }
713− if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
714− eprintln!(" errors: {}", result.diagnostics.iter().filter(|d| d.severity == htmlang::parser::Severity::Error).count());
791+ if result
792+ .diagnostics
793+ .iter()
794+ .any(|d| d.severity == htmlang::parser::Severity::Error)
795+ {
796+ eprintln!(
797+ " errors: {}",
798+ result
799+ .diagnostics
800+ .iter()
801+ .filter(|d| d.severity == htmlang::parser::Severity::Error)
802+ .count()
803+ );
715804 }
716− let warn_count = result.diagnostics.iter().filter(|d| d.severity == htmlang::parser::Severity::Warning).count();
805+ let warn_count = result
806+ .diagnostics
807+ .iter()
808+ .filter(|d| d.severity == htmlang::parser::Severity::Warning)
809+ .count();
717810 if warn_count > 0 {
718811 eprintln!(" warnings: {}", warn_count);
719812 }
@@ -733,13 +826,15 @@ fn count_elements(
733826 // Strip pseudo/media prefixes for color/font detection
734827 let base_key = key.split(':').next_back().unwrap_or(key);
735828 if matches!(base_key, "color" | "background")
736− && let Some(ref v) = attr.value {
737− colors.insert(v.clone());
738− }
829+ && let Some(ref v) = attr.value
830+ {
831+ colors.insert(v.clone());
832+ }
739833 if base_key == "font"
740− && let Some(ref v) = attr.value {
741− fonts.insert(v.clone());
742− }
834+ && let Some(ref v) = attr.value
835+ {
836+ fonts.insert(v.clone());
837+ }
743838 }
744839 count_elements(&elem.children, count, colors, fonts);
745840 }
@@ -755,40 +850,47 @@ fn extract_shared_css(html_files: &[PathBuf], out_dir: &Path) {
755850 if let Ok(html) = fs::read_to_string(file) {
756851 // Extract CSS between <style> and </style>
757852 if let Some(start) = html.find("<style>")
758− && let Some(end) = html[start..].find("</style>") {
759− let css = &html[start + 7..start + end];
760− // Extract individual rules (class-based)
761− let mut seen_in_file = std::collections::HashSet::new();
762− let mut i = 0;
763− let bytes = css.as_bytes();
764− while i < bytes.len() {
765− if bytes[i] == b'.' || bytes[i] == b'@' {
766− // Find end of rule block
767− let start_pos = i;
768− let mut depth = 0;
769− let mut found_open = false;
770− while i < bytes.len() {
771− if bytes[i] == b'{' { depth += 1; found_open = true; }
772− else if bytes[i] == b'}' {
773− depth -= 1;
774− if found_open && depth == 0 { i += 1; break; }
853+ && let Some(end) = html[start..].find("</style>")
854+ {
855+ let css = &html[start + 7..start + end];
856+ // Extract individual rules (class-based)
857+ let mut seen_in_file = std::collections::HashSet::new();
858+ let mut i = 0;
859+ let bytes = css.as_bytes();
860+ while i < bytes.len() {
861+ if bytes[i] == b'.' || bytes[i] == b'@' {
862+ // Find end of rule block
863+ let start_pos = i;
864+ let mut depth = 0;
865+ let mut found_open = false;
866+ while i < bytes.len() {
867+ if bytes[i] == b'{' {
868+ depth += 1;
869+ found_open = true;
870+ } else if bytes[i] == b'}' {
871+ depth -= 1;
872+ if found_open && depth == 0 {
873+ i += 1;
874+ break;
775875 }
776− i += 1;
777876 }
778− let rule = &css[start_pos..i];
779− if !rule.is_empty() && !seen_in_file.contains(rule) {
780− seen_in_file.insert(rule.to_string());
781− *rule_counts.entry(rule.to_string()).or_insert(0) += 1;
782− }
783− } else {
784877 i += 1;
785878 }
879+ let rule = &css[start_pos..i];
880+ if !rule.is_empty() && !seen_in_file.contains(rule) {
881+ seen_in_file.insert(rule.to_string());
882+ *rule_counts.entry(rule.to_string()).or_insert(0) += 1;
883+ }
884+ } else {
885+ i += 1;
786886 }
787887 }
888+ }
788889 }
789890 }
790891 // Rules appearing in ALL files are shared
791− let shared_rules: Vec<&String> = rule_counts.iter()
892+ let shared_rules: Vec<&String> = rule_counts
893+ .iter()
792894 .filter(|(_, count)| **count == total)
793895 .map(|(rule, _)| rule)
794896 .collect();
@@ -797,54 +899,68 @@ fn extract_shared_css(html_files: &[PathBuf], out_dir: &Path) {
797899 }
798900 let shared_set: std::collections::HashSet<&String> = shared_rules.iter().copied().collect();
799901 let shared_css_path = out_dir.join("shared.css");
800− let shared_css: String = shared_rules.iter().map(|r| r.as_str()).collect::<Vec<_>>().join("\n");
902+ let shared_css: String = shared_rules
903+ .iter()
904+ .map(|r| r.as_str())
905+ .collect::<Vec<_>>()
906+ .join("\n");
801907 if fs::write(&shared_css_path, &shared_css).is_err() {
802908 return;
803909 }
804− eprintln!("extracted {} shared CSS rules to {}", shared_rules.len(), shared_css_path.display());
910+ eprintln!(
911+ "extracted {} shared CSS rules to {}",
912+ shared_rules.len(),
913+ shared_css_path.display()
914+ );
805915
806916 // Remove shared rules from individual files and inject <link> tag
807917 for file in html_files {
808918 if let Ok(html) = fs::read_to_string(file)
809919 && let Some(style_start) = html.find("<style>")
810− && let Some(style_end_rel) = html[style_start..].find("</style>") {
811− let css = &html[style_start + 7..style_start + style_end_rel];
812− // Rebuild CSS without shared rules
813− let mut filtered = String::new();
814− let mut i = 0;
815− let bytes = css.as_bytes();
920+ && let Some(style_end_rel) = html[style_start..].find("</style>")
921+ {
922+ let css = &html[style_start + 7..style_start + style_end_rel];
923+ // Rebuild CSS without shared rules
924+ let mut filtered = String::new();
925+ let mut i = 0;
926+ let bytes = css.as_bytes();
927+ while i < bytes.len() {
928+ if bytes[i] == b'.' || bytes[i] == b'@' {
929+ let start_pos = i;
930+ let mut depth = 0;
931+ let mut found_open = false;
816932 while i < bytes.len() {
817− if bytes[i] == b'.' || bytes[i] == b'@' {
818− let start_pos = i;
819− let mut depth = 0;
820− let mut found_open = false;
821− while i < bytes.len() {
822− if bytes[i] == b'{' { depth += 1; found_open = true; }
823− else if bytes[i] == b'}' {
824− depth -= 1;
825− if found_open && depth == 0 { i += 1; break; }
826− }
933+ if bytes[i] == b'{' {
934+ depth += 1;
935+ found_open = true;
936+ } else if bytes[i] == b'}' {
937+ depth -= 1;
938+ if found_open && depth == 0 {
827939 i += 1;
940+ break;
828941 }
829− let rule = &css[start_pos..i];
830− if !shared_set.contains(&rule.to_string()) {
831− filtered.push_str(rule);
832− }
833− } else {
834− filtered.push(css.as_bytes()[i] as char);
835− i += 1;
836942 }
943+ i += 1;
837944 }
838− let link_tag = "<link rel=\"stylesheet\" href=\"shared.css\">";
839− let new_html = format!(
840− "{}{}<style>{}</style>{}",
841− &html[..style_start],
842− link_tag,
843− filtered,
844− &html[style_start + style_end_rel + 8..],
845− );
846− let _ = fs::write(file, new_html);
945+ let rule = &css[start_pos..i];
946+ if !shared_set.contains(&rule.to_string()) {
947+ filtered.push_str(rule);
948+ }
949+ } else {
950+ filtered.push(css.as_bytes()[i] as char);
951+ i += 1;
847952 }
953+ }
954+ let link_tag = "<link rel=\"stylesheet\" href=\"shared.css\">";
955+ let new_html = format!(
956+ "{}{}<style>{}</style>{}",
957+ &html[..style_start],
958+ link_tag,
959+ filtered,
960+ &html[style_start + style_end_rel + 8..],
961+ );
962+ let _ = fs::write(file, new_html);
963+ }
848964 }
849965}
850966
@@ -892,7 +1008,10 @@ fn load_config(target: &Path) -> ProjectConfig {
8921008 let config_path = if target.is_dir() {
8931009 target.join("htmlang.toml")
8941010 } else {
895− target.parent().unwrap_or(Path::new(".")).join("htmlang.toml")
1011+ target
1012+ .parent()
1013+ .unwrap_or(Path::new("."))
1014+ .join("htmlang.toml")
8961015 };
8971016
8981017 let content = match fs::read_to_string(&config_path) {
@@ -911,7 +1030,9 @@ fn load_config(target: &Path) -> ProjectConfig {
9111030 if !matches!(section, "variables" | "breakpoints" | "build" | "watch") {
9121031 eprintln!(
9131032 "warning: {}:{}: unknown section '[{}]' (expected: variables, breakpoints, build, watch)",
914− config_path.display(), line_num + 1, section
1033+ config_path.display(),
1034+ line_num + 1,
1035+ section
9151036 );
9161037 }
9171038 continue;
@@ -926,7 +1047,9 @@ fn load_config(target: &Path) -> ProjectConfig {
9261047 _ => {
9271048 eprintln!(
9281049 "warning: {}:{}: unknown key '{}' (expected: output, port)",
929− config_path.display(), line_num + 1, key
1050+ config_path.display(),
1051+ line_num + 1,
1052+ key
9301053 );
9311054 }
9321055 },
@@ -938,7 +1061,9 @@ fn load_config(target: &Path) -> ProjectConfig {
9381061 _ => {
9391062 eprintln!(
9401063 "warning: {}:{}: unknown build key '{}' (expected: dev, minify, compat, strict)",
941− config_path.display(), line_num + 1, key
1064+ config_path.display(),
1065+ line_num + 1,
1066+ key
9421067 );
9431068 }
9441069 },
@@ -947,7 +1072,9 @@ fn load_config(target: &Path) -> ProjectConfig {
9471072 _ => {
9481073 eprintln!(
9491074 "warning: {}:{}: unknown watch key '{}' (expected: debounce_ms)",
950− config_path.display(), line_num + 1, key
1075+ config_path.display(),
1076+ line_num + 1,
1077+ key
9511078 );
9521079 }
9531080 },
@@ -955,7 +1082,9 @@ fn load_config(target: &Path) -> ProjectConfig {
9551082 config.variables.push((key.to_string(), value.to_string()));
9561083 }
9571084 "breakpoints" => {
958− config.breakpoints.push((key.to_string(), value.to_string()));
1085+ config
1086+ .breakpoints
1087+ .push((key.to_string(), value.to_string()));
9591088 }
9601089 _ => {} // already warned about unknown section
9611090 }
@@ -1102,16 +1231,19 @@ fn main() {
11021231 let _ = fs::create_dir_all(out);
11031232 }
11041233 // Pre-create output directories for each file (must be done before parallel compilation)
1105− let effective_outs: Vec<Option<String>> = hl_files.iter().map(|file| {
1106− out_dir.map(|o| {
1107− let rel = file.strip_prefix(dir).unwrap_or(file);
1108− let out_path = Path::new(o).join(rel).with_extension("html");
1109− if let Some(parent) = out_path.parent() {
1110− let _ = fs::create_dir_all(parent);
1111− }
1112− out_path.to_string_lossy().to_string()
1234+ let effective_outs: Vec<Option<String>> = hl_files
1235+ .iter()
1236+ .map(|file| {
1237+ out_dir.map(|o| {
1238+ let rel = file.strip_prefix(dir).unwrap_or(file);
1239+ let out_path = Path::new(o).join(rel).with_extension("html");
1240+ if let Some(parent) = out_path.parent() {
1241+ let _ = fs::create_dir_all(parent);
1242+ }
1243+ out_path.to_string_lossy().to_string()
1244+ })
11131245 })
1114− }).collect();
1246+ .collect();
11151247
11161248 // Build content hash cache for incremental compilation
11171249 let cache_dir = dir.join(".htmlang-cache");
@@ -1128,34 +1260,45 @@ fn main() {
11281260 let cache_dir = &cache_dir;
11291261 s.spawn(move || {
11301262 // Content hash-based caching: skip if file content hasn't changed
1131− let hash_file_name = file.file_name().unwrap_or_default().to_string_lossy().to_string() + ".hash";
1263+ let hash_file_name = file
1264+ .file_name()
1265+ .unwrap_or_default()
1266+ .to_string_lossy()
1267+ .to_string()
1268+ + ".hash";
11321269 let hash_path = cache_dir.join(&hash_file_name);
11331270 if let Ok(content) = fs::read(file) {
11341271 let mut hasher = std::collections::hash_map::DefaultHasher::new();
11351272 content.hash(&mut hasher);
11361273 let current_hash = hasher.finish().to_string();
11371274 if let Ok(cached_hash) = fs::read_to_string(&hash_path)
1138− && cached_hash.trim() == current_hash {
1139− // Also verify output exists
1140− let out_exists = effective_out.as_ref().map_or(
1141− file.with_extension("html").exists(),
1142− |p| Path::new(p.as_str()).exists(),
1143− );
1144− if out_exists {
1145− skipped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1146− return;
1147− }
1275+ && cached_hash.trim() == current_hash
1276+ {
1277+ // Also verify output exists
1278+ let out_exists = effective_out
1279+ .as_ref()
1280+ .map_or(file.with_extension("html").exists(), |p| {
1281+ Path::new(p.as_str()).exists()
1282+ });
1283+ if out_exists {
1284+ skipped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1285+ return;
11481286 }
1287+ }
11491288 // Update hash cache after compilation
11501289 let _ = fs::write(&hash_path, &current_hash);
11511290 }
11521291 let path_str = file.to_string_lossy().to_string();
1153− let (has_errors, _) = compile(&path_str, &CompileConfig {
1154− output_path: effective_out.as_deref(),
1155− minify: build_minify, compat: build_compat,
1156− strict: build_strict,
1157− ..Default::default()
1158− });
1292+ let (has_errors, _) = compile(
1293+ &path_str,
1294+ &CompileConfig {
1295+ output_path: effective_out.as_deref(),
1296+ minify: build_minify,
1297+ compat: build_compat,
1298+ strict: build_strict,
1299+ ..Default::default()
1300+ },
1301+ );
11591302 if has_errors {
11601303 any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
11611304 }
@@ -1167,17 +1310,26 @@ fn main() {
11671310 let compiled_count = hl_files.len() - skipped_count;
11681311
11691312 // Report build performance
1170− let total_output_size: usize = effective_outs.iter()
1313+ let total_output_size: usize = effective_outs
1314+ .iter()
11711315 .filter_map(|p| p.as_ref())
11721316 .filter_map(|p| fs::metadata(p).ok())
11731317 .map(|m| m.len() as usize)
11741318 .sum();
11751319 eprintln!(
1176− "built {} files in {:.2}s ({}){}", compiled_count,
1177− build_elapsed.as_secs_f64(), format_bytes(total_output_size),
1178− if skipped_count > 0 { format!(", {} skipped", skipped_count) } else { String::new() },
1320+ "built {} files in {:.2}s ({}){}",
1321+ compiled_count,
1322+ build_elapsed.as_secs_f64(),
1323+ format_bytes(total_output_size),
1324+ if skipped_count > 0 {
1325+ format!(", {} skipped", skipped_count)
1326+ } else {
1327+ String::new()
1328+ },
11791329 );
1180− if any_errors.load(std::sync::atomic::Ordering::Relaxed) { process::exit(1); }
1330+ if any_errors.load(std::sync::atomic::Ordering::Relaxed) {
1331+ process::exit(1);
1332+ }
11811333
11821334 // Copy non-.hl static assets to output directory
11831335 if let Some(out) = out_dir {
@@ -1185,7 +1337,8 @@ fn main() {
11851337
11861338 // Shared CSS extraction: find duplicate CSS rules across pages
11871339 let out_path = Path::new(out);
1188− let html_files: Vec<PathBuf> = hl_files.iter()
1340+ let html_files: Vec<PathBuf> = hl_files
1341+ .iter()
11891342 .map(|f| {
11901343 let rel = f.strip_prefix(dir).unwrap_or(f);
11911344 out_path.join(rel).with_extension("html")
@@ -1202,7 +1355,9 @@ fn main() {
12021355 // Handle "sitemap" subcommand
12031356 if args.len() >= 2 && args[1] == "sitemap" {
12041357 let dir = if args.len() >= 3 { &args[2] } else { "." };
1205− let base_url = args.iter().position(|a| a == "--base-url" || a == "-b")
1358+ let base_url = args
1359+ .iter()
1360+ .position(|a| a == "--base-url" || a == "-b")
12061361 .and_then(|i| args.get(i + 1))
12071362 .map(|s| s.as_str())
12081363 .unwrap_or("https://example.com");
@@ -1303,7 +1458,11 @@ fn main() {
13031458 ci += 1;
13041459 }
13051460 let target = check_target.unwrap_or(".");
1306− let json_collector = if check_format_json { Some(Mutex::new(Vec::new())) } else { None };
1461+ let json_collector = if check_format_json {
1462+ Some(Mutex::new(Vec::new()))
1463+ } else {
1464+ None
1465+ };
13071466 let path = Path::new(target);
13081467 let mut any_errors = false;
13091468 if path.is_dir() {
@@ -1314,26 +1473,39 @@ fn main() {
13141473 }
13151474 for file in &hl_files {
13161475 let path_str = file.to_string_lossy().to_string();
1317− let (has_errors, _) = compile(&path_str, &CompileConfig {
1318− check_only: true, format_json: check_format_json,
1476+ let (has_errors, _) = compile(
1477+ &path_str,
1478+ &CompileConfig {
1479+ check_only: true,
1480+ format_json: check_format_json,
1481+ json_collector: json_collector.as_ref(),
1482+ ..Default::default()
1483+ },
1484+ );
1485+ if has_errors {
1486+ any_errors = true;
1487+ }
1488+ }
1489+ } else {
1490+ let (has_errors, _) = compile(
1491+ target,
1492+ &CompileConfig {
1493+ check_only: true,
1494+ format_json: check_format_json,
13191495 json_collector: json_collector.as_ref(),
13201496 ..Default::default()
1321− });
1322− if has_errors { any_errors = true; }
1497+ },
1498+ );
1499+ if has_errors {
1500+ any_errors = true;
13231501 }
1324− } else {
1325− let (has_errors, _) = compile(target, &CompileConfig {
1326− check_only: true, format_json: check_format_json,
1327− json_collector: json_collector.as_ref(),
1328− ..Default::default()
1329− });
1330− if has_errors { any_errors = true; }
13311502 }
1332− if check_format_json
1333− && let Some(collector) = json_collector {
1334− print_json_diagnostics(&collector.lock().unwrap());
1335− }
1336− if any_errors { process::exit(1); }
1503+ if check_format_json && let Some(collector) = json_collector {
1504+ print_json_diagnostics(&collector.lock().unwrap());
1505+ }
1506+ if any_errors {
1507+ process::exit(1);
1508+ }
13371509 return;
13381510 }
13391511
@@ -1364,15 +1536,21 @@ fn main() {
13641536 };
13651537 let base = file.parent();
13661538 let result = htmlang::parser::parse_with_base(&input, base);
1367− let file_errors: Vec<_> = result.diagnostics.iter()
1539+ let file_errors: Vec<_> = result
1540+ .diagnostics
1541+ .iter()
13681542 .filter(|d| d.severity == htmlang::parser::Severity::Error)
13691543 .collect();
1370− let assert_failures: Vec<_> = file_errors.iter()
1544+ let assert_failures: Vec<_> = file_errors
1545+ .iter()
13711546 .filter(|d| d.message.starts_with("assertion failed:"))
13721547 .collect();
13731548 total_asserts += assert_failures.len();
13741549 // Count @assert lines (passes + failures)
1375− let assert_count = input.lines().filter(|l| l.trim().starts_with("@assert ")).count();
1550+ let assert_count = input
1551+ .lines()
1552+ .filter(|l| l.trim().starts_with("@assert "))
1553+ .count();
13761554 total_asserts += assert_count.saturating_sub(assert_failures.len());
13771555 if !assert_failures.is_empty() {
13781556 files_with_errors += 1;
@@ -1383,7 +1561,13 @@ fn main() {
13831561 }
13841562 }
13851563 let passed = total_asserts.saturating_sub(failed_asserts);
1386− eprintln!("\n{} assertions: {} passed, {} failed ({} files)", total_asserts, passed, failed_asserts, hl_files.len());
1564+ eprintln!(
1565+ "\n{} assertions: {} passed, {} failed ({} files)",
1566+ total_asserts,
1567+ passed,
1568+ failed_asserts,
1569+ hl_files.len()
1570+ );
13871571 if failed_asserts > 0 || files_with_errors > 0 {
13881572 process::exit(1);
13891573 }
@@ -1434,15 +1618,23 @@ fn main() {
14341618 let tmp_dir = env::temp_dir().join("htmlang-preview");
14351619 let _ = fs::create_dir_all(&tmp_dir);
14361620 let out_path = tmp_dir.join("preview.html");
1437− let (has_errors, _) = compile(file, &CompileConfig {
1438− dev: true, output_path: Some(&out_path.to_string_lossy()),
1439− ..Default::default()
1440− });
1621+ let (has_errors, _) = compile(
1622+ file,
1623+ &CompileConfig {
1624+ dev: true,
1625+ output_path: Some(&out_path.to_string_lossy()),
1626+ ..Default::default()
1627+ },
1628+ );
14411629 if has_errors {
14421630 process::exit(1);
14431631 }
14441632 let url = format!("file://{}", out_path.display());
1445− let cmd = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
1633+ let cmd = if cfg!(target_os = "macos") {
1634+ "open"
1635+ } else {
1636+ "xdg-open"
1637+ };
14461638 let _ = std::process::Command::new(cmd).arg(&url).spawn();
14471639 eprintln!("opened {}", out_path.display());
14481640 return;
@@ -1460,18 +1652,28 @@ fn main() {
14601652 let input = fs::read_to_string(path).map_err(|e| format!("error: {}: {}", path, e))?;
14611653 let base = Path::new(path).parent();
14621654 let result = htmlang::parser::parse_with_base(&input, base);
1463− if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
1655+ if result
1656+ .diagnostics
1657+ .iter()
1658+ .any(|d| d.severity == htmlang::parser::Severity::Error)
1659+ {
14641660 return Err(format!("error: {} has parse errors", path));
14651661 }
14661662 Ok(htmlang::codegen::generate_dev(&result.document))
14671663 };
14681664 let html1 = match compile_to_string(file1) {
14691665 Ok(h) => h,
1470− Err(e) => { eprintln!("{}", e); process::exit(1); }
1666+ Err(e) => {
1667+ eprintln!("{}", e);
1668+ process::exit(1);
1669+ }
14711670 };
14721671 let html2 = match compile_to_string(file2) {
14731672 Ok(h) => h,
1474− Err(e) => { eprintln!("{}", e); process::exit(1); }
1673+ Err(e) => {
1674+ eprintln!("{}", e);
1675+ process::exit(1);
1676+ }
14751677 };
14761678 let lines1: Vec<&str> = html1.lines().collect();
14771679 let lines2: Vec<&str> = html2.lines().collect();
@@ -1539,11 +1741,16 @@ fn main() {
15391741 let _ = fs::create_dir_all(parent);
15401742 }
15411743 let path_str = file.to_string_lossy().to_string();
1542− let (has_errors, _) = compile(&path_str, &CompileConfig {
1543− output_path: Some(&out_path.to_string_lossy()),
1544− ..Default::default()
1545− });
1546− if has_errors { any_errors = true; }
1744+ let (has_errors, _) = compile(
1745+ &path_str,
1746+ &CompileConfig {
1747+ output_path: Some(&out_path.to_string_lossy()),
1748+ ..Default::default()
1749+ },
1750+ );
1751+ if has_errors {
1752+ any_errors = true;
1753+ }
15471754 }
15481755 // Copy non-.hl assets
15491756 copy_non_hl_files(dir, &tmp_dir);
@@ -1610,7 +1817,11 @@ fn main() {
16101817 let target_url = args.get(si).cloned().unwrap_or_default();
16111818 if !prefix.is_empty() && !target_url.is_empty() {
16121819 _proxy_routes.push((prefix, target_url));
1613− eprintln!("proxy: {} -> {}", _proxy_routes.last().unwrap().0, _proxy_routes.last().unwrap().1);
1820+ eprintln!(
1821+ "proxy: {} -> {}",
1822+ _proxy_routes.last().unwrap().0,
1823+ _proxy_routes.last().unwrap().1
1824+ );
16141825 }
16151826 }
16161827 _ if serve_target.is_none() => serve_target = Some(args[si].clone()),
@@ -1625,7 +1836,11 @@ fn main() {
16251836 let target_path = Path::new(&target);
16261837 // Load config
16271838 let config = load_config(target_path);
1628− let effective_port = if serve_port != 3000 { serve_port } else { config.port };
1839+ let effective_port = if serve_port != 3000 {
1840+ serve_port
1841+ } else {
1842+ config.port
1843+ };
16291844
16301845 // Resolve optional TLS config. Without both --cert and --key, --https is
16311846 // an error — we intentionally do not generate self-signed certificates
@@ -1650,7 +1865,11 @@ fn main() {
16501865 } else {
16511866 None
16521867 };
1653− let scheme = if tls_config.is_some() { "https" } else { "http" };
1868+ let scheme = if tls_config.is_some() {
1869+ "https"
1870+ } else {
1871+ "http"
1872+ };
16541873 let _ = scheme; // announced by watch_loop / open_in_browser downstream
16551874
16561875 // Do initial compile
@@ -1661,34 +1880,64 @@ fn main() {
16611880 let effective_out = config.output.as_ref().map(|o| {
16621881 let rel = file.strip_prefix(target_path).unwrap_or(file);
16631882 let out_p = Path::new(o).join(rel).with_extension("html");
1664− if let Some(parent) = out_p.parent() { let _ = fs::create_dir_all(parent); }
1883+ if let Some(parent) = out_p.parent() {
1884+ let _ = fs::create_dir_all(parent);
1885+ }
16651886 out_p.to_string_lossy().to_string()
16661887 });
1667− compile(&path_str, &CompileConfig {
1668− dev: true, error_overlay: true,
1669− output_path: effective_out.as_deref(),
1670− ..Default::default()
1671− });
1888+ compile(
1889+ &path_str,
1890+ &CompileConfig {
1891+ dev: true,
1892+ error_overlay: true,
1893+ output_path: effective_out.as_deref(),
1894+ ..Default::default()
1895+ },
1896+ );
16721897 }
16731898 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
1674− let serve_dir = config.output.as_ref().map(PathBuf::from).unwrap_or_else(|| target_path.to_path_buf());
1899+ let serve_dir = config
1900+ .output
1901+ .as_ref()
1902+ .map(PathBuf::from)
1903+ .unwrap_or_else(|| target_path.to_path_buf());
16751904 let index_path = serve_dir.join("index.html");
16761905 let server_tx = tx.clone();
16771906 let tls_for_thread = tls_config;
16781907 std::thread::spawn(move || {
16791908 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
16801909 match tls_for_thread {
1681− Some(tls) => rt.block_on(htmlang::serve::run_https(effective_port, index_path, server_tx, tls)),
1910+ Some(tls) => rt.block_on(htmlang::serve::run_https(
1911+ effective_port,
1912+ index_path,
1913+ server_tx,
1914+ tls,
1915+ )),
16821916 None => rt.block_on(htmlang::serve::run(effective_port, index_path, server_tx)),
16831917 }
16841918 });
1685− if serve_open { open_in_browser(effective_port); }
1686− watch_loop(target_path, &hl_files, &[], true, true, Some(tx), effective_port, config.debounce_ms);
1919+ if serve_open {
1920+ open_in_browser(effective_port);
1921+ }
1922+ watch_loop(
1923+ target_path,
1924+ &hl_files,
1925+ &[],
1926+ true,
1927+ true,
1928+ Some(tx),
1929+ effective_port,
1930+ config.debounce_ms,
1931+ );
16871932 } else {
1688− let (_, included) = compile(&target, &CompileConfig {
1689− dev: true, error_overlay: true,
1690− ..Default::default()
1691− });
1933+ let (_, included) = compile(
1934+ &target,
1935+ &CompileConfig {
1936+ dev: true,
1937+ error_overlay: true,
1938+ ..Default::default()
1939+ },
1940+ );
16921941 let (tx, _) = tokio::sync::broadcast::channel::<()>(16);
16931942 let out_path = Path::new(&target).with_extension("html");
16941943 let server_tx = tx.clone();
@@ -1696,13 +1945,29 @@ fn main() {
16961945 std::thread::spawn(move || {
16971946 let rt = tokio::runtime::Runtime::new().expect("failed to create runtime");
16981947 match tls_for_thread {
1699− Some(tls) => rt.block_on(htmlang::serve::run_https(effective_port, out_path, server_tx, tls)),
1948+ Some(tls) => rt.block_on(htmlang::serve::run_https(
1949+ effective_port,
1950+ out_path,
1951+ server_tx,
1952+ tls,
1953+ )),
17001954 None => rt.block_on(htmlang::serve::run(effective_port, out_path, server_tx)),
17011955 }
17021956 });
1703− if serve_open { open_in_browser(effective_port); }
1957+ if serve_open {
1958+ open_in_browser(effective_port);
1959+ }
17041960 let files = vec![PathBuf::from(&target)];
1705− watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, true, true, Some(tx), effective_port, config.debounce_ms);
1961+ watch_loop(
1962+ Path::new(&target).parent().unwrap_or(Path::new(".")),
1963+ &files,
1964+ &included,
1965+ true,
1966+ true,
1967+ Some(tx),
1968+ effective_port,
1969+ config.debounce_ms,
1970+ );
17061971 }
17071972 return;
17081973 }
@@ -1733,30 +1998,58 @@ fn main() {
17331998
17341999 if target_path.is_dir() {
17352000 let hl_files = collect_hl_files_recursive(target_path);
1736− if let Some(ref out) = effective_output { let _ = fs::create_dir_all(out); }
2001+ if let Some(ref out) = effective_output {
2002+ let _ = fs::create_dir_all(out);
2003+ }
17372004 let mut all_included = Vec::new();
17382005 for file in &hl_files {
17392006 let path_str = file.to_string_lossy().to_string();
17402007 let effective_out = effective_output.as_ref().map(|o| {
17412008 let rel = file.strip_prefix(target_path).unwrap_or(file);
17422009 let out_p = Path::new(o).join(rel).with_extension("html");
1743− if let Some(parent) = out_p.parent() { let _ = fs::create_dir_all(parent); }
2010+ if let Some(parent) = out_p.parent() {
2011+ let _ = fs::create_dir_all(parent);
2012+ }
17442013 out_p.to_string_lossy().to_string()
17452014 });
1746− let (_, included) = compile(&path_str, &CompileConfig {
1747− output_path: effective_out.as_deref(),
1748− ..Default::default()
1749− });
2015+ let (_, included) = compile(
2016+ &path_str,
2017+ &CompileConfig {
2018+ output_path: effective_out.as_deref(),
2019+ ..Default::default()
2020+ },
2021+ );
17502022 all_included.extend(included);
17512023 }
1752− watch_loop(target_path, &hl_files, &all_included, false, false, None, 0, config.debounce_ms);
2024+ watch_loop(
2025+ target_path,
2026+ &hl_files,
2027+ &all_included,
2028+ false,
2029+ false,
2030+ None,
2031+ 0,
2032+ config.debounce_ms,
2033+ );
17532034 } else {
1754− let (_, included) = compile(&target, &CompileConfig {
1755− output_path: effective_output.as_deref(),
1756− ..Default::default()
1757− });
2035+ let (_, included) = compile(
2036+ &target,
2037+ &CompileConfig {
2038+ output_path: effective_output.as_deref(),
2039+ ..Default::default()
2040+ },
2041+ );
17582042 let files = vec![PathBuf::from(&target)];
1759− watch_loop(Path::new(&target).parent().unwrap_or(Path::new(".")), &files, &included, false, false, None, 0, config.debounce_ms);
2043+ watch_loop(
2044+ Path::new(&target).parent().unwrap_or(Path::new(".")),
2045+ &files,
2046+ &included,
2047+ false,
2048+ false,
2049+ None,
2050+ 0,
2051+ config.debounce_ms,
2052+ );
17602053 }
17612054 return;
17622055 }
@@ -1797,7 +2090,11 @@ fn main() {
17972090 let prefix = severity_label(d.severity);
17982091 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
17992092 }
1800− if !result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
2093+ if !result
2094+ .diagnostics
2095+ .iter()
2096+ .any(|d| d.severity == htmlang::parser::Severity::Error)
2097+ {
18012098 let html = htmlang::codegen::generate_dev(&result.document);
18022099 let _ = stdout.lock().write_all(html.as_bytes());
18032100 let _ = stdout.lock().write_all(b"\n");
@@ -1807,8 +2104,14 @@ fn main() {
18072104
18082105 // Handle "feed" subcommand
18092106 if args.len() >= 2 && args[1] == "feed" {
1810− let dir = if args.len() >= 3 && !args[2].starts_with('-') { &args[2] } else { "." };
1811− let base_url = args.iter().position(|a| a == "--base-url" || a == "-b")
2107+ let dir = if args.len() >= 3 && !args[2].starts_with('-') {
2108+ &args[2]
2109+ } else {
2110+ "."
2111+ };
2112+ let base_url = args
2113+ .iter()
2114+ .position(|a| a == "--base-url" || a == "-b")
18122115 .and_then(|i| args.get(i + 1))
18132116 .map(|s| s.as_str())
18142117 .unwrap_or("https://example.com");
@@ -1828,14 +2131,20 @@ fn main() {
18282131 let result = htmlang::parser::parse_with_base(&input, base);
18292132 if let Some(ref title) = result.document.page_title {
18302133 let rel = file.strip_prefix(dir_path).unwrap_or(file);
1831− let url_path = rel.with_extension("html").to_string_lossy().replace('\\', "/");
2134+ let url_path = rel
2135+ .with_extension("html")
2136+ .to_string_lossy()
2137+ .replace('\\', "/");
18322138 let url = if url_path == "index.html" {
18332139 format!("{}/", base_url.trim_end_matches('/'))
18342140 } else {
18352141 format!("{}/{}", base_url.trim_end_matches('/'), url_path)
18362142 };
18372143 // Get description from meta tags if available
1838− let description = result.document.meta_tags.iter()
2144+ let description = result
2145+ .document
2146+ .meta_tags
2147+ .iter()
18392148 .find(|(k, _)| k == "description")
18402149 .map(|(_, v)| v.clone())
18412150 .unwrap_or_default();
@@ -1843,7 +2152,10 @@ fn main() {
18432152 }
18442153 }
18452154 // Generate RSS 2.0 feed
1846− let site_title = items.first().map(|(t, _, _)| t.clone()).unwrap_or_else(|| "My Site".to_string());
2155+ let site_title = items
2156+ .first()
2157+ .map(|(t, _, _)| t.clone())
2158+ .unwrap_or_else(|| "My Site".to_string());
18472159 let mut rss = format!(
18482160 "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n\
18492161 <rss version=\"2.0\">\n\
@@ -1896,7 +2208,8 @@ fn main() {
18962208 if let Some(rest) = trimmed.strip_prefix("@fn ") {
18972209 let parts: Vec<&str> = rest.split_whitespace().collect();
18982210 if let Some(&name) = parts.first() {
1899− let params: Vec<&str> = parts[1..].iter()
2211+ let params: Vec<&str> = parts[1..]
2212+ .iter()
19002213 .map(|p| p.strip_prefix('$').unwrap_or(p))
19012214 .collect();
19022215 let param_str = if params.is_empty() {
@@ -1965,7 +2278,11 @@ fn main() {
19652278 } else {
19662279 println!("{}", file);
19672280 for (i, dep) in deps.iter().enumerate() {
1968− let prefix = if i == deps.len() - 1 { "└── " } else { "├── " };
2281+ let prefix = if i == deps.len() - 1 {
2282+ "└── "
2283+ } else {
2284+ "├── "
2285+ };
19692286 println!(" {}{}", prefix, dep);
19702287 }
19712288 }
@@ -2003,7 +2320,7 @@ fn main() {
20032320 process::exit(1);
20042321 }
20052322 // Pass 1: collect all definitions and usages across all files
2006− let mut all_fn_defs: Vec<(String, String, usize)> = Vec::new(); // (name, file, line)
2323+ let mut all_fn_defs: Vec<(String, String, usize)> = Vec::new(); // (name, file, line)
20072324 let mut all_def_defs: Vec<(String, String, usize)> = Vec::new();
20082325 let mut all_let_defs: Vec<(String, String, usize)> = Vec::new();
20092326 let mut all_refs: std::collections::HashSet<String> = std::collections::HashSet::new();
@@ -2012,7 +2329,11 @@ fn main() {
20122329 Ok(s) => s,
20132330 Err(_) => continue,
20142331 };
2015− let rel = file.strip_prefix(path).unwrap_or(file).display().to_string();
2332+ let rel = file
2333+ .strip_prefix(path)
2334+ .unwrap_or(file)
2335+ .display()
2336+ .to_string();
20162337 for (line_num, line) in input.lines().enumerate() {
20172338 let trimmed = line.trim();
20182339 if let Some(rest) = trimmed.strip_prefix("@fn ") {
@@ -2025,29 +2346,43 @@ fn main() {
20252346 all_def_defs.push((name.to_string(), rel.clone(), line_num + 1));
20262347 }
20272348 } else if let Some(rest) = trimmed.strip_prefix("@let ")
2028− && let Some(name) = rest.split_whitespace().next() {
2029− all_let_defs.push((name.to_string(), rel.clone(), line_num + 1));
2030− }
2349+ && let Some(name) = rest.split_whitespace().next()
2350+ {
2351+ all_let_defs.push((name.to_string(), rel.clone(), line_num + 1));
2352+ }
20312353 // Collect references: @name calls and $name usages
2032− if trimmed.starts_with('@') && !trimmed.starts_with("@fn ")
2033− && !trimmed.starts_with("@let ") && !trimmed.starts_with("@define ")
2034− && !trimmed.starts_with("@include ") && !trimmed.starts_with("@import ")
2035− && !trimmed.starts_with("@extends ") && !trimmed.starts_with("@page")
2036− && !trimmed.starts_with("@meta ") && !trimmed.starts_with("@og ")
2037− && !trimmed.starts_with("@head") && !trimmed.starts_with("@style")
2038− && !trimmed.starts_with("@keyframes ") && !trimmed.starts_with("@theme")
2039− && !trimmed.starts_with("@deprecated ") && !trimmed.starts_with("@breakpoint ")
2040− && !trimmed.starts_with("@use ") && !trimmed.starts_with("@slot ")
2041− && !trimmed.starts_with("@lang ") && !trimmed.starts_with("@favicon ")
2354+ if trimmed.starts_with('@')
2355+ && !trimmed.starts_with("@fn ")
2356+ && !trimmed.starts_with("@let ")
2357+ && !trimmed.starts_with("@define ")
2358+ && !trimmed.starts_with("@include ")
2359+ && !trimmed.starts_with("@import ")
2360+ && !trimmed.starts_with("@extends ")
2361+ && !trimmed.starts_with("@page")
2362+ && !trimmed.starts_with("@meta ")
2363+ && !trimmed.starts_with("@og ")
2364+ && !trimmed.starts_with("@head")
2365+ && !trimmed.starts_with("@style")
2366+ && !trimmed.starts_with("@keyframes ")
2367+ && !trimmed.starts_with("@theme")
2368+ && !trimmed.starts_with("@deprecated ")
2369+ && !trimmed.starts_with("@breakpoint ")
2370+ && !trimmed.starts_with("@use ")
2371+ && !trimmed.starts_with("@slot ")
2372+ && !trimmed.starts_with("@lang ")
2373+ && !trimmed.starts_with("@favicon ")
20422374 && !trimmed.starts_with("--")
2043− && let Some(name) = trimmed[1..].split([' ', '[']).next() {
2044− all_refs.insert(name.to_string());
2045− }
2375+ && let Some(name) = trimmed[1..].split([' ', '[']).next()
2376+ {
2377+ all_refs.insert(name.to_string());
2378+ }
20462379 // Collect $var references
20472380 let mut rest = trimmed;
20482381 while let Some(pos) = rest.find('$') {
20492382 let after = &rest[pos + 1..];
2050− let end = after.find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_').unwrap_or(after.len());
2383+ let end = after
2384+ .find(|c: char| !c.is_alphanumeric() && c != '-' && c != '_')
2385+ .unwrap_or(after.len());
20512386 if end > 0 {
20522387 all_refs.insert(after[..end].to_string());
20532388 }
@@ -2109,7 +2444,10 @@ fn main() {
21092444 args[di].as_str()
21102445 }
21112446 _ => {
2112− eprintln!("unknown provider: {} (supported: github-pages, netlify, vercel, cloudflare)", p);
2447+ eprintln!(
2448+ "unknown provider: {} (supported: github-pages, netlify, vercel, cloudflare)",
2449+ p
2450+ );
21132451 process::exit(1);
21142452 }
21152453 };
@@ -2147,10 +2485,13 @@ fn main() {
21472485 }
21482486 let out_str = out_path.to_string_lossy().to_string();
21492487 let path_str = file.to_string_lossy().to_string();
2150− let (has_errors, _) = compile(&path_str, &CompileConfig {
2151− output_path: Some(&out_str),
2152− ..Default::default()
2153− });
2488+ let (has_errors, _) = compile(
2489+ &path_str,
2490+ &CompileConfig {
2491+ output_path: Some(&out_str),
2492+ ..Default::default()
2493+ },
2494+ );
21542495 if has_errors {
21552496 any_errors.store(true, std::sync::atomic::Ordering::Relaxed);
21562497 }
@@ -2169,12 +2510,21 @@ fn main() {
21692510 "netlify" => {
21702511 eprintln!("deploying to Netlify...");
21712512 let status = process::Command::new("npx")
2172− .args(["netlify-cli", "deploy", "--prod", "--dir", &deploy_dir.to_string_lossy()])
2513+ .args([
2514+ "netlify-cli",
2515+ "deploy",
2516+ "--prod",
2517+ "--dir",
2518+ &deploy_dir.to_string_lossy(),
2519+ ])
21732520 .status();
21742521 match status {
21752522 Ok(s) if s.success() => eprintln!("deployed to Netlify!"),
21762523 _ => {
2177− eprintln!("netlify deploy failed — ensure netlify-cli is installed (npx netlify-cli deploy --prod --dir {})", deploy_dir.display());
2524+ eprintln!(
2525+ "netlify deploy failed — ensure netlify-cli is installed (npx netlify-cli deploy --prod --dir {})",
2526+ deploy_dir.display()
2527+ );
21782528 process::exit(1);
21792529 }
21802530 }
@@ -2188,7 +2538,10 @@ fn main() {
21882538 match status {
21892539 Ok(s) if s.success() => eprintln!("deployed to Vercel!"),
21902540 _ => {
2191− eprintln!("vercel deploy failed — ensure vercel CLI is installed (npx vercel --prod {})", deploy_dir.display());
2541+ eprintln!(
2542+ "vercel deploy failed — ensure vercel CLI is installed (npx vercel --prod {})",
2543+ deploy_dir.display()
2544+ );
21922545 process::exit(1);
21932546 }
21942547 }
@@ -2197,12 +2550,20 @@ fn main() {
21972550 "cloudflare" => {
21982551 eprintln!("deploying to Cloudflare Pages...");
21992552 let status = process::Command::new("npx")
2200− .args(["wrangler", "pages", "deploy", deploy_dir.to_string_lossy().as_ref()])
2553+ .args([
2554+ "wrangler",
2555+ "pages",
2556+ "deploy",
2557+ deploy_dir.to_string_lossy().as_ref(),
2558+ ])
22012559 .status();
22022560 match status {
22032561 Ok(s) if s.success() => eprintln!("deployed to Cloudflare Pages!"),
22042562 _ => {
2205− eprintln!("cloudflare deploy failed — ensure wrangler is installed (npx wrangler pages deploy {})", deploy_dir.display());
2563+ eprintln!(
2564+ "cloudflare deploy failed — ensure wrangler is installed (npx wrangler pages deploy {})",
2565+ deploy_dir.display()
2566+ );
22062567 process::exit(1);
22072568 }
22082569 }
@@ -2262,13 +2623,22 @@ fn main() {
22622623 Ok(s) if s.success()
22632624 );
22642625 if !push_ok {
2265− eprintln!("error: push failed — run 'git push -f origin gh-pages' manually from {}", deploy_dir.display());
2626+ eprintln!(
2627+ "error: push failed — run 'git push -f origin gh-pages' manually from {}",
2628+ deploy_dir.display()
2629+ );
22662630 process::exit(1);
22672631 }
22682632 eprintln!("deployed to GitHub Pages!");
22692633 } else {
2270− eprintln!("no git remote found — deploy directory ready at {}", deploy_dir.display());
2271− eprintln!("push manually: cd {} && git remote add origin <url> && git push -f origin gh-pages", deploy_dir.display());
2634+ eprintln!(
2635+ "no git remote found — deploy directory ready at {}",
2636+ deploy_dir.display()
2637+ );
2638+ eprintln!(
2639+ "push manually: cd {} && git remote add origin <url> && git push -f origin gh-pages",
2640+ deploy_dir.display()
2641+ );
22722642 }
22732643 }
22742644 return;
@@ -2276,15 +2646,23 @@ fn main() {
22762646
22772647 // Handle "playground" subcommand — generate self-contained HTML playground
22782648 if args.len() >= 2 && args[1] == "playground" {
2279− let out = if args.len() >= 3 { &args[2] } else { "playground.html" };
2649+ let out = if args.len() >= 3 {
2650+ &args[2]
2651+ } else {
2652+ "playground.html"
2653+ };
22802654
22812655 // Load pre-built WASM binary and JS glue
22822656 let (wasm_bytes, js_glue) = match find_wasm_pkg() {
22832657 Some(pkg) => pkg,
22842658 None => {
22852659 eprintln!("error: WASM module not found. Build it first:");
2286− eprintln!(" cargo build --release --target wasm32-unknown-unknown -p htmlang-wasm");
2287− eprintln!(" wasm-bindgen --target no-modules --no-typescript --out-dir target/wasm-pkg \\");
2660+ eprintln!(
2661+ " cargo build --release --target wasm32-unknown-unknown -p htmlang-wasm"
2662+ );
2663+ eprintln!(
2664+ " wasm-bindgen --target no-modules --no-typescript --out-dir target/wasm-pkg \\"
2665+ );
22882666 eprintln!(" target/wasm32-unknown-unknown/release/htmlang_wasm.wasm");
22892667 process::exit(1);
22902668 }
@@ -2305,7 +2683,8 @@ fn main() {
23052683 );
23062684 let compile_fn = "function compileSource(src){return window._wasmCompile(src);}";
23072685
2308− let playground_html = format!(r##"<!DOCTYPE html>
2686+ let playground_html = format!(
2687+ r##"<!DOCTYPE html>
23092688<html lang="en">
23102689<head>
23112690<meta charset="utf-8">
@@ -2564,7 +2943,10 @@ loadFromHash();
25642943compile();
25652944</script>
25662945</body>
2567−</html>"##, wasm_section = wasm_section, compile_fn = compile_fn);
2946+</html>"##,
2947+ wasm_section = wasm_section,
2948+ compile_fn = compile_fn
2949+ );
25682950 if let Err(e) = fs::write(out, playground_html) {
25692951 eprintln!("error writing {}: {}", out, e);
25702952 process::exit(1);
@@ -2598,11 +2980,16 @@ compile();
25982980 // Also remove sitemap.xml if present
25992981 let sitemap = dir.join("sitemap.xml");
26002982 if sitemap.exists()
2601− && let Ok(()) = fs::remove_file(&sitemap) {
2602− eprintln!("removed {}", sitemap.display());
2603− removed += 1;
2604− }
2605− eprintln!("cleaned {} file{}", removed, if removed == 1 { "" } else { "s" });
2983+ && let Ok(()) = fs::remove_file(&sitemap)
2984+ {
2985+ eprintln!("removed {}", sitemap.display());
2986+ removed += 1;
2987+ }
2988+ eprintln!(
2989+ "cleaned {} file{}",
2990+ removed,
2991+ if removed == 1 { "" } else { "s" }
2992+ );
26062993 return;
26072994 }
26082995
@@ -2641,10 +3028,13 @@ compile();
26413028 print_outline(&elem.children, depth + 1);
26423029 }
26433030 htmlang::ast::Node::Text(segs) => {
2644− let text: String = segs.iter().filter_map(|s| match s {
2645− htmlang::ast::TextSegment::Plain(t) => Some(t.as_str()),
2646− _ => None,
2647− }).collect();
3031+ let text: String = segs
3032+ .iter()
3033+ .filter_map(|s| match s {
3034+ htmlang::ast::TextSegment::Plain(t) => Some(t.as_str()),
3035+ _ => None,
3036+ })
3037+ .collect();
26483038 if !text.trim().is_empty() {
26493039 let display = if text.len() > 40 {
26503040 format!("{}...", &text[..37])
@@ -2687,9 +3077,7 @@ compile();
26873077 }
26883078
26893079 // Check if cargo is available (for building from source)
2690− let cargo_status = process::Command::new("cargo")
2691− .arg("--version")
2692− .output();
3080+ let cargo_status = process::Command::new("cargo").arg("--version").output();
26933081 match cargo_status {
26943082 Ok(output) if output.status.success() => {
26953083 let ver = String::from_utf8_lossy(&output.stdout);
@@ -2702,9 +3090,7 @@ compile();
27023090 }
27033091
27043092 // Check if git is available (for deploy)
2705− let git_status = process::Command::new("git")
2706− .arg("--version")
2707− .output();
3093+ let git_status = process::Command::new("git").arg("--version").output();
27083094 match git_status {
27093095 Ok(output) if output.status.success() => {
27103096 let ver = String::from_utf8_lossy(&output.stdout);
@@ -2784,7 +3170,12 @@ compile();
27843170 if changes > 0 {
27853171 match fs::write(file, &output) {
27863172 Ok(()) => {
2787− eprintln!("migrated {} ({} change{})", file.display(), changes, if changes == 1 { "" } else { "s" });
3173+ eprintln!(
3174+ "migrated {} ({} change{})",
3175+ file.display(),
3176+ changes,
3177+ if changes == 1 { "" } else { "s" }
3178+ );
27883179 total_changes += changes;
27893180 }
27903181 Err(e) => eprintln!("error: {}: {}", file.display(), e),
@@ -2794,7 +3185,11 @@ compile();
27943185 if total_changes == 0 {
27953186 eprintln!("no migrations needed");
27963187 } else {
2797− eprintln!("\n{} total change(s) across {} file(s)", total_changes, hl_files.len());
3188+ eprintln!(
3189+ "\n{} total change(s) across {} file(s)",
3190+ total_changes,
3191+ hl_files.len()
3192+ );
27983193 }
27993194 return;
28003195 }
@@ -2843,7 +3238,11 @@ compile();
28433238 };
28443239 let base = file.parent();
28453240 let result = htmlang::parser::parse_with_base(&input, base);
2846− if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
3241+ if result
3242+ .diagnostics
3243+ .iter()
3244+ .any(|d| d.severity == htmlang::parser::Severity::Error)
3245+ {
28473246 for d in &result.diagnostics {
28483247 if d.severity == htmlang::parser::Severity::Error {
28493248 eprintln!("error: line {}: {}", d.line, d.message);
@@ -2899,7 +3298,11 @@ compile();
28993298 total_source += input.len();
29003299 let base = file.parent();
29013300 let result = htmlang::parser::parse_with_base(&input, base);
2902− if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
3301+ if result
3302+ .diagnostics
3303+ .iter()
3304+ .any(|d| d.severity == htmlang::parser::Severity::Error)
3305+ {
29033306 continue;
29043307 }
29053308 let html = htmlang::codegen::generate(&result.document);
@@ -2951,55 +3354,70 @@ compile();
29513354 let prefix = severity_label(d.severity);
29523355 eprintln!("{}: line {}: {}", prefix, d.line, d.message);
29533356 }
2954− if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
3357+ if result
3358+ .diagnostics
3359+ .iter()
3360+ .any(|d| d.severity == htmlang::parser::Severity::Error)
3361+ {
29553362 process::exit(1);
29563363 }
29573364 let html = htmlang::codegen::generate_dev(&result.document);
29583365 // Extract CSS class mappings from <style> block
29593366 let mut class_css: HashMap<String, String> = HashMap::new();
29603367 if let Some(style_start) = html.find("<style>")
2961− && let Some(style_end) = html[style_start..].find("</style>") {
2962− let css_block = &html[style_start + 7..style_start + style_end];
2963− // Parse each CSS rule: .className { rules }
2964− let mut pos = 0;
2965− let css_bytes = css_block.as_bytes();
2966− while pos < css_bytes.len() {
2967− if css_bytes[pos] == b'.' {
2968− let rule_start = pos;
2969− // Find class name end
3368+ && let Some(style_end) = html[style_start..].find("</style>")
3369+ {
3370+ let css_block = &html[style_start + 7..style_start + style_end];
3371+ // Parse each CSS rule: .className { rules }
3372+ let mut pos = 0;
3373+ let css_bytes = css_block.as_bytes();
3374+ while pos < css_bytes.len() {
3375+ if css_bytes[pos] == b'.' {
3376+ let rule_start = pos;
3377+ // Find class name end
3378+ pos += 1;
3379+ while pos < css_bytes.len()
3380+ && css_bytes[pos] != b' '
3381+ && css_bytes[pos] != b'{'
3382+ && css_bytes[pos] != b':'
3383+ && css_bytes[pos] != b','
3384+ {
29703385 pos += 1;
2971− while pos < css_bytes.len() && css_bytes[pos] != b' ' && css_bytes[pos] != b'{' && css_bytes[pos] != b':' && css_bytes[pos] != b',' {
2972− pos += 1;
2973− }
2974− let class_name = &css_block[rule_start + 1..pos];
2975− // Find rule body
2976− while pos < css_bytes.len() && css_bytes[pos] != b'{' {
2977− pos += 1;
2978− }
2979− if pos < css_bytes.len() {
2980− pos += 1; // skip '{'
2981− let body_start = pos;
2982− let mut depth = 1;
2983− while pos < css_bytes.len() && depth > 0 {
2984− if css_bytes[pos] == b'{' { depth += 1; }
2985− if css_bytes[pos] == b'}' { depth -= 1; }
2986− pos += 1;
3386+ }
3387+ let class_name = &css_block[rule_start + 1..pos];
3388+ // Find rule body
3389+ while pos < css_bytes.len() && css_bytes[pos] != b'{' {
3390+ pos += 1;
3391+ }
3392+ if pos < css_bytes.len() {
3393+ pos += 1; // skip '{'
3394+ let body_start = pos;
3395+ let mut depth = 1;
3396+ while pos < css_bytes.len() && depth > 0 {
3397+ if css_bytes[pos] == b'{' {
3398+ depth += 1;
29873399 }
2988− let body = css_block[body_start..pos.saturating_sub(1)].trim();
2989− if !body.is_empty() {
2990− class_css.entry(class_name.to_string())
2991− .and_modify(|existing| {
2992− existing.push_str("; ");
2993− existing.push_str(body);
2994− })
2995− .or_insert_with(|| body.to_string());
3400+ if css_bytes[pos] == b'}' {
3401+ depth -= 1;
29963402 }
3403+ pos += 1;
3404+ }
3405+ let body = css_block[body_start..pos.saturating_sub(1)].trim();
3406+ if !body.is_empty() {
3407+ class_css
3408+ .entry(class_name.to_string())
3409+ .and_modify(|existing| {
3410+ existing.push_str("; ");
3411+ existing.push_str(body);
3412+ })
3413+ .or_insert_with(|| body.to_string());
29973414 }
2998− } else {
2999− pos += 1;
30003415 }
3416+ } else {
3417+ pos += 1;
30013418 }
30023419 }
3420+ }
30033421 // Extract data-hl-line attributes from dev HTML to map classes to source lines
30043422 let mut line_classes: HashMap<usize, Vec<String>> = HashMap::new();
30053423 let html_body = if let Some(body_start) = html.find("<body") {
@@ -3012,25 +3430,32 @@ compile();
30123430 let mut scan_pos = 0;
30133431 while scan_pos < html_body.len() {
30143432 // Find each opening tag
3015− if html_body.as_bytes()[scan_pos] == b'<' && scan_pos + 1 < html_body.len() && html_body.as_bytes()[scan_pos + 1].is_ascii_alphabetic() {
3433+ if html_body.as_bytes()[scan_pos] == b'<'
3434+ && scan_pos + 1 < html_body.len()
3435+ && html_body.as_bytes()[scan_pos + 1].is_ascii_alphabetic()
3436+ {
30163437 // Find end of tag
3017− let tag_end = html_body[scan_pos..].find('>').map(|p| scan_pos + p).unwrap_or(html_body.len());
3438+ let tag_end = html_body[scan_pos..]
3439+ .find('>')
3440+ .map(|p| scan_pos + p)
3441+ .unwrap_or(html_body.len());
30183442 let tag = &html_body[scan_pos..tag_end];
30193443 // Extract data-hl-line
30203444 if let Some(hl_pos) = tag.find("data-hl-line=\"") {
30213445 let after = &tag[hl_pos + 14..];
30223446 if let Some(end) = after.find('"')
3023− && let Ok(ln) = after[..end].parse::<usize>() {
3024− // Extract class
3025− if let Some(cls_pos) = tag.find("class=\"") {
3026− let cls_after = &tag[cls_pos + 7..];
3027− if let Some(cls_end) = cls_after.find('"') {
3028− for cls in cls_after[..cls_end].split_whitespace() {
3029− line_classes.entry(ln).or_default().push(cls.to_string());
3030− }
3447+ && let Ok(ln) = after[..end].parse::<usize>()
3448+ {
3449+ // Extract class
3450+ if let Some(cls_pos) = tag.find("class=\"") {
3451+ let cls_after = &tag[cls_pos + 7..];
3452+ if let Some(cls_end) = cls_after.find('"') {
3453+ for cls in cls_after[..cls_end].split_whitespace() {
3454+ line_classes.entry(ln).or_default().push(cls.to_string());
30313455 }
30323456 }
30333457 }
3458+ }
30343459 }
30353460 scan_pos = tag_end;
30363461 } else {
@@ -3098,7 +3523,10 @@ compile();
30983523 total_source += input.len();
30993524 let base = file.parent();
31003525 let result = htmlang::parser::parse_with_base(&input, base);
3101− let has_errors = result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error);
3526+ let has_errors = result
3527+ .diagnostics
3528+ .iter()
3529+ .any(|d| d.severity == htmlang::parser::Severity::Error);
31023530 if has_errors {
31033531 total_errors += 1;
31043532 continue;
@@ -3120,7 +3548,10 @@ compile();
31203548 eprintln!(" source size: {} bytes", total_source);
31213549 eprintln!(" output size: {} bytes", total_output);
31223550 if total_source > 0 {
3123− eprintln!(" ratio: {:.1}x", total_output as f64 / total_source as f64);
3551+ eprintln!(
3552+ " ratio: {:.1}x",
3553+ total_output as f64 / total_source as f64
3554+ );
31243555 }
31253556 eprintln!(" elements: {}", total_elements);
31263557 eprintln!(" CSS rules: ~{}", total_css_rules);
@@ -3211,7 +3642,12 @@ compile();
32113642 if changes > 0 {
32123643 match fs::write(file, &output) {
32133644 Ok(()) => {
3214− eprintln!("upgraded {} ({} change{})", file.display(), changes, if changes == 1 { "" } else { "s" });
3645+ eprintln!(
3646+ "upgraded {} ({} change{})",
3647+ file.display(),
3648+ changes,
3649+ if changes == 1 { "" } else { "s" }
3650+ );
32153651 total_changes += changes;
32163652 }
32173653 Err(e) => eprintln!("error: {}: {}", file.display(), e),
@@ -3248,7 +3684,9 @@ compile();
32483684 if !params.is_empty() {
32493685 template.push_str(" [");
32503686 for (i, p) in params.iter().enumerate() {
3251− if i > 0 { template.push_str(", "); }
3687+ if i > 0 {
3688+ template.push_str(", ");
3689+ }
32523690 template.push_str(p);
32533691 template.push_str(" value");
32543692 }
@@ -3400,7 +3838,11 @@ compile();
34003838 let _ = fs::create_dir_all(out);
34013839 }
34023840
3403− let json_collector = if format_json { Some(Mutex::new(Vec::new())) } else { None };
3841+ let json_collector = if format_json {
3842+ Some(Mutex::new(Vec::new()))
3843+ } else {
3844+ None
3845+ };
34043846 let mut any_errors = false;
34053847 let mut all_included: Vec<PathBuf> = Vec::new();
34063848 for file in &hl_files {
@@ -3413,23 +3855,30 @@ compile();
34133855 }
34143856 out_p.to_string_lossy().to_string()
34153857 });
3416− let (has_errors, included) = compile(&path_str, &CompileConfig {
3417− dev, error_overlay: serve, check_only: check,
3418− output_path: effective_out.as_deref(), format_json,
3419− json_collector: json_collector.as_ref(),
3420− compat, strict, partial,
3421− ..Default::default()
3422− });
3858+ let (has_errors, included) = compile(
3859+ &path_str,
3860+ &CompileConfig {
3861+ dev,
3862+ error_overlay: serve,
3863+ check_only: check,
3864+ output_path: effective_out.as_deref(),
3865+ format_json,
3866+ json_collector: json_collector.as_ref(),
3867+ compat,
3868+ strict,
3869+ partial,
3870+ ..Default::default()
3871+ },
3872+ );
34233873 if has_errors {
34243874 any_errors = true;
34253875 }
34263876 all_included.extend(included);
34273877 }
34283878
3429− if format_json
3430− && let Some(collector) = json_collector {
3431− print_json_diagnostics(&collector.lock().unwrap());
3432− }
3879+ if format_json && let Some(collector) = json_collector {
3880+ print_json_diagnostics(&collector.lock().unwrap());
3881+ }
34333882
34343883 if !watch {
34353884 if any_errors {
@@ -3455,23 +3904,43 @@ compile();
34553904 None
34563905 };
34573906
3458− watch_loop(dir, &hl_files, &all_included, dev, serve, reload_tx, port, 50);
3907+ watch_loop(
3908+ dir,
3909+ &hl_files,
3910+ &all_included,
3911+ dev,
3912+ serve,
3913+ reload_tx,
3914+ port,
3915+ 50,
3916+ );
34593917 return;
34603918 }
34613919
34623920 // --- Single file mode ---
3463− let json_collector_single = if format_json { Some(Mutex::new(Vec::new())) } else { None };
3464− let (has_errors, included_files) = compile(&input_path, &CompileConfig {
3465− dev, error_overlay: serve, check_only: check,
3466− output_path: output_path.as_deref(), format_json,
3467− json_collector: json_collector_single.as_ref(),
3468− compat, strict, partial,
3469− ..Default::default()
3470− });
3471− if format_json
3472− && let Some(ref collector) = json_collector_single {
3473− print_json_diagnostics(&collector.lock().unwrap());
3474− }
3921+ let json_collector_single = if format_json {
3922+ Some(Mutex::new(Vec::new()))
3923+ } else {
3924+ None
3925+ };
3926+ let (has_errors, included_files) = compile(
3927+ &input_path,
3928+ &CompileConfig {
3929+ dev,
3930+ error_overlay: serve,
3931+ check_only: check,
3932+ output_path: output_path.as_deref(),
3933+ format_json,
3934+ json_collector: json_collector_single.as_ref(),
3935+ compat,
3936+ strict,
3937+ partial,
3938+ ..Default::default()
3939+ },
3940+ );
3941+ if format_json && let Some(ref collector) = json_collector_single {
3942+ print_json_diagnostics(&collector.lock().unwrap());
3943+ }
34753944
34763945 if !watch {
34773946 if has_errors {
@@ -3625,138 +4094,140 @@ fn watch_loop(
36254094 std::thread::sleep(std::time::Duration::from_millis(debounce_ms));
36264095 while rx.try_recv().is_ok() {}
36274096
3628− // Collect which files actually changed (HashSet for O(1) lookups)
3629− let mut changed_files: HashSet<PathBuf> = HashSet::new();
3630− let check_path = |path: &Path,
3631− hashes: &mut HashMap<PathBuf, u64>|
3632− -> bool {
3633− if let Some(h) = hash_file(path)
3634− && hashes.get(path) != Some(&h) {
3635− hashes.insert(path.to_path_buf(), h);
3636− return true;
3637− }
3638− false
3639− };
4097+ // Collect which files actually changed (HashSet for O(1) lookups)
4098+ let mut changed_files: HashSet<PathBuf> = HashSet::new();
4099+ let check_path = |path: &Path, hashes: &mut HashMap<PathBuf, u64>| -> bool {
4100+ if let Some(h) = hash_file(path)
4101+ && hashes.get(path) != Some(&h)
4102+ {
4103+ hashes.insert(path.to_path_buf(), h);
4104+ return true;
4105+ }
4106+ false
4107+ };
36404108
3641− for file in source_files {
3642− let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3643− if check_path(&canonical, &mut content_hashes) {
3644− changed_files.insert(canonical);
3645− }
3646− }
4109+ for file in source_files {
4110+ let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
4111+ if check_path(&canonical, &mut content_hashes) {
4112+ changed_files.insert(canonical);
4113+ }
4114+ }
36474115
3648− // Check included files for changes
3649− for deps in dep_map.values() {
3650− for dep in deps {
3651− if check_path(dep, &mut content_hashes) {
3652− changed_files.insert(dep.clone());
3653− }
3654− }
4116+ // Check included files for changes
4117+ for deps in dep_map.values() {
4118+ for dep in deps {
4119+ if check_path(dep, &mut content_hashes) {
4120+ changed_files.insert(dep.clone());
36554121 }
4122+ }
4123+ }
36564124
3657− // Check for new .hl files in directory
3658− if watch_dir.is_dir() {
3659− let current_files = collect_hl_files(watch_dir);
3660− for file in &current_files {
3661− let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3662− if check_path(&canonical, &mut content_hashes) {
3663− changed_files.insert(canonical.clone());
3664− let _ = watcher.watch(&canonical, RecursiveMode::NonRecursive);
3665− }
3666− }
4125+ // Check for new .hl files in directory
4126+ if watch_dir.is_dir() {
4127+ let current_files = collect_hl_files(watch_dir);
4128+ for file in &current_files {
4129+ let canonical = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
4130+ if check_path(&canonical, &mut content_hashes) {
4131+ changed_files.insert(canonical.clone());
4132+ let _ = watcher.watch(&canonical, RecursiveMode::NonRecursive);
36674133 }
4134+ }
4135+ }
36684136
3669− if changed_files.is_empty() {
3670− continue;
3671− }
4137+ if changed_files.is_empty() {
4138+ continue;
4139+ }
36724140
3673− eprintln!("\nrecompiling...");
3674−
3675− // Determine which source files need recompilation:
3676− // 1. Source files that changed directly
3677− // 2. Source files whose dependencies changed
3678− let mut files_to_compile: Vec<PathBuf> = Vec::new();
3679− let all_sources: Vec<PathBuf> = {
3680− let mut s: Vec<PathBuf> = source_files.iter()
3681− .map(|f| fs::canonicalize(f).unwrap_or_else(|_| f.clone()))
3682− .collect();
3683− if watch_dir.is_dir() {
3684− for file in &collect_hl_files(watch_dir) {
3685− let c = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
3686− if !s.contains(&c) {
3687− s.push(c);
3688− }
3689− }
3690− }
3691− s
3692− };
4141+ eprintln!("\nrecompiling...");
36934142
3694− for source in &all_sources {
3695− // Recompile if the source itself changed
3696− if changed_files.contains(source) {
3697− if !files_to_compile.contains(source) {
3698− files_to_compile.push(source.clone());
3699− }
3700− continue;
4143+ // Determine which source files need recompilation:
4144+ // 1. Source files that changed directly
4145+ // 2. Source files whose dependencies changed
4146+ let mut files_to_compile: Vec<PathBuf> = Vec::new();
4147+ let all_sources: Vec<PathBuf> = {
4148+ let mut s: Vec<PathBuf> = source_files
4149+ .iter()
4150+ .map(|f| fs::canonicalize(f).unwrap_or_else(|_| f.clone()))
4151+ .collect();
4152+ if watch_dir.is_dir() {
4153+ for file in &collect_hl_files(watch_dir) {
4154+ let c = fs::canonicalize(file).unwrap_or_else(|_| file.clone());
4155+ if !s.contains(&c) {
4156+ s.push(c);
37014157 }
3702− // Recompile if any of its dependencies changed
3703− if let Some(deps) = dep_map.get(source)
3704− && deps.iter().any(|d| changed_files.contains(d))
3705− && !files_to_compile.contains(source) {
3706− files_to_compile.push(source.clone());
3707− }
37084158 }
4159+ }
4160+ s
4161+ };
37094162
3710− // If no specific files identified (e.g., first run), compile all
3711− if files_to_compile.is_empty() {
3712− files_to_compile = all_sources;
4163+ for source in &all_sources {
4164+ // Recompile if the source itself changed
4165+ if changed_files.contains(source) {
4166+ if !files_to_compile.contains(source) {
4167+ files_to_compile.push(source.clone());
37134168 }
4169+ continue;
4170+ }
4171+ // Recompile if any of its dependencies changed
4172+ if let Some(deps) = dep_map.get(source)
4173+ && deps.iter().any(|d| changed_files.contains(d))
4174+ && !files_to_compile.contains(source)
4175+ {
4176+ files_to_compile.push(source.clone());
4177+ }
4178+ }
37144179
3715− let mut recompiled = 0usize;
3716− for file in &files_to_compile {
3717− let path_str = file.to_string_lossy().to_string();
3718− let (_, new_includes) = compile(&path_str, &CompileConfig {
3719− dev, error_overlay: serve,
3720− ..Default::default()
3721− });
3722− recompiled += 1;
3723− // Update dependency map
3724− let canonical_deps: Vec<PathBuf> = new_includes.iter()
3725− .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
3726− .collect();
3727− for inc in &canonical_deps {
3728− if watched_includes.insert(inc.clone()) {
3729− let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
3730− }
3731− if let Some(h) = hash_file(inc) {
3732− content_hashes.insert(inc.clone(), h);
3733− }
3734− }
3735− dep_map.insert(file.clone(), canonical_deps);
3736− }
4180+ // If no specific files identified (e.g., first run), compile all
4181+ if files_to_compile.is_empty() {
4182+ files_to_compile = all_sources;
4183+ }
37374184
3738− // Unwatch include files that are no longer referenced by any
3739− // source. Prevents file-descriptor leaks when @include edges
3740− // are removed mid-session.
3741− let still_needed: HashSet<PathBuf> = dep_map
3742− .values()
3743− .flatten()
3744− .cloned()
3745− .collect();
3746− let to_drop: Vec<PathBuf> = watched_includes
3747− .difference(&still_needed)
3748− .cloned()
3749− .collect();
3750− for path in to_drop {
3751− let _ = watcher.unwatch(&path);
3752− watched_includes.remove(&path);
3753− content_hashes.remove(&path);
4185+ let mut recompiled = 0usize;
4186+ for file in &files_to_compile {
4187+ let path_str = file.to_string_lossy().to_string();
4188+ let (_, new_includes) = compile(
4189+ &path_str,
4190+ &CompileConfig {
4191+ dev,
4192+ error_overlay: serve,
4193+ ..Default::default()
4194+ },
4195+ );
4196+ recompiled += 1;
4197+ // Update dependency map
4198+ let canonical_deps: Vec<PathBuf> = new_includes
4199+ .iter()
4200+ .map(|p| fs::canonicalize(p).unwrap_or_else(|_| p.clone()))
4201+ .collect();
4202+ for inc in &canonical_deps {
4203+ if watched_includes.insert(inc.clone()) {
4204+ let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
4205+ }
4206+ if let Some(h) = hash_file(inc) {
4207+ content_hashes.insert(inc.clone(), h);
37544208 }
4209+ }
4210+ dep_map.insert(file.clone(), canonical_deps);
4211+ }
37554212
3756− eprintln!("recompiled {} file(s)", recompiled);
4213+ // Unwatch include files that are no longer referenced by any
4214+ // source. Prevents file-descriptor leaks when @include edges
4215+ // are removed mid-session.
4216+ let still_needed: HashSet<PathBuf> = dep_map.values().flatten().cloned().collect();
4217+ let to_drop: Vec<PathBuf> = watched_includes
4218+ .difference(&still_needed)
4219+ .cloned()
4220+ .collect();
4221+ for path in to_drop {
4222+ let _ = watcher.unwatch(&path);
4223+ watched_includes.remove(&path);
4224+ content_hashes.remove(&path);
4225+ }
37574226
3758− if let Some(ref tx) = reload_tx {
3759− let _ = tx.send(());
3760− }
4227+ eprintln!("recompiled {} file(s)", recompiled);
4228+
4229+ if let Some(ref tx) = reload_tx {
4230+ let _ = tx.send(());
4231+ }
37614232 }
37624233}
src/serve.rs+17−16
@@ -4,9 +4,9 @@ use std::sync::Arc;
44use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt};
55use tokio::net::TcpListener;
66use tokio::sync::broadcast;
7+use tokio_rustls::TlsAcceptor;
78use tokio_rustls::rustls::ServerConfig;
89use tokio_rustls::rustls::pki_types::{CertificateDer, PrivateKeyDer};
9−use tokio_rustls::TlsAcceptor;
1010
1111/// TLS configuration for the dev server. Load with `load_tls_config` and pass
1212/// to `run_https` / `run_dir_https`.
@@ -156,7 +156,6 @@ pub async fn run_dir_https(
156156 }
157157}
158158
159−
160159async fn handle_dir_request<S>(
161160 mut stream: S,
162161 root_dir: PathBuf,
@@ -249,7 +248,8 @@ where
249248 let content_type = content_type_for(&fp);
250249 let header = format!(
251250 "HTTP/1.1 200 OK\r\nContent-Type: {}\r\nContent-Length: {}\r\nCache-Control: no-cache\r\nAccess-Control-Allow-Origin: *\r\n\r\n",
252− content_type, body.len(),
251+ content_type,
252+ body.len(),
253253 );
254254 stream.write_all(header.as_bytes()).await?;
255255 stream.write_all(&body).await?;
@@ -282,9 +282,9 @@ fn generate_directory_listing(dir: &Path) -> String {
282282 } else {
283283 for page in &pages {
284284 let display = page.trim_start_matches('/');
285− let clean = display.strip_suffix("/index.html").unwrap_or(
286− display.strip_suffix(".html").unwrap_or(display),
287− );
285+ let clean = display
286+ .strip_suffix("/index.html")
287+ .unwrap_or(display.strip_suffix(".html").unwrap_or(display));
288288 let label = if clean.is_empty() { "index" } else { clean };
289289 html.push_str(&format!(
290290 "<a href=\"{}\">{} <span class=\"path\">{}</span></a>",
@@ -305,9 +305,10 @@ fn collect_html_pages(base: &Path, dir: &Path, pages: &mut Vec<String>) {
305305 if path.is_dir() {
306306 collect_html_pages(base, &path, pages);
307307 } else if path.extension().is_some_and(|e| e == "html")
308− && let Ok(rel) = path.strip_prefix(base) {
309− pages.push(format!("/{}", rel.display()));
310− }
308+ && let Ok(rel) = path.strip_prefix(base)
309+ {
310+ pages.push(format!("/{}", rel.display()));
311+ }
311312 }
312313 }
313314}
@@ -341,10 +342,7 @@ where
341342 }
342343}
343344
344−async fn handle_sse<S>(
345− mut stream: S,
346− mut rx: broadcast::Receiver<()>,
347−) -> std::io::Result<()>
345+async fn handle_sse<S>(mut stream: S, mut rx: broadcast::Receiver<()>) -> std::io::Result<()>
348346where
349347 S: AsyncRead + AsyncWrite + Unpin + Send,
350348{
@@ -356,7 +354,11 @@ where
356354 stream.write_all(headers.as_bytes()).await?;
357355 // Send retry hint (ms) so browsers reconnect quickly after a drop, plus an
358356 // initial comment line that flushes headers through proxies.
359− if stream.write_all(b"retry: 1000\n: connected\n\n").await.is_err() {
357+ if stream
358+ .write_all(b"retry: 1000\n: connected\n\n")
359+ .await
360+ .is_err()
361+ {
360362 return Ok(());
361363 }
362364
@@ -408,8 +410,7 @@ where
408410 let file_path = if request_path == "/" {
409411 html_path.to_path_buf()
410412 } else {
411− let dir = root_dir
412− .unwrap_or_else(|| html_path.parent().unwrap_or(Path::new(".")));
413+ let dir = root_dir.unwrap_or_else(|| html_path.parent().unwrap_or(Path::new(".")));
413414 dir.join(&request_path[1..])
414415 };
415416
tests/snapshots.rs+1299−348
@@ -284,9 +284,7 @@ fn error_else_without_if() {
284284fn error_each_bad_syntax() {
285285 let diags = parse_diagnostics("@each $x");
286286 assert!(
287− diags
288− .iter()
289− .any(|d| d.message.contains("@each requires")),
287+ diags.iter().any(|d| d.message.contains("@each requires")),
290288 "expected @each syntax error, got: {:?}",
291289 diags
292290 );
@@ -308,9 +306,7 @@ fn error_numeric_validation() {
308306fn error_opacity_range() {
309307 let diags = parse_diagnostics("@el [opacity 2.0]");
310308 assert!(
311− diags
312− .iter()
313− .any(|d| d.message.contains("between 0 and 1")),
309+ diags.iter().any(|d| d.message.contains("between 0 and 1")),
314310 "expected opacity range warning, got: {:?}",
315311 diags
316312 );
@@ -342,7 +338,9 @@ fn error_else_if_without_if() {
342338
343339#[test]
344340fn else_if_chain() {
345− let output = compile("@let x 2\n@if $x == 1\n @text one\n@else if $x == 2\n @text two\n@else\n @text other");
341+ let output = compile(
342+ "@let x 2\n@if $x == 1\n @text one\n@else if $x == 2\n @text two\n@else\n @text other",
343+ );
346344 assert!(output.contains("two"));
347345 assert!(!output.contains("one"));
348346 assert!(!output.contains("other"));
@@ -410,7 +408,9 @@ fn each_loop_expansion() {
410408fn aria_data_attrs_accepted() {
411409 let diags = parse_diagnostics("@el [aria-label Test, data-id 42]");
412410 assert!(
413− !diags.iter().any(|d| d.message.contains("unknown attribute")),
411+ !diags
412+ .iter()
413+ .any(|d| d.message.contains("unknown attribute")),
414414 "aria-*/data-* should not produce warnings, got: {:?}",
415415 diags
416416 );
@@ -426,7 +426,9 @@ fn aria_data_attrs_accepted() {
426426fn warning_unused_variable() {
427427 let diags = parse_diagnostics("@let color red\n@el [padding 10]");
428428 assert!(
429− diags.iter().any(|d| d.message.contains("unused variable") && d.message.contains("color")),
429+ diags
430+ .iter()
431+ .any(|d| d.message.contains("unused variable") && d.message.contains("color")),
430432 "expected unused variable warning, got: {:?}",
431433 diags
432434 );
@@ -446,7 +448,9 @@ fn no_warning_used_variable() {
446448fn warning_unused_function() {
447449 let diags = parse_diagnostics("@fn card\n @el [padding 10]\n@el");
448450 assert!(
449− diags.iter().any(|d| d.message.contains("unused function") && d.message.contains("card")),
451+ diags
452+ .iter()
453+ .any(|d| d.message.contains("unused function") && d.message.contains("card")),
450454 "expected unused function warning, got: {:?}",
451455 diags
452456 );
@@ -488,7 +492,9 @@ fn no_warning_used_define() {
488492fn warning_spacing_on_text() {
489493 let diags = parse_diagnostics("@text [spacing 10] hello");
490494 assert!(
491− diags.iter().any(|d| d.message.contains("spacing") && d.message.contains("no effect")),
495+ diags
496+ .iter()
497+ .any(|d| d.message.contains("spacing") && d.message.contains("no effect")),
492498 "expected spacing on @text warning, got: {:?}",
493499 diags
494500 );
@@ -498,7 +504,9 @@ fn warning_spacing_on_text() {
498504fn no_warning_spacing_on_row() {
499505 let diags = parse_diagnostics("@row [spacing 10]");
500506 assert!(
501− !diags.iter().any(|d| d.message.contains("spacing") && d.message.contains("no effect")),
507+ !diags
508+ .iter()
509+ .any(|d| d.message.contains("spacing") && d.message.contains("no effect")),
502510 "should not warn about spacing on @row, got: {:?}",
503511 diags
504512 );
@@ -508,7 +516,9 @@ fn no_warning_spacing_on_row() {
508516fn warning_placeholder_on_row() {
509517 let diags = parse_diagnostics("@row [placeholder test]");
510518 assert!(
511− diags.iter().any(|d| d.message.contains("placeholder") && d.message.contains("no effect")),
519+ diags
520+ .iter()
521+ .any(|d| d.message.contains("placeholder") && d.message.contains("no effect")),
512522 "expected placeholder on @row warning, got: {:?}",
513523 diags
514524 );
@@ -518,7 +528,9 @@ fn warning_placeholder_on_row() {
518528fn warning_for_on_non_label() {
519529 let diags = parse_diagnostics("@el [for email]");
520530 assert!(
521− diags.iter().any(|d| d.message.contains("'for'") && d.message.contains("@label")),
531+ diags
532+ .iter()
533+ .any(|d| d.message.contains("'for'") && d.message.contains("@label")),
522534 "expected 'for' on non-label warning, got: {:?}",
523535 diags
524536 );
@@ -546,14 +558,18 @@ fn each_range_with_index() {
546558
547559#[test]
548560fn named_slot_basic() {
549− let output = compile("@fn card\n @el\n @slot header\n @children\n@card\n @slot header\n @text Title\n @text Body");
561+ let output = compile(
562+ "@fn card\n @el\n @slot header\n @children\n@card\n @slot header\n @text Title\n @text Body",
563+ );
550564 assert!(output.contains("Title"));
551565 assert!(output.contains("Body"));
552566}
553567
554568#[test]
555569fn named_slot_default_content() {
556− let output = compile("@fn card\n @el\n @slot header\n @text Default\n @children\n@card\n @text Body");
570+ let output = compile(
571+ "@fn card\n @el\n @slot header\n @text Default\n @children\n@card\n @text Body",
572+ );
557573 assert!(output.contains("Default"));
558574 assert!(output.contains("Body"));
559575}
@@ -562,7 +578,9 @@ fn named_slot_default_content() {
562578
563579#[test]
564580fn style_block_output() {
565− let output = compile("@page Test\n@style\n .custom { color: red; }\n@el [class custom]\n @text styled");
581+ let output = compile(
582+ "@page Test\n@style\n .custom { color: red; }\n@el [class custom]\n @text styled",
583+ );
566584 assert!(output.contains(".custom{color:red;}") || output.contains(".custom { color: red; }"));
567585 assert!(output.contains("styled"));
568586}
@@ -721,7 +739,9 @@ fn item_alias_li() {
721739
722740#[test]
723741fn table_renders_proper_tags() {
724− let output = compile("@page T\n@table\n @thead\n @tr\n @th Header\n @tbody\n @tr\n @td Cell");
742+ let output = compile(
743+ "@page T\n@table\n @thead\n @tr\n @th Header\n @tbody\n @tr\n @td Cell",
744+ );
725745 assert!(output.contains("<table"));
726746 assert!(output.contains("<thead"));
727747 assert!(output.contains("<tbody"));
@@ -768,7 +788,9 @@ fn video_with_multiple_attrs() {
768788
769789#[test]
770790fn match_selects_correct_case() {
771− let output = compile("@let x b\n@match $x\n @case a\n @text A\n @case b\n @text B\n @default\n @text D");
791+ let output = compile(
792+ "@let x b\n@match $x\n @case a\n @text A\n @case b\n @text B\n @default\n @text D",
793+ );
772794 assert!(output.contains("B"));
773795 assert!(!output.contains(">A<"));
774796 assert!(!output.contains(">D<"));
@@ -776,7 +798,8 @@ fn match_selects_correct_case() {
776798
777799#[test]
778800fn match_falls_to_default() {
779− let output = compile("@let x z\n@match $x\n @case a\n @text A\n @default\n @text Default");
801+ let output =
802+ compile("@let x z\n@match $x\n @case a\n @text A\n @default\n @text Default");
780803 assert!(output.contains("Default"));
781804 assert!(!output.contains(">A<"));
782805}
@@ -865,7 +888,9 @@ fn warn_produces_diagnostic() {
865888fn warn_substitutes_variables() {
866889 let diags = parse_diagnostics("@let name test\n@warn Missing $name value");
867890 assert!(
868− diags.iter().any(|d| d.message.contains("Missing test value")),
891+ diags
892+ .iter()
893+ .any(|d| d.message.contains("Missing test value")),
869894 "expected substituted @warn, got: {:?}",
870895 diags
871896 );
@@ -878,7 +903,9 @@ fn warn_substitutes_variables() {
878903#[test]
879904fn image_auto_lazy_loading() {
880905 // First 3 images get fetchpriority="high" (above-the-fold), subsequent get loading="lazy"
881− let output = compile("@page T\n@image https://example.com/1.jpg\n@image https://example.com/2.jpg\n@image https://example.com/3.jpg\n@image https://example.com/4.jpg");
906+ let output = compile(
907+ "@page T\n@image https://example.com/1.jpg\n@image https://example.com/2.jpg\n@image https://example.com/3.jpg\n@image https://example.com/4.jpg",
908+ );
882909 // First 3 images: fetchpriority="high", no lazy loading
883910 assert!(output.contains("fetchpriority=\"high\""));
884911 // 4th image: loading="lazy" + decoding="async"
@@ -935,13 +962,17 @@ fn comparison_operators_contains() {
935962
936963#[test]
937964fn comparison_operators_starts_with() {
938− let output = compile("@page T\n@let url https://example.com\n@if $url starts-with https\n @text secure");
965+ let output = compile(
966+ "@page T\n@let url https://example.com\n@if $url starts-with https\n @text secure",
967+ );
939968 assert!(output.contains("secure"));
940969}
941970
942971#[test]
943972fn string_concat_operator() {
944− let output = compile("@page T\n@let first Hello\n@let last World\n@let full $first ~ \" \" ~ $last\n@text $full");
973+ let output = compile(
974+ "@page T\n@let first Hello\n@let last World\n@let full $first ~ \" \" ~ $last\n@text $full",
975+ );
945976 assert!(output.contains("Hello World"), "got: {}", output);
946977}
947978
@@ -1005,7 +1036,9 @@ fn internal_link_no_noopener() {
10051036
10061037#[test]
10071038fn dns_prefetch_for_external_domains() {
1008− let output = compile("@page T\n@link https://example.com\n Link\n@image https://cdn.example.org/img.jpg");
1039+ let output = compile(
1040+ "@page T\n@link https://example.com\n Link\n@image https://cdn.example.org/img.jpg",
1041+ );
10091042 assert!(output.contains("dns-prefetch"));
10101043 assert!(output.contains("example.com"));
10111044}
@@ -1026,7 +1059,12 @@ fn aria_live_passthrough() {
10261059#[test]
10271060fn defer_directive() {
10281061 let result = htmlang::parser::parse("@page T\n@defer\n @el\n Lazy content");
1029− assert!(result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error));
1062+ assert!(
1063+ result
1064+ .diagnostics
1065+ .iter()
1066+ .all(|d| d.severity != htmlang::parser::Severity::Error)
1067+ );
10301068 let html = htmlang::codegen::generate(&result.document);
10311069 assert!(html.contains("data-hl-defer"));
10321070 assert!(html.contains("IntersectionObserver"));
@@ -1053,7 +1091,9 @@ fn comparison_ends_with() {
10531091fn warning_ordered_on_non_list() {
10541092 let diags = parse_diagnostics("@el [ordered]");
10551093 assert!(
1056− diags.iter().any(|d| d.message.contains("ordered") && d.message.contains("@list")),
1094+ diags
1095+ .iter()
1096+ .any(|d| d.message.contains("ordered") && d.message.contains("@list")),
10571097 "expected ordered on non-list warning, got: {:?}",
10581098 diags
10591099 );
@@ -1063,7 +1103,9 @@ fn warning_ordered_on_non_list() {
10631103fn no_warning_ordered_on_list() {
10641104 let diags = parse_diagnostics("@list [ordered]\n @item x");
10651105 assert!(
1066− !diags.iter().any(|d| d.message.contains("ordered") && d.message.contains("no effect")),
1106+ !diags
1107+ .iter()
1108+ .any(|d| d.message.contains("ordered") && d.message.contains("no effect")),
10671109 "should not warn about ordered on @list, got: {:?}",
10681110 diags
10691111 );
@@ -1073,7 +1115,9 @@ fn no_warning_ordered_on_list() {
10731115fn warning_controls_on_non_media() {
10741116 let diags = parse_diagnostics("@el [controls]");
10751117 assert!(
1076− diags.iter().any(|d| d.message.contains("controls") && d.message.contains("@video")),
1118+ diags
1119+ .iter()
1120+ .any(|d| d.message.contains("controls") && d.message.contains("@video")),
10771121 "expected controls on non-media warning, got: {:?}",
10781122 diags
10791123 );
@@ -1087,7 +1131,10 @@ fn warning_controls_on_non_media() {
10871131fn fmt_normalizes_indentation() {
10881132 let input = "@row\n @col\n @text hello\n @col\n @text world";
10891133 let formatted = htmlang::fmt::format(input);
1090− assert_eq!(formatted, "@row\n @col\n @text hello\n @col\n @text world\n");
1134+ assert_eq!(
1135+ formatted,
1136+ "@row\n @col\n @text hello\n @col\n @text world\n"
1137+ );
10911138}
10921139
10931140#[test]
@@ -1363,7 +1410,9 @@ fn each_destructuring_pairs() {
13631410fn warning_missing_alt_on_image() {
13641411 let diags = parse_diagnostics("@image photo.jpg");
13651412 assert!(
1366− diags.iter().any(|d| d.message.contains("alt") && d.message.contains("accessibility")),
1413+ diags
1414+ .iter()
1415+ .any(|d| d.message.contains("alt") && d.message.contains("accessibility")),
13671416 "expected missing alt warning, got: {:?}",
13681417 diags
13691418 );
@@ -1373,7 +1422,9 @@ fn warning_missing_alt_on_image() {
13731422fn no_warning_with_alt_on_image() {
13741423 let diags = parse_diagnostics("@image [alt A photo] photo.jpg");
13751424 assert!(
1376− !diags.iter().any(|d| d.message.contains("missing") && d.message.contains("alt")),
1425+ !diags
1426+ .iter()
1427+ .any(|d| d.message.contains("missing") && d.message.contains("alt")),
13771428 "should not warn when alt is present, got: {:?}",
13781429 diags
13791430 );
@@ -1383,7 +1434,9 @@ fn no_warning_with_alt_on_image() {
13831434fn warning_invalid_hex_color() {
13841435 let diags = parse_diagnostics("@el [color #ggg]");
13851436 assert!(
1386− diags.iter().any(|d| d.message.contains("invalid hex color")),
1437+ diags
1438+ .iter()
1439+ .any(|d| d.message.contains("invalid hex color")),
13871440 "expected invalid hex color warning, got: {:?}",
13881441 diags
13891442 );
@@ -1393,7 +1446,9 @@ fn warning_invalid_hex_color() {
13931446fn no_warning_valid_hex_color() {
13941447 let diags = parse_diagnostics("@el [color #ff0000]");
13951448 assert!(
1396− !diags.iter().any(|d| d.message.contains("invalid hex color")),
1449+ !diags
1450+ .iter()
1451+ .any(|d| d.message.contains("invalid hex color")),
13971452 "should not warn on valid hex color, got: {:?}",
13981453 diags
13991454 );
@@ -1403,7 +1458,9 @@ fn no_warning_valid_hex_color() {
14031458fn warning_duplicate_attribute() {
14041459 let diags = parse_diagnostics("@el [padding 10, padding 20]");
14051460 assert!(
1406− diags.iter().any(|d| d.message.contains("duplicate attribute")),
1461+ diags
1462+ .iter()
1463+ .any(|d| d.message.contains("duplicate attribute")),
14071464 "expected duplicate attribute warning, got: {:?}",
14081465 diags
14091466 );
@@ -1594,7 +1651,9 @@ fn css_border_collapse() {
15941651
15951652#[test]
15961653fn css_text_decoration_full() {
1597− let output = compile("@page T\n@text [text-decoration underline, text-decoration-color red, text-decoration-style wavy] Hello");
1654+ let output = compile(
1655+ "@page T\n@text [text-decoration underline, text-decoration-color red, text-decoration-style wavy] Hello",
1656+ );
15981657 assert!(output.contains("text-decoration:underline"));
15991658 assert!(output.contains("text-decoration-color:red"));
16001659 assert!(output.contains("text-decoration-style:wavy"));
@@ -1652,9 +1711,13 @@ fn favicon_fallback_href() {
16521711
16531712#[test]
16541713fn no_warning_new_pseudo_prefixes() {
1655− let diags = parse_diagnostics("@el [focus-visible:border 2 blue, disabled:opacity 0.5, checked:background green]");
1714+ let diags = parse_diagnostics(
1715+ "@el [focus-visible:border 2 blue, disabled:opacity 0.5, checked:background green]",
1716+ );
16561717 assert!(
1657− !diags.iter().any(|d| d.message.contains("unknown attribute")),
1718+ !diags
1719+ .iter()
1720+ .any(|d| d.message.contains("unknown attribute")),
16581721 "new pseudo-state prefixes should be recognized, got: {:?}",
16591722 diags
16601723 );
@@ -1662,9 +1725,13 @@ fn no_warning_new_pseudo_prefixes() {
16621725
16631726#[test]
16641727fn no_warning_child_selectors() {
1665− let diags = parse_diagnostics("@el [first:padding 0, last:padding 0, odd:background #eee, even:background white]");
1728+ let diags = parse_diagnostics(
1729+ "@el [first:padding 0, last:padding 0, odd:background #eee, even:background white]",
1730+ );
16661731 assert!(
1667− !diags.iter().any(|d| d.message.contains("unknown attribute")),
1732+ !diags
1733+ .iter()
1734+ .any(|d| d.message.contains("unknown attribute")),
16681735 "child selectors should be recognized, got: {:?}",
16691736 diags
16701737 );
@@ -1672,9 +1739,13 @@ fn no_warning_child_selectors() {
16721739
16731740#[test]
16741741fn no_warning_new_css_attrs() {
1675− let diags = parse_diagnostics("@el [overflow-x hidden, overflow-y auto, inset 0, accent-color blue, hidden]");
1742+ let diags = parse_diagnostics(
1743+ "@el [overflow-x hidden, overflow-y auto, inset 0, accent-color blue, hidden]",
1744+ );
16761745 assert!(
1677− !diags.iter().any(|d| d.message.contains("unknown attribute")),
1746+ !diags
1747+ .iter()
1748+ .any(|d| d.message.contains("unknown attribute")),
16781749 "new CSS attrs should be recognized, got: {:?}",
16791750 diags
16801751 );
@@ -1766,7 +1837,9 @@ fn element_fieldset_legend() {
17661837
17671838#[test]
17681839fn element_picture_source() {
1769− let output = compile("@page T\n@picture\n @source [srcset wide.jpg, media (min-width: 800px)]\n @image [alt Photo] photo.jpg");
1840+ let output = compile(
1841+ "@page T\n@picture\n @source [srcset wide.jpg, media (min-width: 800px)]\n @image [alt Photo] photo.jpg",
1842+ );
17701843 assert!(output.contains("<picture"));
17711844 assert!(output.contains("<source"));
17721845 assert!(output.contains("srcset=\"wide.jpg\""));
@@ -1960,7 +2033,9 @@ fn unless_true_hides_content() {
19602033
19612034#[test]
19622035fn og_tags_in_output() {
1963− let output = compile("@page T\n@og title \"My Page\"\n@og image \"https://example.com/img.png\"\n@text Hello");
2036+ let output = compile(
2037+ "@page T\n@og title \"My Page\"\n@og image \"https://example.com/img.png\"\n@text Hello",
2038+ );
19642039 assert!(output.contains("og:title"));
19652040 assert!(output.contains("My Page"));
19662041 assert!(output.contains("og:image"));
@@ -2030,10 +2105,12 @@ fn no_warning_link_with_text() {
20302105#[test]
20312106fn no_warning_new_css_properties_2() {
20322107 let diags = parse_diagnostics(
2033− "@el [clip-path circle(50%), mix-blend-mode multiply, writing-mode vertical-rl, isolation isolate]"
2108+ "@el [clip-path circle(50%), mix-blend-mode multiply, writing-mode vertical-rl, isolation isolate]",
20342109 );
20352110 assert!(
2036− !diags.iter().any(|d| d.message.contains("unknown attribute")),
2111+ !diags
2112+ .iter()
2113+ .any(|d| d.message.contains("unknown attribute")),
20372114 "new CSS properties should be recognized, got: {:?}",
20382115 diags
20392116 );
@@ -2042,10 +2119,12 @@ fn no_warning_new_css_properties_2() {
20422119#[test]
20432120fn no_warning_new_css_properties_3() {
20442121 let diags = parse_diagnostics(
2045− "@el [column-count 3, column-gap 20, text-indent 2em, hyphens auto, flex-grow 1, flex-shrink 0, flex-basis 200, place-content center, background-image url(x)]"
2122+ "@el [column-count 3, column-gap 20, text-indent 2em, hyphens auto, flex-grow 1, flex-shrink 0, flex-basis 200, place-content center, background-image url(x)]",
20462123 );
20472124 assert!(
2048− !diags.iter().any(|d| d.message.contains("unknown attribute")),
2125+ !diags
2126+ .iter()
2127+ .any(|d| d.message.contains("unknown attribute")),
20492128 "new CSS properties should be recognized, got: {:?}",
20502129 diags
20512130 );
@@ -2054,10 +2133,12 @@ fn no_warning_new_css_properties_3() {
20542133#[test]
20552134fn no_warning_new_media_prefixes() {
20562135 let diags = parse_diagnostics(
2057− "@el [2xl:padding 40, motion-safe:animation none, motion-reduce:transition none, landscape:width 100%, portrait:padding 20]"
2136+ "@el [2xl:padding 40, motion-safe:animation none, motion-reduce:transition none, landscape:width 100%, portrait:padding 20]",
20582137 );
20592138 assert!(
2060− !diags.iter().any(|d| d.message.contains("unknown attribute")),
2139+ !diags
2140+ .iter()
2141+ .any(|d| d.message.contains("unknown attribute")),
20612142 "new media prefixes should be recognized, got: {:?}",
20622143 diags
20632144 );
@@ -2075,27 +2156,43 @@ fn snapshot_if_expr() {
20752156#[test]
20762157fn if_expr_true_branch() {
20772158 let output = compile("@let x true\n@el [background if($x, blue, gray)]\n test");
2078− assert!(output.contains("blue"), "should use true branch, got: {}", output);
2159+ assert!(
2160+ output.contains("blue"),
2161+ "should use true branch, got: {}",
2162+ output
2163+ );
20792164 assert!(!output.contains("gray"), "should not contain false branch");
20802165}
20812166
20822167#[test]
20832168fn if_expr_false_branch() {
20842169 let output = compile("@let x false\n@el [background if($x, blue, gray)]\n test");
2085− assert!(output.contains("gray"), "should use false branch, got: {}", output);
2170+ assert!(
2171+ output.contains("gray"),
2172+ "should use false branch, got: {}",
2173+ output
2174+ );
20862175 assert!(!output.contains("blue"), "should not contain true branch");
20872176}
20882177
20892178#[test]
20902179fn if_expr_equality_condition() {
20912180 let output = compile("@let theme dark\n@el [color if($theme == dark, white, black)]\n test");
2092− assert!(output.contains("white"), "should match equality, got: {}", output);
2181+ assert!(
2182+ output.contains("white"),
2183+ "should match equality, got: {}",
2184+ output
2185+ );
20932186}
20942187
20952188#[test]
20962189fn if_expr_inequality_condition() {
20972190 let output = compile("@let mode light\n@el [color if($mode != dark, green, red)]\n test");
2098− assert!(output.contains("green"), "should match inequality, got: {}", output);
2191+ assert!(
2192+ output.contains("green"),
2193+ "should match inequality, got: {}",
2194+ output
2195+ );
20992196}
21002197
21012198#[test]
@@ -2119,7 +2216,11 @@ fn convert_basic_div() {
21192216#[test]
21202217fn convert_paragraph() {
21212218 let hl = htmlang::convert::convert("<p>Some text</p>");
2122− assert!(hl.contains("@paragraph"), "p should become @paragraph: {}", hl);
2219+ assert!(
2220+ hl.contains("@paragraph"),
2221+ "p should become @paragraph: {}",
2222+ hl
2223+ );
21232224}
21242225
21252226#[test]
@@ -2162,7 +2263,11 @@ fn convert_list() {
21622263#[test]
21632264fn convert_ordered_list() {
21642265 let hl = htmlang::convert::convert("<ol><li>First</li></ol>");
2165− assert!(hl.contains("@list [ordered]"), "ol becomes @list [ordered]: {}", hl);
2266+ assert!(
2267+ hl.contains("@list [ordered]"),
2268+ "ol becomes @list [ordered]: {}",
2269+ hl
2270+ );
21662271}
21672272
21682273#[test]
@@ -2183,8 +2288,14 @@ fn convert_table() {
21832288
21842289#[test]
21852290fn convert_strips_html_boilerplate() {
2186− let hl = htmlang::convert::convert("<!DOCTYPE html><html><head><title>T</title></head><body><p>Hi</p></body></html>");
2187− assert!(hl.contains("@paragraph"), "should extract body content: {}", hl);
2291+ let hl = htmlang::convert::convert(
2292+ "<!DOCTYPE html><html><head><title>T</title></head><body><p>Hi</p></body></html>",
2293+ );
2294+ assert!(
2295+ hl.contains("@paragraph"),
2296+ "should extract body content: {}",
2297+ hl
2298+ );
21882299 assert!(!hl.contains("DOCTYPE"), "should strip doctype: {}", hl);
21892300}
21902301
@@ -2192,7 +2303,11 @@ fn convert_strips_html_boilerplate() {
21922303fn convert_inline_style() {
21932304 let hl = htmlang::convert::convert("<div style=\"padding: 20px; background: red;\">X</div>");
21942305 assert!(hl.contains("padding 20"), "padding converted: {}", hl);
2195− assert!(hl.contains("background red"), "background converted: {}", hl);
2306+ assert!(
2307+ hl.contains("background red"),
2308+ "background converted: {}",
2309+ hl
2310+ );
21962311}
21972312
21982313#[test]
@@ -2228,8 +2343,13 @@ fn snapshot_each_else() {
22282343#[test]
22292344fn each_else_empty_list() {
22302345 // Variable that resolves to empty string: @each produces no items → @else fires
2231− let output = compile("@let empty \"\"\n@each $item in $empty\n @text $item\n@else\n @text fallback");
2232− assert!(output.contains("fallback"), "should render @else block when list is empty: {}", output);
2346+ let output =
2347+ compile("@let empty \"\"\n@each $item in $empty\n @text $item\n@else\n @text fallback");
2348+ assert!(
2349+ output.contains("fallback"),
2350+ "should render @else block when list is empty: {}",
2351+ output
2352+ );
22332353}
22342354
22352355#[test]
@@ -2237,72 +2357,136 @@ fn each_else_non_empty_list() {
22372357 let output = compile("@each $x in a,b\n @text $x\n@else\n @text empty");
22382358 assert!(output.contains("a"), "should render loop items: {}", output);
22392359 assert!(output.contains("b"), "should render loop items: {}", output);
2240− assert!(!output.contains("empty"), "should not render @else block when list is non-empty: {}", output);
2360+ assert!(
2361+ !output.contains("empty"),
2362+ "should not render @else block when list is non-empty: {}",
2363+ output
2364+ );
22412365}
22422366
22432367#[test]
22442368fn pseudo_element_before_content() {
22452369 let output = compile("@el [before:content arrow, before:color red]\n Hello");
2246− assert!(output.contains("::before"), "should generate ::before CSS: {}", output);
2247− assert!(output.contains("content:\"arrow\""), "should generate content property: {}", output);
2248− assert!(output.contains("color:red"), "should generate color in ::before: {}", output);
2370+ assert!(
2371+ output.contains("::before"),
2372+ "should generate ::before CSS: {}",
2373+ output
2374+ );
2375+ assert!(
2376+ output.contains("content:\"arrow\""),
2377+ "should generate content property: {}",
2378+ output
2379+ );
2380+ assert!(
2381+ output.contains("color:red"),
2382+ "should generate color in ::before: {}",
2383+ output
2384+ );
22492385}
22502386
22512387#[test]
22522388fn pseudo_element_after_content() {
22532389 let output = compile("@el [after:content ✓]\n Done");
2254− assert!(output.contains("::after"), "should generate ::after CSS: {}", output);
2255− assert!(output.contains("content:\"✓\""), "should generate content: {}", output);
2390+ assert!(
2391+ output.contains("::after"),
2392+ "should generate ::after CSS: {}",
2393+ output
2394+ );
2395+ assert!(
2396+ output.contains("content:\"✓\""),
2397+ "should generate content: {}",
2398+ output
2399+ );
22562400}
22572401
22582402#[test]
22592403fn css_font_weight_numeric() {
22602404 let output = compile("@text [font-weight 300] Light text");
2261− assert!(output.contains("font-weight:300"), "should generate font-weight CSS: {}", output);
2405+ assert!(
2406+ output.contains("font-weight:300"),
2407+ "should generate font-weight CSS: {}",
2408+ output
2409+ );
22622410}
22632411
22642412#[test]
22652413fn css_text_wrap_balance() {
22662414 let output = compile("@text [text-wrap balance] Balanced");
2267− assert!(output.contains("text-wrap:balance"), "should generate text-wrap CSS: {}", output);
2415+ assert!(
2416+ output.contains("text-wrap:balance"),
2417+ "should generate text-wrap CSS: {}",
2418+ output
2419+ );
22682420}
22692421
22702422#[test]
22712423fn css_touch_action() {
22722424 let output = compile("@el [touch-action none]\n No touch");
2273− assert!(output.contains("touch-action:none"), "should generate touch-action CSS: {}", output);
2425+ assert!(
2426+ output.contains("touch-action:none"),
2427+ "should generate touch-action CSS: {}",
2428+ output
2429+ );
22742430}
22752431
22762432#[test]
22772433fn css_content_visibility() {
22782434 let output = compile("@el [content-visibility auto]\n Lazy");
2279− assert!(output.contains("content-visibility:auto"), "should generate content-visibility CSS: {}", output);
2435+ assert!(
2436+ output.contains("content-visibility:auto"),
2437+ "should generate content-visibility CSS: {}",
2438+ output
2439+ );
22802440}
22812441
22822442#[test]
22832443fn css_scroll_margin() {
22842444 let output = compile("@el [scroll-margin-top 80]\n Offset");
2285− assert!(output.contains("scroll-margin-top:80px"), "should generate scroll-margin-top CSS: {}", output);
2445+ assert!(
2446+ output.contains("scroll-margin-top:80px"),
2447+ "should generate scroll-margin-top CSS: {}",
2448+ output
2449+ );
22862450}
22872451
22882452#[test]
22892453fn element_iframe() {
22902454 let output = compile("@iframe [width fill, height 400] https://example.com");
2291− assert!(output.contains("<iframe"), "should generate iframe tag: {}", output);
2292− assert!(output.contains("src=\"https://example.com\""), "should have src: {}", output);
2455+ assert!(
2456+ output.contains("<iframe"),
2457+ "should generate iframe tag: {}",
2458+ output
2459+ );
2460+ assert!(
2461+ output.contains("src=\"https://example.com\""),
2462+ "should have src: {}",
2463+ output
2464+ );
22932465}
22942466
22952467#[test]
22962468fn element_canvas() {
22972469 let output = compile("@canvas [width 400, height 300, id myCanvas]");
2298− assert!(output.contains("<canvas"), "should generate canvas tag: {}", output);
2299− assert!(output.contains("id=\"myCanvas\""), "should have id: {}", output);
2470+ assert!(
2471+ output.contains("<canvas"),
2472+ "should generate canvas tag: {}",
2473+ output
2474+ );
2475+ assert!(
2476+ output.contains("id=\"myCanvas\""),
2477+ "should have id: {}",
2478+ output
2479+ );
23002480}
23012481
23022482#[test]
23032483fn element_output() {
23042484 let output = compile("@output [for a b]\n 42");
2305− assert!(output.contains("<output"), "should generate output tag: {}", output);
2485+ assert!(
2486+ output.contains("<output"),
2487+ "should generate output tag: {}",
2488+ output
2489+ );
23062490}
23072491
23082492// =========================================================================
@@ -2339,8 +2523,16 @@ fn snapshot_direction_attr() {
23392523#[test]
23402524fn element_grid() {
23412525 let output = compile("@page T\n@grid [grid-cols 3, gap 16]\n @el\n @text A");
2342− assert!(output.contains("display:grid"), "grid should have display:grid: {}", output);
2343− assert!(output.contains("grid-template-columns:repeat(3,1fr)"), "should have 3 cols: {}", output);
2526+ assert!(
2527+ output.contains("display:grid"),
2528+ "grid should have display:grid: {}",
2529+ output
2530+ );
2531+ assert!(
2532+ output.contains("grid-template-columns:repeat(3,1fr)"),
2533+ "should have 3 cols: {}",
2534+ output
2535+ );
23442536}
23452537
23462538// --- Stack element ---
@@ -2348,7 +2540,11 @@ fn element_grid() {
23482540#[test]
23492541fn element_stack() {
23502542 let output = compile("@page T\n@stack [width 200, height 200]\n @el\n @text Layer");
2351− assert!(output.contains("position:relative"), "stack should have position:relative: {}", output);
2543+ assert!(
2544+ output.contains("position:relative"),
2545+ "stack should have position:relative: {}",
2546+ output
2547+ );
23522548}
23532549
23542550// --- Spacer element ---
@@ -2356,7 +2552,11 @@ fn element_stack() {
23562552#[test]
23572553fn element_spacer() {
23582554 let output = compile("@page T\n@row\n @text Left\n @spacer\n @text Right");
2359− assert!(output.contains("flex:1"), "spacer should have flex:1: {}", output);
2555+ assert!(
2556+ output.contains("flex:1"),
2557+ "spacer should have flex:1: {}",
2558+ output
2559+ );
23602560}
23612561
23622562// --- Badge element ---
@@ -2364,8 +2564,16 @@ fn element_spacer() {
23642564#[test]
23652565fn element_badge() {
23662566 let output = compile("@page T\n@badge [background red, color white] 3");
2367− assert!(output.contains("<span"), "badge renders as span: {}", output);
2368− assert!(output.contains("border-radius:9999px"), "badge should be pill-shaped: {}", output);
2567+ assert!(
2568+ output.contains("<span"),
2569+ "badge renders as span: {}",
2570+ output
2571+ );
2572+ assert!(
2573+ output.contains("border-radius:9999px"),
2574+ "badge should be pill-shaped: {}",
2575+ output
2576+ );
23692577 assert!(output.contains("3"), "badge content: {}", output);
23702578}
23712579
@@ -2374,8 +2582,16 @@ fn element_badge() {
23742582#[test]
23752583fn element_tooltip() {
23762584 let output = compile("@page T\n@tooltip Hover for info\n @text Help");
2377− assert!(output.contains("title=\"Hover for info\""), "tooltip should have title attr: {}", output);
2378− assert!(output.contains("cursor:help"), "tooltip should have cursor:help: {}", output);
2585+ assert!(
2586+ output.contains("title=\"Hover for info\""),
2587+ "tooltip should have title attr: {}",
2588+ output
2589+ );
2590+ assert!(
2591+ output.contains("cursor:help"),
2592+ "tooltip should have cursor:help: {}",
2593+ output
2594+ );
23792595}
23802596
23812597// --- @each step ---
@@ -2404,9 +2620,18 @@ fn each_step_reverse() {
24042620
24052621#[test]
24062622fn selection_pseudo_generates_css() {
2407− let output = compile("@page T\n@text [selection:background blue, selection:color white] Select me");
2408− assert!(output.contains("::selection"), "should generate ::selection: {}", output);
2409− assert!(output.contains("background:blue"), "should have bg: {}", output);
2623+ let output =
2624+ compile("@page T\n@text [selection:background blue, selection:color white] Select me");
2625+ assert!(
2626+ output.contains("::selection"),
2627+ "should generate ::selection: {}",
2628+ output
2629+ );
2630+ assert!(
2631+ output.contains("background:blue"),
2632+ "should have bg: {}",
2633+ output
2634+ );
24102635}
24112636
24122637// --- nth: pseudo ---
@@ -2414,14 +2639,26 @@ fn selection_pseudo_generates_css() {
24142639#[test]
24152640fn nth_pseudo_generates_css() {
24162641 let output = compile("@page T\n@el [nth:3:background red]\n @text test");
2417− assert!(output.contains(":nth-child(3)"), "should generate :nth-child(3): {}", output);
2418− assert!(output.contains("background:red"), "should have bg: {}", output);
2642+ assert!(
2643+ output.contains(":nth-child(3)"),
2644+ "should generate :nth-child(3): {}",
2645+ output
2646+ );
2647+ assert!(
2648+ output.contains("background:red"),
2649+ "should have bg: {}",
2650+ output
2651+ );
24192652}
24202653
24212654#[test]
24222655fn nth_pseudo_formula() {
24232656 let output = compile("@page T\n@el [nth:2n:background #eee]\n @text test");
2424− assert!(output.contains(":nth-child(2n)"), "should generate :nth-child(2n): {}", output);
2657+ assert!(
2658+ output.contains(":nth-child(2n)"),
2659+ "should generate :nth-child(2n): {}",
2660+ output
2661+ );
24252662}
24262663
24272664// --- container query prefix ---
@@ -2429,8 +2666,16 @@ fn nth_pseudo_formula() {
24292666#[test]
24302667fn container_query_generates_css() {
24312668 let output = compile("@page T\n@el [container]\n @el [cq-sm:padding 20]\n @text test");
2432− assert!(output.contains("@container(min-width:640px)"), "should generate container query: {}", output);
2433− assert!(output.contains("padding:20px"), "should have padding: {}", output);
2669+ assert!(
2670+ output.contains("@container(min-width:640px)"),
2671+ "should generate container query: {}",
2672+ output
2673+ );
2674+ assert!(
2675+ output.contains("padding:20px"),
2676+ "should have padding: {}",
2677+ output
2678+ );
24342679}
24352680
24362681// --- direction attribute ---
@@ -2438,7 +2683,11 @@ fn container_query_generates_css() {
24382683#[test]
24392684fn direction_rtl() {
24402685 let output = compile("@page T\n@el [direction rtl]\n @text RTL text");
2441− assert!(output.contains("direction:rtl"), "should generate direction:rtl: {}", output);
2686+ assert!(
2687+ output.contains("direction:rtl"),
2688+ "should generate direction:rtl: {}",
2689+ output
2690+ );
24422691}
24432692
24442693// --- contrast checker ---
@@ -2447,7 +2696,9 @@ fn direction_rtl() {
24472696fn warning_low_contrast() {
24482697 let diags = parse_diagnostics("@el [background #ffffff, color #cccccc]\n @text test");
24492698 assert!(
2450− diags.iter().any(|d| d.message.contains("low contrast ratio")),
2699+ diags
2700+ .iter()
2701+ .any(|d| d.message.contains("low contrast ratio")),
24512702 "should warn about low contrast, got: {:?}",
24522703 diags
24532704 );
@@ -2457,7 +2708,9 @@ fn warning_low_contrast() {
24572708fn no_warning_good_contrast() {
24582709 let diags = parse_diagnostics("@el [background #ffffff, color #000000]\n @text test");
24592710 assert!(
2460− !diags.iter().any(|d| d.message.contains("low contrast ratio")),
2711+ !diags
2712+ .iter()
2713+ .any(|d| d.message.contains("low contrast ratio")),
24612714 "should not warn about good contrast, got: {:?}",
24622715 diags
24632716 );
@@ -2469,7 +2722,9 @@ fn no_warning_good_contrast() {
24692722fn no_warning_selection_prefix() {
24702723 let diags = parse_diagnostics("@el [selection:background blue]");
24712724 assert!(
2472− !diags.iter().any(|d| d.message.contains("unknown attribute")),
2725+ !diags
2726+ .iter()
2727+ .any(|d| d.message.contains("unknown attribute")),
24732728 "selection: prefix should be recognized, got: {:?}",
24742729 diags
24752730 );
@@ -2479,7 +2734,9 @@ fn no_warning_selection_prefix() {
24792734fn no_warning_nth_prefix() {
24802735 let diags = parse_diagnostics("@el [nth:3:background red]");
24812736 assert!(
2482− !diags.iter().any(|d| d.message.contains("unknown attribute")),
2737+ !diags
2738+ .iter()
2739+ .any(|d| d.message.contains("unknown attribute")),
24832740 "nth: prefix should be recognized, got: {:?}",
24842741 diags
24852742 );
@@ -2489,7 +2746,9 @@ fn no_warning_nth_prefix() {
24892746fn no_warning_cq_prefix() {
24902747 let diags = parse_diagnostics("@el [cq-sm:padding 20]");
24912748 assert!(
2492− !diags.iter().any(|d| d.message.contains("unknown attribute")),
2749+ !diags
2750+ .iter()
2751+ .any(|d| d.message.contains("unknown attribute")),
24932752 "cq- prefix should be recognized, got: {:?}",
24942753 diags
24952754 );
@@ -2499,7 +2758,9 @@ fn no_warning_cq_prefix() {
24992758fn no_warning_direction_attr() {
25002759 let diags = parse_diagnostics("@el [direction rtl]");
25012760 assert!(
2502− !diags.iter().any(|d| d.message.contains("unknown attribute")),
2761+ !diags
2762+ .iter()
2763+ .any(|d| d.message.contains("unknown attribute")),
25032764 "direction should be recognized, got: {:?}",
25042765 diags
25052766 );
@@ -2510,31 +2771,41 @@ fn no_warning_new_elements() {
25102771 // Grid, stack, spacer, badge, tooltip should all parse without errors
25112772 let diags = parse_diagnostics("@grid\n @text A");
25122773 assert!(
2513− !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2774+ !diags
2775+ .iter()
2776+ .any(|d| d.severity == htmlang::parser::Severity::Error),
25142777 "grid should parse, got: {:?}",
25152778 diags
25162779 );
25172780 let diags = parse_diagnostics("@stack\n @text A");
25182781 assert!(
2519− !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2782+ !diags
2783+ .iter()
2784+ .any(|d| d.severity == htmlang::parser::Severity::Error),
25202785 "stack should parse, got: {:?}",
25212786 diags
25222787 );
25232788 let diags = parse_diagnostics("@row\n @spacer");
25242789 assert!(
2525− !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2790+ !diags
2791+ .iter()
2792+ .any(|d| d.severity == htmlang::parser::Severity::Error),
25262793 "spacer should parse, got: {:?}",
25272794 diags
25282795 );
25292796 let diags = parse_diagnostics("@badge [background red] 5");
25302797 assert!(
2531− !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2798+ !diags
2799+ .iter()
2800+ .any(|d| d.severity == htmlang::parser::Severity::Error),
25322801 "badge should parse, got: {:?}",
25332802 diags
25342803 );
25352804 let diags = parse_diagnostics("@tooltip Help text\n @text Hover");
25362805 assert!(
2537− !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2806+ !diags
2807+ .iter()
2808+ .any(|d| d.severity == htmlang::parser::Severity::Error),
25382809 "tooltip should parse, got: {:?}",
25392810 diags
25402811 );
@@ -2560,92 +2831,188 @@ fn snapshot_css_shorthands() {
25602831#[test]
25612832fn test_variable_filters() {
25622833 let result = htmlang::parser::parse("@let name hello\n@text $name|uppercase");
2563− assert!(result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error));
2834+ assert!(
2835+ result
2836+ .diagnostics
2837+ .iter()
2838+ .all(|d| d.severity != htmlang::parser::Severity::Error)
2839+ );
25642840 let html = htmlang::codegen::generate(&result.document);
2565− assert!(html.contains("HELLO"), "uppercase filter should work, got: {}", html);
2841+ assert!(
2842+ html.contains("HELLO"),
2843+ "uppercase filter should work, got: {}",
2844+ html
2845+ );
25662846
25672847 let result = htmlang::parser::parse("@let name HELLO\n@text $name|lowercase");
25682848 let html = htmlang::codegen::generate(&result.document);
2569− assert!(html.contains("hello"), "lowercase filter should work, got: {}", html);
2849+ assert!(
2850+ html.contains("hello"),
2851+ "lowercase filter should work, got: {}",
2852+ html
2853+ );
25702854
25712855 let result = htmlang::parser::parse("@let name hello\n@text $name|capitalize");
25722856 let html = htmlang::codegen::generate(&result.document);
2573− assert!(html.contains("Hello"), "capitalize filter should work, got: {}", html);
2857+ assert!(
2858+ html.contains("Hello"),
2859+ "capitalize filter should work, got: {}",
2860+ html
2861+ );
25742862
25752863 let result = htmlang::parser::parse("@let name hello\n@text $name|length");
25762864 let html = htmlang::codegen::generate(&result.document);
2577− assert!(html.contains("5"), "length filter should work, got: {}", html);
2865+ assert!(
2866+ html.contains("5"),
2867+ "length filter should work, got: {}",
2868+ html
2869+ );
25782870
25792871 let result = htmlang::parser::parse("@let name hello\n@text $name|reverse");
25802872 let html = htmlang::codegen::generate(&result.document);
2581− assert!(html.contains("olleh"), "reverse filter should work, got: {}", html);
2873+ assert!(
2874+ html.contains("olleh"),
2875+ "reverse filter should work, got: {}",
2876+ html
2877+ );
25822878
25832879 let result = htmlang::parser::parse("@let name hello world\n@text $name|truncate:5");
25842880 let html = htmlang::codegen::generate(&result.document);
2585− assert!(html.contains("hello..."), "truncate filter should work, got: {}", html);
2881+ assert!(
2882+ html.contains("hello..."),
2883+ "truncate filter should work, got: {}",
2884+ html
2885+ );
25862886}
25872887
25882888#[test]
25892889fn test_new_elements_parse() {
25902890 // Avatar
25912891 let diags = parse_diagnostics("@avatar [width 48, height 48]\n @text AB");
2592− assert!(!diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error), "avatar: {:?}", diags);
2892+ assert!(
2893+ !diags
2894+ .iter()
2895+ .any(|d| d.severity == htmlang::parser::Severity::Error),
2896+ "avatar: {:?}",
2897+ diags
2898+ );
25932899
25942900 // Carousel
25952901 let diags = parse_diagnostics("@carousel [gap 16]\n @el Slide 1");
2596− assert!(!diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error), "carousel: {:?}", diags);
2902+ assert!(
2903+ !diags
2904+ .iter()
2905+ .any(|d| d.severity == htmlang::parser::Severity::Error),
2906+ "carousel: {:?}",
2907+ diags
2908+ );
25972909
25982910 // Chip
25992911 let diags = parse_diagnostics("@chip [background #eee] Tag");
2600− assert!(!diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error), "chip: {:?}", diags);
2912+ assert!(
2913+ !diags
2914+ .iter()
2915+ .any(|d| d.severity == htmlang::parser::Severity::Error),
2916+ "chip: {:?}",
2917+ diags
2918+ );
26012919
26022920 // Tag
26032921 let diags = parse_diagnostics("@tag [color blue] v1.0");
2604− assert!(!diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error), "tag: {:?}", diags);
2922+ assert!(
2923+ !diags
2924+ .iter()
2925+ .any(|d| d.severity == htmlang::parser::Severity::Error),
2926+ "tag: {:?}",
2927+ diags
2928+ );
26052929}
26062930
26072931#[test]
26082932fn test_css_shorthands_output() {
26092933 let result = htmlang::parser::parse("@text [truncate] Hello");
26102934 let html = htmlang::codegen::generate(&result.document);
2611− assert!(html.contains("text-overflow:ellipsis"), "truncate should add ellipsis, got: {}", html);
2612− assert!(html.contains("white-space:nowrap"), "truncate should add nowrap");
2935+ assert!(
2936+ html.contains("text-overflow:ellipsis"),
2937+ "truncate should add ellipsis, got: {}",
2938+ html
2939+ );
2940+ assert!(
2941+ html.contains("white-space:nowrap"),
2942+ "truncate should add nowrap"
2943+ );
26132944
26142945 let result = htmlang::parser::parse("@paragraph [line-clamp 3] Text");
26152946 let html = htmlang::codegen::generate(&result.document);
2616− assert!(html.contains("-webkit-line-clamp:3"), "line-clamp should work, got: {}", html);
2947+ assert!(
2948+ html.contains("-webkit-line-clamp:3"),
2949+ "line-clamp should work, got: {}",
2950+ html
2951+ );
26172952
26182953 let result = htmlang::parser::parse("@el [blur 4] Content");
26192954 let html = htmlang::codegen::generate(&result.document);
2620− assert!(html.contains("filter:blur(4px)"), "blur should work, got: {}", html);
2955+ assert!(
2956+ html.contains("filter:blur(4px)"),
2957+ "blur should work, got: {}",
2958+ html
2959+ );
26212960
26222961 let result = htmlang::parser::parse("@el [backdrop-blur 10] Content");
26232962 let html = htmlang::codegen::generate(&result.document);
2624− assert!(html.contains("backdrop-filter:blur(10px)"), "backdrop-blur should work, got: {}", html);
2963+ assert!(
2964+ html.contains("backdrop-filter:blur(10px)"),
2965+ "backdrop-blur should work, got: {}",
2966+ html
2967+ );
26252968
26262969 let result = htmlang::parser::parse("@el [no-scrollbar] Content");
26272970 let html = htmlang::codegen::generate(&result.document);
2628− assert!(html.contains("scrollbar-width:none"), "no-scrollbar should work, got: {}", html);
2971+ assert!(
2972+ html.contains("scrollbar-width:none"),
2973+ "no-scrollbar should work, got: {}",
2974+ html
2975+ );
26292976
26302977 let result = htmlang::parser::parse("@el [skeleton, width 100, height 20] Content");
26312978 let html = htmlang::codegen::generate(&result.document);
2632− assert!(html.contains("hl-skeleton"), "skeleton should add animation, got: {}", html);
2633− assert!(html.contains("@keyframes hl-skeleton"), "skeleton should add keyframes, got: {}", html);
2979+ assert!(
2980+ html.contains("hl-skeleton"),
2981+ "skeleton should add animation, got: {}",
2982+ html
2983+ );
2984+ assert!(
2985+ html.contains("@keyframes hl-skeleton"),
2986+ "skeleton should add keyframes, got: {}",
2987+ html
2988+ );
26342989
26352990 let result = htmlang::parser::parse("@el [gradient #fff #000] Content");
26362991 let html = htmlang::codegen::generate(&result.document);
2637− assert!(html.contains("linear-gradient(#fff,#000)"), "gradient should work, got: {}", html);
2992+ assert!(
2993+ html.contains("linear-gradient(#fff,#000)"),
2994+ "gradient should work, got: {}",
2995+ html
2996+ );
26382997
26392998 let result = htmlang::parser::parse("@el [gradient #fff #000 45deg] Content");
26402999 let html = htmlang::codegen::generate(&result.document);
2641− assert!(html.contains("linear-gradient(45deg,#fff,#000)"), "gradient with angle should work, got: {}", html);
3000+ assert!(
3001+ html.contains("linear-gradient(45deg,#fff,#000)"),
3002+ "gradient with angle should work, got: {}",
3003+ html
3004+ );
26423005}
26433006
26443007#[test]
26453008fn test_carousel_children_snap() {
26463009 let result = htmlang::parser::parse("@carousel\n @el A\n @el B");
26473010 let html = htmlang::codegen::generate(&result.document);
2648− assert!(html.contains("scroll-snap-align:start"), "carousel children should have snap-align, got: {}", html);
3011+ assert!(
3012+ html.contains("scroll-snap-align:start"),
3013+ "carousel children should have snap-align, got: {}",
3014+ html
3015+ );
26493016}
26503017
26513018#[test]
@@ -2653,110 +3020,206 @@ fn test_use_directive() {
26533020 // We can't test @use with actual files in unit tests easily, but we can verify
26543021 // the parser recognizes the directive without errors when it can't find the file
26553022 let result = htmlang::parser::parse("@use nonexistent.hl card");
2656− let has_use_error = result.diagnostics.iter().any(|d|
3023+ let has_use_error = result.diagnostics.iter().any(|d| {
26573024 d.message.contains("cannot use") && d.severity == htmlang::parser::Severity::Error
3025+ });
3026+ assert!(
3027+ has_use_error,
3028+ "@use should report error for missing file, got: {:?}",
3029+ result.diagnostics
26583030 );
2659− assert!(has_use_error, "@use should report error for missing file, got: {:?}", result.diagnostics);
26603031}
26613032
26623033#[test]
26633034fn test_enhanced_keyframes() {
2664− let result = htmlang::parser::parse("@keyframes fade-in\n from [opacity 0]\n to [opacity 1]\n@el [animation fade-in 0.3s] Content");
3035+ let result = htmlang::parser::parse(
3036+ "@keyframes fade-in\n from [opacity 0]\n to [opacity 1]\n@el [animation fade-in 0.3s] Content",
3037+ );
26653038 let html = htmlang::codegen::generate(&result.document);
2666− assert!(html.contains("@keyframes fade-in{from{opacity:0;}to{opacity:1;}}"), "keyframes should parse htmlang syntax, got: {}", html);
3039+ assert!(
3040+ html.contains("@keyframes fade-in{from{opacity:0;}to{opacity:1;}}"),
3041+ "keyframes should parse htmlang syntax, got: {}",
3042+ html
3043+ );
26673044}
26683045
26693046#[test]
26703047fn test_keyframes_percentage() {
2671− let result = htmlang::parser::parse("@keyframes slide\n 0% [transform translateX(-100%)]\n 100% [transform translateX(0)]");
3048+ let result = htmlang::parser::parse(
3049+ "@keyframes slide\n 0% [transform translateX(-100%)]\n 100% [transform translateX(0)]",
3050+ );
26723051 let html = htmlang::codegen::generate(&result.document);
2673− assert!(html.contains("0%{transform:translateX(-100%);}"), "keyframe percentage should work, got: {}", html);
3052+ assert!(
3053+ html.contains("0%{transform:translateX(-100%);}"),
3054+ "keyframe percentage should work, got: {}",
3055+ html
3056+ );
26743057}
26753058
26763059#[test]
26773060fn test_theme_directive() {
2678− let result = htmlang::parser::parse("@theme\n primary #3b82f6\n spacing-md 16\n\n@el [background $primary, padding $spacing-md] Content");
3061+ let result = htmlang::parser::parse(
3062+ "@theme\n primary #3b82f6\n spacing-md 16\n\n@el [background $primary, padding $spacing-md] Content",
3063+ );
26793064 let diags = &result.diagnostics;
2680− let errors: Vec<_> = diags.iter().filter(|d| d.severity == htmlang::parser::Severity::Error).collect();
2681− assert!(errors.is_empty(), "theme should not cause errors: {:?}", errors);
3065+ let errors: Vec<_> = diags
3066+ .iter()
3067+ .filter(|d| d.severity == htmlang::parser::Severity::Error)
3068+ .collect();
3069+ assert!(
3070+ errors.is_empty(),
3071+ "theme should not cause errors: {:?}",
3072+ errors
3073+ );
26823074 let html = htmlang::codegen::generate(&result.document);
2683− assert!(html.contains("--primary:#3b82f6"), "theme should emit CSS vars, got: {}", html);
2684− assert!(html.contains("--spacing-md:16"), "theme should emit spacing var, got: {}", html);
2685− assert!(html.contains("background:#3b82f6"), "theme var should resolve in attrs, got: {}", html);
3075+ assert!(
3076+ html.contains("--primary:#3b82f6"),
3077+ "theme should emit CSS vars, got: {}",
3078+ html
3079+ );
3080+ assert!(
3081+ html.contains("--spacing-md:16"),
3082+ "theme should emit spacing var, got: {}",
3083+ html
3084+ );
3085+ assert!(
3086+ html.contains("background:#3b82f6"),
3087+ "theme var should resolve in attrs, got: {}",
3088+ html
3089+ );
26863090}
26873091
26883092#[test]
26893093fn test_deprecated_fn() {
2690− let result = htmlang::parser::parse("@deprecated Use @new-card instead\n@fn old-card $title\n @text $title\n\n@old-card [title Hello]");
2691− let warnings: Vec<_> = result.diagnostics.iter()
3094+ let result = htmlang::parser::parse(
3095+ "@deprecated Use @new-card instead\n@fn old-card $title\n @text $title\n\n@old-card [title Hello]",
3096+ );
3097+ let warnings: Vec<_> = result
3098+ .diagnostics
3099+ .iter()
26923100 .filter(|d| d.message.contains("deprecated"))
26933101 .collect();
2694− assert!(!warnings.is_empty(), "calling deprecated fn should warn, got: {:?}", result.diagnostics);
2695− assert!(warnings[0].message.contains("Use @new-card instead"), "deprecation message should be included");
3102+ assert!(
3103+ !warnings.is_empty(),
3104+ "calling deprecated fn should warn, got: {:?}",
3105+ result.diagnostics
3106+ );
3107+ assert!(
3108+ warnings[0].message.contains("Use @new-card instead"),
3109+ "deprecation message should be included"
3110+ );
26963111}
26973112
26983113#[test]
26993114fn test_extends_directive() {
27003115 // Can't test with actual files, but verify parse error for missing file
27013116 let result = htmlang::parser::parse("@extends nonexistent.hl\n@slot content\n Hello");
2702− let has_error = result.diagnostics.iter().any(|d|
3117+ let has_error = result.diagnostics.iter().any(|d| {
27033118 d.message.contains("cannot extend") && d.severity == htmlang::parser::Severity::Error
3119+ });
3120+ assert!(
3121+ has_error,
3122+ "@extends should report error for missing file, got: {:?}",
3123+ result.diagnostics
27043124 );
2705− assert!(has_error, "@extends should report error for missing file, got: {:?}", result.diagnostics);
27063125}
27073126
27083127#[test]
27093128fn test_color_filter_lighten() {
2710− let result = htmlang::parser::parse("@let primary #3b82f6\n@el [background $primary|lighten:20] Content");
3129+ let result = htmlang::parser::parse(
3130+ "@let primary #3b82f6\n@el [background $primary|lighten:20] Content",
3131+ );
27113132 let html = htmlang::codegen::generate(&result.document);
27123133 // Lighten #3b82f6 by 20% should produce a lighter blue
2713− assert!(html.contains("background:#"), "lighten filter should produce hex color, got: {}", html);
3134+ assert!(
3135+ html.contains("background:#"),
3136+ "lighten filter should produce hex color, got: {}",
3137+ html
3138+ );
27143139 // Verify it's not the original color
2715− assert!(!html.contains("background:#3b82f6"), "lighten should change the color");
3140+ assert!(
3141+ !html.contains("background:#3b82f6"),
3142+ "lighten should change the color"
3143+ );
27163144}
27173145
27183146#[test]
27193147fn test_color_filter_darken() {
2720− let result = htmlang::parser::parse("@let primary #ffffff\n@el [background $primary|darken:50] Content");
3148+ let result =
3149+ htmlang::parser::parse("@let primary #ffffff\n@el [background $primary|darken:50] Content");
27213150 let html = htmlang::codegen::generate(&result.document);
27223151 // Darken white by 50% should produce gray (#808080 approximately)
2723− assert!(html.contains("background:#"), "darken filter should produce hex color, got: {}", html);
2724− assert!(!html.contains("background:#ffffff"), "darken should change the color");
3152+ assert!(
3153+ html.contains("background:#"),
3154+ "darken filter should produce hex color, got: {}",
3155+ html
3156+ );
3157+ assert!(
3158+ !html.contains("background:#ffffff"),
3159+ "darken should change the color"
3160+ );
27253161}
27263162
27273163#[test]
27283164fn test_color_filter_alpha() {
2729− let result = htmlang::parser::parse("@let primary #3b82f6\n@el [background $primary|alpha:0.5] Content");
3165+ let result =
3166+ htmlang::parser::parse("@let primary #3b82f6\n@el [background $primary|alpha:0.5] Content");
27303167 let html = htmlang::codegen::generate(&result.document);
27313168 // Should produce 8-digit hex with alpha
2732− assert!(html.contains("background:#3b82f67f"), "alpha filter should add alpha channel, got: {}", html);
3169+ assert!(
3170+ html.contains("background:#3b82f67f"),
3171+ "alpha filter should add alpha channel, got: {}",
3172+ html
3173+ );
27333174}
27343175
27353176#[test]
27363177fn test_color_filter_mix() {
2737− let result = htmlang::parser::parse("@let primary #000000\n@el [background $primary|mix:#ffffff:50] Content");
3178+ let result = htmlang::parser::parse(
3179+ "@let primary #000000\n@el [background $primary|mix:#ffffff:50] Content",
3180+ );
27383181 let html = htmlang::codegen::generate(&result.document);
27393182 // Mix black and white at 50% should produce gray
2740− assert!(html.contains("background:#808080") || html.contains("background:#7f7f7f"), "mix filter should blend colors, got: {}", html);
2741−}
3183+ assert!(
3184+ html.contains("background:#808080") || html.contains("background:#7f7f7f"),
3185+ "mix filter should blend colors, got: {}",
3186+ html
3187+ );
3188+}
27423189
27433190#[test]
27443191fn test_autofocus_attribute() {
27453192 let result = htmlang::parser::parse("@input [type text, autofocus]");
27463193 let html = htmlang::codegen::generate(&result.document);
2747− assert!(html.contains("autofocus"), "autofocus should be in output, got: {}", html);
3194+ assert!(
3195+ html.contains("autofocus"),
3196+ "autofocus should be in output, got: {}",
3197+ html
3198+ );
27483199 // Should not produce unknown attribute warning
2749− let unknown_warnings: Vec<_> = result.diagnostics.iter()
3200+ let unknown_warnings: Vec<_> = result
3201+ .diagnostics
3202+ .iter()
27503203 .filter(|d| d.message.contains("unknown attribute") && d.message.contains("autofocus"))
27513204 .collect();
2752− assert!(unknown_warnings.is_empty(), "autofocus should not warn as unknown");
3205+ assert!(
3206+ unknown_warnings.is_empty(),
3207+ "autofocus should not warn as unknown"
3208+ );
27533209}
27543210
27553211#[test]
27563212fn test_repl_components_feed_subcommands_recognized() {
27573213 // Just verify that the parser and codegen work for content that these commands would process
2758− let result = htmlang::parser::parse("@page Test Site\n@meta description A test\n@fn card $title\n @text $title");
2759− assert!(!result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error));
3214+ let result = htmlang::parser::parse(
3215+ "@page Test Site\n@meta description A test\n@fn card $title\n @text $title",
3216+ );
3217+ assert!(
3218+ !result
3219+ .diagnostics
3220+ .iter()
3221+ .any(|d| d.severity == htmlang::parser::Severity::Error)
3222+ );
27603223}
27613224
27623225// =========================================================================
@@ -2798,9 +3261,18 @@ fn snapshot_layer_wrapping() {
27983261
27993262#[test]
28003263fn grid_template_areas_passthrough() {
2801− let output = compile("@page T\n@el [grid, grid-template-areas \"a b\"]\n @el [grid-area a]\n A");
2802− assert!(output.contains("grid-template-areas:\"a b\""), "grid-template-areas should pass through: {}", output);
2803− assert!(output.contains("grid-area:a"), "grid-area should pass through: {}", output);
3264+ let output =
3265+ compile("@page T\n@el [grid, grid-template-areas \"a b\"]\n @el [grid-area a]\n A");
3266+ assert!(
3267+ output.contains("grid-template-areas:\"a b\""),
3268+ "grid-template-areas should pass through: {}",
3269+ output
3270+ );
3271+ assert!(
3272+ output.contains("grid-area:a"),
3273+ "grid-area should pass through: {}",
3274+ output
3275+ );
28043276}
28053277
28063278// --- View transition assertions ---
@@ -2808,15 +3280,25 @@ fn grid_template_areas_passthrough() {
28083280#[test]
28093281fn view_transition_name_passthrough() {
28103282 let output = compile("@page T\n@el [view-transition-name hero]\n Content");
2811− assert!(output.contains("view-transition-name:hero"), "view-transition-name should pass through: {}", output);
3283+ assert!(
3284+ output.contains("view-transition-name:hero"),
3285+ "view-transition-name should pass through: {}",
3286+ output
3287+ );
28123288}
28133289
28143290// --- Animate shorthand assertions ---
28153291
28163292#[test]
28173293fn animate_generates_animation_css() {
2818− let output = compile("@page T\n@keyframes fade\n from [opacity 0]\n to [opacity 1]\n@el [animate fade 0.3s ease]\n Content");
2819− assert!(output.contains("animation:fade 0.3s ease"), "animate should generate animation CSS: {}", output);
3294+ let output = compile(
3295+ "@page T\n@keyframes fade\n from [opacity 0]\n to [opacity 1]\n@el [animate fade 0.3s ease]\n Content",
3296+ );
3297+ assert!(
3298+ output.contains("animation:fade 0.3s ease"),
3299+ "animate should generate animation CSS: {}",
3300+ output
3301+ );
28203302}
28213303
28223304// --- :has() pseudo assertions ---
@@ -2824,15 +3306,25 @@ fn animate_generates_animation_css() {
28243306#[test]
28253307fn has_pseudo_generates_css() {
28263308 let output = compile("@page T\n@el [has(.active):background blue]\n Content");
2827− assert!(output.contains(":has(.active)"), "should generate :has() selector: {}", output);
2828− assert!(output.contains("background:blue"), "should have background:blue in :has() rule: {}", output);
3309+ assert!(
3310+ output.contains(":has(.active)"),
3311+ "should generate :has() selector: {}",
3312+ output
3313+ );
3314+ assert!(
3315+ output.contains("background:blue"),
3316+ "should have background:blue in :has() rule: {}",
3317+ output
3318+ );
28293319}
28303320
28313321#[test]
28323322fn has_pseudo_no_warning() {
28333323 let diags = parse_diagnostics("@el [has(.child):background red]");
28343324 assert!(
2835− !diags.iter().any(|d| d.message.contains("unknown attribute")),
3325+ !diags
3326+ .iter()
3327+ .any(|d| d.message.contains("unknown attribute")),
28363328 "has() prefix should not produce unknown attribute warning: {:?}",
28373329 diags
28383330 );
@@ -2842,8 +3334,13 @@ fn has_pseudo_no_warning() {
28423334
28433335#[test]
28443336fn computed_let_equals_syntax() {
2845− let output = compile("@page T\n@let base 10\n@let doubled = $base * 2\n@el [width $doubled]\n test");
2846− assert!(output.contains("width:20px"), "computed @let with = should work: {}", output);
3337+ let output =
3338+ compile("@page T\n@let base 10\n@let doubled = $base * 2\n@el [width $doubled]\n test");
3339+ assert!(
3340+ output.contains("width:20px"),
3341+ "computed @let with = should work: {}",
3342+ output
3343+ );
28473344}
28483345
28493346// --- @layer wrapping assertions ---
@@ -2851,24 +3348,52 @@ fn computed_let_equals_syntax() {
28513348#[test]
28523349fn output_contains_layer_wrapping() {
28533350 let output = compile("@page T\n@el [padding 10]\n test");
2854− assert!(output.contains("@layer htmlang{"), "output should contain @layer htmlang wrapper: {}", output);
3351+ assert!(
3352+ output.contains("@layer htmlang{"),
3353+ "output should contain @layer htmlang wrapper: {}",
3354+ output
3355+ );
28553356}
28563357
28573358// --- Named slots in @fn assertions ---
28583359
28593360#[test]
28603361fn fn_named_slots() {
2861− let output = compile("@fn layout\n @column\n @slot header\n Default Header\n @slot content\n@layout\n @slot header\n Custom Header\n @slot content\n Page body");
2862− assert!(output.contains("Custom Header"), "named slot should be filled: {}", output);
2863− assert!(output.contains("Page body"), "content slot should be filled: {}", output);
2864− assert!(!output.contains("Default Header"), "default should be overridden: {}", output);
3362+ let output = compile(
3363+ "@fn layout\n @column\n @slot header\n Default Header\n @slot content\n@layout\n @slot header\n Custom Header\n @slot content\n Page body",
3364+ );
3365+ assert!(
3366+ output.contains("Custom Header"),
3367+ "named slot should be filled: {}",
3368+ output
3369+ );
3370+ assert!(
3371+ output.contains("Page body"),
3372+ "content slot should be filled: {}",
3373+ output
3374+ );
3375+ assert!(
3376+ !output.contains("Default Header"),
3377+ "default should be overridden: {}",
3378+ output
3379+ );
28653380}
28663381
28673382#[test]
28683383fn fn_named_slot_default() {
2869− let output = compile("@fn layout\n @column\n @slot header\n Default Header\n @slot content\n@layout\n @slot content\n Only content");
2870− assert!(output.contains("Default Header"), "unfilled slot should use default: {}", output);
2871− assert!(output.contains("Only content"), "filled slot should render: {}", output);
3384+ let output = compile(
3385+ "@fn layout\n @column\n @slot header\n Default Header\n @slot content\n@layout\n @slot content\n Only content",
3386+ );
3387+ assert!(
3388+ output.contains("Default Header"),
3389+ "unfilled slot should use default: {}",
3390+ output
3391+ );
3392+ assert!(
3393+ output.contains("Only content"),
3394+ "filled slot should render: {}",
3395+ output
3396+ );
28723397}
28733398
28743399// --- No warnings for new attributes ---
@@ -2876,10 +3401,12 @@ fn fn_named_slot_default() {
28763401#[test]
28773402fn no_warning_new_attrs_batch6() {
28783403 let diags = parse_diagnostics(
2879− "@el [grid-template-areas \"a b\", grid-area a, view-transition-name hero, animate fade 1s, critical]"
3404+ "@el [grid-template-areas \"a b\", grid-area a, view-transition-name hero, animate fade 1s, critical]",
28803405 );
28813406 assert!(
2882− !diags.iter().any(|d| d.message.contains("unknown attribute")),
3407+ !diags
3408+ .iter()
3409+ .any(|d| d.message.contains("unknown attribute")),
28833410 "new attributes should be recognized: {:?}",
28843411 diags
28853412 );
@@ -2922,14 +3449,22 @@ fn snapshot_new_css_properties_4() {
29223449#[test]
29233450fn script_element_with_src() {
29243451 let output = compile("@script [src app.js, defer]");
2925− assert!(output.contains("<script src=\"app.js\" defer>"), "script src: {}", output);
3452+ assert!(
3453+ output.contains("<script src=\"app.js\" defer>"),
3454+ "script src: {}",
3455+ output
3456+ );
29263457 assert!(output.contains("</script>"), "script close: {}", output);
29273458}
29283459
29293460#[test]
29303461fn script_element_inline() {
29313462 let output = compile("@script\n console.log(42);");
2932− assert!(output.contains("<script>console.log(42);</script>"), "inline script: {}", output);
3463+ assert!(
3464+ output.contains("<script>console.log(42);</script>"),
3465+ "inline script: {}",
3466+ output
3467+ );
29333468}
29343469
29353470#[test]
@@ -2954,7 +3489,11 @@ fn search_element() {
29543489#[test]
29553490fn breadcrumb_generates_nav_ol() {
29563491 let output = compile("@breadcrumb\n @text Home\n @text About");
2957− assert!(output.contains("<nav aria-label=\"breadcrumb\">"), "nav: {}", output);
3492+ assert!(
3493+ output.contains("<nav aria-label=\"breadcrumb\">"),
3494+ "nav: {}",
3495+ output
3496+ );
29583497 assert!(output.contains("<ol>"), "ol: {}", output);
29593498 assert!(output.contains("<li>"), "li: {}", output);
29603499}
@@ -2962,20 +3501,32 @@ fn breadcrumb_generates_nav_ol() {
29623501#[test]
29633502fn canonical_directive() {
29643503 let output = compile("@page T\n@canonical https://example.com/page\n@text Hello");
2965− assert!(output.contains("<link rel=\"canonical\" href=\"https://example.com/page\">"), "canonical: {}", output);
3504+ assert!(
3505+ output.contains("<link rel=\"canonical\" href=\"https://example.com/page\">"),
3506+ "canonical: {}",
3507+ output
3508+ );
29663509}
29673510
29683511#[test]
29693512fn base_directive() {
29703513 let output = compile("@page T\n@base https://example.com/\n@text Hello");
2971− assert!(output.contains("<base href=\"https://example.com/\">"), "base: {}", output);
3514+ assert!(
3515+ output.contains("<base href=\"https://example.com/\">"),
3516+ "base: {}",
3517+ output
3518+ );
29723519}
29733520
29743521#[test]
29753522fn font_face_directive() {
29763523 let output = compile("@page T\n@font-face Inter fonts/inter.woff2\n@text Hello");
29773524 assert!(output.contains("@font-face"), "font-face: {}", output);
2978− assert!(output.contains("font-family:'Inter'"), "font name: {}", output);
3525+ assert!(
3526+ output.contains("font-family:'Inter'"),
3527+ "font name: {}",
3528+ output
3529+ );
29793530 assert!(output.contains("fonts/inter.woff2"), "font url: {}", output);
29803531 assert!(output.contains("woff2"), "format hint: {}", output);
29813532}
@@ -2983,7 +3534,11 @@ fn font_face_directive() {
29833534#[test]
29843535fn json_ld_directive() {
29853536 let output = compile("@page T\n@json-ld\n {\"@type\": \"WebPage\"}\n@text Hello");
2986− assert!(output.contains("application/ld+json"), "json-ld type: {}", output);
3537+ assert!(
3538+ output.contains("application/ld+json"),
3539+ "json-ld type: {}",
3540+ output
3541+ );
29873542 assert!(output.contains("WebPage"), "json-ld content: {}", output);
29883543}
29893544
@@ -3015,28 +3570,42 @@ fn valid_invalid_pseudo() {
30153570#[test]
30163571fn text_underline_offset_property() {
30173572 let output = compile("@text [underline, text-underline-offset 4] Link");
3018− assert!(output.contains("text-underline-offset:4px"), "text-underline-offset: {}", output);
3573+ assert!(
3574+ output.contains("text-underline-offset:4px"),
3575+ "text-underline-offset: {}",
3576+ output
3577+ );
30193578}
30203579
30213580#[test]
30223581fn column_width_property() {
30233582 let output = compile("@el [column-width 200]\n Content");
3024− assert!(output.contains("column-width:200px"), "column-width: {}", output);
3583+ assert!(
3584+ output.contains("column-width:200px"),
3585+ "column-width: {}",
3586+ output
3587+ );
30253588}
30263589
30273590#[test]
30283591fn column_rule_property() {
30293592 let output = compile("@el [column-rule 1px solid #ccc]\n Content");
3030− assert!(output.contains("column-rule:1px solid #ccc"), "column-rule: {}", output);
3593+ assert!(
3594+ output.contains("column-rule:1px solid #ccc"),
3595+ "column-rule: {}",
3596+ output
3597+ );
30313598}
30323599
30333600#[test]
30343601fn no_warning_new_attrs_batch7() {
30353602 let diags = parse_diagnostics(
3036− "@el [text-underline-offset 4, column-width 200, column-rule 1px solid gray]"
3603+ "@el [text-underline-offset 4, column-width 200, column-rule 1px solid gray]",
30373604 );
30383605 assert!(
3039− !diags.iter().any(|d| d.message.contains("unknown attribute")),
3606+ !diags
3607+ .iter()
3608+ .any(|d| d.message.contains("unknown attribute")),
30403609 "new CSS properties should be recognized: {:?}",
30413610 diags
30423611 );
@@ -3045,10 +3614,12 @@ fn no_warning_new_attrs_batch7() {
30453614#[test]
30463615fn no_warning_script_attrs() {
30473616 let diags = parse_diagnostics(
3048− "@script [src app.js, defer, async, crossorigin anonymous, integrity sha384-abc, nomodule]"
3617+ "@script [src app.js, defer, async, crossorigin anonymous, integrity sha384-abc, nomodule]",
30493618 );
30503619 assert!(
3051− !diags.iter().any(|d| d.message.contains("unknown attribute")),
3620+ !diags
3621+ .iter()
3622+ .any(|d| d.message.contains("unknown attribute")),
30523623 "script attributes should be recognized: {:?}",
30533624 diags
30543625 );
@@ -3066,21 +3637,41 @@ fn snapshot_mixin_spread() {
30663637#[test]
30673638fn mixin_expands_in_attrs() {
30683639 let output = compile("@mixin card [padding 20, rounded 8]\n@el [...$card]\n Hi");
3069− assert!(output.contains("padding:20px"), "mixin should expand padding: {}", output);
3070− assert!(output.contains("border-radius:8px"), "mixin should expand rounded: {}", output);
3640+ assert!(
3641+ output.contains("padding:20px"),
3642+ "mixin should expand padding: {}",
3643+ output
3644+ );
3645+ assert!(
3646+ output.contains("border-radius:8px"),
3647+ "mixin should expand rounded: {}",
3648+ output
3649+ );
30713650}
30723651
30733652#[test]
30743653fn mixin_with_dollar_syntax() {
30753654 let output = compile("@mixin card [padding 20, rounded 8]\n@el [$card]\n Hi");
3076− assert!(output.contains("padding:20px"), "mixin with $ syntax should expand: {}", output);
3655+ assert!(
3656+ output.contains("padding:20px"),
3657+ "mixin with $ syntax should expand: {}",
3658+ output
3659+ );
30773660}
30783661
30793662#[test]
30803663fn mixin_compose_with_extra_attrs() {
30813664 let output = compile("@mixin base [padding 10]\n@el [...$base, background red]\n Hi");
3082− assert!(output.contains("padding:10px"), "mixin should expand: {}", output);
3083− assert!(output.contains("background:red"), "extra attrs should work: {}", output);
3665+ assert!(
3666+ output.contains("padding:10px"),
3667+ "mixin should expand: {}",
3668+ output
3669+ );
3670+ assert!(
3671+ output.contains("background:red"),
3672+ "extra attrs should work: {}",
3673+ output
3674+ );
30843675}
30853676
30863677#[test]
@@ -3116,7 +3707,9 @@ fn snapshot_assert_directive() {
31163707fn assert_passes_no_error() {
31173708 let diags = parse_diagnostics("@let x hello\n@assert $x == hello\n@el [padding 10]");
31183709 assert!(
3119− !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
3710+ !diags
3711+ .iter()
3712+ .any(|d| d.severity == htmlang::parser::Severity::Error),
31203713 "passing assertion should produce no error, got: {:?}",
31213714 diags
31223715 );
@@ -3126,7 +3719,10 @@ fn assert_passes_no_error() {
31263719fn assert_fails_produces_error() {
31273720 let diags = parse_diagnostics("@let x hello\n@assert $x == world");
31283721 assert!(
3129− diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error && d.message.contains("assertion failed")),
3722+ diags
3723+ .iter()
3724+ .any(|d| d.severity == htmlang::parser::Severity::Error
3725+ && d.message.contains("assertion failed")),
31303726 "failing assertion should produce error, got: {:?}",
31313727 diags
31323728 );
@@ -3136,7 +3732,9 @@ fn assert_fails_produces_error() {
31363732fn assert_not_equal() {
31373733 let diags = parse_diagnostics("@let x hello\n@assert $x != world\n@el");
31383734 assert!(
3139− !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
3735+ !diags
3736+ .iter()
3737+ .any(|d| d.severity == htmlang::parser::Severity::Error),
31403738 "!= assertion should pass when values differ, got: {:?}",
31413739 diags
31423740 );
@@ -3146,7 +3744,9 @@ fn assert_not_equal() {
31463744fn assert_truthy() {
31473745 let diags = parse_diagnostics("@let x true\n@assert $x\n@el");
31483746 assert!(
3149− !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
3747+ !diags
3748+ .iter()
3749+ .any(|d| d.severity == htmlang::parser::Severity::Error),
31503750 "truthy assertion should pass, got: {:?}",
31513751 diags
31523752 );
@@ -3164,19 +3764,31 @@ fn snapshot_clamp_css() {
31643764#[test]
31653765fn clamp_passthrough() {
31663766 let output = compile("@el [size clamp(16px, 2vw, 24px)]");
3167− assert!(output.contains("font-size:clamp(16px, 2vw, 24px)"), "clamp should pass through: {}", output);
3767+ assert!(
3768+ output.contains("font-size:clamp(16px, 2vw, 24px)"),
3769+ "clamp should pass through: {}",
3770+ output
3771+ );
31683772}
31693773
31703774#[test]
31713775fn min_passthrough() {
31723776 let output = compile("@el [width min(100%, 800px)]");
3173− assert!(output.contains("width:min(100%, 800px)"), "min() should pass through: {}", output);
3777+ assert!(
3778+ output.contains("width:min(100%, 800px)"),
3779+ "min() should pass through: {}",
3780+ output
3781+ );
31743782}
31753783
31763784#[test]
31773785fn max_passthrough() {
31783786 let output = compile("@el [padding max(10px, 2vw)]");
3179− assert!(output.contains("padding:max(10px, 2vw)"), "max() should pass through: {}", output);
3787+ assert!(
3788+ output.contains("padding:max(10px, 2vw)"),
3789+ "max() should pass through: {}",
3790+ output
3791+ );
31803792}
31813793
31823794// ---------------------------------------------------------------------------
@@ -3187,14 +3799,22 @@ fn max_passthrough() {
31873799fn round_trip_basic() {
31883800 let input = "@page Round Trip\n@column [padding 20, spacing 10]\n @text [bold, size 24] Hello\n @text World";
31893801 let result = htmlang::parser::parse(input);
3190− assert!(!result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error));
3802+ assert!(
3803+ !result
3804+ .diagnostics
3805+ .iter()
3806+ .any(|d| d.severity == htmlang::parser::Severity::Error)
3807+ );
31913808 let html = htmlang::codegen::generate_dev(&result.document);
31923809 // Convert back to .hl
31933810 let hl = htmlang::convert::convert(&html);
31943811 // The round-trip should produce valid .hl that parses without errors
31953812 let result2 = htmlang::parser::parse(&hl);
31963813 assert!(
3197− !result2.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
3814+ !result2
3815+ .diagnostics
3816+ .iter()
3817+ .any(|d| d.severity == htmlang::parser::Severity::Error),
31983818 "round-trip should produce valid .hl, got errors: {:?}",
31993819 result2.diagnostics
32003820 );
@@ -3276,7 +3896,8 @@ fn component_wraps_in_scoped_div() {
32763896
32773897#[test]
32783898fn component_with_children() {
3279− let html = compile("@component box\n @el [padding 10]\n @children\n\n@box\n @text Inside\n");
3899+ let html =
3900+ compile("@component box\n @el [padding 10]\n @children\n\n@box\n @text Inside\n");
32803901 assert!(html.contains("hl-box"));
32813902 assert!(html.contains("Inside"));
32823903}
@@ -3287,23 +3908,31 @@ fn component_with_children() {
32873908
32883909#[test]
32893910fn switch_matches_case() {
3290− let html = compile("@let variant primary\n@switch $variant\n @case primary\n @text Primary\n @case danger\n @text Danger\n");
3911+ let html = compile(
3912+ "@let variant primary\n@switch $variant\n @case primary\n @text Primary\n @case danger\n @text Danger\n",
3913+ );
32913914 assert!(html.contains("Primary"));
32923915 assert!(!html.contains("Danger"));
32933916}
32943917
32953918#[test]
32963919fn switch_falls_to_default() {
3297− let html = compile("@let variant unknown\n@switch $variant\n @case primary\n @text Primary\n @default\n @text Default\n");
3920+ let html = compile(
3921+ "@let variant unknown\n@switch $variant\n @case primary\n @text Primary\n @default\n @text Default\n",
3922+ );
32983923 assert!(!html.contains("Primary"));
32993924 assert!(html.contains("Default"));
33003925}
33013926
33023927#[test]
33033928fn switch_with_attrs() {
3304− let _html = compile("@let variant primary\n@switch $variant\n @case primary [background blue, color white]\n @case danger [background red, color white]\n");
3929+ let _html = compile(
3930+ "@let variant primary\n@switch $variant\n @case primary [background blue, color white]\n @case danger [background red, color white]\n",
3931+ );
33053932 // The @switch should register matched attrs as __switch define
3306− let result = htmlang::parser::parse("@let variant primary\n@switch $variant\n @case primary [background blue, color white]\n @case danger [background red, color white]\n");
3933+ let result = htmlang::parser::parse(
3934+ "@let variant primary\n@switch $variant\n @case primary [background blue, color white]\n @case danger [background red, color white]\n",
3935+ );
33073936 assert!(result.document.defines.contains_key("__switch"));
33083937}
33093938
@@ -3317,7 +3946,12 @@ fn minified_output_is_smaller() {
33173946 let result = htmlang::parser::parse(input);
33183947 let normal = htmlang::codegen::generate(&result.document);
33193948 let minified = htmlang::codegen::generate_minified(&result.document);
3320− assert!(minified.len() <= normal.len(), "minified ({}) should be <= normal ({})", minified.len(), normal.len());
3949+ assert!(
3950+ minified.len() <= normal.len(),
3951+ "minified ({}) should be <= normal ({})",
3952+ minified.len(),
3953+ normal.len()
3954+ );
33213955 assert!(minified.contains("Hello World"));
33223956}
33233957
@@ -3349,10 +3983,13 @@ fn critical_attr_inlines_styles() {
33493983#[test]
33503984fn warning_input_without_label() {
33513985 let diags = parse_diagnostics("@input [type text]\n");
3352− let has_label_warning = diags.iter().any(|d|
3353− d.message.contains("aria-label") || d.message.contains("@label")
3986+ let has_label_warning = diags
3987+ .iter()
3988+ .any(|d| d.message.contains("aria-label") || d.message.contains("@label"));
3989+ assert!(
3990+ has_label_warning,
3991+ "should warn about input without label association"
33543992 );
3355− assert!(has_label_warning, "should warn about input without label association");
33563993}
33573994
33583995#[test]
@@ -3365,8 +4002,13 @@ fn warning_iframe_without_title() {
33654002#[test]
33664003fn warning_button_without_text() {
33674004 let diags = parse_diagnostics("@button [background red]\n");
3368− let has_warning = diags.iter().any(|d| d.message.contains("text content") || d.message.contains("aria-label"));
3369− assert!(has_warning, "should warn about button without accessible text");
4005+ let has_warning = diags
4006+ .iter()
4007+ .any(|d| d.message.contains("text content") || d.message.contains("aria-label"));
4008+ assert!(
4009+ has_warning,
4010+ "should warn about button without accessible text"
4011+ );
33704012}
33714013
33724014#[test]
@@ -3379,19 +4021,25 @@ fn warning_positive_tabindex() {
33794021#[test]
33804022fn no_warning_input_with_aria_label() {
33814023 let diags = parse_diagnostics("@input [type text, aria-label Search]\n");
3382− let has_label_warning = diags.iter().any(|d|
3383− d.message.contains("should have an") && d.message.contains("@label")
4024+ let has_label_warning = diags
4025+ .iter()
4026+ .any(|d| d.message.contains("should have an") && d.message.contains("@label"));
4027+ assert!(
4028+ !has_label_warning,
4029+ "should not warn when aria-label is present"
33844030 );
3385− assert!(!has_label_warning, "should not warn when aria-label is present");
33864031}
33874032
33884033#[test]
33894034fn no_warning_input_in_label() {
33904035 let diags = parse_diagnostics("@label\n @input [type text]\n");
3391− let has_label_warning = diags.iter().any(|d|
3392− d.message.contains("should have an") && d.message.contains("@label")
4036+ let has_label_warning = diags
4037+ .iter()
4038+ .any(|d| d.message.contains("should have an") && d.message.contains("@label"));
4039+ assert!(
4040+ !has_label_warning,
4041+ "should not warn when input is inside @label"
33934042 );
3394− assert!(!has_label_warning, "should not warn when input is inside @label");
33954043}
33964044
33974045// --- New feature tests ---
@@ -3418,42 +4066,72 @@ fn snapshot_data_directive() {
34184066
34194067#[test]
34204068fn test_popover_in_output() {
3421− let html = compile("@button [popovertarget my-pop] Open\n@el [popover, id my-pop, padding 10]\n Hello");
3422− assert!(html.contains("popovertarget=\"my-pop\""), "should have popovertarget attr");
3423− assert!(html.contains(" popover"), "should have popover boolean attr");
4069+ let html = compile(
4070+ "@button [popovertarget my-pop] Open\n@el [popover, id my-pop, padding 10]\n Hello",
4071+ );
4072+ assert!(
4073+ html.contains("popovertarget=\"my-pop\""),
4074+ "should have popovertarget attr"
4075+ );
4076+ assert!(
4077+ html.contains(" popover"),
4078+ "should have popover boolean attr"
4079+ );
34244080}
34254081
34264082#[test]
34274083fn test_color_scheme_css() {
34284084 let html = compile("@el [color-scheme light dark]\n Test");
3429− assert!(html.contains("color-scheme:light dark"), "should generate color-scheme CSS");
4085+ assert!(
4086+ html.contains("color-scheme:light dark"),
4087+ "should generate color-scheme CSS"
4088+ );
34304089}
34314090
34324091#[test]
34334092fn test_appearance_css() {
34344093 let html = compile("@input [appearance none, padding 10]");
3435− assert!(html.contains("appearance:none"), "should generate appearance CSS");
4094+ assert!(
4095+ html.contains("appearance:none"),
4096+ "should generate appearance CSS"
4097+ );
34364098}
34374099
34384100#[test]
34394101fn test_inputmode_attr() {
34404102 let html = compile("@input [type search, inputmode search]");
3441− assert!(html.contains("inputmode=\"search\""), "should pass through inputmode");
4103+ assert!(
4104+ html.contains("inputmode=\"search\""),
4105+ "should pass through inputmode"
4106+ );
34424107}
34434108
34444109#[test]
34454110fn test_fetchpriority_attr() {
34464111 let html = compile("@image [fetchpriority high, width 100] hero.jpg");
3447− assert!(html.contains("fetchpriority=\"high\""), "should pass through fetchpriority");
4112+ assert!(
4113+ html.contains("fetchpriority=\"high\""),
4114+ "should pass through fetchpriority"
4115+ );
34484116}
34494117
34504118#[test]
34514119fn test_compat_vendor_prefixes() {
3452− let result = htmlang::parser::parse("@el [backdrop-filter blur(10px), user-select none]\n Test");
4120+ let result =
4121+ htmlang::parser::parse("@el [backdrop-filter blur(10px), user-select none]\n Test");
34534122 let html = htmlang::codegen::generate_compat(&result.document);
3454− assert!(html.contains("-webkit-backdrop-filter"), "should add webkit prefix for backdrop-filter");
3455− assert!(html.contains("-webkit-user-select"), "should add webkit prefix for user-select");
3456− assert!(html.contains("-moz-user-select"), "should add moz prefix for user-select");
4123+ assert!(
4124+ html.contains("-webkit-backdrop-filter"),
4125+ "should add webkit prefix for backdrop-filter"
4126+ );
4127+ assert!(
4128+ html.contains("-webkit-user-select"),
4129+ "should add webkit prefix for user-select"
4130+ );
4131+ assert!(
4132+ html.contains("-moz-user-select"),
4133+ "should add moz prefix for user-select"
4134+ );
34574135}
34584136
34594137// ---------------------------------------------------------------------------
@@ -3473,7 +4151,9 @@ fn test_error_unknown_element() {
34734151fn test_error_unclosed_brackets() {
34744152 let diags = parse_diagnostics("@el [padding 10\n Hello");
34754153 assert!(
3476− diags.iter().any(|d| d.message.contains("unclosed '['") || d.message.contains("unclosed")),
4154+ diags
4155+ .iter()
4156+ .any(|d| d.message.contains("unclosed '['") || d.message.contains("unclosed")),
34774157 "should report unclosed bracket error"
34784158 );
34794159}
@@ -3511,7 +4191,8 @@ fn test_error_circular_include() {
35114191fn test_error_assert_failure() {
35124192 let diags = parse_diagnostics("@let x 5\n@assert $x == 10");
35134193 assert!(
3514− diags.iter().any(|d| d.message.contains("assertion failed") && d.severity == htmlang::parser::Severity::Error),
4194+ diags.iter().any(|d| d.message.contains("assertion failed")
4195+ && d.severity == htmlang::parser::Severity::Error),
35154196 "should report assertion failure as error"
35164197 );
35174198}
@@ -3520,7 +4201,9 @@ fn test_error_assert_failure() {
35204201fn test_error_duplicate_attribute() {
35214202 let diags = parse_diagnostics("@el [padding 10, padding 20]\n Hello");
35224203 assert!(
3523− diags.iter().any(|d| d.message.contains("duplicate attribute")),
4204+ diags
4205+ .iter()
4206+ .any(|d| d.message.contains("duplicate attribute")),
35244207 "should warn on duplicate attribute"
35254208 );
35264209}
@@ -3529,7 +4212,8 @@ fn test_error_duplicate_attribute() {
35294212fn test_warning_unused_variable() {
35304213 let diags = parse_diagnostics("@let unused_var hello\n@text Hello");
35314214 assert!(
3532− diags.iter().any(|d| d.message.contains("unused variable") && d.severity == htmlang::parser::Severity::Warning),
4215+ diags.iter().any(|d| d.message.contains("unused variable")
4216+ && d.severity == htmlang::parser::Severity::Warning),
35334217 "should warn about unused variable"
35344218 );
35354219}
@@ -3538,7 +4222,8 @@ fn test_warning_unused_variable() {
35384222fn test_warning_unused_function() {
35394223 let diags = parse_diagnostics("@fn unused_fn\n @text Hello\n\n@text World");
35404224 assert!(
3541− diags.iter().any(|d| d.message.contains("unused function") && d.severity == htmlang::parser::Severity::Warning),
4225+ diags.iter().any(|d| d.message.contains("unused function")
4226+ && d.severity == htmlang::parser::Severity::Warning),
35424227 "should warn about unused function"
35434228 );
35444229}
@@ -3557,15 +4242,26 @@ fn test_each_index_variable() {
35574242
35584243#[test]
35594244fn test_children_fallback_content() {
3560− let html = compile("@fn wrapper\n @el [padding 10]\n @children\n @text Default content\n\n@wrapper");
3561− assert!(html.contains("Default content"), "should use @children fallback when no children provided");
4245+ let html = compile(
4246+ "@fn wrapper\n @el [padding 10]\n @children\n @text Default content\n\n@wrapper",
4247+ );
4248+ assert!(
4249+ html.contains("Default content"),
4250+ "should use @children fallback when no children provided"
4251+ );
35624252}
35634253
35644254#[test]
35654255fn test_spread_define() {
35664256 let html = compile("@define btn [padding 12, bold]\n@el [...$btn]\n Click");
3567− assert!(html.contains("padding:12px"), "spread define should apply padding");
3568− assert!(html.contains("font-weight:bold") || html.contains("font-weight:700"), "spread define should apply bold");
4257+ assert!(
4258+ html.contains("padding:12px"),
4259+ "spread define should apply padding"
4260+ );
4261+ assert!(
4262+ html.contains("font-weight:bold") || html.contains("font-weight:700"),
4263+ "spread define should apply bold"
4264+ );
35694265}
35704266
35714267#[test]
@@ -3573,7 +4269,10 @@ fn test_log_directive() {
35734269 // @log should not produce errors and should be consumed without output nodes
35744270 let result = htmlang::parser::parse("@let x hello\n@log $x\n@text $x");
35754271 assert!(
3576− result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error),
4272+ result
4273+ .diagnostics
4274+ .iter()
4275+ .all(|d| d.severity != htmlang::parser::Severity::Error),
35774276 "@log should not produce errors"
35784277 );
35794278 let html = htmlang::codegen::generate(&result.document);
@@ -3584,8 +4283,14 @@ fn test_log_directive() {
35844283fn test_short_class_names() {
35854284 let html = compile("@el [padding 10]\n @el [padding 20]\n Hello");
35864285 // Class names should be short single letters, not _0, _1
3587− assert!(!html.contains("class=\"_0\""), "should use short class names, not _0");
3588− assert!(html.contains("class=\"a\"") || html.contains("class=\"b\""), "should use short alphabetic class names");
4286+ assert!(
4287+ !html.contains("class=\"_0\""),
4288+ "should use short class names, not _0"
4289+ );
4290+ assert!(
4291+ html.contains("class=\"a\"") || html.contains("class=\"b\""),
4292+ "should use short alphabetic class names"
4293+ );
35894294}
35904295
35914296// ---------------------------------------------------------------------------
@@ -3603,8 +4308,14 @@ fn test_round_trip() {
36034308 // May have warnings (e.g. unknown attrs from raw HTML) but should not have errors that prevent output
36044309 let html2 = htmlang::codegen::generate(&result2.document);
36054310 // Both HTML outputs should contain the same text content
3606− assert!(html2.contains("Hello"), "round-trip should preserve text content 'Hello'");
3607− assert!(html2.contains("This is a test"), "round-trip should preserve text content 'This is a test'");
4311+ assert!(
4312+ html2.contains("Hello"),
4313+ "round-trip should preserve text content 'Hello'"
4314+ );
4315+ assert!(
4316+ html2.contains("This is a test"),
4317+ "round-trip should preserve text content 'This is a test'"
4318+ );
36084319}
36094320
36104321// ---------------------------------------------------------------------------
@@ -3674,88 +4385,139 @@ fn repeat_directive_basic() {
36744385#[test]
36754386fn with_directive_rebinding() {
36764387 let output = compile("@let x hello\n@with $x as y\n @text $y");
3677− assert!(output.contains("hello"), "expected @with to rebind variable");
4388+ assert!(
4389+ output.contains("hello"),
4390+ "expected @with to rebind variable"
4391+ );
36784392}
36794393
36804394#[test]
36814395fn markdown_renders_heading() {
36824396 let output = compile("@markdown\n # Title\n Some text");
3683− assert!(output.contains("<h1>Title</h1>"), "markdown should render # as <h1>");
3684− assert!(output.contains("<p>Some text</p>"), "markdown should render paragraphs");
4397+ assert!(
4398+ output.contains("<h1>Title</h1>"),
4399+ "markdown should render # as <h1>"
4400+ );
4401+ assert!(
4402+ output.contains("<p>Some text</p>"),
4403+ "markdown should render paragraphs"
4404+ );
36854405}
36864406
36874407#[test]
36884408fn markdown_renders_bold_italic() {
36894409 let output = compile("@markdown\n This is **bold** and *italic*");
3690− assert!(output.contains("<strong>bold</strong>"), "markdown should render **bold**");
3691− assert!(output.contains("<em>italic</em>"), "markdown should render *italic*");
4410+ assert!(
4411+ output.contains("<strong>bold</strong>"),
4412+ "markdown should render **bold**"
4413+ );
4414+ assert!(
4415+ output.contains("<em>italic</em>"),
4416+ "markdown should render *italic*"
4417+ );
36924418}
36934419
36944420#[test]
36954421fn markdown_renders_code() {
36964422 let output = compile("@markdown\n Use `code` here");
3697− assert!(output.contains("<code>code</code>"), "markdown should render `code`");
4423+ assert!(
4424+ output.contains("<code>code</code>"),
4425+ "markdown should render `code`"
4426+ );
36984427}
36994428
37004429#[test]
37014430fn markdown_renders_link() {
37024431 let output = compile("@markdown\n Visit [example](https://example.com)");
3703− assert!(output.contains("<a href=\"https://example.com\">example</a>"), "markdown should render links");
4432+ assert!(
4433+ output.contains("<a href=\"https://example.com\">example</a>"),
4434+ "markdown should render links"
4435+ );
37044436}
37054437
37064438#[test]
37074439fn markdown_renders_list() {
37084440 let output = compile("@markdown\n - one\n - two\n - three");
3709− assert!(output.contains("<ul>"), "markdown should render unordered list");
3710− assert!(output.contains("<li>one</li>"), "markdown should render list items");
4441+ assert!(
4442+ output.contains("<ul>"),
4443+ "markdown should render unordered list"
4444+ );
4445+ assert!(
4446+ output.contains("<li>one</li>"),
4447+ "markdown should render list items"
4448+ );
37114449}
37124450
37134451#[test]
37144452fn scope_block_generates_css() {
37154453 let output = compile("@page Test\n@scope .card\n .title { color: red; }\n@text hello");
3716− assert!(output.contains("@scope"), "should generate @scope CSS block");
4454+ assert!(
4455+ output.contains("@scope"),
4456+ "should generate @scope CSS block"
4457+ );
37174458}
37184459
37194460#[test]
37204461fn starting_style_generates_css() {
37214462 let output = compile("@page Test\n@starting-style\n .fade { opacity: 0; }\n@text hello");
3722− assert!(output.contains("@starting-style"), "should generate @starting-style CSS block");
4463+ assert!(
4464+ output.contains("@starting-style"),
4465+ "should generate @starting-style CSS block"
4466+ );
37234467}
37244468
37254469#[test]
37264470fn manifest_generates_link() {
37274471 let output = compile("@page App\n@manifest My App\n display standalone\n@text hi");
3728− assert!(output.contains("rel=\"manifest\""), "should generate manifest link");
4472+ assert!(
4473+ output.contains("rel=\"manifest\""),
4474+ "should generate manifest link"
4475+ );
37294476}
37304477
37314478#[test]
37324479fn subgrid_support() {
37334480 let output = compile("@el [grid-template-columns subgrid]");
3734− assert!(output.contains("grid-template-columns:subgrid"), "should support CSS subgrid");
4481+ assert!(
4482+ output.contains("grid-template-columns:subgrid"),
4483+ "should support CSS subgrid"
4484+ );
37354485}
37364486
37374487#[test]
37384488fn anchor_positioning_support() {
37394489 let output = compile("@el [anchor-name --my-anchor]\n @text anchor");
3740− assert!(output.contains("anchor-name:--my-anchor"), "should support anchor-name CSS property");
4490+ assert!(
4491+ output.contains("anchor-name:--my-anchor"),
4492+ "should support anchor-name CSS property"
4493+ );
37414494}
37424495
37434496#[test]
37444497fn scroll_driven_animation_support() {
37454498 let output = compile("@el [animation-timeline scroll()]\n @text scroll");
3746− assert!(output.contains("animation-timeline:scroll()"), "should support animation-timeline CSS property");
4499+ assert!(
4500+ output.contains("animation-timeline:scroll()"),
4501+ "should support animation-timeline CSS property"
4502+ );
37474503}
37484504
37494505#[test]
37504506fn initial_letter_support() {
37514507 let output = compile("@text [initial-letter 3] O");
3752− assert!(output.contains("initial-letter:3"), "should support initial-letter CSS property");
4508+ assert!(
4509+ output.contains("initial-letter:3"),
4510+ "should support initial-letter CSS property"
4511+ );
37534512}
37544513
37554514#[test]
37564515fn position_area_support() {
37574516 let output = compile("@el [position-area top]\n @text tooltip");
3758− assert!(output.contains("position-area:top"), "should support position-area CSS property");
4517+ assert!(
4518+ output.contains("position-area:top"),
4519+ "should support position-area CSS property"
4520+ );
37594521}
37604522
37614523// --- Snapshot tests for batch 2 ---
@@ -3789,9 +4551,13 @@ fn snapshot_responsive_images() {
37894551
37904552#[test]
37914553fn translations_inject_variables() {
3792− let output = compile("@translations\n en:\n hello Hi\n world Earth\n@text $t.hello $t.world");
4554+ let output =
4555+ compile("@translations\n en:\n hello Hi\n world Earth\n@text $t.hello $t.world");
37934556 assert!(output.contains("Hi"), "should inject translation for hello");
3794− assert!(output.contains("Earth"), "should inject translation for world");
4557+ assert!(
4558+ output.contains("Earth"),
4559+ "should inject translation for world"
4560+ );
37954561}
37964562
37974563#[test]
@@ -3814,7 +4580,10 @@ fn pagination_page_2() {
38144580#[test]
38154581fn image_auto_preload() {
38164582 let output = compile("@page Test\n@image logo.png\n@image hero.jpg");
3817− assert!(output.contains("rel=\"preload\""), "should auto-preload images");
4583+ assert!(
4584+ output.contains("rel=\"preload\""),
4585+ "should auto-preload images"
4586+ );
38184587 assert!(output.contains("logo.png"), "should preload logo.png");
38194588}
38204589
@@ -3823,15 +4592,28 @@ fn source_map_generation() {
38234592 let input = "@page Test\n@text [bold] Hello";
38244593 let result = htmlang::parser::parse(input);
38254594 let map = htmlang::codegen::generate_source_map(&result.document, "test.hl");
3826− assert!(map.contains("\"version\":3"), "source map should have version 3");
3827− assert!(map.contains("test.hl"), "source map should reference source file");
4595+ assert!(
4596+ map.contains("\"version\":3"),
4597+ "source map should have version 3"
4598+ );
4599+ assert!(
4600+ map.contains("test.hl"),
4601+ "source map should reference source file"
4602+ );
38284603}
38294604
38304605#[test]
38314606fn parser_multiple_errors() {
38324607 let diags = parse_diagnostics("@unknown1\n@text hello\n@unknown2");
3833− let errors: Vec<_> = diags.iter().filter(|d| d.severity == htmlang::parser::Severity::Error).collect();
3834− assert!(errors.len() >= 2, "parser should report multiple errors, got {}", errors.len());
4608+ let errors: Vec<_> = diags
4609+ .iter()
4610+ .filter(|d| d.severity == htmlang::parser::Severity::Error)
4611+ .collect();
4612+ assert!(
4613+ errors.len() >= 2,
4614+ "parser should report multiple errors, got {}",
4615+ errors.len()
4616+ );
38354617}
38364618
38374619#[test]
@@ -3848,45 +4630,78 @@ fn repeat_with_index() {
38484630
38494631#[test]
38504632fn env_directive_with_default() {
3851− let output = compile("@env HTMLANG_TEST_NONEXISTENT fallback_value\n@text $htmlang_test_nonexistent");
3852− assert!(output.contains("fallback_value"), "@env should use default when var is not set, got: {}", output);
4633+ let output =
4634+ compile("@env HTMLANG_TEST_NONEXISTENT fallback_value\n@text $htmlang_test_nonexistent");
4635+ assert!(
4636+ output.contains("fallback_value"),
4637+ "@env should use default when var is not set, got: {}",
4638+ output
4639+ );
38534640}
38544641
38554642#[test]
38564643fn env_directive_from_environment() {
38574644 // Set an env var and check it's picked up
3858− unsafe { std::env::set_var("HTMLANG_TEST_VAR", "hello_world"); }
4645+ unsafe {
4646+ std::env::set_var("HTMLANG_TEST_VAR", "hello_world");
4647+ }
38594648 let output = compile("@env HTMLANG_TEST_VAR\n@text $htmlang_test_var");
3860− assert!(output.contains("hello_world"), "@env should read env var, got: {}", output);
3861− unsafe { std::env::remove_var("HTMLANG_TEST_VAR"); }
4649+ assert!(
4650+ output.contains("hello_world"),
4651+ "@env should read env var, got: {}",
4652+ output
4653+ );
4654+ unsafe {
4655+ std::env::remove_var("HTMLANG_TEST_VAR");
4656+ }
38624657}
38634658
38644659#[test]
38654660fn env_directive_warning_when_missing() {
38664661 let result = htmlang::parser::parse("@env HTMLANG_DEFINITELY_NOT_SET_12345");
3867− let warnings: Vec<_> = result.diagnostics.iter()
3868− .filter(|d| d.severity == htmlang::parser::Severity::Warning && d.message.contains("not set"))
4662+ let warnings: Vec<_> = result
4663+ .diagnostics
4664+ .iter()
4665+ .filter(|d| {
4666+ d.severity == htmlang::parser::Severity::Warning && d.message.contains("not set")
4667+ })
38694668 .collect();
3870− assert!(!warnings.is_empty(), "@env should warn when var is not set, got: {:?}", result.diagnostics);
4669+ assert!(
4670+ !warnings.is_empty(),
4671+ "@env should warn when var is not set, got: {:?}",
4672+ result.diagnostics
4673+ );
38714674}
38724675
38734676#[test]
38744677fn fetch_directive_error_on_https() {
38754678 // @fetch with https should produce an error (no TLS support)
38764679 let result = htmlang::parser::parse("@fetch $data https://example.com/api");
3877− let errors: Vec<_> = result.diagnostics.iter()
4680+ let errors: Vec<_> = result
4681+ .diagnostics
4682+ .iter()
38784683 .filter(|d| d.severity == htmlang::parser::Severity::Error && d.message.contains("https"))
38794684 .collect();
3880− assert!(!errors.is_empty(), "@fetch https should error, got: {:?}", result.diagnostics);
4685+ assert!(
4686+ !errors.is_empty(),
4687+ "@fetch https should error, got: {:?}",
4688+ result.diagnostics
4689+ );
38814690}
38824691
38834692#[test]
38844693fn fetch_directive_error_on_bad_url() {
38854694 let result = htmlang::parser::parse("@fetch $data http://127.0.0.1:1/nonexistent");
3886− let errors: Vec<_> = result.diagnostics.iter()
4695+ let errors: Vec<_> = result
4696+ .diagnostics
4697+ .iter()
38874698 .filter(|d| d.severity == htmlang::parser::Severity::Error)
38884699 .collect();
3889− assert!(!errors.is_empty(), "@fetch should error on unreachable URL, got: {:?}", result.diagnostics);
4700+ assert!(
4701+ !errors.is_empty(),
4702+ "@fetch should error on unreachable URL, got: {:?}",
4703+ result.diagnostics
4704+ );
38904705}
38914706
38924707#[test]
@@ -3899,11 +4714,25 @@ fn svg_directive_inline() {
38994714
39004715 let input = format!("@svg {}", svg_path.display());
39014716 let result = htmlang::parser::parse(&input);
3902− assert!(result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error),
3903− "should parse without errors: {:?}", result.diagnostics);
4717+ assert!(
4718+ result
4719+ .diagnostics
4720+ .iter()
4721+ .all(|d| d.severity != htmlang::parser::Severity::Error),
4722+ "should parse without errors: {:?}",
4723+ result.diagnostics
4724+ );
39044725 let html = htmlang::codegen::generate(&result.document);
3905− assert!(html.contains("<svg"), "should inline SVG content, got: {}", html);
3906− assert!(html.contains("<circle"), "should contain SVG elements, got: {}", html);
4726+ assert!(
4727+ html.contains("<svg"),
4728+ "should inline SVG content, got: {}",
4729+ html
4730+ );
4731+ assert!(
4732+ html.contains("<circle"),
4733+ "should contain SVG elements, got: {}",
4734+ html
4735+ );
39074736
39084737 let _ = std::fs::remove_dir_all(&dir);
39094738}
@@ -3913,13 +4742,25 @@ fn svg_directive_with_attrs() {
39134742 let dir = std::env::temp_dir().join("htmlang_test_svg_attrs");
39144743 let _ = std::fs::create_dir_all(&dir);
39154744 let svg_path = dir.join("icon.svg");
3916− std::fs::write(&svg_path, r#"<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><rect/></svg>"#).unwrap();
4745+ std::fs::write(
4746+ &svg_path,
4747+ r#"<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48"><rect/></svg>"#,
4748+ )
4749+ .unwrap();
39174750
39184751 let input = format!("@svg [width 24, color red] {}", svg_path.display());
39194752 let result = htmlang::parser::parse(&input);
39204753 let html = htmlang::codegen::generate(&result.document);
3921− assert!(html.contains("width=\"24\""), "should override width, got: {}", html);
3922− assert!(html.contains("fill=\"red\""), "should set fill from color attr, got: {}", html);
4754+ assert!(
4755+ html.contains("width=\"24\""),
4756+ "should override width, got: {}",
4757+ html
4758+ );
4759+ assert!(
4760+ html.contains("fill=\"red\""),
4761+ "should set fill from color attr, got: {}",
4762+ html
4763+ );
39234764
39244765 let _ = std::fs::remove_dir_all(&dir);
39254766}
@@ -3927,42 +4768,99 @@ fn svg_directive_with_attrs() {
39274768#[test]
39284769fn svg_directive_missing_file() {
39294770 let result = htmlang::parser::parse("@svg /nonexistent/missing.svg");
3930− let errors: Vec<_> = result.diagnostics.iter()
3931− .filter(|d| d.severity == htmlang::parser::Severity::Error && d.message.contains("cannot load SVG"))
4771+ let errors: Vec<_> = result
4772+ .diagnostics
4773+ .iter()
4774+ .filter(|d| {
4775+ d.severity == htmlang::parser::Severity::Error && d.message.contains("cannot load SVG")
4776+ })
39324777 .collect();
3933− assert!(!errors.is_empty(), "should error on missing SVG, got: {:?}", result.diagnostics);
4778+ assert!(
4779+ !errors.is_empty(),
4780+ "should error on missing SVG, got: {:?}",
4781+ result.diagnostics
4782+ );
39344783}
39354784
39364785#[test]
39374786fn css_property_directive() {
39384787 let input = "@css-property --my-color\n syntax \"<color>\"\n inherits true\n initial-value #000\n\n@el [background var(--my-color)] Content";
39394788 let result = htmlang::parser::parse(input);
3940− assert!(result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error),
3941− "should parse without errors: {:?}", result.diagnostics);
4789+ assert!(
4790+ result
4791+ .diagnostics
4792+ .iter()
4793+ .all(|d| d.severity != htmlang::parser::Severity::Error),
4794+ "should parse without errors: {:?}",
4795+ result.diagnostics
4796+ );
39424797 let html = htmlang::codegen::generate(&result.document);
3943− assert!(html.contains("@property --my-color"), "@css-property should emit CSS @property rule, got: {}", html);
3944− assert!(html.contains("syntax:\"<color>\""), "should include syntax, got: {}", html);
3945− assert!(html.contains("inherits:true"), "should include inherits, got: {}", html);
3946− assert!(html.contains("initial-value:#000"), "should include initial-value, got: {}", html);
4798+ assert!(
4799+ html.contains("@property --my-color"),
4800+ "@css-property should emit CSS @property rule, got: {}",
4801+ html
4802+ );
4803+ assert!(
4804+ html.contains("syntax:\"<color>\""),
4805+ "should include syntax, got: {}",
4806+ html
4807+ );
4808+ assert!(
4809+ html.contains("inherits:true"),
4810+ "should include inherits, got: {}",
4811+ html
4812+ );
4813+ assert!(
4814+ html.contains("initial-value:#000"),
4815+ "should include initial-value, got: {}",
4816+ html
4817+ );
39474818}
39484819
39494820#[test]
39504821fn partial_output() {
39514822 let result = htmlang::parser::parse("@page Test\n@el [padding 20]\n Hello");
39524823 let html = htmlang::codegen::generate_partial(&result.document);
3953− assert!(!html.contains("<!DOCTYPE"), "partial should not have doctype, got: {}", html);
3954− assert!(!html.contains("<html"), "partial should not have html tag, got: {}", html);
3955− assert!(!html.contains("<head"), "partial should not have head tag, got: {}", html);
3956− assert!(!html.contains("<body"), "partial should not have body tag, got: {}", html);
3957− assert!(html.contains("<style>"), "partial should still have CSS, got: {}", html);
3958− assert!(html.contains("Hello"), "partial should have content, got: {}", html);
4824+ assert!(
4825+ !html.contains("<!DOCTYPE"),
4826+ "partial should not have doctype, got: {}",
4827+ html
4828+ );
4829+ assert!(
4830+ !html.contains("<html"),
4831+ "partial should not have html tag, got: {}",
4832+ html
4833+ );
4834+ assert!(
4835+ !html.contains("<head"),
4836+ "partial should not have head tag, got: {}",
4837+ html
4838+ );
4839+ assert!(
4840+ !html.contains("<body"),
4841+ "partial should not have body tag, got: {}",
4842+ html
4843+ );
4844+ assert!(
4845+ html.contains("<style>"),
4846+ "partial should still have CSS, got: {}",
4847+ html
4848+ );
4849+ assert!(
4850+ html.contains("Hello"),
4851+ "partial should have content, got: {}",
4852+ html
4853+ );
39594854}
39604855
39614856#[test]
39624857fn partial_output_dev() {
39634858 let result = htmlang::parser::parse("@page Test\n@el [padding 20]\n Hello");
39644859 let html = htmlang::codegen::generate_partial_dev(&result.document);
3965− assert!(!html.contains("<!DOCTYPE"), "partial dev should not have doctype");
4860+ assert!(
4861+ !html.contains("<!DOCTYPE"),
4862+ "partial dev should not have doctype"
4863+ );
39664864 assert!(html.contains("<style>"), "partial dev should have style");
39674865 assert!(html.contains("Hello"), "partial dev should have content");
39684866}
@@ -3970,11 +4868,31 @@ fn partial_output_dev() {
39704868#[test]
39714869fn responsive_srcset_on_image() {
39724870 let output = compile("@image [responsive 400 800 1200, alt Photo] photo.jpg");
3973− assert!(output.contains("srcset=\""), "should generate srcset, got: {}", output);
3974− assert!(output.contains("photo-400.jpg 400w"), "should have 400w source, got: {}", output);
3975− assert!(output.contains("photo-800.jpg 800w"), "should have 800w source, got: {}", output);
3976− assert!(output.contains("photo-1200.jpg 1200w"), "should have 1200w source, got: {}", output);
3977− assert!(output.contains("sizes="), "should generate sizes attribute, got: {}", output);
4871+ assert!(
4872+ output.contains("srcset=\""),
4873+ "should generate srcset, got: {}",
4874+ output
4875+ );
4876+ assert!(
4877+ output.contains("photo-400.jpg 400w"),
4878+ "should have 400w source, got: {}",
4879+ output
4880+ );
4881+ assert!(
4882+ output.contains("photo-800.jpg 800w"),
4883+ "should have 800w source, got: {}",
4884+ output
4885+ );
4886+ assert!(
4887+ output.contains("photo-1200.jpg 1200w"),
4888+ "should have 1200w source, got: {}",
4889+ output
4890+ );
4891+ assert!(
4892+ output.contains("sizes="),
4893+ "should generate sizes attribute, got: {}",
4894+ output
4895+ );
39784896}
39794897
39804898#[test]
@@ -3994,8 +4912,8 @@ fn auto_image_dimensions_png() {
39944912 0x90, 0x77, 0x53, 0xDE, // CRC
39954913 0x00, 0x00, 0x00, 0x0C, // IDAT length
39964914 0x49, 0x44, 0x41, 0x54, // IDAT
3997− 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01,
3998− 0xE2, 0x21, 0xBC, 0x33, // CRC
4915+ 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC,
4916+ 0x33, // CRC
39994917 0x00, 0x00, 0x00, 0x00, // IEND length
40004918 0x49, 0x45, 0x4E, 0x44, // IEND
40014919 0xAE, 0x42, 0x60, 0x82, // CRC
@@ -4005,8 +4923,16 @@ fn auto_image_dimensions_png() {
40054923 let input = format!("@image [alt test] {}", png_path.display());
40064924 let result = htmlang::parser::parse(&input);
40074925 let html = htmlang::codegen::generate(&result.document);
4008− assert!(html.contains("width=\"1\""), "should auto-detect width=1 from PNG, got: {}", html);
4009− assert!(html.contains("height=\"1\""), "should auto-detect height=1 from PNG, got: {}", html);
4926+ assert!(
4927+ html.contains("width=\"1\""),
4928+ "should auto-detect width=1 from PNG, got: {}",
4929+ html
4930+ );
4931+ assert!(
4932+ html.contains("height=\"1\""),
4933+ "should auto-detect height=1 from PNG, got: {}",
4934+ html
4935+ );
40104936
40114937 let _ = std::fs::remove_dir_all(&dir);
40124938}
@@ -4016,7 +4942,11 @@ fn auto_image_dimensions_not_for_urls() {
40164942 // Remote URLs should not trigger dimension detection
40174943 let output = compile("@image [alt test] https://example.com/photo.png");
40184944 // Should not crash or add dimensions for remote URLs
4019− assert!(output.contains("src=\"https://example.com/photo.png\""), "should keep URL src, got: {}", output);
4945+ assert!(
4946+ output.contains("src=\"https://example.com/photo.png\""),
4947+ "should keep URL src, got: {}",
4948+ output
4949+ );
40204950}
40214951
40224952#[test]
@@ -4026,30 +4956,36 @@ fn auto_image_dimensions_respects_explicit() {
40264956 let _ = std::fs::create_dir_all(&dir);
40274957 let png_path = dir.join("test2.png");
40284958 let png_data: Vec<u8> = vec![
4029− 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A,
4030− 0x00, 0x00, 0x00, 0x0D,
4031− 0x49, 0x48, 0x44, 0x52,
4032− 0x00, 0x00, 0x00, 0x01,
4033− 0x00, 0x00, 0x00, 0x01,
4034− 0x08, 0x02, 0x00, 0x00, 0x00,
4035− 0x90, 0x77, 0x53, 0xDE,
4036− 0x00, 0x00, 0x00, 0x0C,
4037− 0x49, 0x44, 0x41, 0x54,
4038− 0x08, 0xD7, 0x63, 0xF8, 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01,
4039− 0xE2, 0x21, 0xBC, 0x33,
4040− 0x00, 0x00, 0x00, 0x00,
4041− 0x49, 0x45, 0x4E, 0x44,
4042− 0xAE, 0x42, 0x60, 0x82,
4959+ 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44,
4960+ 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90,
4961+ 0x77, 0x53, 0xDE, 0x00, 0x00, 0x00, 0x0C, 0x49, 0x44, 0x41, 0x54, 0x08, 0xD7, 0x63, 0xF8,
4962+ 0xCF, 0xC0, 0x00, 0x00, 0x00, 0x02, 0x00, 0x01, 0xE2, 0x21, 0xBC, 0x33, 0x00, 0x00, 0x00,
4963+ 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82,
40434964 ];
40444965 std::fs::write(&png_path, &png_data).unwrap();
40454966
4046− let input = format!("@image [width 100, height 100, alt test] {}", png_path.display());
4967+ let input = format!(
4968+ "@image [width 100, height 100, alt test] {}",
4969+ png_path.display()
4970+ );
40474971 let result = htmlang::parser::parse(&input);
40484972 let html = htmlang::codegen::generate(&result.document);
40494973 // When width/height are set as CSS attrs, auto-dimensions should not add HTML width/height
4050− assert!(html.contains("width:100px"), "should have CSS width, got: {}", html);
4051− assert!(html.contains("height:100px"), "should have CSS height, got: {}", html);
4052− assert!(!html.contains("width=\"1\""), "should NOT auto-detect dimensions when CSS size is set, got: {}", html);
4974+ assert!(
4975+ html.contains("width:100px"),
4976+ "should have CSS width, got: {}",
4977+ html
4978+ );
4979+ assert!(
4980+ html.contains("height:100px"),
4981+ "should have CSS height, got: {}",
4982+ html
4983+ );
4984+ assert!(
4985+ !html.contains("width=\"1\""),
4986+ "should NOT auto-detect dimensions when CSS size is set, got: {}",
4987+ html
4988+ );
40534989
40544990 let _ = std::fs::remove_dir_all(&dir);
40554991}
@@ -4101,7 +5037,11 @@ fn error_circular_include_filesystem() {
41015037 .iter()
41025038 .any(|d| d.severity == htmlang::parser::Severity::Error && d.message.contains("circular"));
41035039 let _ = std::fs::remove_dir_all(&dir);
4104− assert!(has_cycle, "expected circular include error, got: {:?}", result.diagnostics);
5040+ assert!(
5041+ has_cycle,
5042+ "expected circular include error, got: {:?}",
5043+ result.diagnostics
5044+ );
41055045}
41065046
41075047#[test]
@@ -4119,10 +5059,18 @@ fn import_with_alias_prefixes_definitions() {
41195059 let _ = std::fs::remove_dir_all(&dir);
41205060
41215061 assert!(
4122− result.diagnostics.iter().all(|d| d.severity != htmlang::parser::Severity::Error),
4123− "no errors expected, got: {:?}", result.diagnostics
5062+ result
5063+ .diagnostics
5064+ .iter()
5065+ .all(|d| d.severity != htmlang::parser::Severity::Error),
5066+ "no errors expected, got: {:?}",
5067+ result.diagnostics
5068+ );
5069+ assert!(
5070+ html.contains("card-body"),
5071+ "alias prefixed call should expand, got: {}",
5072+ html
41245073 );
4125− assert!(html.contains("card-body"), "alias prefixed call should expand, got: {}", html);
41265074}
41275075
41285076#[test]
@@ -4135,10 +5083,13 @@ fn error_invalid_json_in_data_directive() {
41355083
41365084 let input = "@data bad.json\n";
41375085 let result = htmlang::parser::parse_with_base(input, Some(&dir));
4138− let has_err = result
4139− .diagnostics
4140− .iter()
4141− .any(|d| d.severity == htmlang::parser::Severity::Error && d.message.contains("invalid JSON"));
5086+ let has_err = result.diagnostics.iter().any(|d| {
5087+ d.severity == htmlang::parser::Severity::Error && d.message.contains("invalid JSON")
5088+ });
41425089 let _ = std::fs::remove_dir_all(&dir);
4143− assert!(has_err, "expected invalid JSON error, got: {:?}", result.diagnostics);
5090+ assert!(
5091+ has_err,
5092+ "expected invalid JSON error, got: {:?}",
5093+ result.diagnostics
5094+ );
41445095}