@@ -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 }