on master help sign in
Add responsive breakpoints, animations, CSS vars, dev mode, LSP definition/rename, and snapshot tests

- Implement media queries with sm:/md:/lg:/xl: prefixes generating @media rules - Add @keyframes directive and animation attribute support - Support CSS custom properties via @let --name value with :root emission - Add --dev flag for pretty-printed HTML with element source comments - Implement LSP go-to-definition and rename refactoring for variables/defines/functions - Add typo suggestions using Levenshtein distance for unknown attributes/elements - Validate attribute values (numeric ranges, keywords, z-index integers) - Create 15-test snapshot suite covering all language features

commit1d60aeedcebe43aa2798e5ae0a7dad33008af383 authoriko <mail@iko.soy> dateTue 14 Apr 2026, 15:00 (6 months ago) parent2b496b9
21 files changed+1019−37
src/ast.rs+2 src/bin/htmlang-lsp.rs+355 src/codegen.rs+225−24 src/main.rs+11−5 src/parser.rs+195−8 tests/snapshots.rs+114 tests/snapshots/alignment.hl+12 tests/snapshots/animations.hl+13 tests/snapshots/attributes.hl+8 tests/snapshots/basic_elements.hl+11 tests/snapshots/chain_operator.hl+4 tests/snapshots/css_vars.hl+10 tests/snapshots/functions.hl+14 tests/snapshots/implicit_el.hl+7 tests/snapshots/inline_text.hl+5 tests/snapshots/no_page.hl+2 tests/snapshots/pseudo_states.hl+3 tests/snapshots/raw_html.hl+5 tests/snapshots/responsive.hl+5 tests/snapshots/sizing.hl+8 tests/snapshots/variables_defines.hl+10
src/ast.rs+2
@@ -6,6 +6,8 @@ pub struct Document {
66 pub page_title: Option<String>,
77 pub variables: HashMap<String, String>,
88 pub defines: HashMap<String, Vec<Attribute>>,
9+ pub keyframes: Vec<(String, String)>,
10+ pub css_vars: Vec<(String, String)>,
911 pub nodes: Vec<Node>,
1012}
1113
src/bin/htmlang-lsp.rs+355
@@ -61,6 +61,11 @@ impl LanguageServer for Backend {
6161 ..Default::default()
6262 }),
6363 hover_provider: Some(HoverProviderCapability::Simple(true)),
64+ definition_provider: Some(OneOf::Left(true)),
65+ rename_provider: Some(OneOf::Right(RenameOptions {
66+ prepare_provider: Some(true),
67+ work_done_progress_options: Default::default(),
68+ })),
6469 ..Default::default()
6570 },
6671 ..Default::default()
@@ -127,6 +132,52 @@ impl LanguageServer for Backend {
127132
128133 Ok(hover_at(&text, pos))
129134 }
135+
136+ async fn goto_definition(
137+ &self,
138+ params: GotoDefinitionParams,
139+ ) -> Result<Option<GotoDefinitionResponse>> {
140+ let uri = params.text_document_position_params.text_document.uri.clone();
141+ let pos = params.text_document_position_params.position;
142+ let docs = self.documents.read().await;
143+ let text = match docs.get(&uri) {
144+ Some(t) => t.clone(),
145+ None => return Ok(None),
146+ };
147+ drop(docs);
148+
149+ Ok(definition_at(&text, pos, &uri))
150+ }
151+
152+ async fn prepare_rename(
153+ &self,
154+ params: TextDocumentPositionParams,
155+ ) -> Result<Option<PrepareRenameResponse>> {
156+ let uri = &params.text_document.uri;
157+ let pos = params.position;
158+ let docs = self.documents.read().await;
159+ let text = match docs.get(uri) {
160+ Some(t) => t.clone(),
161+ None => return Ok(None),
162+ };
163+ drop(docs);
164+
165+ Ok(prepare_rename_at(&text, pos))
166+ }
167+
168+ async fn rename(&self, params: RenameParams) -> Result<Option<WorkspaceEdit>> {
169+ let uri = params.text_document_position.text_document.uri.clone();
170+ let pos = params.text_document_position.position;
171+ let new_name = params.new_name;
172+ let docs = self.documents.read().await;
173+ let text = match docs.get(&uri) {
174+ Some(t) => t.clone(),
175+ None => return Ok(None),
176+ };
177+ drop(docs);
178+
179+ Ok(rename_at(&text, pos, &new_name, &uri))
180+ }
130181}
131182
132183// ---------------------------------------------------------------------------
@@ -264,6 +315,7 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
264315 ("@let", "Define a variable", "@let "),
265316 ("@define", "Define an attribute bundle", "@define "),
266317 ("@fn", "Define a reusable function", "@fn "),
318+ ("@keyframes", "Define a CSS animation", "@keyframes "),
267319 ]
268320 .iter()
269321 .map(|(name, detail, insert)| {
@@ -322,10 +374,17 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
322374 // Identity
323375 ("id", "HTML id attribute", true),
324376 ("class", "HTML class attribute", true),
377+ // Animation
378+ ("animation", "CSS animation (e.g., name 0.3s ease)", true),
325379 // State prefixes
326380 ("hover:", "Style on hover", false),
327381 ("active:", "Style on active/click", false),
328382 ("focus:", "Style on focus", false),
383+ // Responsive prefixes
384+ ("sm:", "Style at 640px+ (small)", false),
385+ ("md:", "Style at 768px+ (medium)", false),
386+ ("lg:", "Style at 1024px+ (large)", false),
387+ ("xl:", "Style at 1280px+ (extra large)", false),
329388 ]
330389 .iter()
331390 .map(|(name, detail, takes_value)| {
@@ -569,6 +628,7 @@ fn hover_builtin(word: &str) -> Option<String> {
569628 "@let" => "**@let** \u{2014} Variable\n\nDefines a variable for `$name` substitution.\n\nUsage: `@let primary #3b82f6`",
570629 "@define" => "**@define** \u{2014} Attribute bundle\n\nDefines a reusable set of attributes.\n\nUsage: `@define card-style [padding 20, rounded 8]`\n\nApply with `$card-style` in attribute lists.",
571630 "@fn" => "**@fn** \u{2014} Function\n\nDefines a reusable component.\n\n```\n@fn card $title\n @el [padding 20]\n @text [bold] $title\n @children\n```",
631+ "@keyframes" => "**@keyframes** \u{2014} CSS Animation\n\nDefines keyframes for CSS animations.\n\n```\n@keyframes fade-in\n from{opacity:0}to{opacity:1}\n```\n\nUse with `animation` attribute: `[animation fade-in 0.3s ease]`",
572632 "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
573633 // Attributes
574634 "spacing" => "**spacing** `<value>`\n\nGap between children in pixels. Maps to CSS `gap`.",
@@ -610,6 +670,7 @@ fn hover_builtin(word: &str) -> Option<String> {
610670 "wrap" => "**wrap** \u{2014} Enable flex-wrap for children.",
611671 "id" => "**id** `<value>` \u{2014} HTML id attribute.",
612672 "class" => "**class** `<value>` \u{2014} HTML class attribute.",
673+ "animation" => "**animation** `<value>` \u{2014} CSS animation shorthand (e.g., `fade-in 0.3s ease`).\n\nDefine animations with `@keyframes`.",
613674 _ => return None,
614675 };
615676
@@ -620,6 +681,300 @@ fn hover_builtin(word: &str) -> Option<String> {
620681 })
621682}
622683
684+// ---------------------------------------------------------------------------
685+// Go to definition
686+// ---------------------------------------------------------------------------
687+
688+fn definition_at(text: &str, position: Position, uri: &Url) -> Option<GotoDefinitionResponse> {
689+ let lines: Vec<&str> = text.lines().collect();
690+ let line = lines.get(position.line as usize)?;
691+ let col = (position.character as usize).min(line.len());
692+ let word = word_at(line, col)?;
693+
694+ // Find definition location
695+ let (def_line, def_col, def_len) = if word.starts_with('$') {
696+ let name = &word[1..];
697+ find_definition(text, name)?
698+ } else if word.starts_with('@') {
699+ let name = &word[1..];
700+ find_fn_definition(text, name)?
701+ } else {
702+ return None;
703+ };
704+
705+ Some(GotoDefinitionResponse::Scalar(Location {
706+ uri: uri.clone(),
707+ range: Range::new(
708+ Position::new(def_line, def_col),
709+ Position::new(def_line, def_col + def_len),
710+ ),
711+ }))
712+}
713+
714+/// Find @let, @define, or @fn parameter definition for a $name reference.
715+fn find_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
716+ for (i, line) in text.lines().enumerate() {
717+ let trimmed = line.trim();
718+ let offset = (line.len() - trimmed.len()) as u32;
719+
720+ if let Some(rest) = trimmed.strip_prefix("@let ") {
721+ if let Some((n, _)) = rest.trim().split_once(' ') {
722+ if n == name {
723+ let col = offset + "@let ".len() as u32;
724+ return Some((i as u32, col, n.len() as u32));
725+ }
726+ }
727+ }
728+
729+ if let Some(rest) = trimmed.strip_prefix("@define ") {
730+ let rest = rest.trim();
731+ if let Some(bracket) = rest.find('[') {
732+ let n = rest[..bracket].trim();
733+ if n == name {
734+ let col = offset + "@define ".len() as u32;
735+ return Some((i as u32, col, n.len() as u32));
736+ }
737+ }
738+ }
739+
740+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
741+ let parts: Vec<&str> = rest.split_whitespace().collect();
742+ for param in &parts[1..] {
743+ let p = param.strip_prefix('$').unwrap_or(param);
744+ if p == name {
745+ // Find the param position in the line
746+ if let Some(pos) = line.find(param) {
747+ return Some((i as u32, pos as u32, param.len() as u32));
748+ }
749+ }
750+ }
751+ }
752+ }
753+ None
754+}
755+
756+/// Find @fn definition for an @name function call.
757+fn find_fn_definition(text: &str, name: &str) -> Option<(u32, u32, u32)> {
758+ for (i, line) in text.lines().enumerate() {
759+ let trimmed = line.trim();
760+ let offset = (line.len() - trimmed.len()) as u32;
761+
762+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
763+ let parts: Vec<&str> = rest.split_whitespace().collect();
764+ if parts.first() == Some(&name) {
765+ let col = offset + "@fn ".len() as u32;
766+ return Some((i as u32, col, name.len() as u32));
767+ }
768+ }
769+ }
770+ None
771+}
772+
773+// ---------------------------------------------------------------------------
774+// Rename
775+// ---------------------------------------------------------------------------
776+
777+fn prepare_rename_at(text: &str, position: Position) -> Option<PrepareRenameResponse> {
778+ let lines: Vec<&str> = text.lines().collect();
779+ let line = lines.get(position.line as usize)?;
780+ let col = (position.character as usize).min(line.len());
781+ let word = word_at(line, col)?;
782+
783+ // Only allow renaming $variables and @function calls/definitions
784+ if !word.starts_with('$') && !word.starts_with('@') {
785+ return None;
786+ }
787+
788+ let name = &word[1..];
789+
790+ // Check that the symbol actually has a definition
791+ if word.starts_with('$') {
792+ find_definition(text, name)?;
793+ } else {
794+ find_fn_definition(text, name)?;
795+ }
796+
797+ // Find the range of the word in the line
798+ let bytes = line.as_bytes();
799+ let mut start = col;
800+ while start > 0 && is_word_byte(bytes[start - 1]) {
801+ start -= 1;
802+ }
803+ let mut end = col;
804+ while end < bytes.len() && is_word_byte(bytes[end]) {
805+ end += 1;
806+ }
807+
808+ Some(PrepareRenameResponse::Range(Range::new(
809+ Position::new(position.line, start as u32),
810+ Position::new(position.line, end as u32),
811+ )))
812+}
813+
814+fn rename_at(text: &str, position: Position, new_name: &str, uri: &Url) -> Option<WorkspaceEdit> {
815+ let lines: Vec<&str> = text.lines().collect();
816+ let line = lines.get(position.line as usize)?;
817+ let col = (position.character as usize).min(line.len());
818+ let word = word_at(line, col)?;
819+
820+ let is_var = word.starts_with('$');
821+ let name = &word[1..]; // strip $ or @
822+
823+ // Strip $ or @ from new_name if user included it
824+ let new_base = new_name
825+ .strip_prefix('$')
826+ .or_else(|| new_name.strip_prefix('@'))
827+ .unwrap_or(new_name);
828+
829+ let mut edits = Vec::new();
830+
831+ for (i, line) in text.lines().enumerate() {
832+ let line_num = i as u32;
833+ let trimmed = line.trim();
834+
835+ if is_var {
836+ // Rename @let definition
837+ if let Some(rest) = trimmed.strip_prefix("@let ") {
838+ if let Some((n, _)) = rest.trim().split_once(' ') {
839+ if n == name {
840+ if let Some(pos) = line.find(n) {
841+ edits.push(TextEdit {
842+ range: Range::new(
843+ Position::new(line_num, pos as u32),
844+ Position::new(line_num, (pos + n.len()) as u32),
845+ ),
846+ new_text: new_base.to_string(),
847+ });
848+ }
849+ }
850+ }
851+ }
852+
853+ // Rename @define definition
854+ if let Some(rest) = trimmed.strip_prefix("@define ") {
855+ let rest_trimmed = rest.trim();
856+ if let Some(bracket) = rest_trimmed.find('[') {
857+ let n = rest_trimmed[..bracket].trim();
858+ if n == name {
859+ if let Some(pos) = line.find(n) {
860+ edits.push(TextEdit {
861+ range: Range::new(
862+ Position::new(line_num, pos as u32),
863+ Position::new(line_num, (pos + n.len()) as u32),
864+ ),
865+ new_text: new_base.to_string(),
866+ });
867+ continue; // Don't also match $ references on this line
868+ }
869+ }
870+ }
871+ }
872+
873+ // Rename @fn parameter definitions
874+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
875+ let parts: Vec<&str> = rest.split_whitespace().collect();
876+ for param in &parts[1..] {
877+ let p = param.strip_prefix('$').unwrap_or(param);
878+ if p == name {
879+ if let Some(pos) = line.find(param) {
880+ let prefix = if param.starts_with('$') { "$" } else { "" };
881+ edits.push(TextEdit {
882+ range: Range::new(
883+ Position::new(line_num, pos as u32),
884+ Position::new(line_num, (pos + param.len()) as u32),
885+ ),
886+ new_text: format!("{}{}", prefix, new_base),
887+ });
888+ }
889+ }
890+ }
891+ }
892+
893+ // Rename all $name references
894+ let search = format!("${}", name);
895+ let replace = format!("${}", new_base);
896+ let mut offset = 0;
897+ while let Some(pos) = line[offset..].find(&search) {
898+ let abs_pos = offset + pos;
899+ // Check it's not part of a longer identifier
900+ let after = abs_pos + search.len();
901+ let is_end = after >= line.len()
902+ || !line.as_bytes()[after].is_ascii_alphanumeric()
903+ && line.as_bytes()[after] != b'-'
904+ && line.as_bytes()[after] != b'_';
905+ if is_end {
906+ edits.push(TextEdit {
907+ range: Range::new(
908+ Position::new(line_num, abs_pos as u32),
909+ Position::new(line_num, (abs_pos + search.len()) as u32),
910+ ),
911+ new_text: replace.clone(),
912+ });
913+ }
914+ offset = abs_pos + search.len();
915+ }
916+ } else {
917+ // Rename @fn definition
918+ if let Some(rest) = trimmed.strip_prefix("@fn ") {
919+ let parts: Vec<&str> = rest.split_whitespace().collect();
920+ if parts.first() == Some(&name) {
921+ if let Some(pos) = line.find(&format!("@fn {}", name)) {
922+ let start = pos + 4; // skip "@fn "
923+ edits.push(TextEdit {
924+ range: Range::new(
925+ Position::new(line_num, start as u32),
926+ Position::new(line_num, (start + name.len()) as u32),
927+ ),
928+ new_text: new_base.to_string(),
929+ });
930+ }
931+ }
932+ }
933+
934+ // Rename @name function calls
935+ let search = format!("@{}", name);
936+ let replace = format!("@{}", new_base);
937+ let mut offset = 0;
938+ while let Some(pos) = line[offset..].find(&search) {
939+ let abs_pos = offset + pos;
940+ // Don't match @fn definition (handled above)
941+ if trimmed.starts_with("@fn ") {
942+ offset = abs_pos + search.len();
943+ continue;
944+ }
945+ // Check it's not part of a longer identifier
946+ let after = abs_pos + search.len();
947+ let is_end = after >= line.len()
948+ || !line.as_bytes()[after].is_ascii_alphanumeric()
949+ && line.as_bytes()[after] != b'-'
950+ && line.as_bytes()[after] != b'_';
951+ if is_end {
952+ edits.push(TextEdit {
953+ range: Range::new(
954+ Position::new(line_num, abs_pos as u32),
955+ Position::new(line_num, (abs_pos + search.len()) as u32),
956+ ),
957+ new_text: replace.clone(),
958+ });
959+ }
960+ offset = abs_pos + search.len();
961+ }
962+ }
963+ }
964+
965+ if edits.is_empty() {
966+ return None;
967+ }
968+
969+ let mut changes = HashMap::new();
970+ changes.insert(uri.clone(), edits);
971+
972+ Some(WorkspaceEdit {
973+ changes: Some(changes),
974+ ..Default::default()
975+ })
976+}
977+
623978// ---------------------------------------------------------------------------
624979// Entry point
625980// ---------------------------------------------------------------------------
src/codegen.rs+225−24
@@ -6,12 +6,22 @@ use crate::ast::*;
66// Style collector: deduplicates CSS and assigns class names
77// ---------------------------------------------------------------------------
88
9+/// (min-width breakpoint, prefix)
10+const BREAKPOINTS: &[(&str, &str)] = &[
11+ ("sm", "640px"),
12+ ("md", "768px"),
13+ ("lg", "1024px"),
14+ ("xl", "1280px"),
15+];
16+
917struct StyleEntry {
1018 class_name: String,
1119 base: String,
1220 hover: String,
1321 active: String,
1422 focus: String,
23+ /// Responsive overrides: (breakpoint_prefix, css)
24+ responsive: Vec<(String, String)>,
1525}
1626
1727struct StyleCollector {
@@ -34,11 +44,22 @@ impl StyleCollector {
3444 hover: String,
3545 active: String,
3646 focus: String,
47+ responsive: Vec<(String, String)>,
3748 ) -> Option<String> {
38− if base.is_empty() && hover.is_empty() && active.is_empty() && focus.is_empty() {
49+ if base.is_empty()
50+ && hover.is_empty()
51+ && active.is_empty()
52+ && focus.is_empty()
53+ && responsive.is_empty()
54+ {
3955 return None;
4056 }
41− let key = format!("{}|{}|{}|{}", base, hover, active, focus);
57+ let resp_key: String = responsive
58+ .iter()
59+ .map(|(bp, css)| format!("{}={}", bp, css))
60+ .collect::<Vec<_>>()
61+ .join("|");
62+ let key = format!("{}|{}|{}|{}|{}", base, hover, active, focus, resp_key);
4263 if let Some(&idx) = self.index.get(&key) {
4364 return Some(self.entries[idx].class_name.clone());
4465 }
@@ -50,57 +71,183 @@ impl StyleCollector {
5071 hover,
5172 active,
5273 focus,
74+ responsive,
5375 });
5476 self.index.insert(key, idx);
5577 Some(name)
5678 }
5779
58− fn to_css(&self) -> String {
80+ fn to_css_formatted(&self, dev: bool) -> String {
5981 let mut css = String::new();
82+ let (sp, nl) = if dev { (" ", "\n") } else { ("", "") };
83+
84+ // Non-responsive rules
6085 for e in &self.entries {
6186 if !e.base.is_empty() {
62− css.push_str(&format!(".{}{{{}}}", e.class_name, e.base));
87+ css.push_str(&format!(".{}{sp}{{{}}}{nl}", e.class_name, e.base));
6388 }
6489 if !e.hover.is_empty() {
65− css.push_str(&format!(".{}:hover{{{}}}", e.class_name, e.hover));
90+ css.push_str(&format!(".{}:hover{sp}{{{}}}{nl}", e.class_name, e.hover));
6691 }
6792 if !e.active.is_empty() {
68− css.push_str(&format!(".{}:active{{{}}}", e.class_name, e.active));
93+ css.push_str(&format!(".{}:active{sp}{{{}}}{nl}", e.class_name, e.active));
6994 }
7095 if !e.focus.is_empty() {
71− css.push_str(&format!(".{}:focus{{{}}}", e.class_name, e.focus));
96+ css.push_str(&format!(".{}:focus{sp}{{{}}}{nl}", e.class_name, e.focus));
7297 }
7398 }
99+
100+ // Responsive rules grouped by breakpoint
101+ for &(bp_name, bp_width) in BREAKPOINTS {
102+ let mut bp_css = String::new();
103+ for e in &self.entries {
104+ for (bp, rule_css) in &e.responsive {
105+ if bp == bp_name && !rule_css.is_empty() {
106+ if dev {
107+ bp_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, rule_css));
108+ } else {
109+ bp_css.push_str(&format!(".{}{{{}}}", e.class_name, rule_css));
110+ }
111+ }
112+ }
113+ }
114+ if !bp_css.is_empty() {
115+ if dev {
116+ css.push_str(&format!(
117+ "@media (min-width: {}) {{\n{}}}\n",
118+ bp_width, bp_css
119+ ));
120+ } else {
121+ css.push_str(&format!(
122+ "@media(min-width:{}){{{}}}",
123+ bp_width, bp_css
124+ ));
125+ }
126+ }
127+ }
128+
74129 css
75130 }
76131}
77132
133+struct GenContext {
134+ dev: bool,
135+ depth: usize,
136+}
137+
138+impl GenContext {
139+ fn indent(&self) -> String {
140+ if self.dev {
141+ " ".repeat(self.depth)
142+ } else {
143+ String::new()
144+ }
145+ }
146+
147+ fn nl(&self) -> &str {
148+ if self.dev { "\n" } else { "" }
149+ }
150+}
151+
78152// ---------------------------------------------------------------------------
79153// Public API
80154// ---------------------------------------------------------------------------
81155
82156pub fn generate(doc: &Document) -> String {
157+ generate_with_options(doc, false)
158+}
159+
160+pub fn generate_dev(doc: &Document) -> String {
161+ generate_with_options(doc, true)
162+}
163+
164+fn generate_with_options(doc: &Document, dev: bool) -> String {
83165 let mut styles = StyleCollector::new();
166+ let mut ctx = GenContext { dev, depth: 0 };
84167 let mut body = String::new();
85168
86169 for node in &doc.nodes {
87− generate_node(node, None, &mut body, &mut styles);
170+ generate_node(node, None, &mut body, &mut styles, &mut ctx);
88171 }
89172
90− let element_css = styles.to_css();
173+ let mut element_css = String::new();
174+
175+ // CSS custom properties
176+ if !doc.css_vars.is_empty() {
177+ if dev {
178+ element_css.push_str(":root {\n");
179+ for (name, value) in &doc.css_vars {
180+ element_css.push_str(&format!(" {}: {};\n", name, value));
181+ }
182+ element_css.push_str("}\n");
183+ } else {
184+ element_css.push_str(":root{");
185+ for (name, value) in &doc.css_vars {
186+ element_css.push_str(name);
187+ element_css.push(':');
188+ element_css.push_str(value);
189+ element_css.push(';');
190+ }
191+ element_css.push('}');
192+ }
193+ }
194+
195+ element_css.push_str(&styles.to_css_formatted(dev));
196+
197+ // @keyframes
198+ for (name, kf_body) in &doc.keyframes {
199+ if dev {
200+ element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
201+ } else {
202+ element_css.push_str(&format!("@keyframes {}{{{}}}", name, kf_body));
203+ }
204+ }
91205
92206 match &doc.page_title {
93− Some(title) => format!(
94− "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title><style>*,*::before,*::after{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif}}img{{display:block}}{element_css}</style></head><body>{body}</body></html>",
95− title = html_escape(title),
96− element_css = element_css,
97− body = body,
98− ),
207+ Some(title) => {
208+ if dev {
209+ format!(
210+ "\
211+<!DOCTYPE html>
212+<html>
213+<head>
214+<meta charset=\"utf-8\">
215+<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
216+<title>{title}</title>
217+<style>
218+*, *::before, *::after {{ box-sizing: border-box; }}
219+body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
220+img {{ display: block; }}
221+{element_css}\
222+</style>
223+</head>
224+<body>
225+{body}\
226+</body>
227+</html>
228+",
229+ title = html_escape(title),
230+ element_css = element_css,
231+ body = body,
232+ )
233+ } else {
234+ format!(
235+ "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title><style>*,*::before,*::after{{box-sizing:border-box}}body{{margin:0;font-family:system-ui,-apple-system,sans-serif}}img{{display:block}}{element_css}</style></head><body>{body}</body></html>",
236+ title = html_escape(title),
237+ element_css = element_css,
238+ body = body,
239+ )
240+ }
241+ }
99242 None => {
100243 if element_css.is_empty() {
101244 body
102245 } else {
103− format!("<style>{}</style>{}", element_css, body)
246+ if dev {
247+ format!("<style>\n{}</style>\n{}", element_css, body)
248+ } else {
249+ format!("<style>{}</style>{}", element_css, body)
250+ }
104251 }
105252 }
106253 }
@@ -115,24 +262,29 @@ fn generate_node(
115262 parent_kind: Option<&ElementKind>,
116263 out: &mut String,
117264 styles: &mut StyleCollector,
265+ ctx: &mut GenContext,
118266) {
119267 match node {
120− Node::Element(elem) => generate_element(elem, parent_kind, out, styles),
268+ Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
121269 Node::Text(segments) => {
122270 let needs_wrap = matches!(
123271 parent_kind,
124272 Some(ElementKind::Row) | Some(ElementKind::Column) | Some(ElementKind::El)
125273 );
126274 if needs_wrap {
275+ out.push_str(&ctx.indent());
127276 out.push_str("<span>");
128277 }
129− generate_text_segments(segments, out, styles);
278+ generate_text_segments(segments, out, styles, ctx);
130279 if needs_wrap {
131280 out.push_str("</span>");
281+ out.push_str(ctx.nl());
132282 }
133283 }
134284 Node::Raw(content) => {
285+ out.push_str(&ctx.indent());
135286 out.push_str(content);
287+ out.push_str(ctx.nl());
136288 }
137289 }
138290}
@@ -142,9 +294,10 @@ fn generate_element(
142294 parent_kind: Option<&ElementKind>,
143295 out: &mut String,
144296 styles: &mut StyleCollector,
297+ ctx: &mut GenContext,
145298) {
146299 if elem.kind == ElementKind::Image {
147− generate_image(elem, parent_kind, out, styles);
300+ generate_image(elem, parent_kind, out, styles, ctx);
148301 return;
149302 }
150303 if elem.kind == ElementKind::Children {
@@ -159,10 +312,25 @@ fn generate_element(
159312 ElementKind::Image | ElementKind::Children => unreachable!(),
160313 };
161314
315+ let kind_label = match elem.kind {
316+ ElementKind::Row => "row",
317+ ElementKind::Column => "column",
318+ ElementKind::El => "el",
319+ ElementKind::Text => "text",
320+ ElementKind::Paragraph => "paragraph",
321+ ElementKind::Link => "link",
322+ _ => "",
323+ };
324+
162325 // Compute CSS for each state and get a class name
163326 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
164327 let (id, user_class) = extract_id_class(&elem.attrs);
165328
329+ if ctx.dev {
330+ out.push_str(&ctx.indent());
331+ out.push_str(&format!("<!-- @{} -->\n", kind_label));
332+ }
333+ out.push_str(&ctx.indent());
166334 out.push('<');
167335 out.push_str(tag);
168336
@@ -182,6 +350,7 @@ fn generate_element(
182350 out.push('"');
183351 }
184352 out.push('>');
353+ out.push_str(ctx.nl());
185354
186355 // Inline text argument (@text content)
187356 if elem.kind == ElementKind::Text {
@@ -191,17 +360,21 @@ fn generate_element(
191360 }
192361
193362 // Children
363+ ctx.depth += 1;
194364 let is_paragraph = elem.kind == ElementKind::Paragraph;
195365 for (i, child) in elem.children.iter().enumerate() {
196− generate_node(child, Some(&elem.kind), out, styles);
366+ generate_node(child, Some(&elem.kind), out, styles, ctx);
197367 if is_paragraph && i < elem.children.len() - 1 {
198368 out.push(' ');
199369 }
200370 }
371+ ctx.depth -= 1;
201372
373+ out.push_str(&ctx.indent());
202374 out.push_str("</");
203375 out.push_str(tag);
204376 out.push('>');
377+ out.push_str(ctx.nl());
205378}
206379
207380fn generate_image(
@@ -209,11 +382,17 @@ fn generate_image(
209382 parent_kind: Option<&ElementKind>,
210383 out: &mut String,
211384 styles: &mut StyleCollector,
385+ ctx: &GenContext,
212386) {
213387 let src = elem.argument.as_deref().unwrap_or("");
214388 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
215389 let (id, user_class) = extract_id_class(&elem.attrs);
216390
391+ if ctx.dev {
392+ out.push_str(&ctx.indent());
393+ out.push_str("<!-- @image -->\n");
394+ }
395+ out.push_str(&ctx.indent());
217396 out.push_str("<img src=\"");
218397 out.push_str(&html_escape(src));
219398 out.push('"');
@@ -226,19 +405,21 @@ fn generate_image(
226405 out.push('"');
227406 }
228407 out.push('>');
408+ out.push_str(ctx.nl());
229409}
230410
231411fn generate_text_segments(
232412 segments: &[TextSegment],
233413 out: &mut String,
234414 styles: &mut StyleCollector,
415+ ctx: &mut GenContext,
235416) {
236417 for segment in segments {
237418 match segment {
238419 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
239420 TextSegment::Inline(elem) => {
240421 let mut buf = String::new();
241− generate_element(elem, None, &mut buf, styles);
422+ generate_element(elem, None, &mut buf, styles, ctx);
242423 out.push_str(buf.trim_end());
243424 }
244425 }
@@ -259,7 +440,18 @@ fn compute_class(
259440 let hover = attrs_to_css(attrs, "hover:", kind, parent_kind);
260441 let active = attrs_to_css(attrs, "active:", kind, parent_kind);
261442 let focus = attrs_to_css(attrs, "focus:", kind, parent_kind);
262− styles.get_class(base, hover, active, focus)
443+
444+ // Collect responsive overrides
445+ let mut responsive = Vec::new();
446+ for &(bp_name, _) in BREAKPOINTS {
447+ let prefix = format!("{}:", bp_name);
448+ let css = attrs_to_css(attrs, &prefix, kind, parent_kind);
449+ if !css.is_empty() {
450+ responsive.push((bp_name.to_string(), css));
451+ }
452+ }
453+
454+ styles.get_class(base, hover, active, focus, responsive)
263455}
264456
265457fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
@@ -290,9 +482,11 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
290482// ---------------------------------------------------------------------------
291483
292484const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
485+const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:"];
293486
294−fn is_state_attr(key: &str) -> bool {
487+fn is_prefixed_attr(key: &str) -> bool {
295488 STATE_PREFIXES.iter().any(|p| key.starts_with(p))
489+ || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
296490}
297491
298492fn attrs_to_css(
@@ -317,7 +511,7 @@ fn attrs_to_css(
317511 for attr in attrs {
318512 // Determine the effective key for this pass
319513 let effective_key = if state_prefix.is_empty() {
320− if is_state_attr(&attr.key) {
514+ if is_prefixed_attr(&attr.key) {
321515 continue;
322516 }
323517 attr.key.as_str()
@@ -582,6 +776,13 @@ fn attrs_to_css(
582776 }
583777 }
584778
779+ // Animation
780+ "animation" => {
781+ if let Some(v) = val {
782+ push_css(&mut css, "animation", v);
783+ }
784+ }
785+
585786 // Identity — not CSS
586787 "id" | "class" => {}
587788
src/main.rs+11−5
@@ -3,7 +3,7 @@ use std::fs;
33use std::path::{Path, PathBuf};
44use std::process;
55
6−fn compile(input_path: &str) -> (bool, Vec<PathBuf>) {
6+fn compile(input_path: &str, dev: bool) -> (bool, Vec<PathBuf>) {
77 let input = match fs::read_to_string(input_path) {
88 Ok(s) => s,
99 Err(e) => {
@@ -29,7 +29,11 @@ fn compile(input_path: &str) -> (bool, Vec<PathBuf>) {
2929 .any(|d| d.severity == htmlang::parser::Severity::Error);
3030
3131 if !has_errors {
32− let html = htmlang::codegen::generate(&result.document);
32+ let html = if dev {
33+ htmlang::codegen::generate_dev(&result.document)
34+ } else {
35+ htmlang::codegen::generate(&result.document)
36+ };
3337 let out_path = Path::new(input_path).with_extension("html");
3438 match fs::write(&out_path, &html) {
3539 Ok(()) => eprintln!("wrote {}", out_path.display()),
@@ -44,6 +48,7 @@ fn main() {
4448 let args: Vec<String> = env::args().collect();
4549 let mut watch = false;
4650 let mut serve = false;
51+ let mut dev = false;
4752 let mut port: u16 = 3000;
4853 let mut input_path = None;
4954
@@ -51,6 +56,7 @@ fn main() {
5156 while i < args.len() {
5257 match args[i].as_str() {
5358 "--watch" | "-w" => watch = true,
59+ "--dev" | "-d" => dev = true,
5460 "--serve" | "-s" => {
5561 serve = true;
5662 watch = true;
@@ -82,12 +88,12 @@ fn main() {
8288 let input_path = match input_path {
8389 Some(p) => p,
8490 None => {
85− eprintln!("Usage: htmlang [--watch] [--serve [--port N]] <file.hl>");
91+ eprintln!("Usage: htmlang [--watch] [--serve [--port N]] [--dev] <file.hl>");
8692 process::exit(1);
8793 }
8894 };
8995
90− let (has_errors, included_files) = compile(&input_path);
96+ let (has_errors, included_files) = compile(&input_path, dev);
9197
9298 if !watch {
9399 if has_errors {
@@ -144,7 +150,7 @@ fn main() {
144150 while rx.try_recv().is_ok() {}
145151
146152 eprintln!("\nrecompiling...");
147− let (_, new_includes) = compile(&input_path);
153+ let (_, new_includes) = compile(&input_path, dev);
148154
149155 for inc in &new_includes {
150156 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
src/parser.rs+195−8
@@ -67,6 +67,8 @@ struct ParseContext {
6767 variables: HashMap<String, String>,
6868 defines: HashMap<String, Vec<Attribute>>,
6969 functions: HashMap<String, FnDef>,
70+ keyframes: Vec<(String, String)>,
71+ css_vars: Vec<(String, String)>,
7072 diagnostics: Vec<Diagnostic>,
7173 base_path: Option<PathBuf>,
7274 included_files: Vec<PathBuf>,
@@ -90,6 +92,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
9092 variables: HashMap::new(),
9193 defines: HashMap::new(),
9294 functions: HashMap::new(),
95+ keyframes: Vec::new(),
96+ css_vars: Vec::new(),
9397 diagnostics: Vec::new(),
9498 base_path: base_path.map(|p| p.to_path_buf()),
9599 included_files: Vec::new(),
@@ -101,6 +105,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
101105 page_title: ctx.page_title,
102106 variables: ctx.variables,
103107 defines: ctx.defines,
108+ keyframes: ctx.keyframes,
109+ css_vars: ctx.css_vars,
104110 nodes,
105111 },
106112 diagnostics: ctx.diagnostics,
@@ -273,8 +279,12 @@ impl Parser {
273279 if let Some(rest) = content.strip_prefix("@let ") {
274280 let rest = rest.trim();
275281 if let Some((name, value)) = rest.split_once(' ') {
276− ctx.variables
277− .insert(name.to_string(), substitute_vars(value.trim(), &ctx.variables));
282+ let value = substitute_vars(value.trim(), &ctx.variables);
283+ if name.starts_with("--") {
284+ // CSS custom property
285+ ctx.css_vars.push((name.to_string(), value.clone()));
286+ }
287+ ctx.variables.insert(name.to_string(), value);
278288 }
279289 return Ok(None);
280290 }
@@ -336,6 +346,29 @@ impl Parser {
336346 }
337347 }
338348
349+ // --- @keyframes directive ---
350+
351+ if let Some(rest) = content.strip_prefix("@keyframes ") {
352+ let name = rest.trim().to_string();
353+ if name.is_empty() {
354+ return Err(ParseError {
355+ line: line_num,
356+ message: "@keyframes requires a name".to_string(),
357+ });
358+ }
359+ // Collect body lines (indented deeper)
360+ let mut body = String::new();
361+ while self.pos < self.lines.len() && self.lines[self.pos].indent > current_indent {
362+ if let LineContent::Normal(ref s) = self.lines[self.pos].content {
363+ let trimmed = s.trim();
364+ body.push_str(trimmed);
365+ }
366+ self.pos += 1;
367+ }
368+ ctx.keyframes.push((name, body));
369+ return Ok(None);
370+ }
371+
339372 // --- Function definition ---
340373
341374 if let Some(rest) = content.strip_prefix("@fn ") {
@@ -583,6 +616,12 @@ fn parse_single_element(
583616 })
584617}
585618
619+const KNOWN_ELEMENTS: &[&str] = &[
620+ "row", "column", "col", "el", "text", "paragraph", "p", "image", "img", "link", "children",
621+];
622+
623+const KNOWN_DIRECTIVES: &[&str] = &["page", "let", "define", "fn", "include", "raw", "keyframes"];
624+
586625fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
587626 match s {
588627 "row" => Ok(ElementKind::Row),
@@ -593,13 +632,59 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
593632 "image" | "img" => Ok(ElementKind::Image),
594633 "link" => Ok(ElementKind::Link),
595634 "children" => Ok(ElementKind::Children),
596− _ => Err(ParseError {
597− line: line_num,
598− message: format!("unknown element @{}", s),
599− }),
635+ _ => {
636+ let all_known: Vec<&str> = KNOWN_ELEMENTS
637+ .iter()
638+ .chain(KNOWN_DIRECTIVES.iter())
639+ .copied()
640+ .collect();
641+ let suggestion = suggest_closest(s, &all_known);
642+ let msg = match suggestion {
643+ Some(closest) => format!("unknown element @{}, did you mean @{}?", s, closest),
644+ None => format!("unknown element @{}", s),
645+ };
646+ Err(ParseError {
647+ line: line_num,
648+ message: msg,
649+ })
650+ }
600651 }
601652}
602653
654+fn levenshtein(a: &str, b: &str) -> usize {
655+ let a: Vec<char> = a.chars().collect();
656+ let b: Vec<char> = b.chars().collect();
657+ let mut dp = vec![vec![0usize; b.len() + 1]; a.len() + 1];
658+ for i in 0..=a.len() {
659+ dp[i][0] = i;
660+ }
661+ for j in 0..=b.len() {
662+ dp[0][j] = j;
663+ }
664+ for i in 1..=a.len() {
665+ for j in 1..=b.len() {
666+ let cost = if a[i - 1] == b[j - 1] { 0 } else { 1 };
667+ dp[i][j] = (dp[i - 1][j] + 1)
668+ .min(dp[i][j - 1] + 1)
669+ .min(dp[i - 1][j - 1] + cost);
670+ }
671+ }
672+ dp[a.len()][b.len()]
673+}
674+
675+fn suggest_closest<'a>(input: &str, candidates: &[&'a str]) -> Option<&'a str> {
676+ let mut best = None;
677+ let mut best_dist = usize::MAX;
678+ for &candidate in candidates {
679+ let dist = levenshtein(input, candidate);
680+ if dist < best_dist && dist <= 2 && dist < input.len() {
681+ best_dist = dist;
682+ best = Some(candidate);
683+ }
684+ }
685+ best
686+}
687+
603688// ---------------------------------------------------------------------------
604689// Attribute parsing
605690// ---------------------------------------------------------------------------
@@ -614,8 +699,93 @@ const KNOWN_ATTRS: &[&str] = &[
614699 "text-align", "line-height", "overflow", "position", "z-index", "shadow",
615700 "wrap", "gap-x", "gap-y",
616701 "id", "class",
702+ "animation",
703+];
704+
705+/// Attributes that expect purely numeric values (px-based).
706+const NUMERIC_ATTRS: &[&str] = &[
707+ "spacing", "padding", "padding-x", "padding-y",
708+ "min-width", "max-width", "min-height", "max-height",
709+ "rounded", "size", "gap-x", "gap-y",
617710];
618711
712+/// Attributes that accept numeric OR keyword values.
713+const NUMERIC_OR_KEYWORD_ATTRS: &[&str] = &["width", "height"];
714+const SIZE_KEYWORDS: &[&str] = &["fill", "shrink"];
715+
716+fn validate_attr_value(attr: &Attribute, line_num: usize, ctx: &mut ParseContext) {
717+ let base_key = attr.key.as_str();
718+ let base_key = base_key
719+ .strip_prefix("hover:")
720+ .or_else(|| base_key.strip_prefix("active:"))
721+ .or_else(|| base_key.strip_prefix("focus:"))
722+ .unwrap_or(base_key);
723+ let base_key = base_key
724+ .strip_prefix("sm:")
725+ .or_else(|| base_key.strip_prefix("md:"))
726+ .or_else(|| base_key.strip_prefix("lg:"))
727+ .or_else(|| base_key.strip_prefix("xl:"))
728+ .unwrap_or(base_key);
729+
730+ if let Some(val) = &attr.value {
731+ if NUMERIC_ATTRS.contains(&base_key) {
732+ // All space-separated parts must be numeric (for padding with multiple values)
733+ for part in val.split_whitespace() {
734+ if part.parse::<f64>().is_err() {
735+ ctx.diagnostics.push(Diagnostic {
736+ line: line_num,
737+ message: format!(
738+ "'{}' expects a numeric value, got '{}'",
739+ attr.key, val
740+ ),
741+ severity: Severity::Warning,
742+ });
743+ return;
744+ }
745+ }
746+ } else if NUMERIC_OR_KEYWORD_ATTRS.contains(&base_key) {
747+ let is_keyword = SIZE_KEYWORDS.contains(&val.as_str());
748+ let is_numeric = val.parse::<f64>().is_ok();
749+ if !is_keyword && !is_numeric {
750+ ctx.diagnostics.push(Diagnostic {
751+ line: line_num,
752+ message: format!(
753+ "'{}' expects a number or one of [{}], got '{}'",
754+ attr.key,
755+ SIZE_KEYWORDS.join(", "),
756+ val
757+ ),
758+ severity: Severity::Warning,
759+ });
760+ }
761+ } else if base_key == "opacity" {
762+ if let Ok(v) = val.parse::<f64>() {
763+ if !(0.0..=1.0).contains(&v) {
764+ ctx.diagnostics.push(Diagnostic {
765+ line: line_num,
766+ message: format!("'opacity' should be between 0 and 1, got '{}'", val),
767+ severity: Severity::Warning,
768+ });
769+ }
770+ } else {
771+ ctx.diagnostics.push(Diagnostic {
772+ line: line_num,
773+ message: format!("'opacity' expects a numeric value, got '{}'", val),
774+ severity: Severity::Warning,
775+ });
776+ }
777+ } else if base_key == "z-index" {
778+ if val.parse::<i32>().is_err() {
779+ ctx.diagnostics.push(Diagnostic {
780+ line: line_num,
781+ message: format!("'z-index' expects an integer, got '{}'", val),
782+ severity: Severity::Warning,
783+ });
784+ }
785+ }
786+ }
787+}
788+
619789fn parse_attr_brackets(
620790 input: &str,
621791 line_num: usize,
@@ -705,17 +875,34 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
705875 // Warn on unknown attributes
706876 if validate {
707877 let base_key = attr.key.as_str();
878+ // Strip state prefix (hover:, active:, focus:)
708879 let base_key = base_key
709880 .strip_prefix("hover:")
710881 .or_else(|| base_key.strip_prefix("active:"))
711882 .or_else(|| base_key.strip_prefix("focus:"))
712883 .unwrap_or(base_key);
884+ // Strip responsive prefix (sm:, md:, lg:, xl:)
885+ let base_key = base_key
886+ .strip_prefix("sm:")
887+ .or_else(|| base_key.strip_prefix("md:"))
888+ .or_else(|| base_key.strip_prefix("lg:"))
889+ .or_else(|| base_key.strip_prefix("xl:"))
890+ .unwrap_or(base_key);
713891 if !KNOWN_ATTRS.contains(&base_key) {
892+ let suggestion = suggest_closest(base_key, KNOWN_ATTRS);
893+ let msg = match suggestion {
894+ Some(closest) => {
895+ format!("unknown attribute '{}', did you mean '{}'?", attr.key, closest)
896+ }
897+ None => format!("unknown attribute '{}'", attr.key),
898+ };
714899 ctx.diagnostics.push(Diagnostic {
715900 line: line_num,
716− message: format!("unknown attribute '{}'", attr.key),
901+ message: msg,
717902 severity: Severity::Warning,
718903 });
904+ } else {
905+ validate_attr_value(&attr, line_num, ctx);
719906 }
720907 }
721908
@@ -874,7 +1061,7 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
8741061 while i < chars.len() {
8751062 if chars[i] == '$'
8761063 && i + 1 < chars.len()
877− && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_')
1064+ && (chars[i + 1].is_alphanumeric() || chars[i + 1] == '_' || chars[i + 1] == '-')
8781065 {
8791066 let start = i + 1;
8801067 let mut end = start;
tests/snapshots.rsnew file+114
@@ -0,0 +1,114 @@
1+use std::fs;
2+use std::path::Path;
3+
4+fn compile(input: &str) -> String {
5+ let result = htmlang::parser::parse(input);
6+ assert!(
7+ result
8+ .diagnostics
9+ .iter()
10+ .all(|d| d.severity != htmlang::parser::Severity::Error),
11+ "unexpected parse errors: {:?}",
12+ result.diagnostics
13+ );
14+ htmlang::codegen::generate(&result.document)
15+}
16+
17+fn snapshot_test(name: &str) {
18+ let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/snapshots");
19+ let hl_path = dir.join(format!("{}.hl", name));
20+ let html_path = dir.join(format!("{}.html", name));
21+
22+ let input = fs::read_to_string(&hl_path)
23+ .unwrap_or_else(|e| panic!("failed to read {}: {}", hl_path.display(), e));
24+ let actual = compile(&input);
25+
26+ if std::env::var("UPDATE_SNAPSHOTS").is_ok() {
27+ fs::write(&html_path, &actual).unwrap();
28+ return;
29+ }
30+
31+ if !html_path.exists() {
32+ fs::write(&html_path, &actual).unwrap();
33+ eprintln!("created snapshot: {}", html_path.display());
34+ return;
35+ }
36+
37+ let expected = fs::read_to_string(&html_path).unwrap();
38+ assert_eq!(actual, expected, "snapshot mismatch for {}", name);
39+}
40+
41+#[test]
42+fn snapshot_basic_elements() {
43+ snapshot_test("basic_elements");
44+}
45+
46+#[test]
47+fn snapshot_attributes() {
48+ snapshot_test("attributes");
49+}
50+
51+#[test]
52+fn snapshot_alignment() {
53+ snapshot_test("alignment");
54+}
55+
56+#[test]
57+fn snapshot_variables_defines() {
58+ snapshot_test("variables_defines");
59+}
60+
61+#[test]
62+fn snapshot_functions() {
63+ snapshot_test("functions");
64+}
65+
66+#[test]
67+fn snapshot_pseudo_states() {
68+ snapshot_test("pseudo_states");
69+}
70+
71+#[test]
72+fn snapshot_chain_operator() {
73+ snapshot_test("chain_operator");
74+}
75+
76+#[test]
77+fn snapshot_inline_text() {
78+ snapshot_test("inline_text");
79+}
80+
81+#[test]
82+fn snapshot_raw_html() {
83+ snapshot_test("raw_html");
84+}
85+
86+#[test]
87+fn snapshot_sizing() {
88+ snapshot_test("sizing");
89+}
90+
91+#[test]
92+fn snapshot_no_page() {
93+ snapshot_test("no_page");
94+}
95+
96+#[test]
97+fn snapshot_implicit_el() {
98+ snapshot_test("implicit_el");
99+}
100+
101+#[test]
102+fn snapshot_responsive() {
103+ snapshot_test("responsive");
104+}
105+
106+#[test]
107+fn snapshot_animations() {
108+ snapshot_test("animations");
109+}
110+
111+#[test]
112+fn snapshot_css_vars() {
113+ snapshot_test("css_vars");
114+}
tests/snapshots/alignment.hlnew file+12
@@ -0,0 +1,12 @@
1+@page Alignment
2+@column [height 400]
3+ @el [center-x] Centered X
4+ @el [center-y] Centered Y
5+ @el [align-left] Left
6+ @el [align-right] Right
7+ @el [align-top] Top
8+ @el [align-bottom] Bottom
9+@row [height 200]
10+ @el [center-y] In row
11+ @el [align-top] Top in row
12+ @el [align-bottom] Bottom in row
tests/snapshots/animations.hlnew file+13
@@ -0,0 +1,13 @@
1+@page Animations
2+
3+@keyframes fade-in
4+ from{opacity:0}to{opacity:1}
5+
6+@keyframes slide-up
7+ 0%{transform:translateY(20px);opacity:0}100%{transform:translateY(0);opacity:1}
8+
9+@column [spacing 20]
10+ @el [animation fade-in 0.3s ease]
11+ @text Fading in
12+ @el [animation slide-up 0.5s ease-out]
13+ @text Sliding up
tests/snapshots/attributes.hlnew file+8
@@ -0,0 +1,8 @@
1+@page Attributes
2+@el [width 200, height 100, padding 10 20, background #fff, color #333, border 1 #ccc, rounded 8, opacity 0.9]
3+ @text [bold, italic, underline, size 16, font monospace] Styled
4+@row [wrap, gap-x 10, gap-y 5, spacing 8]
5+ @el [min-width 50, max-width 200, min-height 30, max-height 100]
6+@column [overflow hidden, position relative, z-index 10]
7+ @text [text-align center, line-height 1.5] Aligned
8+@el [shadow 0 2px 4px rgba(0,0,0,0.1), transition all 0.2s, cursor pointer]
tests/snapshots/basic_elements.hlnew file+11
@@ -0,0 +1,11 @@
1+@page Basic Elements
2+@row [spacing 10]
3+ @text [bold] Hello
4+ @text World
5+@column [spacing 5]
6+ @el [padding 10]
7+ @text Inner
8+@paragraph
9+ Some text here
10+@image [width 100] https://example.com/img.png
11+@link https://example.com Click me
tests/snapshots/chain_operator.hlnew file+4
@@ -0,0 +1,4 @@
1+@page Chain
2+@el [padding 10] > @link https://example.com
3+ @text [bold] Click
4+@row [spacing 5] > @el [padding 20] > @text [color red] Deep chain
tests/snapshots/css_vars.hlnew file+10
@@ -0,0 +1,10 @@
1+@page CSS Variables
2+@let --primary #3b82f6
3+@let --spacing 20
4+@let --radius 8
5+
6+@column [spacing $--spacing]
7+ @el [background var(--primary), rounded $--radius, padding $--spacing]
8+ @text [color white] Using CSS vars
9+ @el [border 1 var(--primary), rounded $--radius, padding $--spacing]
10+ @text [color var(--primary)] Border with var
tests/snapshots/functions.hlnew file+14
@@ -0,0 +1,14 @@
1+@page Functions
2+@fn card $title
3+ @el [padding 20, rounded 8]
4+ @text [bold] $title
5+ @children
6+
7+@fn wrapper
8+ @column [spacing 10]
9+ @children
10+
11+@wrapper
12+ @card [title Hello]
13+ @text Content inside card
14+ @card [title World]
tests/snapshots/implicit_el.hlnew file+7
@@ -0,0 +1,7 @@
1+@page Implicit El
2+[padding 20, background #f5f5f5]
3+ @text Hello from implicit el
4+[rounded 8, border 1 #ccc]
5+ @row [spacing 10]
6+ @text A
7+ @text B
tests/snapshots/inline_text.hlnew file+5
@@ -0,0 +1,5 @@
1+@page Inline Text
2+@paragraph
3+ This is {@text [bold] bold} and {@text [italic, color red] styled} text.
4+@paragraph
5+ Multiple {@text [underline] inline} elements {@text [size 20] here}.
tests/snapshots/no_page.hlnew file+2
@@ -0,0 +1,2 @@
1+@el [padding 10, background #fff]
2+ @text [bold] Fragment without @page
tests/snapshots/pseudo_states.hlnew file+3
@@ -0,0 +1,3 @@
1+@page Pseudo States
2+@el [background white, hover:background #eee, active:background #ddd, focus:background #ccc, transition all 0.2s]
3+ @text [color black, hover:color blue] Hover me
tests/snapshots/raw_html.hlnew file+5
@@ -0,0 +1,5 @@
1+@page Raw HTML
2+@column [spacing 10]
3+ @text Before raw
4+ @raw """<div class="custom">Raw HTML content</div>"""
5+ @text After raw
tests/snapshots/responsive.hlnew file+5
@@ -0,0 +1,5 @@
1+@page Responsive
2+@row [spacing 10, sm:spacing 20, lg:spacing 40]
3+ @el [width fill, sm:width 200, md:width 300, lg:width 400, xl:width 500]
4+ @text [size 14, md:size 18, lg:size 24] Responsive text
5+ @el [padding 10, sm:padding 20]
tests/snapshots/sizing.hlnew file+8
@@ -0,0 +1,8 @@
1+@page Sizing
2+@row [height 200]
3+ @el [width fill, background #eee] Fill width
4+ @el [width 100, background #ddd] Fixed 100
5+ @el [width shrink, background #ccc] Shrink
6+@column [width 400]
7+ @el [height fill, background #eee] Fill height
8+ @el [height 50, background #ddd] Fixed 50
tests/snapshots/variables_defines.hlnew file+10
@@ -0,0 +1,10 @@
1+@page Variables
2+@let primary #3b82f6
3+@let gap 20
4+@define card-style [padding 20, rounded 8, background white]
5+
6+@column [spacing $gap]
7+ @el [$card-style]
8+ @text [color $primary] Hello
9+ @el [$card-style, border 1 $primary]
10+ @text World