@@ -22,6 +22,10 @@ struct StyleEntry {
22 focus: String,
22 focus: String,
23 /// Responsive overrides: (breakpoint_prefix, css)
23 /// Responsive overrides: (breakpoint_prefix, css)
24 responsive: Vec<(String, String)>,
24 responsive: Vec<(String, String)>,
25+ /// Dark mode overrides
27struct StyleCollector {
31struct StyleCollector {
@@ -45,12 +49,16 @@ impl StyleCollector {
45 active: String,
49 active: String,
46 focus: String,
50 focus: String,
47 responsive: Vec<(String, String)>,
51 responsive: Vec<(String, String)>,
48 ) -> Option<String> {
54 ) -> Option<String> {
49 if base.is_empty()
55 if base.is_empty()
50 && hover.is_empty()
56 && hover.is_empty()
51 && active.is_empty()
57 && active.is_empty()
52 && focus.is_empty()
58 && focus.is_empty()
53 && responsive.is_empty()
59 && responsive.is_empty()
55 return None;
63 return None;
@@ -59,7 +67,7 @@ impl StyleCollector {
59 .map(|(bp, css)| format!("{}={}", bp, css))
67 .map(|(bp, css)| format!("{}={}", bp, css))
60 .collect::<Vec<_>>()
68 .collect::<Vec<_>>()
61 .join("|");
69 .join("|");
62− let key = format!("{}|{}|{}|{}|{}", base, hover, active, focus, resp_key);
70+ let key = format!("{}|{}|{}|{}|{}|{}|{}", base, hover, active, focus, resp_key, dark, print);
63 if let Some(&idx) = self.index.get(&key) {
71 if let Some(&idx) = self.index.get(&key) {
64 return Some(self.entries[idx].class_name.clone());
72 return Some(self.entries[idx].class_name.clone());
@@ -72,6 +80,8 @@ impl StyleCollector {
74 responsive,
82 responsive,
76 self.index.insert(key, idx);
86 self.index.insert(key, idx);
77 Some(name)
87 Some(name)
@@ -126,6 +136,44 @@ impl StyleCollector {
140+ let mut dark_css = String::new();
141+ for e in &self.entries {
142+ if !e.dark.is_empty() {
144+ dark_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.dark));
146+ dark_css.push_str(&format!(".{}{{{}}}", e.class_name, e.dark));
150+ if !dark_css.is_empty() {
152+ css.push_str(&format!("@media (prefers-color-scheme: dark) {{\n{}}}\n", dark_css));
154+ css.push_str(&format!("@media(prefers-color-scheme:dark){{{}}}", dark_css));
159+ let mut print_css = String::new();
160+ for e in &self.entries {
161+ if !e.print.is_empty() {
163+ print_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.print));
165+ print_css.push_str(&format!(".{}{{{}}}", e.class_name, e.print));
169+ if !print_css.is_empty() {
171+ css.push_str(&format!("@media print {{\n{}}}\n", print_css));
173+ css.push_str(&format!("@media print{{{}}}", print_css));
@@ -331,6 +379,8 @@ fn generate_node(
331 | Some(ElementKind::Nav) | Some(ElementKind::Header) | Some(ElementKind::Footer)
379 | Some(ElementKind::Nav) | Some(ElementKind::Header) | Some(ElementKind::Footer)
332 | Some(ElementKind::Main) | Some(ElementKind::Section) | Some(ElementKind::Article)
380 | Some(ElementKind::Main) | Some(ElementKind::Section) | Some(ElementKind::Article)
333 | Some(ElementKind::Aside) | Some(ElementKind::ListItem)
381 | Some(ElementKind::Aside) | Some(ElementKind::ListItem)
382+ | Some(ElementKind::Form) | Some(ElementKind::Details) | Some(ElementKind::Figure)
383+ | Some(ElementKind::Blockquote)
335 if needs_wrap {
385 if needs_wrap {
336 out.push_str(&ctx.indent());
386 out.push_str(&ctx.indent());
@@ -362,12 +412,21 @@ const HTML_PASSTHROUGH_ATTRS: &[&str] = &[
362 "loading", "decoding",
412 "loading", "decoding",
363 // Media src (explicit attribute form)
413 // Media src (explicit attribute form)
420+ "low", "high", "optimum",
422+ "colspan", "rowspan", "scope",
367/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
425/// Boolean HTML attributes (rendered without a value, e.g., `<input disabled>`).
368const BOOLEAN_HTML_ATTRS: &[&str] = &[
426const BOOLEAN_HTML_ATTRS: &[&str] = &[
369 "disabled", "required", "checked", "multiple",
427 "disabled", "required", "checked", "multiple",
370 "controls", "autoplay", "loop", "muted",
428 "controls", "autoplay", "loop", "muted",
429+ "open", "novalidate",
373fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
432fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
@@ -400,7 +459,7 @@ fn generate_element(
400 ctx: &mut GenContext,
459 ctx: &mut GenContext,
402 // Self-closing elements
461 // Self-closing elements
403− if matches!(elem.kind, ElementKind::Image | ElementKind::Input) {
462+ if matches!(elem.kind, ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule) {
404 generate_self_closing(elem, parent_kind, out, styles, ctx);
463 generate_self_closing(elem, parent_kind, out, styles, ctx);
@@ -444,7 +503,20 @@ fn generate_element(
445 ElementKind::Video => "video",
504 ElementKind::Video => "video",
446 ElementKind::Audio => "audio",
505 ElementKind::Audio => "audio",
506+ // Additional semantic elements
507+ ElementKind::Form => "form",
508+ ElementKind::Details => "details",
509+ ElementKind::Summary => "summary",
510+ ElementKind::Blockquote => "blockquote",
511+ ElementKind::Cite => "cite",
512+ ElementKind::Code => "code",
513+ ElementKind::Pre => "pre",
514+ ElementKind::Figure => "figure",
515+ ElementKind::FigCaption => "figcaption",
516+ ElementKind::Progress => "progress",
517+ ElementKind::Meter => "meter",
447− ElementKind::Image | ElementKind::Input | ElementKind::Children | ElementKind::Slot(_) => unreachable!(),
518+ ElementKind::Image | ElementKind::Input | ElementKind::HorizontalRule
519+ | ElementKind::Children | ElementKind::Slot(_) => unreachable!(),
450 let kind_label = match elem.kind {
522 let kind_label = match elem.kind {
@@ -476,6 +548,15 @@ fn generate_element(
476 ElementKind::TableHeaderCell => "th",
548 ElementKind::TableHeaderCell => "th",
477 ElementKind::Video => "video",
549 ElementKind::Video => "video",
478 ElementKind::Audio => "audio",
550 ElementKind::Audio => "audio",
551+ ElementKind::Form => "form",
552+ ElementKind::Details => "details",
553+ ElementKind::Summary => "summary",
554+ ElementKind::Blockquote => "blockquote",
555+ ElementKind::Cite => "cite",
556+ ElementKind::Code => "code",
557+ ElementKind::Pre => "pre",
558+ ElementKind::Figure => "figure",
559+ ElementKind::FigCaption => "figcaption",
@@ -508,6 +589,17 @@ fn generate_element(
593+ if elem.kind == ElementKind::Form {
594+ if let Some(action) = &elem.argument {
595+ out.push_str(" action=\"");
596+ out.push_str(&html_escape(action));
601+ // Details open attribute (handled via HTML_PASSTHROUGH_ATTRS, no extra handling needed)
511 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
603 emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
513 if let Some(id) = id {
605 if let Some(id) = id {
@@ -532,6 +624,10 @@ fn generate_element(
532 | ElementKind::ListItem
624 | ElementKind::ListItem
533 | ElementKind::TableCell
625 | ElementKind::TableCell
534 | ElementKind::TableHeaderCell
626 | ElementKind::TableHeaderCell
627+ | ElementKind::Summary
630+ | ElementKind::FigCaption
536 if let Some(text) = &elem.argument {
632 if let Some(text) = &elem.argument {
537 out.push_str(&html_escape(text));
633 out.push_str(&html_escape(text));
@@ -569,6 +665,7 @@ fn generate_self_closing(
569 let (tag, kind_label) = match elem.kind {
665 let (tag, kind_label) = match elem.kind {
570 ElementKind::Image => ("img", "image"),
666 ElementKind::Image => ("img", "image"),
571 ElementKind::Input => ("input", "input"),
667 ElementKind::Input => ("input", "input"),
668+ ElementKind::HorizontalRule => ("hr", "hr"),
572 _ => unreachable!(),
669 _ => unreachable!(),
@@ -600,6 +697,21 @@ fn generate_self_closing(
601 // Image optimization: auto-add loading="lazy" and decoding="async"
698 // Image optimization: auto-add loading="lazy" and decoding="async"
602 if elem.kind == ElementKind::Image {
699 if elem.kind == ElementKind::Image {
700+ // SVG inlining: @image [inline] logo.svg
701+ if elem.attrs.iter().any(|a| a.key == "inline") {
702+ if let Some(src) = &elem.argument {
703+ if src.ends_with(".svg") {
704+ if let Ok(svg_content) = std::fs::read_to_string(src) {
705+ // Close the tag we opened, then emit inline SVG instead
706+ out.truncate(out.rfind('<').unwrap_or(0));
707+ out.push_str(&ctx.indent());
708+ out.push_str(svg_content.trim());
709+ out.push_str(ctx.nl());
603 if !elem.attrs.iter().any(|a| a.key == "loading") {
715 if !elem.attrs.iter().any(|a| a.key == "loading") {
604 out.push_str(" loading=\"lazy\"");
716 out.push_str(" loading=\"lazy\"");
@@ -655,7 +767,13 @@ fn compute_class(
771+ let dark = attrs_to_css(attrs, "dark:", kind, parent_kind);
774+ let print = attrs_to_css(attrs, "print:", kind, parent_kind);
658− styles.get_class(base, hover, active, focus, responsive)
776+ styles.get_class(base, hover, active, focus, responsive, dark, print)
661fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
779fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
@@ -687,10 +805,12 @@ fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option
688const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
806const STATE_PREFIXES: &[&str] = &["hover:", "active:", "focus:"];
689const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:"];
807const RESPONSIVE_PREFIXES: &[&str] = &["sm:", "md:", "lg:", "xl:"];
808+const MEDIA_PREFIXES: &[&str] = &["dark:", "print:"];
691fn is_prefixed_attr(key: &str) -> bool {
810fn is_prefixed_attr(key: &str) -> bool {
692 STATE_PREFIXES.iter().any(|p| key.starts_with(p))
811 STATE_PREFIXES.iter().any(|p| key.starts_with(p))
693 || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
812 || RESPONSIVE_PREFIXES.iter().any(|p| key.starts_with(p))
813+ || MEDIA_PREFIXES.iter().any(|p| key.starts_with(p))
696fn attrs_to_css(
816fn attrs_to_css(
@@ -715,6 +835,18 @@ fn attrs_to_css(
715 // Lists: reset browser defaults
835 // Lists: reset browser defaults
716 ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
836 ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
717 ElementKind::ListItem => css.push_str("display:flex;flex-direction:column;"),
837 ElementKind::ListItem => css.push_str("display:flex;flex-direction:column;"),
838+ // Form: flex column like @el
839+ ElementKind::Form => css.push_str("display:flex;flex-direction:column;"),
840+ // Details: flex column
841+ ElementKind::Details => css.push_str("display:flex;flex-direction:column;"),
842+ // Figure: flex column
843+ ElementKind::Figure => css.push_str("display:flex;flex-direction:column;margin:0;"),
844+ // Blockquote: flex column, reset browser margin
845+ ElementKind::Blockquote => css.push_str("display:flex;flex-direction:column;margin:0;"),
846+ // Pre: preserve whitespace
847+ ElementKind::Pre => css.push_str("margin:0;white-space:pre;font-family:ui-monospace,monospace;"),
848+ // Code: monospace font
849+ ElementKind::Code => css.push_str("font-family:ui-monospace,monospace;"),
@@ -1140,6 +1272,118 @@ fn attrs_to_css(
1277+ if let Some(v) = val {
1278+ push_css(&mut css, "margin", &css_px_multi(v));
1282+ if let Some(v) = val {
1283+ push_css(&mut css, "margin-left", &css_px(v));
1284+ push_css(&mut css, "margin-right", &css_px(v));
1288+ if let Some(v) = val {
1289+ push_css(&mut css, "margin-top", &css_px(v));
1290+ push_css(&mut css, "margin-bottom", &css_px(v));
1296+ if let Some(v) = val {
1297+ push_css(&mut css, "filter", v);
1301+ // Object fit/position (for images)
1303+ if let Some(v) = val {
1304+ push_css(&mut css, "object-fit", v);
1307+ "object-position" => {
1308+ if let Some(v) = val {
1309+ push_css(&mut css, "object-position", v);
1315+ if let Some(v) = val {
1316+ push_css(&mut css, "text-shadow", v);
1321+ "text-overflow" => {
1322+ if let Some(v) = val {
1323+ push_css(&mut css, "text-overflow", v);
1328+ "pointer-events" => {
1329+ if let Some(v) = val {
1330+ push_css(&mut css, "pointer-events", v);
1334+ if let Some(v) = val {
1335+ push_css(&mut css, "user-select", v);
1339+ // Flexbox/grid alignment
1340+ "justify-content" => {
1341+ if let Some(v) = val {
1342+ push_css(&mut css, "justify-content", v);
1346+ if let Some(v) = val {
1347+ push_css(&mut css, "align-items", v);
1353+ if let Some(v) = val {
1354+ push_css(&mut css, "order", v);
1358+ // Background extras
1359+ "background-size" => {
1360+ if let Some(v) = val {
1361+ push_css(&mut css, "background-size", v);
1364+ "background-position" => {
1365+ if let Some(v) = val {
1366+ push_css(&mut css, "background-position", v);
1369+ "background-repeat" => {
1370+ if let Some(v) = val {
1371+ push_css(&mut css, "background-repeat", v);
1377+ if let Some(v) = val {
1378+ push_css(&mut css, "word-break", v);
1381+ "overflow-wrap" => {
1382+ if let Some(v) = val {
1383+ push_css(&mut css, "overflow-wrap", v);
1143 // Container queries
1387 // Container queries
1144 "container" => {
1388 "container" => {
1145 push_css(&mut css, "container-type", "inline-size");
1389 push_css(&mut css, "container-type", "inline-size");
@@ -1162,7 +1406,9 @@ fn attrs_to_css(
1162 | "max" | "step" | "pattern" | "maxlength" | "rows" | "cols"
1406 | "max" | "step" | "pattern" | "maxlength" | "rows" | "cols"
1163 | "multiple" | "alt" | "role" | "tabindex" | "title"
1407 | "multiple" | "alt" | "role" | "tabindex" | "title"
1164 | "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload"
1408 | "controls" | "autoplay" | "loop" | "muted" | "poster" | "preload"
1165− | "loading" | "decoding" | "ordered" | "src" => {}
1409+ | "loading" | "decoding" | "ordered" | "src"
1410+ | "open" | "novalidate" | "low" | "high" | "optimum"
1411+ | "colspan" | "rowspan" | "scope" | "inline" => {}