on master help sign in
Add responsive breakpoints, animations, CSS vars, dev mode, LSP definition/rename, and snapshot tests
1d60aeeiko
src/ast.rs+2
@@ -6,6 +6,8 @@ pub struct Document {
6 pub page_title: Option<String>,
6 pub page_title: Option<String>,
7 pub variables: HashMap<String, String>,
7 pub variables: HashMap<String, String>,
8 pub defines: HashMap<String, Vec<Attribute>>,
8 pub defines: HashMap<String, Vec<Attribute>>,
9+ pub keyframes: Vec<(String, String)>,
10+ pub css_vars: Vec<(String, String)>,
9 pub nodes: Vec<Node>,
11 pub nodes: Vec<Node>,
10}
12}
11
13
src/bin/htmlang-lsp.rs+355
@@ -61,6 +61,11 @@ impl LanguageServer for Backend {
61 ..Default::default()
61 ..Default::default()
62 }),
62 }),
63 hover_provider: Some(HoverProviderCapability::Simple(true)),
63 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+ })),
64 ..Default::default()
69 ..Default::default()
65 },
70 },
66 ..Default::default()
71 ..Default::default()
@@ -127,6 +132,52 @@ impl LanguageServer for Backend {
127
132
128 Ok(hover_at(&text, pos))
133 Ok(hover_at(&text, pos))
129 }
134 }
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+ }
130}
181}
131
182
132// ---------------------------------------------------------------------------
183// ---------------------------------------------------------------------------
@@ -264,6 +315,7 @@ fn directive_completions(range: Range) -> Vec<CompletionItem> {
264 ("@let", "Define a variable", "@let "),
315 ("@let", "Define a variable", "@let "),
265 ("@define", "Define an attribute bundle", "@define "),
316 ("@define", "Define an attribute bundle", "@define "),
266 ("@fn", "Define a reusable function", "@fn "),
317 ("@fn", "Define a reusable function", "@fn "),
318+ ("@keyframes", "Define a CSS animation", "@keyframes "),
267 ]
319 ]
268 .iter()
320 .iter()
269 .map(|(name, detail, insert)| {
321 .map(|(name, detail, insert)| {
@@ -322,10 +374,17 @@ fn attr_completions(range: Range) -> Vec<CompletionItem> {
322 // Identity
374 // Identity
323 ("id", "HTML id attribute", true),
375 ("id", "HTML id attribute", true),
324 ("class", "HTML class attribute", true),
376 ("class", "HTML class attribute", true),
377+ // Animation
378+ ("animation", "CSS animation (e.g., name 0.3s ease)", true),
325 // State prefixes
379 // State prefixes
326 ("hover:", "Style on hover", false),
380 ("hover:", "Style on hover", false),
327 ("active:", "Style on active/click", false),
381 ("active:", "Style on active/click", false),
328 ("focus:", "Style on focus", false),
382 ("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),
329 ]
388 ]
330 .iter()
389 .iter()
331 .map(|(name, detail, takes_value)| {
390 .map(|(name, detail, takes_value)| {
@@ -569,6 +628,7 @@ fn hover_builtin(word: &str) -> Option<String> {
569 "@let" => "**@let** \u{2014} Variable\n\nDefines a variable for `$name` substitution.\n\nUsage: `@let primary #3b82f6`",
628 "@let" => "**@let** \u{2014} Variable\n\nDefines a variable for `$name` substitution.\n\nUsage: `@let primary #3b82f6`",
570 "@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.",
629 "@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.",
571 "@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```",
630 "@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]`",
572 "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
632 "@children" => "**@children** \u{2014} Children slot\n\nPlaceholder inside `@fn` body replaced with the caller's children.",
573 // Attributes
633 // Attributes
574 "spacing" => "**spacing** `<value>`\n\nGap between children in pixels. Maps to CSS `gap`.",
634 "spacing" => "**spacing** `<value>`\n\nGap between children in pixels. Maps to CSS `gap`.",
@@ -610,6 +670,7 @@ fn hover_builtin(word: &str) -> Option<String> {
610 "wrap" => "**wrap** \u{2014} Enable flex-wrap for children.",
670 "wrap" => "**wrap** \u{2014} Enable flex-wrap for children.",
611 "id" => "**id** `<value>` \u{2014} HTML id attribute.",
671 "id" => "**id** `<value>` \u{2014} HTML id attribute.",
612 "class" => "**class** `<value>` \u{2014} HTML class attribute.",
672 "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`.",
613 _ => return None,
674 _ => return None,
614 };
675 };
615
676
@@ -620,6 +681,300 @@ fn hover_builtin(word: &str) -> Option<String> {
620 })
681 })
621}
682}
622
683
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+
623// ---------------------------------------------------------------------------
978// ---------------------------------------------------------------------------
624// Entry point
979// Entry point
625// ---------------------------------------------------------------------------
980// ---------------------------------------------------------------------------
src/codegen.rs+225−24
@@ -6,12 +6,22 @@ use crate::ast::*;
6// Style collector: deduplicates CSS and assigns class names
6// Style collector: deduplicates CSS and assigns class names
7// ---------------------------------------------------------------------------
7// ---------------------------------------------------------------------------
8
8
9+/// (min-width breakpoint, prefix)
10+const BREAKPOINTS: &[(&str, &str)] = &[
11+ ("sm", "640px"),
12+ ("md", "768px"),
13+ ("lg", "1024px"),
14+ ("xl", "1280px"),
15+];
16+
9struct StyleEntry {
17struct StyleEntry {
10 class_name: String,
18 class_name: String,
11 base: String,
19 base: String,
12 hover: String,
20 hover: String,
13 active: String,
21 active: String,
14 focus: String,
22 focus: String,
23+ /// Responsive overrides: (breakpoint_prefix, css)
24+ responsive: Vec<(String, String)>,
15}
25}
16
26
17struct StyleCollector {
27struct StyleCollector {
@@ -34,11 +44,22 @@ impl StyleCollector {
34 hover: String,
44 hover: String,
35 active: String,
45 active: String,
36 focus: String,
46 focus: String,
47+ responsive: Vec<(String, String)>,
37 ) -> Option<String> {
48 ) -> 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+ {
39 return None;
55 return None;
40 }
56 }
57+ let resp_key: String = responsive
58+ .iter()
59+ .map(|(bp, css)| format!("{}={}", bp, css))
60+ .collect::<Vec<_>>()
61+ .join("|");
41− let key = format!("{}|{}|{}|{}", base, hover, active, focus);
62+ let key = format!("{}|{}|{}|{}|{}", base, hover, active, focus, resp_key);
42 if let Some(&idx) = self.index.get(&key) {
63 if let Some(&idx) = self.index.get(&key) {
43 return Some(self.entries[idx].class_name.clone());
64 return Some(self.entries[idx].class_name.clone());
44 }
65 }
@@ -50,57 +71,183 @@ impl StyleCollector {
50 hover,
71 hover,
51 active,
72 active,
52 focus,
73 focus,
74+ responsive,
53 });
75 });
54 self.index.insert(key, idx);
76 self.index.insert(key, idx);
55 Some(name)
77 Some(name)
56 }
78 }
57
79
58− fn to_css(&self) -> String {
80+ fn to_css_formatted(&self, dev: bool) -> String {
59 let mut css = String::new();
81 let mut css = String::new();
82+ let (sp, nl) = if dev { (" ", "\n") } else { ("", "") };
83+
84+ // Non-responsive rules
60 for e in &self.entries {
85 for e in &self.entries {
61 if !e.base.is_empty() {
86 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));
63 }
88 }
64 if !e.hover.is_empty() {
89 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));
66 }
91 }
67 if !e.active.is_empty() {
92 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));
69 }
94 }
70 if !e.focus.is_empty() {
95 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));
72 }
97 }
73 }
98 }
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+
74 css
129 css
75 }
130 }
76}
131}
77
132
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+
78// ---------------------------------------------------------------------------
152// ---------------------------------------------------------------------------
79// Public API
153// Public API
80// ---------------------------------------------------------------------------
154// ---------------------------------------------------------------------------
81
155
82pub fn generate(doc: &Document) -> String {
156pub 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 {
83 let mut styles = StyleCollector::new();
165 let mut styles = StyleCollector::new();
166+ let mut ctx = GenContext { dev, depth: 0 };
84 let mut body = String::new();
167 let mut body = String::new();
85
168
86 for node in &doc.nodes {
169 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);
88 }
171 }
89
172
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+ }
91
205
92 match &doc.page_title {
206 match &doc.page_title {
93− Some(title) => format!(
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!(
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>",
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>",
95− title = html_escape(title),
236+ title = html_escape(title),
96− element_css = element_css,
237+ element_css = element_css,
97− body = body,
238+ body = body,
98− ),
239+ )
240+ }
241+ }
99 None => {
242 None => {
100 if element_css.is_empty() {
243 if element_css.is_empty() {
101 body
244 body
102 } else {
245 } else {
246+ if dev {
103− format!("<style>{}</style>{}", element_css, body)
247+ format!("<style>\n{}</style>\n{}", element_css, body)
248+ } else {
249+ format!("<style>{}</style>{}", element_css, body)
250+ }
104 }
251 }
105 }
252 }
106 }
253 }
@@ -115,24 +262,29 @@ fn generate_node(
115 parent_kind: Option<&ElementKind>,
262 parent_kind: Option<&ElementKind>,
116 out: &mut String,
263 out: &mut String,
117 styles: &mut StyleCollector,
264 styles: &mut StyleCollector,
265+ ctx: &mut GenContext,
118) {
266) {
119 match node {
267 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),
121 Node::Text(segments) => {
269 Node::Text(segments) => {
122 let needs_wrap = matches!(
270 let needs_wrap = matches!(
123 parent_kind,
271 parent_kind,
124 Some(ElementKind::Row) | Some(ElementKind::Column) | Some(ElementKind::El)
272 Some(ElementKind::Row) | Some(ElementKind::Column) | Some(ElementKind::El)
125 );
273 );
126 if needs_wrap {
274 if needs_wrap {
275+ out.push_str(&ctx.indent());
127 out.push_str("<span>");
276 out.push_str("<span>");
128 }
277 }
129− generate_text_segments(segments, out, styles);
278+ generate_text_segments(segments, out, styles, ctx);
130 if needs_wrap {
279 if needs_wrap {
131 out.push_str("</span>");
280 out.push_str("</span>");
281+ out.push_str(ctx.nl());
132 }
282 }
133 }
283 }
134 Node::Raw(content) => {
284 Node::Raw(content) => {
285+ out.push_str(&ctx.indent());
135 out.push_str(content);
286 out.push_str(content);
287+ out.push_str(ctx.nl());
136 }
288 }
137 }
289 }
138}
290}
@@ -142,9 +294,10 @@ fn generate_element(
142 parent_kind: Option<&ElementKind>,
294 parent_kind: Option<&ElementKind>,
143 out: &mut String,
295 out: &mut String,
144 styles: &mut StyleCollector,
296 styles: &mut StyleCollector,
297+ ctx: &mut GenContext,
145) {
298) {
146 if elem.kind == ElementKind::Image {
299 if elem.kind == ElementKind::Image {
147− generate_image(elem, parent_kind, out, styles);
300+ generate_image(elem, parent_kind, out, styles, ctx);
148 return;
301 return;
149 }
302 }
150 if elem.kind == ElementKind::Children {
303 if elem.kind == ElementKind::Children {
@@ -159,10 +312,25 @@ fn generate_element(
159 ElementKind::Image | ElementKind::Children => unreachable!(),
312 ElementKind::Image | ElementKind::Children => unreachable!(),
160 };
313 };
161
314
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+
162 // Compute CSS for each state and get a class name
325 // Compute CSS for each state and get a class name
163 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
326 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
164 let (id, user_class) = extract_id_class(&elem.attrs);
327 let (id, user_class) = extract_id_class(&elem.attrs);
165
328
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());
166 out.push('<');
334 out.push('<');
167 out.push_str(tag);
335 out.push_str(tag);
168
336
@@ -182,6 +350,7 @@ fn generate_element(
182 out.push('"');
350 out.push('"');
183 }
351 }
184 out.push('>');
352 out.push('>');
353+ out.push_str(ctx.nl());
185
354
186 // Inline text argument (@text content)
355 // Inline text argument (@text content)
187 if elem.kind == ElementKind::Text {
356 if elem.kind == ElementKind::Text {
@@ -191,17 +360,21 @@ fn generate_element(
191 }
360 }
192
361
193 // Children
362 // Children
363+ ctx.depth += 1;
194 let is_paragraph = elem.kind == ElementKind::Paragraph;
364 let is_paragraph = elem.kind == ElementKind::Paragraph;
195 for (i, child) in elem.children.iter().enumerate() {
365 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);
197 if is_paragraph && i < elem.children.len() - 1 {
367 if is_paragraph && i < elem.children.len() - 1 {
198 out.push(' ');
368 out.push(' ');
199 }
369 }
200 }
370 }
371+ ctx.depth -= 1;
201
372
373+ out.push_str(&ctx.indent());
202 out.push_str("</");
374 out.push_str("</");
203 out.push_str(tag);
375 out.push_str(tag);
204 out.push('>');
376 out.push('>');
377+ out.push_str(ctx.nl());
205}
378}
206
379
207fn generate_image(
380fn generate_image(
@@ -209,11 +382,17 @@ fn generate_image(
209 parent_kind: Option<&ElementKind>,
382 parent_kind: Option<&ElementKind>,
210 out: &mut String,
383 out: &mut String,
211 styles: &mut StyleCollector,
384 styles: &mut StyleCollector,
385+ ctx: &GenContext,
212) {
386) {
213 let src = elem.argument.as_deref().unwrap_or("");
387 let src = elem.argument.as_deref().unwrap_or("");
214 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
388 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
215 let (id, user_class) = extract_id_class(&elem.attrs);
389 let (id, user_class) = extract_id_class(&elem.attrs);
216
390
391+ if ctx.dev {
392+ out.push_str(&ctx.indent());
393+ out.push_str("<!-- @image -->\n");
394+ }
395+ out.push_str(&ctx.indent());
217 out.push_str("<img src=\"");
396 out.push_str("<img src=\"");
218 out.push_str(&html_escape(src));
397 out.push_str(&html_escape(src));
219 out.push('"');
398 out.push('"');
@@ -226,19 +405,21 @@ fn generate_image(
226 out.push('"');
405 out.push('"');
227 }
406 }
228 out.push('>');
407 out.push('>');
408+ out.push_str(ctx.nl());
229}
409}
230
410
231fn generate_text_segments(
411fn generate_text_segments(
232 segments: &[TextSegment],
412 segments: &[TextSegment],
233 out: &mut String,
413 out: &mut String,
234 styles: &mut StyleCollector,
414 styles: &mut StyleCollector,
415+ ctx: &mut GenContext,
235) {
416) {
236 for segment in segments {
417 for segment in segments {
237 match segment {
418 match segment {
238 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
419 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
239 TextSegment::Inline(elem) => {
420 TextSegment::Inline(elem) => {
240 let mut buf = String::new();
421 let mut buf = String::new();
241− generate_element(elem, None, &mut buf, styles);
422+ generate_element(elem, None, &mut buf, styles, ctx);
242 out.push_str(buf.trim_end());
423 out.push_str(buf.trim_end());
243 }
424 }
244 }
425 }
@@ -259,7 +440,18 @@ fn compute_class(
259 let hover = attrs_to_css(attrs, "hover:", kind, parent_kind);
440 let hover = attrs_to_css(attrs, "hover:", kind, parent_kind);
260 let active = attrs_to_css(attrs, "active:", kind, parent_kind);
441 let active = attrs_to_css(attrs, "active:", kind, parent_kind);
261 let focus = attrs_to_css(attrs, "focus:", kind, parent_kind);
442 let focus = attrs_to_css(attrs, "focus:", kind, parent_kind);
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+
262− styles.get_class(base, hover, active, focus)
454+ styles.get_class(base, hover, active, focus, responsive)
263}
455}
264
456
265fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
457fn 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
290// ---------------------------------------------------------------------------
482// ---------------------------------------------------------------------------
291
483
292const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
484const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
485+const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:"];
293
486
294−fn is_state_attr(key: &str) -> bool {
487+fn is_prefixed_attr(key: &str) -> bool {
295 STATE_PREFIXES.iter().any(|p| key.starts_with(p))
488 STATE_PREFIXES.iter().any(|p| key.starts_with(p))
489+ || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
296}
490}
297
491
298fn attrs_to_css(
492fn attrs_to_css(
@@ -317,7 +511,7 @@ fn attrs_to_css(
317 for attr in attrs {
511 for attr in attrs {
318 // Determine the effective key for this pass
512 // Determine the effective key for this pass
319 let effective_key = if state_prefix.is_empty() {
513 let effective_key = if state_prefix.is_empty() {
320− if is_state_attr(&attr.key) {
514+ if is_prefixed_attr(&attr.key) {
321 continue;
515 continue;
322 }
516 }
323 attr.key.as_str()
517 attr.key.as_str()
@@ -582,6 +776,13 @@ fn attrs_to_css(
582 }
776 }
583 }
777 }
584
778
779+ // Animation
780+ "animation" => {
781+ if let Some(v) = val {
782+ push_css(&mut css, "animation", v);
783+ }
784+ }
785+
585 // Identity — not CSS
786 // Identity — not CSS
586 "id" | "class" => {}
787 "id" | "class" => {}
587
788
src/main.rs+11−5
@@ -3,7 +3,7 @@ use std::fs;
3use std::path::{Path, PathBuf};
3use std::path::{Path, PathBuf};
4use std::process;
4use std::process;
5
5
6−fn compile(input_path: &str) -> (bool, Vec<PathBuf>) {
6+fn compile(input_path: &str, dev: bool) -> (bool, Vec<PathBuf>) {
7 let input = match fs::read_to_string(input_path) {
7 let input = match fs::read_to_string(input_path) {
8 Ok(s) => s,
8 Ok(s) => s,
9 Err(e) => {
9 Err(e) => {
@@ -29,7 +29,11 @@ fn compile(input_path: &str) -> (bool, Vec<PathBuf>) {
29 .any(|d| d.severity == htmlang::parser::Severity::Error);
29 .any(|d| d.severity == htmlang::parser::Severity::Error);
30
30
31 if !has_errors {
31 if !has_errors {
32+ let html = if dev {
33+ htmlang::codegen::generate_dev(&result.document)
34+ } else {
32− let html = htmlang::codegen::generate(&result.document);
35+ htmlang::codegen::generate(&result.document)
36+ };
33 let out_path = Path::new(input_path).with_extension("html");
37 let out_path = Path::new(input_path).with_extension("html");
34 match fs::write(&out_path, &html) {
38 match fs::write(&out_path, &html) {
35 Ok(()) => eprintln!("wrote {}", out_path.display()),
39 Ok(()) => eprintln!("wrote {}", out_path.display()),
@@ -44,6 +48,7 @@ fn main() {
44 let args: Vec<String> = env::args().collect();
48 let args: Vec<String> = env::args().collect();
45 let mut watch = false;
49 let mut watch = false;
46 let mut serve = false;
50 let mut serve = false;
51+ let mut dev = false;
47 let mut port: u16 = 3000;
52 let mut port: u16 = 3000;
48 let mut input_path = None;
53 let mut input_path = None;
49
54
@@ -51,6 +56,7 @@ fn main() {
51 while i < args.len() {
56 while i < args.len() {
52 match args[i].as_str() {
57 match args[i].as_str() {
53 "--watch" | "-w" => watch = true,
58 "--watch" | "-w" => watch = true,
59+ "--dev" | "-d" => dev = true,
54 "--serve" | "-s" => {
60 "--serve" | "-s" => {
55 serve = true;
61 serve = true;
56 watch = true;
62 watch = true;
@@ -82,12 +88,12 @@ fn main() {
82 let input_path = match input_path {
88 let input_path = match input_path {
83 Some(p) => p,
89 Some(p) => p,
84 None => {
90 None => {
85− eprintln!("Usage: htmlang [--watch] [--serve [--port N]] <file.hl>");
91+ eprintln!("Usage: htmlang [--watch] [--serve [--port N]] [--dev] <file.hl>");
86 process::exit(1);
92 process::exit(1);
87 }
93 }
88 };
94 };
89
95
90− let (has_errors, included_files) = compile(&input_path);
96+ let (has_errors, included_files) = compile(&input_path, dev);
91
97
92 if !watch {
98 if !watch {
93 if has_errors {
99 if has_errors {
@@ -144,7 +150,7 @@ fn main() {
144 while rx.try_recv().is_ok() {}
150 while rx.try_recv().is_ok() {}
145
151
146 eprintln!("\nrecompiling...");
152 eprintln!("\nrecompiling...");
147− let (_, new_includes) = compile(&input_path);
153+ let (_, new_includes) = compile(&input_path, dev);
148
154
149 for inc in &new_includes {
155 for inc in &new_includes {
150 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
156 let _ = watcher.watch(inc, RecursiveMode::NonRecursive);
src/parser.rs+195−8
@@ -67,6 +67,8 @@ struct ParseContext {
67 variables: HashMap<String, String>,
67 variables: HashMap<String, String>,
68 defines: HashMap<String, Vec<Attribute>>,
68 defines: HashMap<String, Vec<Attribute>>,
69 functions: HashMap<String, FnDef>,
69 functions: HashMap<String, FnDef>,
70+ keyframes: Vec<(String, String)>,
71+ css_vars: Vec<(String, String)>,
70 diagnostics: Vec<Diagnostic>,
72 diagnostics: Vec<Diagnostic>,
71 base_path: Option<PathBuf>,
73 base_path: Option<PathBuf>,
72 included_files: Vec<PathBuf>,
74 included_files: Vec<PathBuf>,
@@ -90,6 +92,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
90 variables: HashMap::new(),
92 variables: HashMap::new(),
91 defines: HashMap::new(),
93 defines: HashMap::new(),
92 functions: HashMap::new(),
94 functions: HashMap::new(),
95+ keyframes: Vec::new(),
96+ css_vars: Vec::new(),
93 diagnostics: Vec::new(),
97 diagnostics: Vec::new(),
94 base_path: base_path.map(|p| p.to_path_buf()),
98 base_path: base_path.map(|p| p.to_path_buf()),
95 included_files: Vec::new(),
99 included_files: Vec::new(),
@@ -101,6 +105,8 @@ pub fn parse_with_base(input: &str, base_path: Option<&Path>) -> ParseResult {
101 page_title: ctx.page_title,
105 page_title: ctx.page_title,
102 variables: ctx.variables,
106 variables: ctx.variables,
103 defines: ctx.defines,
107 defines: ctx.defines,
108+ keyframes: ctx.keyframes,
109+ css_vars: ctx.css_vars,
104 nodes,
110 nodes,
105 },
111 },
106 diagnostics: ctx.diagnostics,
112 diagnostics: ctx.diagnostics,
@@ -273,8 +279,12 @@ impl Parser {
273 if let Some(rest) = content.strip_prefix("@let ") {
279 if let Some(rest) = content.strip_prefix("@let ") {
274 let rest = rest.trim();
280 let rest = rest.trim();
275 if let Some((name, value)) = rest.split_once(' ') {
281 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);
278 }
288 }
279 return Ok(None);
289 return Ok(None);
280 }
290 }
@@ -336,6 +346,29 @@ impl Parser {
336 }
346 }
337 }
347 }
338
348
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+
339 // --- Function definition ---
372 // --- Function definition ---
340
373
341 if let Some(rest) = content.strip_prefix("@fn ") {
374 if let Some(rest) = content.strip_prefix("@fn ") {
@@ -583,6 +616,12 @@ fn parse_single_element(
583 })
616 })
584}
617}
585
618
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+
586fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
625fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseError> {
587 match s {
626 match s {
588 "row" => Ok(ElementKind::Row),
627 "row" => Ok(ElementKind::Row),
@@ -593,13 +632,59 @@ fn parse_element_kind(s: &str, line_num: usize) -> Result<ElementKind, ParseErro
593 "image" | "img" => Ok(ElementKind::Image),
632 "image" | "img" => Ok(ElementKind::Image),
594 "link" => Ok(ElementKind::Link),
633 "link" => Ok(ElementKind::Link),
595 "children" => Ok(ElementKind::Children),
634 "children" => Ok(ElementKind::Children),
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+ };
596− _ => Err(ParseError {
646+ Err(ParseError {
597− line: line_num,
647+ line: line_num,
598− message: format!("unknown element @{}", s),
648+ message: msg,
649+ })
599− }),
650+ }
600 }
651 }
601}
652}
602
653
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+
603// ---------------------------------------------------------------------------
688// ---------------------------------------------------------------------------
604// Attribute parsing
689// Attribute parsing
605// ---------------------------------------------------------------------------
690// ---------------------------------------------------------------------------
@@ -614,8 +699,93 @@ const KNOWN_ATTRS: &[&str] = &[
614 "text-align", "line-height", "overflow", "position", "z-index", "shadow",
699 "text-align", "line-height", "overflow", "position", "z-index", "shadow",
615 "wrap", "gap-x", "gap-y",
700 "wrap", "gap-x", "gap-y",
616 "id", "class",
701 "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",
617];
710];
618
711
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+
619fn parse_attr_brackets(
789fn parse_attr_brackets(
620 input: &str,
790 input: &str,
621 line_num: usize,
791 line_num: usize,
@@ -705,17 +875,34 @@ fn parse_attr_list(input: &str, line_num: usize, ctx: &mut ParseContext, validat
705 // Warn on unknown attributes
875 // Warn on unknown attributes
706 if validate {
876 if validate {
707 let base_key = attr.key.as_str();
877 let base_key = attr.key.as_str();
878+ // Strip state prefix (hover:, active:, focus:)
708 let base_key = base_key
879 let base_key = base_key
709 .strip_prefix("hover:")
880 .strip_prefix("hover:")
710 .or_else(|| base_key.strip_prefix("active:"))
881 .or_else(|| base_key.strip_prefix("active:"))
711 .or_else(|| base_key.strip_prefix("focus:"))
882 .or_else(|| base_key.strip_prefix("focus:"))
712 .unwrap_or(base_key);
883 .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);
713 if !KNOWN_ATTRS.contains(&base_key) {
891 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+ };
714 ctx.diagnostics.push(Diagnostic {
899 ctx.diagnostics.push(Diagnostic {
715 line: line_num,
900 line: line_num,
716− message: format!("unknown attribute '{}'", attr.key),
901+ message: msg,
717 severity: Severity::Warning,
902 severity: Severity::Warning,
718 });
903 });
904+ } else {
905+ validate_attr_value(&attr, line_num, ctx);
719 }
906 }
720 }
907 }
721
908
@@ -874,7 +1061,7 @@ fn substitute_vars(input: &str, vars: &HashMap<String, String>) -> String {
874 while i < chars.len() {
1061 while i < chars.len() {
875 if chars[i] == '$'
1062 if chars[i] == '$'
876 && i + 1 < chars.len()
1063 && 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] == '-')
878 {
1065 {
879 let start = i + 1;
1066 let start = i + 1;
880 let mut end = start;
1067 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