@@ -1,11 +1,94 @@
1+use std::collections::HashMap;
1use crate::ast::*;
3use crate::ast::*;
5+// ---------------------------------------------------------------------------
6+// Style collector: deduplicates CSS and assigns class names
7+// ---------------------------------------------------------------------------
17+struct StyleCollector {
18+ entries: Vec<StyleEntry>,
19+ index: HashMap<String, usize>,
26+ index: HashMap::new(),
30+ /// Returns a class name for this style combination, or None if all empty.
37+ ) -> Option<String> {
38+ if base.is_empty() && hover.is_empty() && active.is_empty() && focus.is_empty() {
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());
45+ let name = format!("_{}", self.entries.len());
46+ let idx = self.entries.len();
47+ self.entries.push(StyleEntry {
48+ class_name: name.clone(),
54+ self.index.insert(key, idx);
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));
64+ if !e.hover.is_empty() {
65+ css.push_str(&format!(".{}:hover{{{}}}\n", e.class_name, e.hover));
67+ if !e.active.is_empty() {
68+ css.push_str(&format!(".{}:active{{{}}}\n", e.class_name, e.active));
70+ if !e.focus.is_empty() {
71+ css.push_str(&format!(".{}:focus{{{}}}\n", e.class_name, e.focus));
78+// ---------------------------------------------------------------------------
80+// ---------------------------------------------------------------------------
3pub fn generate(doc: &Document) -> String {
82pub fn generate(doc: &Document) -> String {
83+ let mut styles = StyleCollector::new();
4 let mut body = String::new();
84 let mut body = String::new();
5 for node in &doc.nodes {
86 for node in &doc.nodes {
6− generate_node(node, None, &mut body);
87+ generate_node(node, None, &mut body, &mut styles);
90+ let element_css = styles.to_css();
9 match &doc.page_title {
92 match &doc.page_title {
10 Some(title) => format!(
93 Some(title) => format!(
@@ -19,6 +102,7 @@ pub fn generate(doc: &Document) -> String {
19*, *::before, *::after {{ box-sizing: border-box; }}
102*, *::before, *::after {{ box-sizing: border-box; }}
20body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
103body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
21img {{ display: block; }}
104img {{ display: block; }}
@@ -27,15 +111,31 @@ img {{ display: block; }}
29 title = html_escape(title),
113 title = html_escape(title),
114+ element_css = element_css,
30 body = body,
115 body = body,
32− None => body,
117+ None => {
118+ if element_css.is_empty() {
121+ format!("<style>\n{}</style>\n{}", element_css, body)
127+// ---------------------------------------------------------------------------
129+// ---------------------------------------------------------------------------
36−fn generate_node(node: &Node, parent_kind: Option<&ElementKind>, out: &mut String) {
133+ parent_kind: Option<&ElementKind>,
135+ styles: &mut StyleCollector,
37 match node {
137 match node {
38− Node::Element(elem) => generate_element(elem, parent_kind, out),
138+ Node::Element(elem) => generate_element(elem, parent_kind, out, styles),
39 Node::Text(segments) => {
139 Node::Text(segments) => {
40 let needs_wrap = matches!(
140 let needs_wrap = matches!(
41 parent_kind,
141 parent_kind,
@@ -44,7 +144,7 @@ fn generate_node(node: &Node, parent_kind: Option<&ElementKind>, out: &mut Strin
44 if needs_wrap {
144 if needs_wrap {
45 out.push_str("<span>");
145 out.push_str("<span>");
47− generate_text_segments(segments, out);
147+ generate_text_segments(segments, out, styles);
48 if needs_wrap {
148 if needs_wrap {
49 out.push_str("</span>");
149 out.push_str("</span>");
@@ -56,14 +156,16 @@ fn generate_node(node: &Node, parent_kind: Option<&ElementKind>, out: &mut Strin
59−fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut String) {
161+ parent_kind: Option<&ElementKind>,
60− // Image is a void element
162+ out: &mut String,
163+ styles: &mut StyleCollector,
61 if elem.kind == ElementKind::Image {
165 if elem.kind == ElementKind::Image {
62− generate_image(elem, parent_kind, out);
166+ generate_image(elem, parent_kind, out, styles);
66− // @children should be replaced during function expansion; skip if it leaks through
67 if elem.kind == ElementKind::Children {
169 if elem.kind == ElementKind::Children {
@@ -76,8 +178,9 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
76 ElementKind::Image | ElementKind::Children => unreachable!(),
178 ElementKind::Image | ElementKind::Children => unreachable!(),
181+ // Compute CSS for each state and get a class name
79− let styles = attrs_to_css(&elem.attrs, &elem.kind, parent_kind);
182+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
80− let (id, class) = extract_id_class(&elem.attrs);
183+ let (id, user_class) = extract_id_class(&elem.attrs);
82 out.push('<');
185 out.push('<');
83 out.push_str(tag);
186 out.push_str(tag);
@@ -90,21 +193,13 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
93− if !styles.is_empty() {
196+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
94− out.push_str(" style=\"");
197+
95− out.push_str(&styles);
98 if let Some(id) = id {
198 if let Some(id) = id {
99 out.push_str(" id=\"");
199 out.push_str(" id=\"");
100 out.push_str(&html_escape(&id));
200 out.push_str(&html_escape(&id));
101 out.push('"');
201 out.push('"');
103− if let Some(class) = class {
104− out.push_str(" class=\"");
105− out.push_str(&html_escape(&class));
108 out.push('>');
203 out.push('>');
110 // Inline text argument (@text content)
205 // Inline text argument (@text content)
@@ -117,8 +212,7 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
117 // Children
212 // Children
118 let is_paragraph = elem.kind == ElementKind::Paragraph;
213 let is_paragraph = elem.kind == ElementKind::Paragraph;
119 for (i, child) in elem.children.iter().enumerate() {
214 for (i, child) in elem.children.iter().enumerate() {
120− generate_node(child, Some(&elem.kind), out);
215+ generate_node(child, Some(&elem.kind), out, styles);
121− // In paragraphs, add newline between children so HTML renders a space
122 if is_paragraph && i < elem.children.len() - 1 {
216 if is_paragraph && i < elem.children.len() - 1 {
123 out.push('\n');
217 out.push('\n');
@@ -129,71 +223,133 @@ fn generate_element(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut
129 out.push_str(">\n");
223 out.push_str(">\n");
132−fn generate_image(elem: &Element, parent_kind: Option<&ElementKind>, out: &mut String) {
228+ parent_kind: Option<&ElementKind>,
230+ styles: &mut StyleCollector,
133 let src = elem.argument.as_deref().unwrap_or("");
232 let src = elem.argument.as_deref().unwrap_or("");
134− let styles = attrs_to_css(&elem.attrs, &elem.kind, parent_kind);
233+ let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
135− let (id, class) = extract_id_class(&elem.attrs);
234+ let (id, user_class) = extract_id_class(&elem.attrs);
137 out.push_str("<img src=\"");
236 out.push_str("<img src=\"");
138 out.push_str(&html_escape(src));
237 out.push_str(&html_escape(src));
139 out.push('"');
238 out.push('"');
141− if !styles.is_empty() {
240+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
142− out.push_str(" style=\"");
241+
143− out.push_str(&styles);
146 if let Some(id) = id {
242 if let Some(id) = id {
147 out.push_str(" id=\"");
243 out.push_str(" id=\"");
148 out.push_str(&html_escape(&id));
244 out.push_str(&html_escape(&id));
149 out.push('"');
245 out.push('"');
151− if let Some(class) = class {
152− out.push_str(" class=\"");
153− out.push_str(&html_escape(&class));
156 out.push_str(">\n");
247 out.push_str(">\n");
159−fn generate_text_segments(segments: &[TextSegment], out: &mut String) {
250+fn generate_text_segments(
251+ segments: &[TextSegment],
253+ styles: &mut StyleCollector,
160 for segment in segments {
255 for segment in segments {
161 match segment {
256 match segment {
162 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
257 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
163 TextSegment::Inline(elem) => {
258 TextSegment::Inline(elem) => {
164− // Render to buffer and trim trailing newlines for inline context
165 let mut buf = String::new();
259 let mut buf = String::new();
166− generate_element(elem, None, &mut buf);
260+ generate_element(elem, None, &mut buf, styles);
167 out.push_str(buf.trim_end());
261 out.push_str(buf.trim_end());
267+// ---------------------------------------------------------------------------
269+// ---------------------------------------------------------------------------
272+ attrs: &[Attribute],
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)
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=\"");
290+ out.push_str(&html_escape(u));
293+ (Some(g), None) => {
294+ out.push_str(" class=\"");
298+ (None, Some(u)) => {
299+ out.push_str(" class=\"");
300+ out.push_str(&html_escape(u));
173// ---------------------------------------------------------------------------
307// ---------------------------------------------------------------------------
174// Attribute → CSS mapping
308// Attribute → CSS mapping
175// ---------------------------------------------------------------------------
309// ---------------------------------------------------------------------------
311+const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
313+fn is_state_attr(key: &str) -> bool {
314+ STATE_PREFIXES.iter().any(|p| key.starts_with(p))
177fn attrs_to_css(
317fn attrs_to_css(
178 attrs: &[Attribute],
318 attrs: &[Attribute],
179 kind: &ElementKind,
320 kind: &ElementKind,
180 parent_kind: Option<&ElementKind>,
321 parent_kind: Option<&ElementKind>,
181) -> String {
322) -> String {
182 let mut css = String::new();
323 let mut css = String::new();
184− // Base styles from element kind
325+ // Base element styles only for the default (non-state) pass
326+ if state_prefix.is_empty() {
185− match kind {
327+ match kind {
186− ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
328+ ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
187− ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
329+ ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
188− ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
330+ ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
189− ElementKind::Paragraph => css.push_str("margin:0;"),
331+ ElementKind::Paragraph => css.push_str("margin:0;"),
193 for attr in attrs {
336 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) {
344+ match attr.key.strip_prefix(state_prefix) {
194 let val = attr.value.as_deref();
350 let val = attr.value.as_deref();
196− match attr.key.as_str() {
352+ match effective_key {
197 // Layout
353 // Layout
198 "spacing" => {
354 "spacing" => {
199 if let Some(v) = val {
355 if let Some(v) = val {
@@ -246,9 +402,7 @@ fn attrs_to_css(
247 _ => push_css(&mut css, "width", "100%"),
403 _ => push_css(&mut css, "width", "100%"),
250− push_css(&mut css, "flex-shrink", "0");
405+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
252 _ => push_css(&mut css, "width", &format!("{}px", v)),
406 _ => push_css(&mut css, "width", &format!("{}px", v)),
@@ -263,9 +417,7 @@ fn attrs_to_css(
264 _ => push_css(&mut css, "height", "100%"),
418 _ => push_css(&mut css, "height", "100%"),
267− push_css(&mut css, "flex-shrink", "0");
420+ "shrink" => push_css(&mut css, "flex-shrink", "0"),
269 _ => push_css(&mut css, "height", &format!("{}px", v)),
421 _ => push_css(&mut css, "height", &format!("{}px", v)),
@@ -302,9 +454,7 @@ fn attrs_to_css(
304 "center-y" => match parent_kind {
456 "center-y" => match parent_kind {
305− Some(ElementKind::Row) => {
306− push_css(&mut css, "align-self", "center");
457+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
309 push_css(&mut css, "margin-top", "auto");
459 push_css(&mut css, "margin-top", "auto");
310 push_css(&mut css, "margin-bottom", "auto");
460 push_css(&mut css, "margin-bottom", "auto");
@@ -314,33 +464,21 @@ fn attrs_to_css(
314 Some(ElementKind::Column) | Some(ElementKind::El) => {
464 Some(ElementKind::Column) | Some(ElementKind::El) => {
315 push_css(&mut css, "align-self", "flex-start");
465 push_css(&mut css, "align-self", "flex-start");
318− push_css(&mut css, "margin-right", "auto");
467+ _ => push_css(&mut css, "margin-right", "auto"),
321 "align-right" => match parent_kind {
469 "align-right" => match parent_kind {
322 Some(ElementKind::Column) | Some(ElementKind::El) => {
470 Some(ElementKind::Column) | Some(ElementKind::El) => {
323 push_css(&mut css, "align-self", "flex-end");
471 push_css(&mut css, "align-self", "flex-end");
326− push_css(&mut css, "margin-left", "auto");
473+ _ => push_css(&mut css, "margin-left", "auto"),
329 "align-top" => match parent_kind {
475 "align-top" => match parent_kind {
330− Some(ElementKind::Row) => {
331− push_css(&mut css, "align-self", "flex-start");
476+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
334− push_css(&mut css, "margin-bottom", "auto");
477+ _ => push_css(&mut css, "margin-bottom", "auto"),
337 "align-bottom" => match parent_kind {
479 "align-bottom" => match parent_kind {
338− Some(ElementKind::Row) => {
339− push_css(&mut css, "align-self", "flex-end");
480+ Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
342− push_css(&mut css, "margin-top", "auto");
481+ _ => push_css(&mut css, "margin-top", "auto"),
@@ -390,14 +528,28 @@ fn attrs_to_css(
390 push_css(&mut css, "font-family", v);
528 push_css(&mut css, "font-family", v);
532+ if let Some(v) = val {
533+ push_css(&mut css, "transition", v);
537+ if let Some(v) = val {
538+ push_css(&mut css, "cursor", v);
542+ if let Some(v) = val {
543+ push_css(&mut css, "opacity", v);
395 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
548 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
397− // Identity — handled separately, not CSS
550+ // Identity — not CSS
398 "id" | "class" => {}
551 "id" | "class" => {}