on cd9c463 help sign in
htmlang/src/codegen.rs
1086 lines 35 kB history raw
1use std::collections::HashMap;
3use crate::ast::*;
5// ---------------------------------------------------------------------------
6// Style collector: deduplicates CSS and assigns class names
7// ---------------------------------------------------------------------------
9/// (min-width breakpoint, prefix)
10const BREAKPOINTS: &[(&str, &str)] = &[
11 ("sm", "640px"),
12 ("md", "768px"),
13 ("lg", "1024px"),
14 ("xl", "1280px"),
15];
17struct StyleEntry {
18 class_name: String,
19 base: String,
20 hover: String,
21 active: String,
22 focus: String,
23 /// Responsive overrides: (breakpoint_prefix, css)
24 responsive: Vec<(String, String)>,
27struct StyleCollector {
28 entries: Vec<StyleEntry>,
29 index: HashMap<String, usize>,
32impl StyleCollector {
33 fn new() -> Self {
34 StyleCollector {
35 entries: Vec::new(),
36 index: HashMap::new(),
37 }
38 }
40 /// Returns a class name for this style combination, or None if all empty.
41 fn get_class(
42 &mut self,
43 base: String,
44 hover: String,
45 active: String,
46 focus: String,
47 responsive: Vec<(String, String)>,
48 ) -> Option<String> {
49 if base.is_empty()
50 && hover.is_empty()
51 && active.is_empty()
52 && focus.is_empty()
53 && responsive.is_empty()
54 {
55 return None;
56 }
57 let resp_key: String = responsive
58 .iter()
59 .map(|(bp, css)| format!("{}={}", bp, css))
60 .collect::<Vec<_>>()
61 .join("|");
62 let key = format!("{}|{}|{}|{}|{}", base, hover, active, focus, resp_key);
63 if let Some(&idx) = self.index.get(&key) {
64 return Some(self.entries[idx].class_name.clone());
65 }
66 let name = format!("_{}", self.entries.len());
67 let idx = self.entries.len();
68 self.entries.push(StyleEntry {
69 class_name: name.clone(),
70 base,
71 hover,
72 active,
73 focus,
74 responsive,
75 });
76 self.index.insert(key, idx);
77 Some(name)
78 }
80 fn to_css_formatted(&self, dev: bool) -> String {
81 let mut css = String::new();
82 let (sp, nl) = if dev { (" ", "\n") } else { ("", "") };
84 // Non-responsive rules
85 for e in &self.entries {
86 if !e.base.is_empty() {
87 css.push_str(&format!(".{}{sp}{{{}}}{nl}", e.class_name, e.base));
88 }
89 if !e.hover.is_empty() {
90 css.push_str(&format!(".{}:hover{sp}{{{}}}{nl}", e.class_name, e.hover));
91 }
92 if !e.active.is_empty() {
93 css.push_str(&format!(".{}:active{sp}{{{}}}{nl}", e.class_name, e.active));
94 }
95 if !e.focus.is_empty() {
96 css.push_str(&format!(".{}:focus{sp}{{{}}}{nl}", e.class_name, e.focus));
97 }
98 }
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 }
129 css
130 }
133struct GenContext {
134 dev: bool,
135 depth: usize,
138impl GenContext {
139 fn indent(&self) -> String {
140 if self.dev {
141 " ".repeat(self.depth)
142 } else {
143 String::new()
144 }
145 }
147 fn nl(&self) -> &str {
148 if self.dev { "\n" } else { "" }
149 }
152// ---------------------------------------------------------------------------
153// Public API
154// ---------------------------------------------------------------------------
156pub fn generate(doc: &Document) -> String {
157 generate_with_options(doc, false)
160pub fn generate_dev(doc: &Document) -> String {
161 generate_with_options(doc, true)
164fn generate_with_options(doc: &Document, dev: bool) -> String {
165 let mut styles = StyleCollector::new();
166 let mut ctx = GenContext { dev, depth: 0 };
167 let mut body = String::new();
169 for node in &doc.nodes {
170 generate_node(node, None, &mut body, &mut styles, &mut ctx);
171 }
173 let mut element_css = String::new();
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 }
195 element_css.push_str(&styles.to_css_formatted(dev));
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 }
206 // @style blocks (custom CSS)
207 for block in &doc.custom_css {
208 if dev {
209 element_css.push_str(block);
210 element_css.push('\n');
211 } else {
212 // Minify: collapse whitespace
213 let minified: String = block
214 .lines()
215 .map(|l| l.trim())
216 .collect::<Vec<_>>()
217 .join("");
218 element_css.push_str(&minified);
219 }
220 }
222 // Build meta tags string
223 let meta_html = if doc.meta_tags.is_empty() {
224 String::new()
225 } else {
226 let mut m = String::new();
227 for (name, content) in &doc.meta_tags {
228 if dev {
229 m.push_str(&format!(
230 "<meta name=\"{}\" content=\"{}\">\n",
231 html_escape(name),
232 html_escape(content)
233 ));
234 } else {
235 m.push_str(&format!(
236 "<meta name=\"{}\" content=\"{}\">",
237 html_escape(name),
238 html_escape(content)
239 ));
240 }
241 }
242 m
243 };
245 // Build head blocks string
246 let head_html = if doc.head_blocks.is_empty() {
247 String::new()
248 } else {
249 let mut h = String::new();
250 for block in &doc.head_blocks {
251 h.push_str(block);
252 if dev {
253 h.push('\n');
254 }
255 }
256 h
257 };
259 match &doc.page_title {
260 Some(title) => {
261 if dev {
262 format!(
263 "\
264<!DOCTYPE html>
265<html>
266<head>
267<meta charset=\"utf-8\">
268<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">
269<title>{title}</title>
270{meta_html}{head_html}\
271<style>
272*, *::before, *::after {{ box-sizing: border-box; }}
273body {{ margin: 0; font-family: system-ui, -apple-system, sans-serif; }}
274img {{ display: block; }}
275{element_css}\
276</style>
277</head>
278<body>
279{body}\
280</body>
281</html>
282",
283 title = html_escape(title),
284 meta_html = meta_html,
285 head_html = head_html,
286 element_css = element_css,
287 body = body,
288 )
289 } else {
290 format!(
291 "<!DOCTYPE html><html><head><meta charset=\"utf-8\"><meta name=\"viewport\" content=\"width=device-width, initial-scale=1\"><title>{title}</title>{meta_html}{head_html}<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>",
292 title = html_escape(title),
293 meta_html = meta_html,
294 head_html = head_html,
295 element_css = element_css,
296 body = body,
297 )
298 }
299 }
300 None => {
301 if element_css.is_empty() {
302 body
303 } else {
304 if dev {
305 format!("<style>\n{}</style>\n{}", element_css, body)
306 } else {
307 format!("<style>{}</style>{}", element_css, body)
308 }
309 }
310 }
311 }
314// ---------------------------------------------------------------------------
315// Node generation
316// ---------------------------------------------------------------------------
318fn generate_node(
319 node: &Node,
320 parent_kind: Option<&ElementKind>,
321 out: &mut String,
322 styles: &mut StyleCollector,
323 ctx: &mut GenContext,
324) {
325 match node {
326 Node::Element(elem) => generate_element(elem, parent_kind, out, styles, ctx),
327 Node::Text(segments) => {
328 let needs_wrap = matches!(
329 parent_kind,
330 Some(ElementKind::Row) | Some(ElementKind::Column) | Some(ElementKind::El)
331 );
332 if needs_wrap {
333 out.push_str(&ctx.indent());
334 out.push_str("<span>");
335 }
336 generate_text_segments(segments, out, styles, ctx);
337 if needs_wrap {
338 out.push_str("</span>");
339 out.push_str(ctx.nl());
340 }
341 }
342 Node::Raw(content) => {
343 out.push_str(&ctx.indent());
344 out.push_str(content);
345 out.push_str(ctx.nl());
346 }
347 }
350/// HTML attributes that are passed through to the HTML tag rather than converted to CSS.
351const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
352 "type", "placeholder", "name", "value", "disabled", "required", "checked",
353 "for", "action", "method", "autocomplete",
354 "min", "max", "step", "pattern", "maxlength", "rows", "cols", "multiple",
355 "alt", "role", "tabindex", "title",
356];
358/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
359const BOOLEAN_HTML_ATTRS: &[&str] = &["disabled", "required", "checked", "multiple"];
361fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
362 for attr in attrs {
363 let key = attr.key.as_str();
364 let is_passthrough = HTML_PASSTHROUGH_ATTRS.contains(&key)
365 || key.starts_with("aria-")
366 || key.starts_with("data-");
367 if !is_passthrough {
368 continue;
369 }
370 if BOOLEAN_HTML_ATTRS.contains(&key) && attr.value.is_none() {
371 out.push(' ');
372 out.push_str(key);
373 } else if let Some(val) = &attr.value {
374 out.push(' ');
375 out.push_str(key);
376 out.push_str("=\"");
377 out.push_str(&html_escape(val));
378 out.push('"');
379 }
380 }
383fn generate_element(
384 elem: &Element,
385 parent_kind: Option<&ElementKind>,
386 out: &mut String,
387 styles: &mut StyleCollector,
388 ctx: &mut GenContext,
389) {
390 // Self-closing elements
391 if matches!(elem.kind, ElementKind::Image | ElementKind::Input) {
392 generate_self_closing(elem, parent_kind, out, styles, ctx);
393 return;
394 }
395 if elem.kind == ElementKind::Children {
396 return;
397 }
398 if matches!(elem.kind, ElementKind::Slot(_)) {
399 return;
400 }
402 let tag = match elem.kind {
403 ElementKind::Row | ElementKind::Column | ElementKind::El => "div",
404 ElementKind::Text => "span",
405 ElementKind::Paragraph => "p",
406 ElementKind::Link => "a",
407 ElementKind::Button => "button",
408 ElementKind::Select => "select",
409 ElementKind::Textarea => "textarea",
410 ElementKind::Option => "option",
411 ElementKind::Label => "label",
412 ElementKind::Image | ElementKind::Input | ElementKind::Children | ElementKind::Slot(_) => unreachable!(),
413 };
415 let kind_label = match elem.kind {
416 ElementKind::Row => "row",
417 ElementKind::Column => "column",
418 ElementKind::El => "el",
419 ElementKind::Text => "text",
420 ElementKind::Paragraph => "paragraph",
421 ElementKind::Link => "link",
422 ElementKind::Button => "button",
423 ElementKind::Select => "select",
424 ElementKind::Textarea => "textarea",
425 ElementKind::Option => "option",
426 ElementKind::Label => "label",
427 _ => "",
428 };
430 // Compute CSS for each state and get a class name
431 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
432 let (id, user_class) = extract_id_class(&elem.attrs);
434 if ctx.dev && elem.line_num > 0 {
435 out.push_str(&ctx.indent());
436 out.push_str(&format!("<!-- @{} line {} -->\n", kind_label, elem.line_num));
437 }
438 out.push_str(&ctx.indent());
439 out.push('<');
440 out.push_str(tag);
442 if elem.kind == ElementKind::Link {
443 if let Some(url) = &elem.argument {
444 out.push_str(" href=\"");
445 out.push_str(&html_escape(url));
446 out.push('"');
447 }
448 }
450 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
452 if let Some(id) = id {
453 out.push_str(" id=\"");
454 out.push_str(&html_escape(&id));
455 out.push('"');
456 }
458 emit_html_passthrough_attrs(out, &elem.attrs);
460 out.push('>');
461 out.push_str(ctx.nl());
463 // Inline text argument
464 if matches!(
465 elem.kind,
466 ElementKind::Text
467 | ElementKind::Button
468 | ElementKind::Label
469 | ElementKind::Option
470 | ElementKind::Textarea
471 ) {
472 if let Some(text) = &elem.argument {
473 out.push_str(&html_escape(text));
474 }
475 }
477 // Children
478 ctx.depth += 1;
479 let is_paragraph = elem.kind == ElementKind::Paragraph;
480 for (i, child) in elem.children.iter().enumerate() {
481 generate_node(child, Some(&elem.kind), out, styles, ctx);
482 if is_paragraph && i < elem.children.len() - 1 {
483 out.push(' ');
484 }
485 }
486 ctx.depth -= 1;
488 out.push_str(&ctx.indent());
489 out.push_str("</");
490 out.push_str(tag);
491 out.push('>');
492 out.push_str(ctx.nl());
495fn generate_self_closing(
496 elem: &Element,
497 parent_kind: Option<&ElementKind>,
498 out: &mut String,
499 styles: &mut StyleCollector,
500 ctx: &GenContext,
501) {
502 let gen_class = compute_class(&elem.attrs, &elem.kind, parent_kind, styles);
503 let (id, user_class) = extract_id_class(&elem.attrs);
505 let (tag, kind_label) = match elem.kind {
506 ElementKind::Image => ("img", "image"),
507 ElementKind::Input => ("input", "input"),
508 _ => unreachable!(),
509 };
511 if ctx.dev && elem.line_num > 0 {
512 out.push_str(&ctx.indent());
513 out.push_str(&format!("<!-- @{} line {} -->\n", kind_label, elem.line_num));
514 }
515 out.push_str(&ctx.indent());
516 out.push('<');
517 out.push_str(tag);
519 // Image src
520 if elem.kind == ElementKind::Image {
521 let src = elem.argument.as_deref().unwrap_or("");
522 out.push_str(" src=\"");
523 out.push_str(&html_escape(src));
524 out.push('"');
525 }
527 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
529 if let Some(id) = id {
530 out.push_str(" id=\"");
531 out.push_str(&html_escape(&id));
532 out.push('"');
533 }
535 emit_html_passthrough_attrs(out, &elem.attrs);
537 out.push('>');
538 out.push_str(ctx.nl());
541fn generate_text_segments(
542 segments: &[TextSegment],
543 out: &mut String,
544 styles: &mut StyleCollector,
545 ctx: &mut GenContext,
546) {
547 for segment in segments {
548 match segment {
549 TextSegment::Plain(text) => out.push_str(&html_escape(text)),
550 TextSegment::Inline(elem) => {
551 let mut buf = String::new();
552 generate_element(elem, None, &mut buf, styles, ctx);
553 out.push_str(buf.trim_end());
554 }
555 }
556 }
559// ---------------------------------------------------------------------------
560// Style helpers
561// ---------------------------------------------------------------------------
563fn compute_class(
564 attrs: &[Attribute],
565 kind: &ElementKind,
566 parent_kind: Option<&ElementKind>,
567 styles: &mut StyleCollector,
568) -> Option<String> {
569 let base = attrs_to_css(attrs, "", kind, parent_kind);
570 let hover = attrs_to_css(attrs, "hover:", kind, parent_kind);
571 let active = attrs_to_css(attrs, "active:", kind, parent_kind);
572 let focus = attrs_to_css(attrs, "focus:", kind, parent_kind);
574 // Collect responsive overrides
575 let mut responsive = Vec::new();
576 for &(bp_name, _) in BREAKPOINTS {
577 let prefix = format!("{}:", bp_name);
578 let css = attrs_to_css(attrs, &prefix, kind, parent_kind);
579 if !css.is_empty() {
580 responsive.push((bp_name.to_string(), css));
581 }
582 }
584 styles.get_class(base, hover, active, focus, responsive)
587fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
588 match (gen_class, user_class) {
589 (Some(g), Some(u)) => {
590 out.push_str(" class=\"");
591 out.push_str(g);
592 out.push(' ');
593 out.push_str(&html_escape(u));
594 out.push('"');
595 }
596 (Some(g), None) => {
597 out.push_str(" class=\"");
598 out.push_str(g);
599 out.push('"');
600 }
601 (None, Some(u)) => {
602 out.push_str(" class=\"");
603 out.push_str(&html_escape(u));
604 out.push('"');
605 }
606 (None, None) => {}
607 }
610// ---------------------------------------------------------------------------
611// Attribute → CSS mapping
612// ---------------------------------------------------------------------------
614const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
615const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:"];
617fn is_prefixed_attr(key: &str) -> bool {
618 STATE_PREFIXES.iter().any(|p| key.starts_with(p))
619 || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
622fn attrs_to_css(
623 attrs: &[Attribute],
624 state_prefix: &str,
625 kind: &ElementKind,
626 parent_kind: Option<&ElementKind>,
627) -> String {
628 let mut css = String::new();
630 // Base element styles only for the default (non-state) pass
631 if state_prefix.is_empty() {
632 match kind {
633 ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
634 ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
635 ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
636 ElementKind::Paragraph => css.push_str("margin:0;"),
637 _ => {}
638 }
639 }
641 for attr in attrs {
642 // Determine the effective key for this pass
643 let effective_key = if state_prefix.is_empty() {
644 if is_prefixed_attr(&attr.key) {
645 continue;
646 }
647 attr.key.as_str()
648 } else {
649 match attr.key.strip_prefix(state_prefix) {
650 Some(k) => k,
651 None => continue,
652 }
653 };
655 let val = attr.value.as_deref();
657 match effective_key {
658 // Layout
659 "spacing" | "gap" => {
660 if let Some(v) = val {
661 push_css(&mut css, "gap", &css_px(v));
662 }
663 }
664 "padding" => {
665 if let Some(v) = val {
666 push_css(&mut css, "padding", &css_px_multi(v));
667 }
668 }
669 "padding-x" => {
670 if let Some(v) = val {
671 push_css(&mut css, "padding-left", &css_px(v));
672 push_css(&mut css, "padding-right", &css_px(v));
673 }
674 }
675 "padding-y" => {
676 if let Some(v) = val {
677 push_css(&mut css, "padding-top", &css_px(v));
678 push_css(&mut css, "padding-bottom", &css_px(v));
679 }
680 }
682 // Sizing
683 "width" => {
684 if let Some(v) = val {
685 match v {
686 "fill" => match parent_kind {
687 Some(ElementKind::Row) => {
688 push_css(&mut css, "flex", "1");
689 push_css(&mut css, "min-width", "0");
690 }
691 _ => push_css(&mut css, "width", "100%"),
692 },
693 "shrink" => push_css(&mut css, "flex-shrink", "0"),
694 _ => push_css(&mut css, "width", &css_px(v)),
695 }
696 }
697 }
698 "height" => {
699 if let Some(v) = val {
700 match v {
701 "fill" => match parent_kind {
702 Some(ElementKind::Column) => {
703 push_css(&mut css, "flex", "1");
704 push_css(&mut css, "min-height", "0");
705 }
706 _ => push_css(&mut css, "height", "100%"),
707 },
708 "shrink" => push_css(&mut css, "flex-shrink", "0"),
709 _ => push_css(&mut css, "height", &css_px(v)),
710 }
711 }
712 }
713 "min-width" => {
714 if let Some(v) = val {
715 push_css(&mut css, "min-width", &css_px(v));
716 }
717 }
718 "max-width" => {
719 if let Some(v) = val {
720 push_css(&mut css, "max-width", &css_px(v));
721 }
722 }
723 "min-height" => {
724 if let Some(v) = val {
725 push_css(&mut css, "min-height", &css_px(v));
726 }
727 }
728 "max-height" => {
729 if let Some(v) = val {
730 push_css(&mut css, "max-height", &css_px(v));
731 }
732 }
734 // Alignment
735 "center-x" => match parent_kind {
736 Some(ElementKind::Column) | Some(ElementKind::El) => {
737 push_css(&mut css, "align-self", "center");
738 }
739 _ => {
740 push_css(&mut css, "margin-left", "auto");
741 push_css(&mut css, "margin-right", "auto");
742 }
743 },
744 "center-y" => match parent_kind {
745 Some(ElementKind::Row) => push_css(&mut css, "align-self", "center"),
746 _ => {
747 push_css(&mut css, "margin-top", "auto");
748 push_css(&mut css, "margin-bottom", "auto");
749 }
750 },
751 "align-left" => match parent_kind {
752 Some(ElementKind::Column) | Some(ElementKind::El) => {
753 push_css(&mut css, "align-self", "flex-start");
754 }
755 _ => push_css(&mut css, "margin-right", "auto"),
756 },
757 "align-right" => match parent_kind {
758 Some(ElementKind::Column) | Some(ElementKind::El) => {
759 push_css(&mut css, "align-self", "flex-end");
760 }
761 _ => push_css(&mut css, "margin-left", "auto"),
762 },
763 "align-top" => match parent_kind {
764 Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-start"),
765 _ => push_css(&mut css, "margin-bottom", "auto"),
766 },
767 "align-bottom" => match parent_kind {
768 Some(ElementKind::Row) => push_css(&mut css, "align-self", "flex-end"),
769 _ => push_css(&mut css, "margin-top", "auto"),
770 },
772 // Style
773 "background" => {
774 if let Some(v) = val {
775 push_css(&mut css, "background", v);
776 }
777 }
778 "color" => {
779 if let Some(v) = val {
780 push_css(&mut css, "color", v);
781 }
782 }
783 "border" => {
784 if let Some(v) = val {
785 let parts: Vec<&str> = v.splitn(2, ' ').collect();
786 if parts.len() == 2 {
787 push_css(
788 &mut css,
789 "border",
790 &format!("{} solid {}", css_px(parts[0]), parts[1]),
791 );
792 } else {
793 push_css(
794 &mut css,
795 "border",
796 &format!("{} solid currentColor", css_px(parts[0])),
797 );
798 }
799 }
800 }
801 "border-top" | "border-bottom" | "border-left" | "border-right" => {
802 if let Some(v) = val {
803 let parts: Vec<&str> = v.splitn(2, ' ').collect();
804 if parts.len() == 2 {
805 push_css(
806 &mut css,
807 effective_key,
808 &format!("{} solid {}", css_px(parts[0]), parts[1]),
809 );
810 } else {
811 push_css(
812 &mut css,
813 effective_key,
814 &format!("{} solid currentColor", css_px(parts[0])),
815 );
816 }
817 }
818 }
819 "rounded" => {
820 if let Some(v) = val {
821 push_css(&mut css, "border-radius", &css_px(v));
822 }
823 }
824 "bold" => push_css(&mut css, "font-weight", "bold"),
825 "italic" => push_css(&mut css, "font-style", "italic"),
826 "underline" => push_css(&mut css, "text-decoration", "underline"),
827 "size" => {
828 if let Some(v) = val {
829 push_css(&mut css, "font-size", &css_px(v));
830 }
831 }
832 "font" => {
833 if let Some(v) = val {
834 push_css(&mut css, "font-family", v);
835 }
836 }
837 "transition" => {
838 if let Some(v) = val {
839 push_css(&mut css, "transition", v);
840 }
841 }
842 "cursor" => {
843 if let Some(v) = val {
844 push_css(&mut css, "cursor", v);
845 }
846 }
847 "opacity" => {
848 if let Some(v) = val {
849 push_css(&mut css, "opacity", v);
850 }
851 }
853 // Typography
854 "text-align" => {
855 if let Some(v) = val {
856 push_css(&mut css, "text-align", v);
857 }
858 }
859 "line-height" => {
860 if let Some(v) = val {
861 push_css(&mut css, "line-height", v);
862 }
863 }
864 "letter-spacing" => {
865 if let Some(v) = val {
866 push_css(&mut css, "letter-spacing", &css_px(v));
867 }
868 }
869 "text-transform" => {
870 if let Some(v) = val {
871 push_css(&mut css, "text-transform", v);
872 }
873 }
874 "white-space" => {
875 if let Some(v) = val {
876 push_css(&mut css, "white-space", v);
877 }
878 }
880 // Overflow & positioning
881 "overflow" => {
882 if let Some(v) = val {
883 push_css(&mut css, "overflow", v);
884 }
885 }
886 "position" => {
887 if let Some(v) = val {
888 push_css(&mut css, "position", v);
889 }
890 }
891 "top" => {
892 if let Some(v) = val {
893 push_css(&mut css, "top", &css_px(v));
894 }
895 }
896 "right" => {
897 if let Some(v) = val {
898 push_css(&mut css, "right", &css_px(v));
899 }
900 }
901 "bottom" => {
902 if let Some(v) = val {
903 push_css(&mut css, "bottom", &css_px(v));
904 }
905 }
906 "left" => {
907 if let Some(v) = val {
908 push_css(&mut css, "left", &css_px(v));
909 }
910 }
911 "z-index" => {
912 if let Some(v) = val {
913 push_css(&mut css, "z-index", v);
914 }
915 }
917 // Display & visibility
918 "display" => {
919 if let Some(v) = val {
920 push_css(&mut css, "display", v);
921 }
922 }
923 "visibility" => {
924 if let Some(v) = val {
925 push_css(&mut css, "visibility", v);
926 }
927 }
929 // Transform & filters
930 "transform" => {
931 if let Some(v) = val {
932 push_css(&mut css, "transform", v);
933 }
934 }
935 "backdrop-filter" => {
936 if let Some(v) = val {
937 push_css(&mut css, "backdrop-filter", v);
938 }
939 }
941 // Effects
942 "shadow" => {
943 if let Some(v) = val {
944 push_css(&mut css, "box-shadow", v);
945 }
946 }
948 // Flow
949 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
950 "gap-x" => {
951 if let Some(v) = val {
952 push_css(&mut css, "column-gap", &css_px(v));
953 }
954 }
955 "gap-y" => {
956 if let Some(v) = val {
957 push_css(&mut css, "row-gap", &css_px(v));
958 }
959 }
961 // Grid
962 "grid" => {
963 push_css(&mut css, "display", "grid");
964 }
965 "grid-cols" => {
966 if let Some(v) = val {
967 if let Ok(n) = v.parse::<u32>() {
968 push_css(&mut css, "grid-template-columns", &format!("repeat({},1fr)", n));
969 } else {
970 push_css(&mut css, "grid-template-columns", v);
971 }
972 }
973 }
974 "grid-rows" => {
975 if let Some(v) = val {
976 if let Ok(n) = v.parse::<u32>() {
977 push_css(&mut css, "grid-template-rows", &format!("repeat({},1fr)", n));
978 } else {
979 push_css(&mut css, "grid-template-rows", v);
980 }
981 }
982 }
983 "col-span" => {
984 if let Some(v) = val {
985 push_css(&mut css, "grid-column", &format!("span {}", v));
986 }
987 }
988 "row-span" => {
989 if let Some(v) = val {
990 push_css(&mut css, "grid-row", &format!("span {}", v));
991 }
992 }
994 // Animation
995 "animation" => {
996 if let Some(v) = val {
997 push_css(&mut css, "animation", v);
998 }
999 }
1001 // Container queries
1002 "container" => {
1003 push_css(&mut css, "container-type", "inline-size");
1005 "container-name" => {
1006 if let Some(v) = val {
1007 push_css(&mut css, "container-name", v);
1010 "container-type" => {
1011 if let Some(v) = val {
1012 push_css(&mut css, "container-type", v);
1016 // Identity and HTML passthrough — not CSS
1017 "id" | "class" => {}
1018 "type" | "placeholder" | "name" | "value" | "disabled" | "required"
1019 | "checked" | "for" | "action" | "method" | "autocomplete" | "min"
1020 | "max" | "step" | "pattern" | "maxlength" | "rows" | "cols"
1021 | "multiple" | "alt" | "role" | "tabindex" | "title" => {}
1023 _ => {}
1027 css
1030fn push_css(css: &mut String, prop: &str, value: &str) {
1031 css.push_str(prop);
1032 css.push(':');
1033 css.push_str(value);
1034 css.push(';');
1037/// Known CSS units — if a value ends with one, skip appending `px`.
1038const CSS_UNITS: &[&str] = &[
1039 "%", "rem", "em", "vh", "vw", "vmin", "vmax", "dvh", "svh", "lvh",
1040 "ch", "ex", "cm", "mm", "in", "pt", "pc", "fr",
1043/// Format a numeric value: if it already has a CSS unit, pass through as-is;
1044/// otherwise append `px`.
1045fn css_px(value: &str) -> String {
1046 let v = value.trim();
1047 if v == "0" {
1048 return "0".to_string();
1050 if CSS_UNITS.iter().any(|u| v.ends_with(u)) {
1051 return v.to_string();
1053 if v.starts_with("var(") || v.starts_with("calc(") {
1054 return v.to_string();
1056 format!("{}px", v)
1059/// Format multiple space-separated values, each getting px if needed.
1060fn css_px_multi(value: &str) -> String {
1061 value
1062 .split_whitespace()
1063 .map(|p| css_px(p))
1064 .collect::<Vec<_>>()
1065 .join(" ")
1068fn extract_id_class(attrs: &[Attribute]) -> (Option<String>, Option<String>) {
1069 let mut id = None;
1070 let mut class = None;
1071 for attr in attrs {
1072 match attr.key.as_str() {
1073 "id" => id = attr.value.clone(),
1074 "class" => class = attr.value.clone(),
1075 _ => {}
1078 (id, class)
1081fn html_escape(s: &str) -> String {
1082 s.replace('&', "&amp;")
1083 .replace('<', "&lt;")
1084 .replace('>', "&gt;")
1085 .replace('"', "&quot;")