@@ -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// ---------------------------------------------------------------------------
9+/// (min-width breakpoint, prefix)
10+const BREAKPOINTS: &[(&str, &str)] = &[
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)>,
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()
53+ && responsive.is_empty()
39 return None;
55 return None;
57+ let resp_key: String = responsive
59+ .map(|(bp, css)| format!("{}={}", bp, css))
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());
@@ -50,57 +71,183 @@ impl StyleCollector {
54 self.index.insert(key, idx);
76 self.index.insert(key, idx);
55 Some(name)
77 Some(name)
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 { ("", "") };
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));
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));
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));
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));
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() {
107+ bp_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, rule_css));
109+ bp_css.push_str(&format!(".{}{{{}}}", e.class_name, rule_css));
114+ if !bp_css.is_empty() {
116+ css.push_str(&format!(
117+ "@media (min-width: {}) {{\n{}}}\n",
121+ css.push_str(&format!(
122+ "@media(min-width:{}){{{}}}",
139+ fn indent(&self) -> String {
141+ " ".repeat(self.depth)
147+ fn nl(&self) -> &str {
148+ if self.dev { "\n" } else { "" }
78// ---------------------------------------------------------------------------
152// ---------------------------------------------------------------------------
79// Public API
153// Public API
80// ---------------------------------------------------------------------------
154// ---------------------------------------------------------------------------
82pub fn generate(doc: &Document) -> String {
156pub fn generate(doc: &Document) -> String {
157+ generate_with_options(doc, false)
160+pub fn generate_dev(doc: &Document) -> String {
161+ generate_with_options(doc, true)
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();
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);
90− let element_css = styles.to_css();
173+ let mut element_css = String::new();
175+ // CSS custom properties
176+ if !doc.css_vars.is_empty() {
178+ element_css.push_str(":root {\n");
179+ for (name, value) in &doc.css_vars {
180+ element_css.push_str(&format!(" {}: {};\n", name, value));
182+ element_css.push_str("}\n");
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(';');
191+ element_css.push('}');
195+ element_css.push_str(&styles.to_css_formatted(dev));
198+ for (name, kf_body) in &doc.keyframes {
200+ element_css.push_str(&format!("@keyframes {} {{\n{}\n}}\n", name, kf_body));
202+ element_css.push_str(&format!("@keyframes {}{{{}}}", name, kf_body));
92 match &doc.page_title {
206 match &doc.page_title {
93− Some(title) => format!(
207+ Some(title) => {
214+<meta charset=\"utf-8\">
215+<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
216+<title>{title}</title>
218+*, *::before, *::after {{ box-sizing: border-box; }}
219+body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
220+img {{ display: block; }}
229+ title = html_escape(title),
230+ element_css = element_css,
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,
99 None => {
242 None => {
100 if element_css.is_empty() {
243 if element_css.is_empty() {
103− format!("<style>{}</style>{}", element_css, body)
247+ format!("<style>\n{}</style>\n{}", element_css, body)
249+ format!("<style>{}</style>{}", element_css, body)
@@ -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,
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)
126 if needs_wrap {
274 if needs_wrap {
275+ out.push_str(&ctx.indent());
127 out.push_str("<span>");
276 out.push_str("<span>");
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());
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());
@@ -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,
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);
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!(),
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",
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);
330+ out.push_str(&ctx.indent());
331+ out.push_str(&format!("<!-- @{} -->\n", kind_label));
333+ out.push_str(&ctx.indent());
166 out.push('<');
334 out.push('<');
167 out.push_str(tag);
335 out.push_str(tag);
@@ -182,6 +350,7 @@ fn generate_element(
182 out.push('"');
350 out.push('"');
184 out.push('>');
352 out.push('>');
353+ out.push_str(ctx.nl());
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(
193 // Children
362 // Children
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(' ');
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());
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,
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);
392+ out.push_str(&ctx.indent());
393+ out.push_str("<!-- @image -->\n");
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('"');
228 out.push('>');
407 out.push('>');
408+ out.push_str(ctx.nl());
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,
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());
@@ -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);
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));
262− styles.get_class(base, hover, active, focus)
454+ styles.get_class(base, hover, active, focus, responsive)
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// ---------------------------------------------------------------------------
292const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
484const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
485+const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:"];
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))
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;
323 attr.key.as_str()
517 attr.key.as_str()
@@ -582,6 +776,13 @@ fn attrs_to_css(
781+ if let Some(v) = val {
782+ push_css(&mut css, "animation", v);
585 // Identity — not CSS
786 // Identity — not CSS
586 "id" | "class" => {}
787 "id" | "class" => {}