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
5c61488iko
src/ast.rs+6
@@ -110,6 +110,12 @@ pub enum ElementKind {
110 Iframe,
110 Iframe,
111 Output,
111 Output,
112 Canvas,
112 Canvas,
113+ // New convenience elements
114+ Grid,
115+ Stack,
116+ Spacer,
117+ Badge,
118+ Tooltip,
113}
119}
114
120
115#[derive(Debug, Clone)]
121#[derive(Debug, Clone)]
src/bin/htmlang-lsp.rs+293−4
@@ -67,7 +67,15 @@ impl LanguageServer for Backend {
67 work_done_progress_options: Default::default(),
67 work_done_progress_options: Default::default(),
68 })),
68 })),
69 document_symbol_provider: Some(OneOf::Left(true)),
69 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+ )),
71 color_provider: Some(ColorProviderCapability::Simple(true)),
79 color_provider: Some(ColorProviderCapability::Simple(true)),
72 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
80 folding_range_provider: Some(FoldingRangeProviderCapability::Simple(true)),
73 semantic_tokens_provider: Some(
81 semantic_tokens_provider: Some(
@@ -485,6 +493,11 @@ fn completions(text: &str, position: Position) -> Vec<CompletionItem> {
485 }
493 }
486 }
494 }
487
495
496+ // Color value completions after color-related attributes
497+ if let Some(colors) = color_value_completions(before, edit_range) {
498+ return colors;
499+ }
500+
488 return attr_completions(edit_range);
501 return attr_completions(edit_range);
489 }
502 }
490
503
@@ -1100,6 +1113,94 @@ fn state_attr_completions(prefix: &str, range: Range) -> Vec<CompletionItem> {
1100 .collect()
1113 .collect()
1101}
1114}
1102
1115
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+
1103fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1204fn variable_completions(text: &str, range: Range) -> Vec<CompletionItem> {
1104 let mut items = Vec::new();
1205 let mut items = Vec::new();
1105
1206
@@ -2018,7 +2119,7 @@ fn document_symbols(text: &str) -> Vec<SymbolInformation> {
2018
2119
2019fn code_actions(
2120fn code_actions(
2020 text: &str,
2121 text: &str,
2021− _range: &Range,
2122+ selection: &Range,
2022 diagnostics: &[Diagnostic],
2123 diagnostics: &[Diagnostic],
2023 uri: &Url,
2124 uri: &Url,
2024) -> Vec<CodeActionOrCommand> {
2125) -> Vec<CodeActionOrCommand> {
@@ -2087,6 +2188,9 @@ fn code_actions(
2087 if let Some(source_line) = lines.get(line) {
2188 if let Some(source_line) = lines.get(line) {
2088 let trimmed = source_line.trim_start();
2189 let trimmed = source_line.trim_start();
2089 if trimmed.starts_with("@let ") {
2190 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("?");
2090 let edit = TextEdit {
2194 let edit = TextEdit {
2091 range: Range::new(
2195 range: Range::new(
2092 Position::new(diag.range.start.line, 0),
2196 Position::new(diag.range.start.line, 0),
@@ -2097,7 +2201,7 @@ fn code_actions(
2097 let mut changes = HashMap::new();
2201 let mut changes = HashMap::new();
2098 changes.insert(uri.clone(), vec![edit]);
2202 changes.insert(uri.clone(), vec![edit]);
2099 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2203 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2100− title: "Remove unused variable".into(),
2204+ title: format!("Remove unused variable '${}'", var_name),
2101 kind: Some(CodeActionKind::QUICKFIX),
2205 kind: Some(CodeActionKind::QUICKFIX),
2102 diagnostics: Some(vec![diag.clone()]),
2206 diagnostics: Some(vec![diag.clone()]),
2103 edit: Some(WorkspaceEdit {
2207 edit: Some(WorkspaceEdit {
@@ -2117,6 +2221,12 @@ fn code_actions(
2117 if let Some(source_line) = lines.get(line) {
2221 if let Some(source_line) = lines.get(line) {
2118 let trimmed = source_line.trim_start();
2222 let trimmed = source_line.trim_start();
2119 if trimmed.starts_with("@define ") {
2223 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("?");
2120 let edit = TextEdit {
2230 let edit = TextEdit {
2121 range: Range::new(
2231 range: Range::new(
2122 Position::new(diag.range.start.line, 0),
2232 Position::new(diag.range.start.line, 0),
@@ -2127,7 +2237,57 @@ fn code_actions(
2127 let mut changes = HashMap::new();
2237 let mut changes = HashMap::new();
2128 changes.insert(uri.clone(), vec![edit]);
2238 changes.insert(uri.clone(), vec![edit]);
2129 actions.push(CodeActionOrCommand::CodeAction(CodeAction {
2239 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),
2131 kind: Some(CodeActionKind::QUICKFIX),
2291 kind: Some(CodeActionKind::QUICKFIX),
2132 diagnostics: Some(vec![diag.clone()]),
2292 diagnostics: Some(vec![diag.clone()]),
2133 edit: Some(WorkspaceEdit {
2293 edit: Some(WorkspaceEdit {
@@ -2193,6 +2353,135 @@ fn code_actions(
2193 }
2353 }
2194 }
2354 }
2195 }
2355 }
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+ }
2196 }
2485 }
2197
2486
2198 actions
2487 actions
src/codegen.rs+135−10
@@ -30,6 +30,8 @@ struct StyleEntry {
30 motion_reduce: String,
30 motion_reduce: String,
31 landscape: String,
31 landscape: String,
32 portrait: String,
32 portrait: String,
33+ /// Container query overrides: (breakpoint_prefix, css)
34+ container: Vec<(String, String)>,
33}
35}
34
36
35struct StyleCollector {
37struct StyleCollector {
@@ -57,6 +59,7 @@ impl StyleCollector {
57 motion_reduce: String,
59 motion_reduce: String,
58 landscape: String,
60 landscape: String,
59 portrait: String,
61 portrait: String,
62+ container: Vec<(String, String)>,
60 ) -> Option<String> {
63 ) -> Option<String> {
61 if base.is_empty()
64 if base.is_empty()
62 && pseudo.is_empty()
65 && pseudo.is_empty()
@@ -67,6 +70,7 @@ impl StyleCollector {
67 && motion_reduce.is_empty()
70 && motion_reduce.is_empty()
68 && landscape.is_empty()
71 && landscape.is_empty()
69 && portrait.is_empty()
72 && portrait.is_empty()
73+ && container.is_empty()
70 {
74 {
71 return None;
75 return None;
72 }
76 }
@@ -80,7 +84,12 @@ impl StyleCollector {
80 .map(|(bp, css)| format!("{}={}", bp, css))
84 .map(|(bp, css)| format!("{}={}", bp, css))
81 .collect::<Vec<_>>()
85 .collect::<Vec<_>>()
82 .join("|");
86 .join("|");
87+ let cq_key: String = container
88+ .iter()
89+ .map(|(bp, css)| format!("{}={}", bp, css))
90+ .collect::<Vec<_>>()
91+ .join("|");
83− let key = format!("{}|{}|{}|{}|{}|{}|{}|{}|{}", base, pseudo_key, resp_key, dark, print, motion_safe, motion_reduce, landscape, portrait);
92+ let key = format!("{}|{}|{}|{}|{}|{}|{}|{}|{}|{}", base, pseudo_key, resp_key, dark, print, motion_safe, motion_reduce, landscape, portrait, cq_key);
84 if let Some(&idx) = self.index.get(&key) {
93 if let Some(&idx) = self.index.get(&key) {
85 return Some(self.entries[idx].class_name.clone());
94 return Some(self.entries[idx].class_name.clone());
86 }
95 }
@@ -97,6 +106,7 @@ impl StyleCollector {
97 motion_reduce,
106 motion_reduce,
98 landscape,
107 landscape,
99 portrait,
108 portrait,
109+ container,
100 });
110 });
101 self.index.insert(key, idx);
111 self.index.insert(key, idx);
102 Some(name)
112 Some(name)
@@ -261,6 +271,35 @@ impl StyleCollector {
261 }
271 }
262 }
272 }
263
273
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+
264 css
303 css
265 }
304 }
266}
305}
@@ -535,7 +574,7 @@ fn generate_node(
535 | Some(ElementKind::Blockquote)
574 | Some(ElementKind::Blockquote)
536 | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
575 | Some(ElementKind::Dialog) | Some(ElementKind::DefinitionList)
537 | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
576 | Some(ElementKind::DefinitionDescription) | Some(ElementKind::Fieldset)
538− | Some(ElementKind::Datalist)
577+ | Some(ElementKind::Datalist) | Some(ElementKind::Grid) | Some(ElementKind::Stack)
539 );
578 );
540 if needs_wrap {
579 if needs_wrap {
541 out.push_str(&ctx.indent());
580 out.push_str(&ctx.indent());
@@ -616,7 +655,7 @@ fn generate_element(
616 ctx: &mut GenContext,
655 ctx: &mut GenContext,
617) {
656) {
618 // Self-closing elements
657 // 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) {
620 generate_self_closing(elem, parent_kind, out, styles, ctx);
659 generate_self_closing(elem, parent_kind, out, styles, ctx);
621 return;
660 return;
622 }
661 }
@@ -635,7 +674,8 @@ fn generate_element(
635 }
674 }
636
675
637 let tag = match &elem.kind {
676 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",
639 ElementKind::Text => "span",
679 ElementKind::Text => "span",
640 ElementKind::Paragraph => "p",
680 ElementKind::Paragraph => "p",
641 ElementKind::Link => "a",
681 ElementKind::Link => "a",
@@ -695,9 +735,11 @@ fn generate_element(
695 ElementKind::Iframe => "iframe",
735 ElementKind::Iframe => "iframe",
696 ElementKind::Output => "output",
736 ElementKind::Output => "output",
697 ElementKind::Canvas => "canvas",
737 ElementKind::Canvas => "canvas",
738+ ElementKind::Badge => "span",
739+ ElementKind::Tooltip => "span",
698 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
740 ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
699 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
741 | ElementKind::Children | ElementKind::Slot(_) | ElementKind::Fragment
700− | ElementKind::Source => unreachable!(),
742+ | ElementKind::Source | ElementKind::Spacer => unreachable!(),
701 };
743 };
702
744
703 let kind_label = match elem.kind {
745 let kind_label = match elem.kind {
@@ -754,6 +796,10 @@ fn generate_element(
754 ElementKind::Iframe => "iframe",
796 ElementKind::Iframe => "iframe",
755 ElementKind::Output => "output",
797 ElementKind::Output => "output",
756 ElementKind::Canvas => "canvas",
798 ElementKind::Canvas => "canvas",
799+ ElementKind::Grid => "grid",
800+ ElementKind::Stack => "stack",
801+ ElementKind::Badge => "badge",
802+ ElementKind::Tooltip => "tooltip",
757 _ => "",
803 _ => "",
758 };
804 };
759
805
@@ -804,6 +850,15 @@ fn generate_element(
804 }
850 }
805 }
851 }
806
852
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+
807 // Time datetime
862 // Time datetime
808 if elem.kind == ElementKind::Time {
863 if elem.kind == ElementKind::Time {
809 if let Some(dt) = elem.attrs.iter().find(|a| a.key == "datetime") {
864 if let Some(dt) = elem.attrs.iter().find(|a| a.key == "datetime") {
@@ -855,6 +910,8 @@ fn generate_element(
855 | ElementKind::Abbr
910 | ElementKind::Abbr
856 | ElementKind::Time
911 | ElementKind::Time
857 | ElementKind::DefinitionDescription
912 | ElementKind::DefinitionDescription
913+ | ElementKind::Badge
914+ | ElementKind::Tooltip
858 ) {
915 ) {
859 if let Some(text) = &elem.argument {
916 if let Some(text) = &elem.argument {
860 out.push_str(&html_escape(text));
917 out.push_str(&html_escape(text));
@@ -894,6 +951,7 @@ fn generate_self_closing(
894 ElementKind::Input => ("input", "input"),
951 ElementKind::Input => ("input", "input"),
895 ElementKind::HorizontalRule => ("hr", "hr"),
952 ElementKind::HorizontalRule => ("hr", "hr"),
896 ElementKind::Source => ("source", "source"),
953 ElementKind::Source => ("source", "source"),
954+ ElementKind::Spacer => ("div", "spacer"),
897 _ => unreachable!(),
955 _ => unreachable!(),
898 };
956 };
899
957
@@ -905,12 +963,34 @@ fn generate_self_closing(
905 out.push('<');
963 out.push('<');
906 out.push_str(tag);
964 out.push_str(tag);
907
965
908− // Image src
966+ // Image src (with optional non-SVG base64 inlining)
909 if elem.kind == ElementKind::Image {
967 if elem.kind == ElementKind::Image {
910 let src = elem.argument.as_deref().unwrap_or("");
968 let src = elem.argument.as_deref().unwrap_or("");
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,");
911− out.push_str(" src=\"");
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 {
912− out.push_str(&html_escape(src));
990+ out.push_str(" src=\"");
991+ out.push_str(&html_escape(src));
913− out.push('"');
992+ out.push('"');
993+ }
914 }
994 }
915
995
916 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
996 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
@@ -996,6 +1076,28 @@ fn compute_class(
996 }
1076 }
997 }
1077 }
998
1078
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+
999 // Collect responsive overrides
1101 // Collect responsive overrides
1000 let mut responsive = Vec::new();
1102 let mut responsive = Vec::new();
1001 for &(bp_name, _) in BREAKPOINTS {
1103 for &(bp_name, _) in BREAKPOINTS {
@@ -1006,6 +1108,16 @@ fn compute_class(
1006 }
1108 }
1007 }
1109 }
1008
1110
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+
1009 let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
1121 let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
1010 let print = attrs_to_css(attrs, "print:", kind, parent_kind);
1122 let print = attrs_to_css(attrs, "print:", kind, parent_kind);
1011 let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind);
1123 let motion_safe = attrs_to_css(attrs, "motion-safe:", kind, parent_kind);
@@ -1013,7 +1125,7 @@ fn compute_class(
1013 let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind);
1125 let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind);
1014 let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind);
1126 let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind);
1015
1127
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)
1017}
1129}
1018
1130
1019fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
1131fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
@@ -1059,14 +1171,18 @@ const PSEUDO_PREFIXES: &[(&str, &str)] = &[
1059 ("even:", ":nth-child(even)"),
1171 ("even:", ":nth-child(even)"),
1060 ("before:", "::before"),
1172 ("before:", "::before"),
1061 ("after:", "::after"),
1173 ("after:", "::after"),
1174+ ("selection:", "::selection"),
1062];
1175];
1063const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
1176const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:", "2xl:"];
1064const MEDIA_PREFIXES: &[&str] = &["dark:", "print:", "motion-safe:", "motion-reduce:", "landscape:", "portrait:"];
1177const 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:"];
1065
1179
1066fn is_prefixed_attr(key: &str) -> bool {
1180fn is_prefixed_attr(key: &str) -> bool {
1067 PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
1181 PSEUDO_PREFIXES.iter().any(|&(p, _)| key.starts_with(p))
1068 || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
1182 || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
1069 || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
1183 || 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:")
1070}
1186}
1071
1187
1072fn attrs_to_css(
1188fn attrs_to_css(
@@ -1109,6 +1225,11 @@ fn attrs_to_css(
1109 ElementKind::DefinitionDescription => css.push_str("margin:0;display:flex;flex-direction:column;"),
1225 ElementKind::DefinitionDescription => css.push_str("margin:0;display:flex;flex-direction:column;"),
1110 ElementKind::Fieldset => css.push_str("display:flex;flex-direction:column;border:1px solid currentColor;padding:0.5em;margin:0;"),
1226 ElementKind::Fieldset => css.push_str("display:flex;flex-direction:column;border:1px solid currentColor;padding:0.5em;margin:0;"),
1111 ElementKind::Kbd => css.push_str("font-family:ui-monospace,monospace;"),
1227 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;"),
1112 _ => {}
1233 _ => {}
1113 }
1234 }
1114 }
1235 }
@@ -1839,6 +1960,10 @@ fn attrs_to_css(
1839 "content-visibility" => {
1960 "content-visibility" => {
1840 if let Some(v) = val { push_css(&mut css, "content-visibility", v); }
1961 if let Some(v) = val { push_css(&mut css, "content-visibility", v); }
1841 }
1962 }
1963+ "direction" => {
1964+ if let Some(v) = val { push_css(&mut css, "direction", v); }
1965+ }
1966+
1842 "content" => {
1967 "content" => {
1843 if let Some(v) = val {
1968 if let Some(v) = val {
1844 // Wrap in quotes if not already quoted and not a CSS keyword
1969 // Wrap in quotes if not already quoted and not a CSS keyword
src/main.rs+189−1
@@ -209,6 +209,9 @@ Commands:
209 sitemap <dir> Generate sitemap.xml from .hl files
209 sitemap <dir> Generate sitemap.xml from .hl files
210 lint <file.hl | dir> Stricter lint checks (accessibility, nesting, etc.)
210 lint <file.hl | dir> Stricter lint checks (accessibility, nesting, etc.)
211 stats <file.hl | dir> Show file statistics (elements, CSS rules, colors)
211 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
212
215
213Options:
216Options:
214 -w, --watch Watch for changes and recompile
217 -w, --watch Watch for changes and recompile
@@ -305,6 +308,25 @@ fn collect_hl_recursive_inner(dir: &Path, files: &mut Vec<PathBuf>) {
305 }
308 }
306}
309}
307
310
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+
308fn generate_sitemap(dir: &str, base_url: &str) {
330fn generate_sitemap(dir: &str, base_url: &str) {
309 let dir = Path::new(dir);
331 let dir = Path::new(dir);
310 let hl_files = collect_hl_files_recursive(dir);
332 let hl_files = collect_hl_files_recursive(dir);
@@ -589,12 +611,28 @@ fn main() {
589 })
611 })
590 }).collect();
612 }).collect();
591
613
592− // Compile files in parallel
614+ // Compile files in parallel (with incremental skip for unchanged files)
593 let any_errors = std::sync::atomic::AtomicBool::new(false);
615 let any_errors = std::sync::atomic::AtomicBool::new(false);
616+ let skipped = std::sync::atomic::AtomicUsize::new(0);
594 std::thread::scope(|s| {
617 std::thread::scope(|s| {
595 for (file, effective_out) in hl_files.iter().zip(effective_outs.iter()) {
618 for (file, effective_out) in hl_files.iter().zip(effective_outs.iter()) {
596 let any_errors = &any_errors;
619 let any_errors = &any_errors;
620+ let skipped = &skipped;
597 s.spawn(move || {
621 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+ }
598 let path_str = file.to_string_lossy().to_string();
636 let path_str = file.to_string_lossy().to_string();
599 let (has_errors, _) = compile(&path_str, false, false, false, effective_out.as_deref(), false, None);
637 let (has_errors, _) = compile(&path_str, false, false, false, effective_out.as_deref(), false, None);
600 if has_errors {
638 if has_errors {
@@ -603,6 +641,10 @@ fn main() {
603 });
641 });
604 }
642 }
605 });
643 });
644+ let skipped_count = skipped.load(std::sync::atomic::Ordering::Relaxed);
645+ if skipped_count > 0 {
646+ eprintln!("skipped {} unchanged files", skipped_count);
647+ }
606 if any_errors.load(std::sync::atomic::Ordering::Relaxed) { process::exit(1); }
648 if any_errors.load(std::sync::atomic::Ordering::Relaxed) { process::exit(1); }
607
649
608 // Copy non-.hl static assets to output directory
650 // Copy non-.hl static assets to output directory
@@ -754,6 +796,152 @@ fn main() {
754 return;
796 return;
755 }
797 }
756
798
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+
757 // Handle "convert" subcommand
945 // Handle "convert" subcommand
758 if args.len() >= 2 && args[1] == "convert" {
946 if args.len() >= 2 && args[1] == "convert" {
759 if args.len() < 3 {
947 if args.len() < 3 {
src/parser.rs+108−6
@@ -703,13 +703,24 @@ impl Parser {
703
703
704 let list_str = substitute_vars(&list_str, &ctx.variables);
704 let list_str = substitute_vars(&list_str, &ctx.variables);
705 track_var_refs(&list_str, &mut ctx.used_variables);
705 track_var_refs(&list_str, &mut ctx.used_variables);
706− // Support range syntax: @each $i in 1..5
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, end_s)) = list_str.split_once("..") {
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+ };
708− if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.trim().parse::<i64>()) {
713+ if let (Ok(start), Ok(end)) = (start_s.trim().parse::<i64>(), end_s.parse::<i64>()) {
709 if start <= end {
714 if start <= end {
715+ let mut items = Vec::new();
716+ let mut n = start;
710− (start..=end).map(|n| n.to_string()).collect()
717+ while n <= end { items.push(n.to_string()); n += step; }
718+ items
711 } else {
719 } else {
720+ let mut items = Vec::new();
721+ let mut n = start;
712− (end..=start).rev().map(|n| n.to_string()).collect()
722+ while n >= end { items.push(n.to_string()); n -= step; }
723+ items
713 }
724 }
714 } else {
725 } else {
715 list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
726 list_str.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect()
@@ -1257,6 +1268,7 @@ const KNOWN_ELEMENTS: &[&str] = &[
1257 "btn", "ul", "dialog", "dl", "dt", "dd", "fieldset", "legend",
1268 "btn", "ul", "dialog", "dl", "dt", "dd", "fieldset", "legend",
1258 "picture", "source", "time", "mark", "kbd", "abbr", "datalist",
1269 "picture", "source", "time", "mark", "kbd", "abbr", "datalist",
1259 "iframe", "output", "canvas",
1270 "iframe", "output", "canvas",
1271+ "grid", "stack", "spacer", "badge", "tooltip",
1260];
1272];
1261
1273
1262const KNOWN_DIRECTIVES: &[&str] = &[
1274const KNOWN_DIRECTIVES: &[&str] = &[
@@ -1330,6 +1342,11 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
1330 "output" => Ok(ElementKind::Output),
1342 "output" => Ok(ElementKind::Output),
1331 "canvas" => Ok(ElementKind::Canvas),
1343 "canvas" => Ok(ElementKind::Canvas),
1332 "ul" => Ok(ElementKind::List),
1344 "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),
1333 _ => {
1350 _ => {
1334 let all_known: Vec<&str> = KNOWN_ELEMENTS
1351 let all_known: Vec<&str> = KNOWN_ELEMENTS
1335 .iter()
1352 .iter()
@@ -1473,7 +1490,7 @@ const KNOWN_ATTRS: &[&str] = &[
1473 "clip-path", "mix-blend-mode", "background-blend-mode", "writing-mode",
1490 "clip-path", "mix-blend-mode", "background-blend-mode", "writing-mode",
1474 "column-count", "column-gap", "text-indent", "hyphens",
1491 "column-count", "column-gap", "text-indent", "hyphens",
1475 "flex-grow", "flex-shrink", "flex-basis", "isolation",
1492 "flex-grow", "flex-shrink", "flex-basis", "isolation",
1476− "place-content", "background-image", "datetime",
1493+ "place-content", "background-image", "datetime", "direction",
1477 // New CSS properties (batch 2)
1494 // New CSS properties (batch 2)
1478 "font-weight", "font-style", "text-wrap", "will-change", "touch-action",
1495 "font-weight", "font-style", "text-wrap", "will-change", "touch-action",
1479 "vertical-align", "contain", "content-visibility",
1496 "vertical-align", "contain", "content-visibility",
@@ -1973,6 +1990,18 @@ fn strip_all_prefixes(key: &str) -> &str {
1973 .or_else(|| key.strip_prefix("last:"))
1990 .or_else(|| key.strip_prefix("last:"))
1974 .or_else(|| key.strip_prefix("odd:"))
1991 .or_else(|| key.strip_prefix("odd:"))
1975 .or_else(|| key.strip_prefix("even:"))
1992 .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+ })
1976 .unwrap_or(key);
2005 .unwrap_or(key);
1977 // Strip responsive prefixes
2006 // Strip responsive prefixes
1978 let key = key.strip_prefix("sm:")
2007 let key = key.strip_prefix("sm:")
@@ -1990,6 +2019,13 @@ fn strip_all_prefixes(key: &str) -> &str {
1990 .or_else(|| key.strip_prefix("landscape:"))
2019 .or_else(|| key.strip_prefix("landscape:"))
1991 .or_else(|| key.strip_prefix("portrait:"))
2020 .or_else(|| key.strip_prefix("portrait:"))
1992 .unwrap_or(key);
2021 .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);
1993 key
2029 key
1994}
2030}
1995
2031
@@ -2063,6 +2099,11 @@ fn element_kind_name(kind: &ElementKind) -> &'static str {
2063 ElementKind::Iframe => "@iframe",
2099 ElementKind::Iframe => "@iframe",
2064 ElementKind::Output => "@output",
2100 ElementKind::Output => "@output",
2065 ElementKind::Canvas => "@canvas",
2101 ElementKind::Canvas => "@canvas",
2102+ ElementKind::Grid => "@grid",
2103+ ElementKind::Stack => "@stack",
2104+ ElementKind::Spacer => "@spacer",
2105+ ElementKind::Badge => "@badge",
2106+ ElementKind::Tooltip => "@tooltip",
2066 }
2107 }
2067}
2108}
2068
2109
@@ -2077,6 +2118,7 @@ fn is_container(kind: &ElementKind) -> bool {
2077 | ElementKind::Dialog | ElementKind::DefinitionList | ElementKind::DefinitionTerm
2118 | ElementKind::Dialog | ElementKind::DefinitionList | ElementKind::DefinitionTerm
2078 | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
2119 | ElementKind::DefinitionDescription | ElementKind::Fieldset | ElementKind::Datalist
2079 | ElementKind::Iframe | ElementKind::Canvas
2120 | ElementKind::Iframe | ElementKind::Canvas
2121+ | ElementKind::Grid | ElementKind::Stack
2080 )
2122 )
2081}
2123}
2082
2124
@@ -2231,11 +2273,71 @@ fn validate_tree(
2231 }
2273 }
2232 }
2274 }
2233
2275
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+
2234 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
2302 validate_tree(&elem.children, Some(&elem.kind), diagnostics);
2235 }
2303 }
2236 }
2304 }
2237}
2305}
2238
2306
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+
2239// ---------------------------------------------------------------------------
2341// ---------------------------------------------------------------------------
2240// Variable substitution
2342// Variable substitution
2241// ---------------------------------------------------------------------------
2343// ---------------------------------------------------------------------------
tests/snapshots.rs+235
@@ -2135,3 +2135,238 @@ fn element_output() {
2135 let output = compile("@output [for a b]\n 42");
2135 let output = compile("@output [for a b]\n 42");
2136 assert!(output.contains("<output"), "should generate output tag: {}", output);
2136 assert!(output.contains("<output"), "should generate output tag: {}", output);
2137}
2137}
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 @@
3@el [container, container-name sidebar]
3@el [container, container-name sidebar]
4 @el [width 200]
4 @el [width 200]
5 @text Sidebar content
5 @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