on master help sign in
Switch to CSS classes for hover/active/focus pseudo-selector support
commit44eeeb3626061b1e87511aa3301cb912cfeede40 authoriko <mail@iko.soy> dateTue 14 Apr 2026, 13:30 (6 months ago) parent1105278
4 files changed+279−93
DESIGN.md+14 examples/demo.hl+5−5 examples/demo.html+31−11 src/codegen.rs+229−77
DESIGN.md+14
@@ -284,6 +284,20 @@ Triple-quoted block pasted verbatim into output. Use for arbitrary HTML, CSS, or
284284| `underline` | Underlined text |
285285| `size N` | Font size in px |
286286| `font NAME` | Font family |
287+| `transition VALUE` | CSS transition |
288+| `cursor VALUE` | Cursor style |
289+| `opacity VALUE` | Opacity (0–1) |
290+
291+### Pseudo-states
292+
293+Prefix any style attribute with `hover:`, `active:`, or `focus:` to apply it on that state.
294+
295+```
296+@el [padding 16, background #3b82f6, hover:background #2563eb, active:background #1d4ed8, rounded 8, transition all 0.15s ease]
297+ @text [color white] Click me
298+```
299+
300+All style attributes support state prefixes: `hover:color`, `active:rounded`, `focus:border`, etc.
287301
288302### Flow
289303
examples/demo.hl+5−5
@@ -2,9 +2,9 @@
22@let primary #3b82f6
33@let gap 20
44
5−-- reusable card component
5+-- reusable card with hover effect
66@fn card $title
7− @el [padding 20, background white, rounded 8, border 1 #e5e7eb]
7+ @el [padding 20, background white, rounded 8, border 1 #e5e7eb, hover:border 1 $primary, transition all 0.15s ease, cursor pointer]
88 @text [bold] $title
99 @children
1010
@@ -23,7 +23,7 @@
2323 {@text [bold, color $primary] static HTML}.
2424 @image [width 80, height 80, rounded 40] https://picsum.photos/80
2525
26− -- cards using @fn
26+ -- cards using @fn with hover
2727 @page-section [title Features]
2828 @row [wrap, spacing 10]
2929 @card [title Simple]
@@ -33,9 +33,9 @@
3333 @card [title Minimal]
3434 No JavaScript required
3535
36− -- footer
36+ -- footer with hover button
3737 @row [spacing 10]
38− @el [padding 16, background $primary, rounded 8] > @link https://github.com
38+ @el [padding 16, background $primary, hover:background #2563eb, active:background #1d4ed8, rounded 8, transition all 0.15s ease, cursor pointer] > @link https://github.com
3939 @text [color white, bold] Get Started
4040 @el [width fill]
4141 @text [color #888] © 2026
examples/demo.html+31−11
@@ -8,29 +8,49 @@
88*, *::before, *::after { box-sizing: border-box; }
99body { margin: 0; font-family: system-ui, -apple-system, sans-serif; }
1010img { display: block; }
11+._0{display:flex;flex-direction:column;max-width:800px;margin-left:auto;margin-right:auto;padding:40px;gap:20px;}
12+._1{display:flex;flex-direction:row;gap:20px;}
13+._2{display:flex;flex-direction:column;flex:1;min-width:0;gap:10px;}
14+._3{font-weight:bold;font-size:32px;}
15+._4{margin:0;}
16+._5{font-weight:bold;color:#3b82f6;}
17+._6{width:80px;height:80px;border-radius:40px;}
18+._7{display:flex;flex-direction:column;gap:10px;}
19+._8{font-weight:bold;font-size:24px;color:#333;}
20+._9{display:flex;flex-direction:row;flex-wrap:wrap;gap:10px;}
21+._10{display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #e5e7eb;transition:all 0.15s ease;cursor:pointer;}
22+._10:hover{border:1px solid #3b82f6;}
23+._11{font-weight:bold;}
24+._12{display:flex;flex-direction:row;gap:10px;}
25+._13{display:flex;flex-direction:column;padding:16px;background:#3b82f6;border-radius:8px;transition:all 0.15s ease;cursor:pointer;}
26+._13:hover{background:#2563eb;}
27+._13:active{background:#1d4ed8;}
28+._14{color:white;font-weight:bold;}
29+._15{display:flex;flex-direction:column;flex:1;min-width:0;}
30+._16{color:#888;}
1131</style>
1232</head>
1333<body>
14−<div style="display:flex;flex-direction:column;max-width:800px;margin-left:auto;margin-right:auto;padding:40px;gap:20px;"><div style="display:flex;flex-direction:row;gap:20px;"><div style="display:flex;flex-direction:column;flex:1;min-width:0;gap:10px;"><span style="font-weight:bold;font-size:32px;">Welcome to htmlang</span>
15−<p style="margin:0;">A minimalist layout language that compiles to
16−<span style="font-weight:bold;color:#3b82f6;">static HTML</span>.</p>
34+<div class="_0"><div class="_1"><div class="_2"><span class="_3">Welcome to htmlang</span>
35+<p class="_4">A minimalist layout language that compiles to
36+<span class="_5">static HTML</span>.</p>
1737</div>
18−<img src="https://picsum.photos/80" style="width:80px;height:80px;border-radius:40px;">
38+<img src="https://picsum.photos/80" class="_6">
1939</div>
20−<div style="display:flex;flex-direction:column;gap:10px;"><span style="font-weight:bold;font-size:24px;color:#333;">Features</span>
21−<div style="display:flex;flex-direction:row;flex-wrap:wrap;gap:10px;"><div style="display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #e5e7eb;"><span style="font-weight:bold;">Simple</span>
40+<div class="_7"><span class="_8">Features</span>
41+<div class="_9"><div class="_10"><span class="_11">Simple</span>
2242<span>Write layouts without CSS</span></div>
23−<div style="display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #e5e7eb;"><span style="font-weight:bold;">Fast</span>
43+<div class="_10"><span class="_11">Fast</span>
2444<span>Compiles to a single HTML file</span></div>
25−<div style="display:flex;flex-direction:column;padding:20px;background:white;border-radius:8px;border:1px solid #e5e7eb;"><span style="font-weight:bold;">Minimal</span>
45+<div class="_10"><span class="_11">Minimal</span>
2646<span>No JavaScript required</span></div>
2747</div>
2848</div>
29−<div style="display:flex;flex-direction:row;gap:10px;"><div style="display:flex;flex-direction:column;padding:16px;background:#3b82f6;border-radius:8px;"><a href="https://github.com"><span style="color:white;font-weight:bold;">Get Started</span>
49+<div class="_12"><div class="_13"><a href="https://github.com"><span class="_14">Get Started</span>
3050</a>
3151</div>
32−<div style="display:flex;flex-direction:column;flex:1;min-width:0;"></div>
33−<span style="color:#888;">© 2026</span>
52+<div class="_15"></div>
53+<span class="_16">© 2026</span>
3454</div>
3555</div>
3656</body>
src/codegen.rs+229−77
@@ -1,11 +1,94 @@
1+use std::collections::HashMap;
2+
13use crate::ast::*;
24
5+// ---------------------------------------------------------------------------
6+// Style collector: deduplicates CSS and assigns class names
7+// ---------------------------------------------------------------------------
8+
9+struct StyleEntry {
10+ class_name: String,
11+ base: String,
12+ hover: String,
13+ active: String,
14+ focus: String,
15+}
16+
17+struct StyleCollector {
18+ entries: Vec<StyleEntry>,
19+ index: HashMap<String, usize>,
20+}
21+
22+impl StyleCollector {
23+ fn new() -> Self {
24+ StyleCollector {
25+ entries: Vec::new(),
26+ index: HashMap::new(),
27+ }
28+ }
29+
30+ /// Returns a class name for this style combination, or None if all empty.
31+ fn get_class(
32+ &mut self,
33+ base: String,
34+ hover: String,
35+ active: String,
36+ focus: String,
37+ ) -> Option<String> {
38+ if base.is_empty() && hover.is_empty() && active.is_empty() && focus.is_empty() {
39+ return None;
40+ }
41+ let key = format!("{}|{}|{}|{}", base, hover, active, focus);
42+ if let Some(&idx) = self.index.get(&key) {
43+ return Some(self.entries[idx].class_name.clone());
44+ }
45+ let name = format!("_{}", self.entries.len());
46+ let idx = self.entries.len();
47+ self.entries.push(StyleEntry {
48+ class_name: name.clone(),
49+ base,
50+ hover,
51+ active,
52+ focus,
53+ });
54+ self.index.insert(key, idx);
55+ Some(name)
56+ }
57+
58+ fn to_css(&self) -> String {
59+ let mut css = String::new();
60+ for e in &self.entries {
61+ if !e.base.is_empty() {
62+ css.push_str(&format!(".{}{{{}}}\n", e.class_name, e.base));
63+ }
64+ if !e.hover.is_empty() {
65+ css.push_str(&format!(".{}:hover{{{}}}\n", e.class_name, e.hover));
66+ }
67+ if !e.active.is_empty() {
68+ css.push_str(&format!(".{}:active{{{}}}\n", e.class_name, e.active));
69+ }
70+ if !e.focus.is_empty() {
71+ css.push_str(&format!(".{}:focus{{{}}}\n", e.class_name, e.focus));
72+ }
73+ }
74+ css
75+ }
76+}
77+
78+// ---------------------------------------------------------------------------
79+// Public API
80+// ---------------------------------------------------------------------------
81+
382pub fn generate(doc: &Document) -> String {
83+ let mut styles = StyleCollector::new();
484 let mut body = String::new();
85+
586 for node in &doc.nodes {
6− generate_node(node, None, &mut body);
87+ generate_node(node, None, &mut body, &mut styles);
788 }
889
90+ let element_css = styles.to_css();
91+
992 match &doc.page_title {
1093 Some(title) => format!(
1194 "\
@@ -19,6 +102,7 @@ pub fn generate(doc: &Document) -> String {
19102*, *::before, *::after {{ box-sizing: border-box; }}
20103body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
21104img {{ display: block; }}
105+{element_css}\
22106</style>
23107</head>
24108<body>
@@ -27,15 +111,31 @@ img {{ display: block; }}
27111</html>
28112",
29113 title = html_escape(title),
114+ element_css = element_css,
30115 body = body,
31116 ),
32− None => body,
117+ None => {
118+ if element_css.is_empty() {
119+ body
120+ } else {
121+ format!("<style>\n{}</style>\n{}", element_css, body)
122+ }
123+ }
33124 }
34125}
35126
36−fn generate_node(node: &Node, parent_kind: Option<&ElementKind>, out: &mut String) {
127+// ---------------------------------------------------------------------------
128+// Node generation
129+// ---------------------------------------------------------------------------
130+
131+fn generate_node(
132+ node: &Node,
133+ parent_kind: Option<&ElementKind>,
134+ out: &mut String,
135+ styles: &mut StyleCollector,
136+) {
37137 match node {
38− Node::Element(elem) => generate_element(elem, parent_kind, out),
138+ Node::Element(elem) => generate_element(elem, parent_kind, out, styles),
39139 Node::Text(segments) => {
40140 let needs_wrap = matches!(
41141 parent_kind,
@@ -44,7 +144,7 @@ fn generate_node(node: &Node, parent_kind: Option<&ElementKind>, out: &mut Strin
44144 if needs_wrap {
45145 out.push_str("<span>");
46146 }
47− generate_text_segments(segments, out);
147+ generate_text_segments(segments, out, styles);
48148 if needs_wrap {
49149 out.push_str("</span>");
50150 }
@@ -56,14 +156,16 @@ fn generate_node(node: &Node, parent_kind: Option<&ElementKind>, out: &mut Strin
56156 }
57157}
58158
59−fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut String) {
60− // Image is a void element
159+fn generate_element(
160+ elem: &Element,
161+ parent_kind: Option<&ElementKind>,
162+ out: &mut String,
163+ styles: &mut StyleCollector,
164+) {
61165 if elem.kind == ElementKind::Image {
62− generate_image(elem, parent_kind, out);
166+ generate_image(elem, parent_kind, out, styles);
63167 return;
64168 }
65−
66− // @children should be replaced during function expansion; skip if it leaks through
67169 if elem.kind == ElementKind::Children {
68170 return;
69171 }
@@ -76,8 +178,9 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
76178 ElementKind::Image | ElementKind::Children => unreachable!(),
77179 };
78180
79− let styles = attrs_to_css(&elem.attrs, &elem.kind, parent_kind);
80− let (id, class) = extract_id_class(&elem.attrs);
181+ // Compute CSS for each state and get a class name
182+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
183+ let (id, user_class) = extract_id_class(&elem.attrs);
81184
82185 out.push('<');
83186 out.push_str(tag);
@@ -90,21 +193,13 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
90193 }
91194 }
92195
93− if !styles.is_empty() {
94− out.push_str(" style=\"");
95− out.push_str(&styles);
96− out.push('"');
97− }
196+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
197+
98198 if let Some(id) = id {
99199 out.push_str(" id=\"");
100200 out.push_str(&html_escape(&id));
101201 out.push('"');
102202 }
103− if let Some(class) = class {
104− out.push_str(" class=\"");
105− out.push_str(&html_escape(&class));
106− out.push('"');
107− }
108203 out.push('>');
109204
110205 // Inline text argument (@text content)
@@ -117,8 +212,7 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
117212 // Children
118213 let is_paragraph = elem.kind == ElementKind::Paragraph;
119214 for (i, child) in elem.children.iter().enumerate() {
120− generate_node(child, Some(&elem.kind), out);
121− // In paragraphs, add newline between children so HTML renders a space
215+ generate_node(child, Some(&elem.kind), out, styles);
122216 if is_paragraph && i < elem.children.len() - 1 {
123217 out.push('\n');
124218 }
@@ -129,71 +223,133 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
129223 out.push_str(">\n");
130224}
131225
132−fn generate_image(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut String) {
226+fn generate_image(
227+ elem: &Element,
228+ parent_kind: Option<&ElementKind>,
229+ out: &mut String,
230+ styles: &mut StyleCollector,
231+) {
133232 let src = elem.argument.as_deref().unwrap_or("");
134− let styles = attrs_to_css(&elem.attrs, &elem.kind, parent_kind);
135− let (id, class) = extract_id_class(&elem.attrs);
233+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
234+ let (id, user_class) = extract_id_class(&elem.attrs);
136235
137236 out.push_str("<img src=\"");
138237 out.push_str(&html_escape(src));
139238 out.push('"');
140239
141− if !styles.is_empty() {
142− out.push_str(" style=\"");
143− out.push_str(&styles);
144− out.push('"');
145− }
240+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
241+
146242 if let Some(id) = id {
147243 out.push_str(" id=\"");
148244 out.push_str(&html_escape(&id));
149245 out.push('"');
150246 }
151− if let Some(class) = class {
152− out.push_str(" class=\"");
153− out.push_str(&html_escape(&class));
154− out.push('"');
155− }
156247 out.push_str(">\n");
157248}
158249
159−fn generate_text_segments(segments: &[TextSegment], out: &mut String) {
250+fn generate_text_segments(
251+ segments: &[TextSegment],
252+ out: &mut String,
253+ styles: &mut StyleCollector,
254+) {
160255 for segment in segments {
161256 match segment {
162257 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
163258 TextSegment::Inline(elem) => {
164− // Render to buffer and trim trailing newlines for inline context
165259 let mut buf = String::new();
166− generate_element(elem, None, &mut buf);
260+ generate_element(elem, None, &mut buf, styles);
167261 out.push_str(buf.trim_end());
168262 }
169263 }
170264 }
171265}
172266
267+// ---------------------------------------------------------------------------
268+// Style helpers
269+// ---------------------------------------------------------------------------
270+
271+fn compute_class(
272+ attrs: &[Attribute],
273+ kind: &ElementKind,
274+ parent_kind: Option<&ElementKind>,
275+ styles: &mut StyleCollector,
276+) -> Option<String> {
277+ let base = attrs_to_css(attrs, "", kind, parent_kind);
278+ let hover = attrs_to_css(attrs, "hover:", kind, parent_kind);
279+ let active = attrs_to_css(attrs, "active:", kind, parent_kind);
280+ let focus = attrs_to_css(attrs, "focus:", kind, parent_kind);
281+ styles.get_class(base, hover, active, focus)
282+}
283+
284+fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
285+ match (gen_class, user_class) {
286+ (Some(g), Some(u)) => {
287+ out.push_str(" class=\"");
288+ out.push_str(g);
289+ out.push(' ');
290+ out.push_str(&html_escape(u));
291+ out.push('"');
292+ }
293+ (Some(g), None) => {
294+ out.push_str(" class=\"");
295+ out.push_str(g);
296+ out.push('"');
297+ }
298+ (None, Some(u)) => {
299+ out.push_str(" class=\"");
300+ out.push_str(&html_escape(u));
301+ out.push('"');
302+ }
303+ (None, None) => {}
304+ }
305+}
306+
173307// ---------------------------------------------------------------------------
174308// Attribute → CSS mapping
175309// ---------------------------------------------------------------------------
176310
311+const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
312+
313+fn is_state_attr(key: &str) -> bool {
314+ STATE_PREFIXES.iter().any(|p| key.starts_with(p))
315+}
316+
177317fn attrs_to_css(
178318 attrs: &[Attribute],
319+ state_prefix: &str,
179320 kind: &ElementKind,
180321 parent_kind: Option<&ElementKind>,
181322) -> String {
182323 let mut css = String::new();
183324
184− // Base styles from element kind
185− match kind {
186− ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
187− ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
188− ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
189− ElementKind::Paragraph => css.push_str("margin:0;"),
190− _ => {}
325+ // Base element styles only for the default (non-state) pass
326+ if state_prefix.is_empty() {
327+ match kind {
328+ ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
329+ ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
330+ ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
331+ ElementKind::Paragraph => css.push_str("margin:0;"),
332+ _ => {}
333+ }
191334 }
192335
193336 for attr in attrs {
337+ // Determine the effective key for this pass
338+ let effective_key = if state_prefix.is_empty() {
339+ if is_state_attr(&attr.key) {
340+ continue;
341+ }
342+ attr.key.as_str()
343+ } else {
344+ match attr.key.strip_prefix(state_prefix) {
345+ Some(k) => k,
346+ None => continue,
347+ }
348+ };
349+
194350 let val = attr.value.as_deref();
195351
196− match attr.key.as_str() {
352+ match effective_key {
197353 // Layout
198354 "spacing" => {
199355 if let Some(v) = val {
@@ -246,9 +402,7 @@ fn attrs_to_css(
246402 }
247403 _ => push_css(&mut css, "width", "100%"),
248404 },
249− "shrink" => {
250− push_css(&mut css, "flex-shrink", "0");
251− }
405+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
252406 _ => push_css(&mut css, "width", &format!("{}px", v)),
253407 }
254408 }
@@ -263,9 +417,7 @@ fn attrs_to_css(
263417 }
264418 _ => push_css(&mut css, "height", "100%"),
265419 },
266− "shrink" => {
267− push_css(&mut css, "flex-shrink", "0");
268− }
420+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
269421 _ => push_css(&mut css, "height", &format!("{}px", v)),
270422 }
271423 }
@@ -302,9 +454,7 @@ fn attrs_to_css(
302454 }
303455 },
304456 "center-y" => match parent_kind {
305− Some(ElementKind::Row) => {
306− push_css(&mut css, "align-self", "center");
307− }
457+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
308458 _ => {
309459 push_css(&mut css, "margin-top", "auto");
310460 push_css(&mut css, "margin-bottom", "auto");
@@ -314,33 +464,21 @@ fn attrs_to_css(
314464 Some(ElementKind::Column) | Some(ElementKind::El) => {
315465 push_css(&mut css, "align-self", "flex-start");
316466 }
317− _ => {
318− push_css(&mut css, "margin-right", "auto");
319− }
467+ _ => push_css(&mut css, "margin-right", "auto"),
320468 },
321469 "align-right" => match parent_kind {
322470 Some(ElementKind::Column) | Some(ElementKind::El) => {
323471 push_css(&mut css, "align-self", "flex-end");
324472 }
325− _ => {
326− push_css(&mut css, "margin-left", "auto");
327− }
473+ _ => push_css(&mut css, "margin-left", "auto"),
328474 },
329475 "align-top" => match parent_kind {
330− Some(ElementKind::Row) => {
331− push_css(&mut css, "align-self", "flex-start");
332− }
333− _ => {
334− push_css(&mut css, "margin-bottom", "auto");
335− }
476+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
477+ _ => push_css(&mut css, "margin-bottom", "auto"),
336478 },
337479 "align-bottom" => match parent_kind {
338− Some(ElementKind::Row) => {
339− push_css(&mut css, "align-self", "flex-end");
340− }
341− _ => {
342− push_css(&mut css, "margin-top", "auto");
343− }
480+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
481+ _ => push_css(&mut css, "margin-top", "auto"),
344482 },
345483
346484 // Style
@@ -390,14 +528,28 @@ fn attrs_to_css(
390528 push_css(&mut css, "font-family", v);
391529 }
392530 }
531+ "transition" => {
532+ if let Some(v) = val {
533+ push_css(&mut css, "transition", v);
534+ }
535+ }
536+ "cursor" => {
537+ if let Some(v) = val {
538+ push_css(&mut css, "cursor", v);
539+ }
540+ }
541+ "opacity" => {
542+ if let Some(v) = val {
543+ push_css(&mut css, "opacity", v);
544+ }
545+ }
393546
394547 // Flow
395548 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
396549
397− // Identity — handled separately, not CSS
550+ // Identity — not CSS
398551 "id" | "class" => {}
399552
400− // Unknown — ignore
401553 _ => {}
402554 }
403555 }