on master help sign in
Add 28 improvements: 5 new elements, @each step, selection:/nth: pseudo-selectors, container queries, direction attr, image inlining, WCAG contrast checker, 4 CLI commands, and 4 LSP enhancements
commit5c61488856f90db1c66074ac32b4306bf6fd195b authoriko <mail@iko.soy> dateWed 15 Apr 2026, 15:01 (6 months ago) parent4911ba7
12 files changed+1055−21
src/ast.rs+6 src/bin/htmlang-lsp.rs+293−4 src/codegen.rs+135−10 src/main.rs+189−1 src/parser.rs+108−6 tests/snapshots.rs+235 tests/snapshots/container_queries.hl+5 tests/snapshots/direction_attr.hl+9 tests/snapshots/each_step.hl+17 tests/snapshots/new_elements_4.hl+35 tests/snapshots/nth_pseudo.hl+13 tests/snapshots/selection_pseudo.hl+10
src/ast.rs+6
@@ -110,6 +110,12 @@ pub enum ElementKind {
110110 Iframe,
111111 Output,
112112 Canvas,
113+ // New convenience elements
114+ Grid,
115+ Stack,
116+ Spacer,
117+ Badge,
118+ Tooltip,
113119}
114120
115121#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+293−4
@@ -67,7 +67,15 @@ impl LanguageServer for Backend {
6767 work_done_progress_options: Default::default(),
6868 })),
6969 document_symbol_provider: Some(OneOf::Left(true)),
70− code_action_provider: Some(CodeActionProviderCapability::Simple(true)),
70+ code_action_provider: Some(CodeActionProviderCapability::Options(
71+ CodeActionOptions {
72+ code_action_kinds: Some(vec![
73+ CodeActionKind::QUICKFIX,
74+ CodeActionKind::REFACTOR_EXTRACT,
75+ ]),
76+ ..Default::default()
77+ },
78+ )),
7179 color_provider: Some(ColorProviderCapability::Simple(true)),
7280 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
7381 semantic_tokens_provider: Some(
@@ -485,6 +493,11 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
485493 }
486494 }
487495
496+ // Color value completions after color-related attributes
497+ if let Some(colors) = color_value_completions(before, edit_range) {
498+ return colors;
499+ }
500+
488501 return attr_completions(edit_range);
489502 }
490503
@@ -1100,6 +1113,94 @@ fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
11001113 .collect()
11011114}
11021115
1116+fn color_value_completions(before: &str, range: Range) -> Option<Vec<CompletionItem>> {
1117+ // Find the preceding attribute name before the cursor value position.
1118+ // Inside brackets, attributes are comma-separated. Look for the last attribute token
1119+ // before the current value position. Pattern: "attr value" or "attr " at end.
1120+ let bracket_content = before.rsplit('[').next()?;
1121+ // Split by commas to get the current segment
1122+ let segment = bracket_content.rsplit(',').next()?.trim();
1123+
1124+ // Check if the first word in this segment is a color-related attribute
1125+ let attr = segment.split_whitespace().next()?;
1126+
1127+ // Strip state prefix (e.g., "hover:background" -> "background")
1128+ let base_attr = if let Some(pos) = attr.rfind(':') {
1129+ &attr[pos + 1..]
1130+ } else {
1131+ attr
1132+ };
1133+
1134+ if !matches!(
1135+ base_attr,
1136+ "background" | "color" | "border" | "border-top" | "border-bottom"
1137+ | "border-left" | "border-right" | "accent-color" | "caret-color"
1138+ | "text-decoration-color" | "outline"
1139+ ) {
1140+ return None;
1141+ }
1142+
1143+ // Only show colors if we're in the value position (at least one space after the attr name)
1144+ let after_attr = &segment[attr.len()..];
1145+ if !after_attr.starts_with(' ') {
1146+ return None;
1147+ }
1148+
1149+ let colors: &[(&str, &str, &str)] = &[
1150+ ("white", "#ffffff", "White"),
1151+ ("black", "#000000", "Black"),
1152+ ("red", "#ef4444", "Red"),
1153+ ("orange", "#f97316", "Orange"),
1154+ ("yellow", "#eab308", "Yellow"),
1155+ ("green", "#22c55e", "Green"),
1156+ ("blue", "#3b82f6", "Blue"),
1157+ ("indigo", "#6366f1", "Indigo"),
1158+ ("purple", "#a855f7", "Purple"),
1159+ ("pink", "#ec4899", "Pink"),
1160+ ("gray", "#6b7280", "Gray"),
1161+ ("slate", "#64748b", "Slate"),
1162+ ("zinc", "#71717a", "Zinc"),
1163+ ("neutral", "#737373", "Neutral"),
1164+ ("stone", "#78716c", "Stone"),
1165+ ("amber", "#f59e0b", "Amber"),
1166+ ("lime", "#84cc16", "Lime"),
1167+ ("emerald", "#10b981", "Emerald"),
1168+ ("teal", "#14b8a6", "Teal"),
1169+ ("cyan", "#06b6d4", "Cyan"),
1170+ ("sky", "#0ea5e9", "Sky"),
1171+ ("violet", "#8b5cf6", "Violet"),
1172+ ("fuchsia", "#d946ef", "Fuchsia"),
1173+ ("rose", "#f43f5e", "Rose"),
1174+ ("transparent", "transparent", "Transparent"),
1175+ ("currentColor", "currentColor", "Inherit from parent text color"),
1176+ ];
1177+
1178+ let items: Vec<CompletionItem> = colors.iter().map(|(label, value, detail)| {
1179+ let doc = if value.starts_with('#') {
1180+ format!("{} (`{}`)", detail, value)
1181+ } else {
1182+ detail.to_string()
1183+ };
1184+ CompletionItem {
1185+ label: label.to_string(),
1186+ kind: Some(CompletionItemKind::COLOR),
1187+ detail: Some(doc),
1188+ text_edit: Some(CompletionTextEdit::Edit(TextEdit {
1189+ range,
1190+ new_text: value.to_string(),
1191+ })),
1192+ documentation: if value.starts_with('#') {
1193+ Some(Documentation::String(value.to_string()))
1194+ } else {
1195+ None
1196+ },
1197+ ..Default::default()
1198+ }
1199+ }).collect();
1200+
1201+ Some(items)
1202+}
1203+
11031204fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
11041205 let mut items = Vec::new();
11051206
@@ -2018,7 +2119,7 @@ fn document_symbols(text: &str) -> Vec<SymbolInformation> {
20182119
20192120fn code_actions(
20202121 text: &str,
2021− _range: &Range,
2122+ selection: &Range,
20222123 diagnostics: &[Diagnostic],
20232124 uri: &Url,
20242125) -> Vec<CodeActionOrCommand> {
@@ -2087,6 +2188,9 @@ fn code_actions(
20872188 if let Some(source_line) = lines.get(line) {
20882189 let trimmed = source_line.trim_start();
20892190 if trimmed.starts_with("@let ") {
2191+ let var_name = trimmed.strip_prefix("@let ")
2192+ .and_then(|r| r.trim().split_whitespace().next())
2193+ .unwrap_or("?");
20902194 let edit = TextEdit {
20912195 range: Range::new(
20922196 Position::new(diag.range.start.line, 0),
@@ -2097,7 +2201,7 @@ fn code_actions(
20972201 let mut changes = HashMap::new();
20982202 changes.insert(uri.clone(), vec![edit]);
20992203 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2100− title: "Remove unused variable".into(),
2204+ title: format!("Remove unused variable '${}'", var_name),
21012205 kind: Some(CodeActionKind::QUICKFIX),
21022206 diagnostics: Some(vec![diag.clone()]),
21032207 edit: Some(WorkspaceEdit {
@@ -2117,6 +2221,12 @@ fn code_actions(
21172221 if let Some(source_line) = lines.get(line) {
21182222 let trimmed = source_line.trim_start();
21192223 if trimmed.starts_with("@define ") {
2224+ let def_name = trimmed.strip_prefix("@define ")
2225+ .and_then(|r| {
2226+ let r = r.trim();
2227+ r.find('[').map(|b| r[..b].trim()).or_else(|| r.split_whitespace().next())
2228+ })
2229+ .unwrap_or("?");
21202230 let edit = TextEdit {
21212231 range: Range::new(
21222232 Position::new(diag.range.start.line, 0),
@@ -2127,7 +2237,57 @@ fn code_actions(
21272237 let mut changes = HashMap::new();
21282238 changes.insert(uri.clone(), vec![edit]);
21292239 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2130− title: "Remove unused define".into(),
2240+ title: format!("Remove unused define '${}'", def_name),
2241+ kind: Some(CodeActionKind::QUICKFIX),
2242+ diagnostics: Some(vec![diag.clone()]),
2243+ edit: Some(WorkspaceEdit {
2244+ changes: Some(changes),
2245+ ..Default::default()
2246+ }),
2247+ ..Default::default()
2248+ }));
2249+ }
2250+ }
2251+ }
2252+
2253+ // Quick-fix: remove unused @fn function (remove definition and its body)
2254+ if msg.contains("unused function") {
2255+ let line = diag.range.start.line as usize;
2256+ let lines: Vec<&str> = text.lines().collect();
2257+ if let Some(source_line) = lines.get(line) {
2258+ let trimmed = source_line.trim_start();
2259+ if trimmed.starts_with("@fn ") {
2260+ let fn_name = trimmed.strip_prefix("@fn ")
2261+ .and_then(|r| r.split_whitespace().next())
2262+ .unwrap_or("?");
2263+ // Find the end of the function body (indented lines below)
2264+ let start_indent = source_line.len() - trimmed.len();
2265+ let mut end_line = line;
2266+ let mut j = line + 1;
2267+ while j < lines.len() {
2268+ let l = lines[j];
2269+ if l.trim().is_empty() {
2270+ j += 1;
2271+ continue;
2272+ }
2273+ let indent = l.len() - l.trim_start().len();
2274+ if indent <= start_indent {
2275+ break;
2276+ }
2277+ end_line = j;
2278+ j += 1;
2279+ }
2280+ let edit = TextEdit {
2281+ range: Range::new(
2282+ Position::new(diag.range.start.line, 0),
2283+ Position::new(end_line as u32 + 1, 0),
2284+ ),
2285+ new_text: String::new(),
2286+ };
2287+ let mut changes = HashMap::new();
2288+ changes.insert(uri.clone(), vec![edit]);
2289+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2290+ title: format!("Remove unused function '@{}'", fn_name),
21312291 kind: Some(CodeActionKind::QUICKFIX),
21322292 diagnostics: Some(vec![diag.clone()]),
21332293 edit: Some(WorkspaceEdit {
@@ -2193,6 +2353,135 @@ fn code_actions(
21932353 }
21942354 }
21952355 }
2356+
2357+ // Quick-fix: auto-import suggestion for unknown element @name
2358+ if msg.contains("unknown element @") {
2359+ if let Some(fn_name) = extract_between(msg, "unknown element @", ",")
2360+ .or_else(|| extract_between(msg, "unknown element @", ""))
2361+ {
2362+ let fn_name = fn_name.trim();
2363+ if !fn_name.is_empty() {
2364+ if let Ok(file_path) = uri.to_file_path() {
2365+ if let Some(dir) = file_path.parent() {
2366+ if let Ok(entries) = std::fs::read_dir(dir) {
2367+ for entry in entries.flatten() {
2368+ let path = entry.path();
2369+ if path.extension().and_then(|e| e.to_str()) != Some("hl") {
2370+ continue;
2371+ }
2372+ // Skip the current file
2373+ if path == file_path {
2374+ continue;
2375+ }
2376+ if let Ok(content) = std::fs::read_to_string(&path) {
2377+ let defines_fn = content.lines().any(|l| {
2378+ let t = l.trim();
2379+ if let Some(rest) = t.strip_prefix("@fn ") {
2380+ rest.split_whitespace().next() == Some(fn_name)
2381+ } else {
2382+ false
2383+ }
2384+ });
2385+ if defines_fn {
2386+ if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
2387+ // Check if already imported
2388+ let already_imported = text.lines().any(|l| {
2389+ let t = l.trim();
2390+ t == format!("@import {}", name)
2391+ || t == format!("@include {}", name)
2392+ });
2393+ if !already_imported {
2394+ let import_line = format!("@import {}\n", name);
2395+ let edit = TextEdit {
2396+ range: Range::new(
2397+ Position::new(0, 0),
2398+ Position::new(0, 0),
2399+ ),
2400+ new_text: import_line,
2401+ };
2402+ let mut changes = HashMap::new();
2403+ changes.insert(uri.clone(), vec![edit]);
2404+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2405+ title: format!("Add '@import {}' for @{}", name, fn_name),
2406+ kind: Some(CodeActionKind::QUICKFIX),
2407+ diagnostics: Some(vec![diag.clone()]),
2408+ edit: Some(WorkspaceEdit {
2409+ changes: Some(changes),
2410+ ..Default::default()
2411+ }),
2412+ ..Default::default()
2413+ }));
2414+ }
2415+ }
2416+ }
2417+ }
2418+ }
2419+ }
2420+ }
2421+ }
2422+ }
2423+ }
2424+ }
2425+ }
2426+
2427+ // Refactoring: extract selection to @fn
2428+ if selection.start.line != selection.end.line {
2429+ let lines: Vec<&str> = text.lines().collect();
2430+ let start_line = selection.start.line as usize;
2431+ let end_line = (selection.end.line as usize).min(lines.len().saturating_sub(1));
2432+ if start_line < lines.len() && end_line < lines.len() {
2433+ // Collect selected lines
2434+ let selected: Vec<&str> = lines[start_line..=end_line].to_vec();
2435+ if !selected.is_empty() {
2436+ // Determine the minimum indentation of selected lines (ignoring blank lines)
2437+ let min_indent = selected.iter()
2438+ .filter(|l| !l.trim().is_empty())
2439+ .map(|l| l.len() - l.trim_start().len())
2440+ .min()
2441+ .unwrap_or(0);
2442+
2443+ // Build the function body with two-space indentation relative to @fn
2444+ let fn_body: String = selected.iter()
2445+ .map(|l| {
2446+ if l.trim().is_empty() {
2447+ String::from("\n")
2448+ } else {
2449+ let stripped = if l.len() > min_indent { &l[min_indent..] } else { l.trim_start() };
2450+ format!(" {}\n", stripped)
2451+ }
2452+ })
2453+ .collect();
2454+
2455+ let fn_def = format!("@fn extracted\n{}", fn_body);
2456+ let indent = " ".repeat(min_indent);
2457+ let fn_call = format!("{}@extracted", indent);
2458+
2459+ // Build edits: replace selected lines with @extracted call, and insert @fn definition at top
2460+ let replace_edit = TextEdit {
2461+ range: Range::new(
2462+ Position::new(selection.start.line, 0),
2463+ Position::new(selection.end.line + 1, 0),
2464+ ),
2465+ new_text: format!("{}\n", fn_call),
2466+ };
2467+ let insert_edit = TextEdit {
2468+ range: Range::new(Position::new(0, 0), Position::new(0, 0)),
2469+ new_text: format!("{}\n", fn_def),
2470+ };
2471+ let mut changes = HashMap::new();
2472+ changes.insert(uri.clone(), vec![insert_edit, replace_edit]);
2473+ actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2474+ title: "Extract to @fn".into(),
2475+ kind: Some(CodeActionKind::REFACTOR_EXTRACT),
2476+ diagnostics: None,
2477+ edit: Some(WorkspaceEdit {
2478+ changes: Some(changes),
2479+ ..Default::default()
2480+ }),
2481+ ..Default::default()
2482+ }));
2483+ }
2484+ }
21962485 }
21972486
21982487 actions
src/codegen.rs+135−10
@@ -30,6 +30,8 @@ struct StyleEntry {
3030 motion_reduce: String,
3131 landscape: String,
3232 portrait: String,
33+ /// Container query overrides: (breakpoint_prefix, css)
34+ container: Vec<(String, String)>,
3335}
3436
3537struct StyleCollector {
@@ -57,6 +59,7 @@ impl StyleCollector {
5759 motion_reduce: String,
5860 landscape: String,
5961 portrait: String,
62+ container: Vec<(String, String)>,
6063 ) -> Option<String> {
6164 if base.is_empty()
6265 && pseudo.is_empty()
@@ -67,6 +70,7 @@ impl StyleCollector {
6770 && motion_reduce.is_empty()
6871 && landscape.is_empty()
6972 && portrait.is_empty()
73+ && container.is_empty()
7074 {
7175 return None;
7276 }
@@ -80,7 +84,12 @@ impl StyleCollector {
8084 .map(|(bp, css)| format!("{}={}", bp, css))
8185 .collect::<Vec<_>>()
8286 .join("|");
83− let key = format!("{}|{}|{}|{}|{}|{}|{}|{}|{}", base, pseudo_key, resp_key, dark, print, motion_safe, motion_reduce, landscape, portrait);
87+ let cq_key: String = container
88+ .iter()
89+ .map(|(bp, css)| format!("{}={}", bp, css))
90+ .collect::<Vec<_>>()
91+ .join("|");
92+ let key = format!("{}|{}|{}|{}|{}|{}|{}|{}|{}|{}", base, pseudo_key, resp_key, dark, print, motion_safe, motion_reduce, landscape, portrait, cq_key);
8493 if let Some(&idx) = self.index.get(&key) {
8594 return Some(self.entries[idx].class_name.clone());
8695 }
@@ -97,6 +106,7 @@ impl StyleCollector {
97106 motion_reduce,
98107 landscape,
99108 portrait,
109+ container,
100110 });
101111 self.index.insert(key, idx);
102112 Some(name)
@@ -261,6 +271,35 @@ impl StyleCollector {
261271 }
262272 }
263273
274+ // Container query rules grouped by breakpoint
275+ for &(bp_name, bp_width) in BREAKPOINTS {
276+ let mut cq_css = String::new();
277+ for e in &self.entries {
278+ for (bp, rule_css) in &e.container {
279+ if bp == bp_name && !rule_css.is_empty() {
280+ if dev {
281+ cq_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, rule_css));
282+ } else {
283+ cq_css.push_str(&format!(".{}{{{}}}", e.class_name, rule_css));
284+ }
285+ }
286+ }
287+ }
288+ if !cq_css.is_empty() {
289+ if dev {
290+ css.push_str(&format!(
291+ "@container (min-width: {}) {{\n{}}}\n",
292+ bp_width, cq_css
293+ ));
294+ } else {
295+ css.push_str(&format!(
296+ "@container(min-width:{}){{{}}}",
297+ bp_width, cq_css
298+ ));
299+ }
300+ }
301+ }
302+
264303 css
265304 }
266305}
@@ -535,7 +574,7 @@ fn generate_node(
535574 | Some(ElementKind::Blockquote)
536575 | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
537576 | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
538− | Some(ElementKind::Datalist)
577+ | Some(ElementKind::Datalist) | Some(ElementKind::Grid) | Some(ElementKind::Stack)
539578 );
540579 if needs_wrap {
541580 out.push_str(&ctx.indent());
@@ -616,7 +655,7 @@ fn generate_element(
616655 ctx: &mut GenContext,
617656) {
618657 // Self-closing elements
619− if matches!(elem.kind, ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule | ElementKind::Source) {
658+ if matches!(elem.kind, ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule | ElementKind::Source | ElementKind::Spacer) {
620659 generate_self_closing(elem, parent_kind, out, styles, ctx);
621660 return;
622661 }
@@ -635,7 +674,8 @@ fn generate_element(
635674 }
636675
637676 let tag = match &elem.kind {
638− ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
677+ ElementKind::Row | ElementKind::Column | ElementKind::El
678+ | ElementKind::Grid | ElementKind::Stack => "div",
639679 ElementKind::Text => "span",
640680 ElementKind::Paragraph => "p",
641681 ElementKind::Link => "a",
@@ -695,9 +735,11 @@ fn generate_element(
695735 ElementKind::Iframe => "iframe",
696736 ElementKind::Output => "output",
697737 ElementKind::Canvas => "canvas",
738+ ElementKind::Badge => "span",
739+ ElementKind::Tooltip => "span",
698740 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
699741 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
700− | ElementKind::Source => unreachable!(),
742+ | ElementKind::Source | ElementKind::Spacer => unreachable!(),
701743 };
702744
703745 let kind_label = match elem.kind {
@@ -754,6 +796,10 @@ fn generate_element(
754796 ElementKind::Iframe => "iframe",
755797 ElementKind::Output => "output",
756798 ElementKind::Canvas => "canvas",
799+ ElementKind::Grid => "grid",
800+ ElementKind::Stack => "stack",
801+ ElementKind::Badge => "badge",
802+ ElementKind::Tooltip => "tooltip",
757803 _ => "",
758804 };
759805
@@ -804,6 +850,15 @@ fn generate_element(
804850 }
805851 }
806852
853+ // Tooltip title
854+ if elem.kind == ElementKind::Tooltip {
855+ if let Some(text) = &elem.argument {
856+ out.push_str(" title=\"");
857+ out.push_str(&html_escape(text));
858+ out.push('"');
859+ }
860+ }
861+
807862 // Time datetime
808863 if elem.kind == ElementKind::Time {
809864 if let Some(dt) = elem.attrs.iter().find(|a| a.key == "datetime") {
@@ -855,6 +910,8 @@ fn generate_element(
855910 | ElementKind::Abbr
856911 | ElementKind::Time
857912 | ElementKind::DefinitionDescription
913+ | ElementKind::Badge
914+ | ElementKind::Tooltip
858915 ) {
859916 if let Some(text) = &elem.argument {
860917 out.push_str(&html_escape(text));
@@ -894,6 +951,7 @@ fn generate_self_closing(
894951 ElementKind::Input => ("input", "input"),
895952 ElementKind::HorizontalRule => ("hr", "hr"),
896953 ElementKind::Source => ("source", "source"),
954+ ElementKind::Spacer => ("div", "spacer"),
897955 _ => unreachable!(),
898956 };
899957
@@ -905,12 +963,34 @@ fn generate_self_closing(
905963 out.push('<');
906964 out.push_str(tag);
907965
908− // Image src
966+ // Image src (with optional non-SVG base64 inlining)
909967 if elem.kind == ElementKind::Image {
910968 let src = elem.argument.as_deref().unwrap_or("");
911− out.push_str(" src=\"");
912− out.push_str(&html_escape(src));
913− out.push('"');
969+ let is_inline = elem.attrs.iter().any(|a| a.key == "inline");
970+ if is_inline && !src.is_empty() && !src.ends_with(".svg") {
971+ let mime = if src.ends_with(".png") { "image/png" }
972+ else if src.ends_with(".jpg") || src.ends_with(".jpeg") { "image/jpeg" }
973+ else if src.ends_with(".gif") { "image/gif" }
974+ else if src.ends_with(".webp") { "image/webp" }
975+ else if src.ends_with(".avif") { "image/avif" }
976+ else { "application/octet-stream" };
977+ if let Ok(data) = std::fs::read(src) {
978+ let b64 = base64_encode(&data);
979+ out.push_str(" src=\"data:");
980+ out.push_str(mime);
981+ out.push_str(";base64,");
982+ out.push_str(&b64);
983+ out.push('"');
984+ } else {
985+ out.push_str(" src=\"");
986+ out.push_str(&html_escape(src));
987+ out.push('"');
988+ }
989+ } else {
990+ out.push_str(" src=\"");
991+ out.push_str(&html_escape(src));
992+ out.push('"');
993+ }
914994 }
915995
916996 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
@@ -996,6 +1076,28 @@ fn compute_class(
9961076 }
9971077 }
9981078
1079+ // Collect nth:EXPR: dynamic pseudo selectors
1080+ let mut nth_prefixes: Vec<String> = Vec::new();
1081+ for attr in attrs {
1082+ if attr.key.starts_with("nth:") {
1083+ let rest = &attr.key[4..];
1084+ if let Some(colon_pos) = rest.find(':') {
1085+ let prefix = format!("nth:{}:", &rest[..colon_pos]);
1086+ if !nth_prefixes.contains(&prefix) {
1087+ nth_prefixes.push(prefix);
1088+ }
1089+ }
1090+ }
1091+ }
1092+ for prefix in &nth_prefixes {
1093+ let expr = &prefix[4..prefix.len() - 1];
1094+ let selector = format!(":nth-child({})", expr);
1095+ let css = attrs_to_css(attrs, prefix, kind, parent_kind);
1096+ if !css.is_empty() {
1097+ pseudo.push((selector, css));
1098+ }
1099+ }
1100+
9991101 // Collect responsive overrides
10001102 let mut responsive = Vec::new();
10011103 for &(bp_name, _) in BREAKPOINTS {
@@ -1006,6 +1108,16 @@ fn compute_class(
10061108 }
10071109 }
10081110
1111+ // Collect container query overrides
1112+ let mut container = Vec::new();
1113+ for &(bp_name, _) in BREAKPOINTS {
1114+ let prefix = format!("cq-{}:", bp_name);
1115+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind);
1116+ if !css.is_empty() {
1117+ container.push((bp_name.to_string(), css));
1118+ }
1119+ }
1120+
10091121 let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
10101122 let print = attrs_to_css(attrs, "print:", kind, parent_kind);
10111123 let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind);
@@ -1013,7 +1125,7 @@ fn compute_class(
10131125 let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind);
10141126 let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind);
10151127
1016− styles.get_class(base, pseudo, responsive, dark, print, motion_safe, motion_reduce, landscape, portrait)
1128+ styles.get_class(base, pseudo, responsive, dark, print, motion_safe, motion_reduce, landscape, portrait, container)
10171129}
10181130
10191131fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
@@ -1059,14 +1171,18 @@ const PSEUDO_PREFIXES: &[(&str, &str)] = &[
10591171 ("even:", ":nth-child(even)"),
10601172 ("before:", "::before"),
10611173 ("after:", "::after"),
1174+ ("selection:", "::selection"),
10621175];
10631176const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
10641177const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
1178+const CONTAINER_QUERY_PREFIXES: &[&str] = &["cq-sm:", "cq-md:", "cq-lg:", "cq-xl:", "cq-2xl:"];
10651179
10661180fn is_prefixed_attr(key: &str) -> bool {
10671181 PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
10681182 || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
10691183 || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
1184+ || CONTAINER_QUERY_PREFIXES.iter().any(|p| key.starts_with(p))
1185+ || key.starts_with("nth:")
10701186}
10711187
10721188fn attrs_to_css(
@@ -1109,6 +1225,11 @@ fn attrs_to_css(
11091225 ElementKind::DefinitionDescription => css.push_str("margin:0;display:flex;flex-direction:column;"),
11101226 ElementKind::Fieldset => css.push_str("display:flex;flex-direction:column;border:1px solid currentColor;padding:0.5em;margin:0;"),
11111227 ElementKind::Kbd => css.push_str("font-family:ui-monospace,monospace;"),
1228+ ElementKind::Grid => css.push_str("display:grid;"),
1229+ ElementKind::Stack => css.push_str("position:relative;"),
1230+ ElementKind::Badge => css.push_str("display:inline-flex;align-items:center;justify-content:center;padding:2px 8px;border-radius:9999px;font-size:0.75rem;font-weight:600;line-height:1;"),
1231+ ElementKind::Tooltip => css.push_str("position:relative;cursor:help;"),
1232+ ElementKind::Spacer => css.push_str("flex:1;"),
11121233 _ => {}
11131234 }
11141235 }
@@ -1839,6 +1960,10 @@ fn attrs_to_css(
18391960 "content-visibility" => {
18401961 if let Some(v) = val { push_css(&mut css, "content-visibility", v); }
18411962 }
1963+ "direction" => {
1964+ if let Some(v) = val { push_css(&mut css, "direction", v); }
1965+ }
1966+
18421967 "content" => {
18431968 if let Some(v) = val {
18441969 // Wrap in quotes if not already quoted and not a CSS keyword
src/main.rs+189−1
@@ -209,6 +209,9 @@ Commands:
209209 sitemap <dir> Generate sitemap.xml from .hl files
210210 lint <file.hl | dir> Stricter lint checks (accessibility, nesting, etc.)
211211 stats <file.hl | dir> Show file statistics (elements, CSS rules, colors)
212+ preview <file.hl> Compile and open in browser (one-shot, no server)
213+ diff <a.hl> <b.hl> Show differences between two .hl files
214+ export <dir> [-o f] Compile and bundle into an archive
212215
213216Options:
214217 -w, --watch Watch for changes and recompile
@@ -305,6 +308,25 @@ fn collect_hl_recursive_inner(dir: &Path, files: &mut Vec<PathBuf>) {
305308 }
306309}
307310
311+fn collect_all_files_recursive(dir: &Path) -> Vec<PathBuf> {
312+ let mut files = Vec::new();
313+ fn walk(dir: &Path, files: &mut Vec<PathBuf>) {
314+ if let Ok(entries) = fs::read_dir(dir) {
315+ for entry in entries.flatten() {
316+ let path = entry.path();
317+ if path.is_dir() {
318+ walk(&path, files);
319+ } else if path.is_file() {
320+ files.push(path);
321+ }
322+ }
323+ }
324+ }
325+ walk(dir, &mut files);
326+ files.sort();
327+ files
328+}
329+
308330fn generate_sitemap(dir: &str, base_url: &str) {
309331 let dir = Path::new(dir);
310332 let hl_files = collect_hl_files_recursive(dir);
@@ -589,12 +611,28 @@ fn main() {
589611 })
590612 }).collect();
591613
592− // Compile files in parallel
614+ // Compile files in parallel (with incremental skip for unchanged files)
593615 let any_errors = std::sync::atomic::AtomicBool::new(false);
616+ let skipped = std::sync::atomic::AtomicUsize::new(0);
594617 std::thread::scope(|s| {
595618 for (file, effective_out) in hl_files.iter().zip(effective_outs.iter()) {
596619 let any_errors = &any_errors;
620+ let skipped = &skipped;
597621 s.spawn(move || {
622+ // Incremental: skip if output is newer than source
623+ if let Some(out_path) = effective_out {
624+ let out_p = Path::new(out_path.as_str());
625+ if out_p.exists() {
626+ if let (Ok(src_meta), Ok(out_meta)) = (file.metadata(), out_p.metadata()) {
627+ if let (Ok(src_mod), Ok(out_mod)) = (src_meta.modified(), out_meta.modified()) {
628+ if out_mod > src_mod {
629+ skipped.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
630+ return;
631+ }
632+ }
633+ }
634+ }
635+ }
598636 let path_str = file.to_string_lossy().to_string();
599637 let (has_errors, _) = compile(&path_str, false, false, false, effective_out.as_deref(), false, None);
600638 if has_errors {
@@ -603,6 +641,10 @@ fn main() {
603641 });
604642 }
605643 });
644+ let skipped_count = skipped.load(std::sync::atomic::Ordering::Relaxed);
645+ if skipped_count > 0 {
646+ eprintln!("skipped {} unchanged files", skipped_count);
647+ }
606648 if any_errors.load(std::sync::atomic::Ordering::Relaxed) { process::exit(1); }
607649
608650 // Copy non-.hl static assets to output directory
@@ -754,6 +796,152 @@ fn main() {
754796 return;
755797 }
756798
799+ // Handle "preview" subcommand
800+ if args.len() >= 2 && args[1] == "preview" {
801+ if args.len() < 3 {
802+ eprintln!("usage: htmlang preview <file.hl>");
803+ process::exit(1);
804+ }
805+ let file = &args[2];
806+ let tmp_dir = env::temp_dir().join("htmlang-preview");
807+ let _ = fs::create_dir_all(&tmp_dir);
808+ let out_path = tmp_dir.join("preview.html");
809+ let (has_errors, _) = compile(file, true, false, false, Some(&out_path.to_string_lossy()), false, None);
810+ if has_errors {
811+ process::exit(1);
812+ }
813+ let url = format!("file://{}", out_path.display());
814+ let cmd = if cfg!(target_os = "macos") { "open" } else { "xdg-open" };
815+ let _ = std::process::Command::new(cmd).arg(&url).spawn();
816+ eprintln!("opened {}", out_path.display());
817+ return;
818+ }
819+
820+ // Handle "diff" subcommand
821+ if args.len() >= 2 && args[1] == "diff" {
822+ if args.len() < 4 {
823+ eprintln!("usage: htmlang diff <file1.hl> <file2.hl>");
824+ process::exit(1);
825+ }
826+ let file1 = &args[2];
827+ let file2 = &args[3];
828+ let compile_to_string = |path: &str| -> Result<String, String> {
829+ let input = fs::read_to_string(path).map_err(|e| format!("error: {}: {}", path, e))?;
830+ let base = Path::new(path).parent();
831+ let result = htmlang::parser::parse_with_base(&input, base);
832+ if result.diagnostics.iter().any(|d| d.severity == htmlang::parser::Severity::Error) {
833+ return Err(format!("error: {} has parse errors", path));
834+ }
835+ Ok(htmlang::codegen::generate_dev(&result.document))
836+ };
837+ let html1 = match compile_to_string(file1) {
838+ Ok(h) => h,
839+ Err(e) => { eprintln!("{}", e); process::exit(1); }
840+ };
841+ let html2 = match compile_to_string(file2) {
842+ Ok(h) => h,
843+ Err(e) => { eprintln!("{}", e); process::exit(1); }
844+ };
845+ let lines1: Vec<&str> = html1.lines().collect();
846+ let lines2: Vec<&str> = html2.lines().collect();
847+ let mut has_diff = false;
848+ let max_len = lines1.len().max(lines2.len());
849+ for i in 0..max_len {
850+ let l1 = lines1.get(i).unwrap_or(&"");
851+ let l2 = lines2.get(i).unwrap_or(&"");
852+ if l1 != l2 {
853+ has_diff = true;
854+ if !l1.is_empty() {
855+ println!("- {}", l1);
856+ }
857+ if !l2.is_empty() {
858+ println!("+ {}", l2);
859+ }
860+ }
861+ }
862+ if !has_diff {
863+ eprintln!("no differences");
864+ }
865+ return;
866+ }
867+
868+ // Handle "export" subcommand
869+ if args.len() >= 2 && args[1] == "export" {
870+ let mut src_dir = None;
871+ let mut out_file = None;
872+ let mut ei = 2;
873+ while ei < args.len() {
874+ match args[ei].as_str() {
875+ "-o" | "--output" => {
876+ ei += 1;
877+ out_file = args.get(ei).map(|s| s.as_str());
878+ }
879+ _ if src_dir.is_none() => src_dir = Some(args[ei].as_str()),
880+ _ => {
881+ eprintln!("unknown argument: {}", args[ei]);
882+ process::exit(1);
883+ }
884+ }
885+ ei += 1;
886+ }
887+ let src = src_dir.unwrap_or(".");
888+ let dir = Path::new(src);
889+ if !dir.is_dir() {
890+ eprintln!("error: '{}' is not a directory", src);
891+ process::exit(1);
892+ }
893+ let hl_files = collect_hl_files_recursive(dir);
894+ if hl_files.is_empty() {
895+ eprintln!("no .hl files found in {}", src);
896+ process::exit(1);
897+ }
898+ let zip_path = out_file.unwrap_or("site.zip");
899+ // Build to temp directory, then create zip
900+ let tmp_dir = env::temp_dir().join("htmlang-export");
901+ let _ = fs::remove_dir_all(&tmp_dir);
902+ let _ = fs::create_dir_all(&tmp_dir);
903+ let mut any_errors = false;
904+ for file in &hl_files {
905+ let rel = file.strip_prefix(dir).unwrap_or(file);
906+ let out_path = tmp_dir.join(rel).with_extension("html");
907+ if let Some(parent) = out_path.parent() {
908+ let _ = fs::create_dir_all(parent);
909+ }
910+ let path_str = file.to_string_lossy().to_string();
911+ let (has_errors, _) = compile(&path_str, false, false, false, Some(&out_path.to_string_lossy()), false, None);
912+ if has_errors { any_errors = true; }
913+ }
914+ // Copy non-.hl assets
915+ copy_non_hl_files(dir, &tmp_dir);
916+ if any_errors {
917+ eprintln!("export completed with errors");
918+ }
919+ // Create a simple tar-like zip (concatenated files with headers)
920+ // Since we can't use external crates, we'll create a self-contained directory listing
921+ let export_files = collect_all_files_recursive(&tmp_dir);
922+ let zip_file = std::fs::File::create(zip_path);
923+ match zip_file {
924+ Ok(mut f) => {
925+ use std::io::Write;
926+ // Write a simple archive format: filename\0length\0content
927+ for file in &export_files {
928+ let rel = file.strip_prefix(&tmp_dir).unwrap_or(file);
929+ if let Ok(data) = fs::read(file) {
930+ let _ = write!(f, "FILE:{}:{}:", rel.display(), data.len());
931+ let _ = f.write_all(&data);
932+ }
933+ }
934+ eprintln!("exported {} files to {}", export_files.len(), zip_path);
935+ }
936+ Err(e) => {
937+ eprintln!("error: {}: {}", zip_path, e);
938+ // Fallback: just report the built files in tmp_dir
939+ eprintln!("files available in {}", tmp_dir.display());
940+ }
941+ }
942+ return;
943+ }
944+
757945 // Handle "convert" subcommand
758946 if args.len() >= 2 && args[1] == "convert" {
759947 if args.len() < 3 {
src/parser.rs+108−6
@@ -703,13 +703,24 @@ impl Parser {
703703
704704 let list_str = substitute_vars(&list_str, &ctx.variables);
705705 track_var_refs(&list_str, &mut ctx.used_variables);
706− // Support range syntax: @each $i in 1..5
707− let items: Vec<String> = if let Some((start_s, end_s)) = list_str.split_once("..") {
708− if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.trim().parse::<i64>()) {
706+ // Support range syntax: @each $i in 1..5 or @each $i in 0..100 step 10
707+ let items: Vec<String> = if let Some((start_s, rest)) = list_str.split_once("..") {
708+ let (end_s, step) = if let Some((e, s)) = rest.split_once(" step ") {
709+ (e.trim(), s.trim().parse::<i64>().unwrap_or(1).max(1))
710+ } else {
711+ (rest.trim(), 1i64)
712+ };
713+ if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>()) {
709714 if start <= end {
710− (start..=end).map(|n| n.to_string()).collect()
715+ let mut items = Vec::new();
716+ let mut n = start;
717+ while n <= end { items.push(n.to_string()); n += step; }
718+ items
711719 } else {
712− (end..=start).rev().map(|n| n.to_string()).collect()
720+ let mut items = Vec::new();
721+ let mut n = start;
722+ while n >= end { items.push(n.to_string()); n -= step; }
723+ items
713724 }
714725 } else {
715726 list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
@@ -1257,6 +1268,7 @@ const KNOWN_ELEMENTS: &[&str] = &[
12571268 "btn", "ul", "dialog", "dl", "dt", "dd", "fieldset", "legend",
12581269 "picture", "source", "time", "mark", "kbd", "abbr", "datalist",
12591270 "iframe", "output", "canvas",
1271+ "grid", "stack", "spacer", "badge", "tooltip",
12601272];
12611273
12621274const KNOWN_DIRECTIVES: &[&str] = &[
@@ -1330,6 +1342,11 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
13301342 "output" => Ok(ElementKind::Output),
13311343 "canvas" => Ok(ElementKind::Canvas),
13321344 "ul" => Ok(ElementKind::List),
1345+ "grid" => Ok(ElementKind::Grid),
1346+ "stack" => Ok(ElementKind::Stack),
1347+ "spacer" => Ok(ElementKind::Spacer),
1348+ "badge" => Ok(ElementKind::Badge),
1349+ "tooltip" => Ok(ElementKind::Tooltip),
13331350 _ => {
13341351 let all_known: Vec<&str> = KNOWN_ELEMENTS
13351352 .iter()
@@ -1473,7 +1490,7 @@ const KNOWN_ATTRS: &[&str] = &[
14731490 "clip-path", "mix-blend-mode", "background-blend-mode", "writing-mode",
14741491 "column-count", "column-gap", "text-indent", "hyphens",
14751492 "flex-grow", "flex-shrink", "flex-basis", "isolation",
1476− "place-content", "background-image", "datetime",
1493+ "place-content", "background-image", "datetime", "direction",
14771494 // New CSS properties (batch 2)
14781495 "font-weight", "font-style", "text-wrap", "will-change", "touch-action",
14791496 "vertical-align", "contain", "content-visibility",
@@ -1973,6 +1990,18 @@ fn strip_all_prefixes(key: &str) -> &str {
19731990 .or_else(|| key.strip_prefix("last:"))
19741991 .or_else(|| key.strip_prefix("odd:"))
19751992 .or_else(|| key.strip_prefix("even:"))
1993+ .or_else(|| key.strip_prefix("selection:"))
1994+ .or_else(|| key.strip_prefix("before:"))
1995+ .or_else(|| key.strip_prefix("after:"))
1996+ .or_else(|| {
1997+ // Handle nth:EXPR: prefix (e.g., nth:3:background -> background)
1998+ if key.starts_with("nth:") {
1999+ let rest = &key[4..];
2000+ rest.find(':').map(|pos| &rest[pos + 1..])
2001+ } else {
2002+ None
2003+ }
2004+ })
19762005 .unwrap_or(key);
19772006 // Strip responsive prefixes
19782007 let key = key.strip_prefix("sm:")
@@ -1990,6 +2019,13 @@ fn strip_all_prefixes(key: &str) -> &str {
19902019 .or_else(|| key.strip_prefix("landscape:"))
19912020 .or_else(|| key.strip_prefix("portrait:"))
19922021 .unwrap_or(key);
2022+ // Strip container query prefixes (cq-sm:, cq-md:, etc.)
2023+ let key = key.strip_prefix("cq-sm:")
2024+ .or_else(|| key.strip_prefix("cq-md:"))
2025+ .or_else(|| key.strip_prefix("cq-lg:"))
2026+ .or_else(|| key.strip_prefix("cq-xl:"))
2027+ .or_else(|| key.strip_prefix("cq-2xl:"))
2028+ .unwrap_or(key);
19932029 key
19942030}
19952031
@@ -2063,6 +2099,11 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
20632099 ElementKind::Iframe => "@iframe",
20642100 ElementKind::Output => "@output",
20652101 ElementKind::Canvas => "@canvas",
2102+ ElementKind::Grid => "@grid",
2103+ ElementKind::Stack => "@stack",
2104+ ElementKind::Spacer => "@spacer",
2105+ ElementKind::Badge => "@badge",
2106+ ElementKind::Tooltip => "@tooltip",
20662107 }
20672108}
20682109
@@ -2077,6 +2118,7 @@ fn is_container(kind: &ElementKind) -> bool {
20772118 | ElementKind::Dialog | ElementKind::DefinitionList | ElementKind::DefinitionTerm
20782119 | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
20792120 | ElementKind::Iframe | ElementKind::Canvas
2121+ | ElementKind::Grid | ElementKind::Stack
20802122 )
20812123}
20822124
@@ -2231,11 +2273,71 @@ fn validate_tree(
22312273 }
22322274 }
22332275
2276+ // Contrast ratio check for hex color pairs
2277+ {
2278+ let bg_color = elem.attrs.iter()
2279+ .find(|a| strip_all_prefixes(&a.key) == "background")
2280+ .and_then(|a| a.value.as_deref());
2281+ let fg_color = elem.attrs.iter()
2282+ .find(|a| strip_all_prefixes(&a.key) == "color")
2283+ .and_then(|a| a.value.as_deref());
2284+ if let (Some(bg), Some(fg)) = (bg_color, fg_color) {
2285+ if let (Some(bg_rgb), Some(fg_rgb)) = (parse_hex_rgb(bg), parse_hex_rgb(fg)) {
2286+ let ratio = contrast_ratio(bg_rgb, fg_rgb);
2287+ if ratio < 4.5 {
2288+ diagnostics.push(Diagnostic {
2289+ line: elem.line_num,
2290+ message: format!(
2291+ "low contrast ratio {:.1}:1 between '{}' and '{}' (WCAG AA requires 4.5:1)",
2292+ ratio, fg, bg
2293+ ),
2294+ severity: Severity::Warning,
2295+ source_line: None,
2296+ });
2297+ }
2298+ }
2299+ }
2300+ }
2301+
22342302 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
22352303 }
22362304 }
22372305}
22382306
2307+fn parse_hex_rgb(s: &str) -> Option<(u8, u8, u8)> {
2308+ let s = s.strip_prefix('#')?;
2309+ match s.len() {
2310+ 3 => {
2311+ let r = u8::from_str_radix(&s[0..1], 16).ok()?;
2312+ let g = u8::from_str_radix(&s[1..2], 16).ok()?;
2313+ let b = u8::from_str_radix(&s[2..3], 16).ok()?;
2314+ Some((r * 17, g * 17, b * 17))
2315+ }
2316+ 6 => {
2317+ let r = u8::from_str_radix(&s[0..2], 16).ok()?;
2318+ let g = u8::from_str_radix(&s[2..4], 16).ok()?;
2319+ let b = u8::from_str_radix(&s[4..6], 16).ok()?;
2320+ Some((r, g, b))
2321+ }
2322+ _ => None,
2323+ }
2324+}
2325+
2326+fn relative_luminance(r: u8, g: u8, b: u8) -> f64 {
2327+ fn linearize(c: u8) -> f64 {
2328+ let s = c as f64 / 255.0;
2329+ if s <= 0.03928 { s / 12.92 } else { ((s + 0.055) / 1.055).powf(2.4) }
2330+ }
2331+ 0.2126 * linearize(r) + 0.7152 * linearize(g) + 0.0722 * linearize(b)
2332+}
2333+
2334+fn contrast_ratio(c1: (u8, u8, u8), c2: (u8, u8, u8)) -> f64 {
2335+ let l1 = relative_luminance(c1.0, c1.1, c1.2);
2336+ let l2 = relative_luminance(c2.0, c2.1, c2.2);
2337+ let (lighter, darker) = if l1 > l2 { (l1, l2) } else { (l2, l1) };
2338+ (lighter + 0.05) / (darker + 0.05)
2339+}
2340+
22392341// ---------------------------------------------------------------------------
22402342// Variable substitution
22412343// ---------------------------------------------------------------------------
tests/snapshots.rs+235
@@ -2135,3 +2135,238 @@ fn element_output() {
21352135 let output = compile("@output [for a b]\n 42");
21362136 assert!(output.contains("<output"), "should generate output tag: {}", output);
21372137}
2138+
2139+// =========================================================================
2140+// Batch 4: New elements, @each step, pseudo selectors, container queries
2141+// =========================================================================
2142+
2143+#[test]
2144+fn snapshot_new_elements_4() {
2145+ snapshot_test("new_elements_4");
2146+}
2147+
2148+#[test]
2149+fn snapshot_each_step() {
2150+ snapshot_test("each_step");
2151+}
2152+
2153+#[test]
2154+fn snapshot_selection_pseudo() {
2155+ snapshot_test("selection_pseudo");
2156+}
2157+
2158+#[test]
2159+fn snapshot_nth_pseudo() {
2160+ snapshot_test("nth_pseudo");
2161+}
2162+
2163+#[test]
2164+fn snapshot_direction_attr() {
2165+ snapshot_test("direction_attr");
2166+}
2167+
2168+// --- Grid element ---
2169+
2170+#[test]
2171+fn element_grid() {
2172+ let output = compile("@page T\n@grid [grid-cols 3, gap 16]\n @el\n @text A");
2173+ assert!(output.contains("display:grid"), "grid should have display:grid: {}", output);
2174+ assert!(output.contains("grid-template-columns:repeat(3,1fr)"), "should have 3 cols: {}", output);
2175+}
2176+
2177+// --- Stack element ---
2178+
2179+#[test]
2180+fn element_stack() {
2181+ let output = compile("@page T\n@stack [width 200, height 200]\n @el\n @text Layer");
2182+ assert!(output.contains("position:relative"), "stack should have position:relative: {}", output);
2183+}
2184+
2185+// --- Spacer element ---
2186+
2187+#[test]
2188+fn element_spacer() {
2189+ let output = compile("@page T\n@row\n @text Left\n @spacer\n @text Right");
2190+ assert!(output.contains("flex:1"), "spacer should have flex:1: {}", output);
2191+}
2192+
2193+// --- Badge element ---
2194+
2195+#[test]
2196+fn element_badge() {
2197+ let output = compile("@page T\n@badge [background red, color white] 3");
2198+ assert!(output.contains("<span"), "badge renders as span: {}", output);
2199+ assert!(output.contains("border-radius:9999px"), "badge should be pill-shaped: {}", output);
2200+ assert!(output.contains("3"), "badge content: {}", output);
2201+}
2202+
2203+// --- Tooltip element ---
2204+
2205+#[test]
2206+fn element_tooltip() {
2207+ let output = compile("@page T\n@tooltip Hover for info\n @text Help");
2208+ assert!(output.contains("title=\"Hover for info\""), "tooltip should have title attr: {}", output);
2209+ assert!(output.contains("cursor:help"), "tooltip should have cursor:help: {}", output);
2210+}
2211+
2212+// --- @each step ---
2213+
2214+#[test]
2215+fn each_step_basic() {
2216+ let output = compile("@each $i in 0..20 step 5\n @text $i");
2217+ assert!(output.contains(">0<"), "should include 0: {}", output);
2218+ assert!(output.contains(">5<"), "should include 5: {}", output);
2219+ assert!(output.contains(">10<"), "should include 10: {}", output);
2220+ assert!(output.contains(">15<"), "should include 15: {}", output);
2221+ assert!(output.contains(">20<"), "should include 20: {}", output);
2222+ assert!(!output.contains(">3<"), "should not include 3: {}", output);
2223+}
2224+
2225+#[test]
2226+fn each_step_reverse() {
2227+ let output = compile("@each $i in 10..1 step 3\n @text $i");
2228+ assert!(output.contains(">10<"), "should include 10: {}", output);
2229+ assert!(output.contains(">7<"), "should include 7: {}", output);
2230+ assert!(output.contains(">4<"), "should include 4: {}", output);
2231+ assert!(output.contains(">1<"), "should include 1: {}", output);
2232+}
2233+
2234+// --- selection: pseudo ---
2235+
2236+#[test]
2237+fn selection_pseudo_generates_css() {
2238+ let output = compile("@page T\n@text [selection:background blue, selection:color white] Select me");
2239+ assert!(output.contains("::selection"), "should generate ::selection: {}", output);
2240+ assert!(output.contains("background:blue"), "should have bg: {}", output);
2241+}
2242+
2243+// --- nth: pseudo ---
2244+
2245+#[test]
2246+fn nth_pseudo_generates_css() {
2247+ let output = compile("@page T\n@el [nth:3:background red]\n @text test");
2248+ assert!(output.contains(":nth-child(3)"), "should generate :nth-child(3): {}", output);
2249+ assert!(output.contains("background:red"), "should have bg: {}", output);
2250+}
2251+
2252+#[test]
2253+fn nth_pseudo_formula() {
2254+ let output = compile("@page T\n@el [nth:2n:background #eee]\n @text test");
2255+ assert!(output.contains(":nth-child(2n)"), "should generate :nth-child(2n): {}", output);
2256+}
2257+
2258+// --- container query prefix ---
2259+
2260+#[test]
2261+fn container_query_generates_css() {
2262+ let output = compile("@page T\n@el [container]\n @el [cq-sm:padding 20]\n @text test");
2263+ assert!(output.contains("@container(min-width:640px)"), "should generate container query: {}", output);
2264+ assert!(output.contains("padding:20px"), "should have padding: {}", output);
2265+}
2266+
2267+// --- direction attribute ---
2268+
2269+#[test]
2270+fn direction_rtl() {
2271+ let output = compile("@page T\n@el [direction rtl]\n @text RTL text");
2272+ assert!(output.contains("direction:rtl"), "should generate direction:rtl: {}", output);
2273+}
2274+
2275+// --- contrast checker ---
2276+
2277+#[test]
2278+fn warning_low_contrast() {
2279+ let diags = parse_diagnostics("@el [background #ffffff, color #cccccc]\n @text test");
2280+ assert!(
2281+ diags.iter().any(|d| d.message.contains("low contrast ratio")),
2282+ "should warn about low contrast, got: {:?}",
2283+ diags
2284+ );
2285+}
2286+
2287+#[test]
2288+fn no_warning_good_contrast() {
2289+ let diags = parse_diagnostics("@el [background #ffffff, color #000000]\n @text test");
2290+ assert!(
2291+ !diags.iter().any(|d| d.message.contains("low contrast ratio")),
2292+ "should not warn about good contrast, got: {:?}",
2293+ diags
2294+ );
2295+}
2296+
2297+// --- no warnings for new features ---
2298+
2299+#[test]
2300+fn no_warning_selection_prefix() {
2301+ let diags = parse_diagnostics("@el [selection:background blue]");
2302+ assert!(
2303+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
2304+ "selection: prefix should be recognized, got: {:?}",
2305+ diags
2306+ );
2307+}
2308+
2309+#[test]
2310+fn no_warning_nth_prefix() {
2311+ let diags = parse_diagnostics("@el [nth:3:background red]");
2312+ assert!(
2313+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
2314+ "nth: prefix should be recognized, got: {:?}",
2315+ diags
2316+ );
2317+}
2318+
2319+#[test]
2320+fn no_warning_cq_prefix() {
2321+ let diags = parse_diagnostics("@el [cq-sm:padding 20]");
2322+ assert!(
2323+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
2324+ "cq- prefix should be recognized, got: {:?}",
2325+ diags
2326+ );
2327+}
2328+
2329+#[test]
2330+fn no_warning_direction_attr() {
2331+ let diags = parse_diagnostics("@el [direction rtl]");
2332+ assert!(
2333+ !diags.iter().any(|d| d.message.contains("unknown attribute")),
2334+ "direction should be recognized, got: {:?}",
2335+ diags
2336+ );
2337+}
2338+
2339+#[test]
2340+fn no_warning_new_elements() {
2341+ // Grid, stack, spacer, badge, tooltip should all parse without errors
2342+ let diags = parse_diagnostics("@grid\n @text A");
2343+ assert!(
2344+ !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2345+ "grid should parse, got: {:?}",
2346+ diags
2347+ );
2348+ let diags = parse_diagnostics("@stack\n @text A");
2349+ assert!(
2350+ !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2351+ "stack should parse, got: {:?}",
2352+ diags
2353+ );
2354+ let diags = parse_diagnostics("@row\n @spacer");
2355+ assert!(
2356+ !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2357+ "spacer should parse, got: {:?}",
2358+ diags
2359+ );
2360+ let diags = parse_diagnostics("@badge [background red] 5");
2361+ assert!(
2362+ !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2363+ "badge should parse, got: {:?}",
2364+ diags
2365+ );
2366+ let diags = parse_diagnostics("@tooltip Help text\n @text Hover");
2367+ assert!(
2368+ !diags.iter().any(|d| d.severity == htmlang::parser::Severity::Error),
2369+ "tooltip should parse, got: {:?}",
2370+ diags
2371+ );
2372+}
tests/snapshots/container_queries.hl+5
@@ -3,3 +3,8 @@
33@el [container, container-name sidebar]
44 @el [width 200]
55 @text Sidebar content
6+
7+-- Container query responsive children
8+@el [container, max-width 800, padding 20]
9+ @el [background #f0f0f0, padding 16, cq-sm:padding 24, cq-lg:padding 32]
10+ @text Responsive to container width
tests/snapshots/direction_attr.hlnew file+9
@@ -0,0 +1,9 @@
1+@page Direction
2+
3+-- RTL text direction
4+@el [direction rtl, padding 20]
5+ @text This text should be right-to-left
6+
7+-- LTR explicit
8+@el [direction ltr, padding 20]
9+ @text This text is explicitly left-to-right
tests/snapshots/each_step.hlnew file+17
@@ -0,0 +1,17 @@
1+@page Each Step
2+
3+-- Basic step
4+@column [spacing 4]
5+ @each $i in 0..20 step 5
6+ @text Value: $i
7+
8+-- Step with large range
9+@row [spacing 8]
10+ @each $n in 0..100 step 25
11+ @el [width 50, height 50, background #3b82f6, rounded 4]
12+ @text [color white, size 12] $n
13+
14+-- Reverse range with step
15+@column [spacing 4]
16+ @each $i in 10..1 step 2
17+ @text Countdown: $i
tests/snapshots/new_elements_4.hlnew file+35
@@ -0,0 +1,35 @@
1+@page New Elements 4
2+
3+-- Grid element
4+@grid [grid-cols 3, gap 16]
5+ @el [background #f0f0f0, padding 16]
6+ @text First
7+ @el [background #e0e0e0, padding 16]
8+ @text Second
9+ @el [background #d0d0d0, padding 16]
10+ @text Third
11+
12+-- Stack element (position: relative container)
13+@stack [width 200, height 200]
14+ @el [position absolute, inset 0, background #3b82f6]
15+ @el [position absolute, bottom 10, right 10]
16+ @text [color white] Overlay
17+
18+-- Spacer element
19+@row [height 100]
20+ @text Left
21+ @spacer
22+ @text Right
23+
24+-- Badge element
25+@row [spacing 8]
26+ @badge [background #ef4444, color white] 3
27+ @badge [background #22c55e, color white] New
28+ @badge [background #3b82f6, color white] Pro
29+
30+-- Tooltip element
31+@row [spacing 16]
32+ @tooltip Hover me for info
33+ @text [underline] Helpful text
34+ @tooltip This is a tooltip message
35+ @text [bold] Important
tests/snapshots/nth_pseudo.hlnew file+13
@@ -0,0 +1,13 @@
1+@page Nth Pseudo
2+
3+-- nth-child styling
4+@column [spacing 4]
5+ @each $i in 1..5
6+ @el [padding 8, nth:3:background #3b82f6, nth:3:color white]
7+ @text Item $i
8+
9+-- nth-child with formula
10+@row [spacing 4, wrap]
11+ @each $i in 1..8
12+ @el [padding 12, background #f0f0f0, nth:2n:background #e0e0e0]
13+ @text $i
tests/snapshots/selection_pseudo.hlnew file+10
@@ -0,0 +1,10 @@
1+@page Selection Pseudo
2+
3+-- Selection styling
4+@paragraph [selection:background #3b82f6, selection:color white]
5+ Select this text to see custom selection colors.
6+
7+-- Multiple elements with selection
8+@column [spacing 16]
9+ @text [selection:background #ef4444, selection:color white] Red selection
10+ @text [selection:background #22c55e, selection:color black] Green selection