@@ -786,7 +786,13 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
786786 }
787787
788788 let has_custom_css = !doc.custom_css.is_empty();
789− element_css.push_str(&styles.to_css_formatted(dev, !has_custom_css));
789+ let styles_css = styles.to_css_formatted(dev, !has_custom_css);
790+ // Fold literal values declared via `@theme` / `@let --name` back into
791+ // `var(--name)` references so the generated CSS actually uses the
792+ // custom properties emitted in `:root`. Saves bytes and makes runtime
793+ // theming take effect.
794+ let styles_css = substitute_css_vars(&styles_css, &doc.css_vars);
795+ element_css.push_str(&styles_css);
790796
791797 // @keyframes
792798 for (name, kf_body) in &doc.keyframes {
@@ -1295,7 +1301,9 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
12951301 }
12961302 }
12971303
1298− element_css.push_str(&styles.to_css_formatted(dev, !has_custom_css));
1304+ let styles_css = styles.to_css_formatted(dev, !has_custom_css);
1305+ let styles_css = substitute_css_vars(&styles_css, &doc.css_vars);
1306+ element_css.push_str(&styles_css);
12991307
13001308 for (name, kf_body) in &doc.keyframes {
13011309 if dev {
@@ -1495,12 +1503,33 @@ fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
14951503 out.push(' ');
14961504 out.push_str(key);
14971505 out.push_str("=\"");
1498− out.push_str(&html_escape(val));
1506+ out.push_str(&html_escape(strip_string_quotes(val)));
14991507 out.push('"');
15001508 }
15011509 }
15021510}
15031511
1512+/// If the value is wrapped in a single pair of matching quotes (e.g.
1513+/// `"Avatar"`), return the inner content. Quotes act as source-level
1514+/// delimiters in the `.hl` syntax and shouldn't leak into HTML attribute
1515+/// values. Multi-quoted values like `"h h" "s m"` are left unchanged —
1516+/// those are real string tokens (used e.g. by CSS `grid-template-areas`).
1517+fn strip_string_quotes(val: &str) -> &str {
1518+ let bytes = val.as_bytes();
1519+ if bytes.len() < 2 {
1520+ return val;
1521+ }
1522+ let first = bytes[0];
1523+ let last = bytes[bytes.len() - 1];
1524+ if (first == b'"' && last == b'"') || (first == b'\'' && last == b'\'') {
1525+ let inner = &val[1..val.len() - 1];
1526+ if !inner.as_bytes().contains(&first) {
1527+ return inner;
1528+ }
1529+ }
1530+ val
1531+}
1532+
15041533fn generate_element(
15051534 elem: &Element,
15061535 parent_kind: Option<&ElementKind>,
@@ -1867,16 +1896,6 @@ fn generate_element(
18671896 out.push('"');
18681897 }
18691898
1870− // Time datetime
1871− if elem.kind == ElementKind::Time
1872− && let Some(dt) = elem.attrs.iter().find(|a| a.key == "datetime")
1873− && let Some(val) = &dt.value
1874− {
1875− out.push_str(" datetime=\"");
1876− out.push_str(&html_escape(val));
1877− out.push('"');
1878− }
1879−
18801899 // Critical CSS: inline styles directly instead of using a class
18811900 let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
18821901 if is_critical {
@@ -2257,6 +2276,30 @@ fn compute_class(
22572276 let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind);
22582277 let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind);
22592278
2279+ // Dedupe: if a property is declared twice within a single rule, keep only
2280+ // the last occurrence (element-kind defaults are written before
2281+ // user attributes, so a user [list-style disc] correctly overrides the
2282+ // default list-style:none, and we don't need to ship both).
2283+ let base = dedupe_declarations(&base);
2284+ let pseudo: Vec<(String, String)> = pseudo
2285+ .into_iter()
2286+ .map(|(sel, css)| (sel, dedupe_declarations(&css)))
2287+ .collect();
2288+ let responsive: Vec<(String, String)> = responsive
2289+ .into_iter()
2290+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
2291+ .collect();
2292+ let dark = dedupe_declarations(&dark);
2293+ let print = dedupe_declarations(&print);
2294+ let motion_safe = dedupe_declarations(&motion_safe);
2295+ let motion_reduce = dedupe_declarations(&motion_reduce);
2296+ let landscape = dedupe_declarations(&landscape);
2297+ let portrait = dedupe_declarations(&portrait);
2298+ let container: Vec<(String, String)> = container
2299+ .into_iter()
2300+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
2301+ .collect();
2302+
22602303 styles.get_class(
22612304 base,
22622305 pseudo,
@@ -2271,6 +2314,80 @@ fn compute_class(
22712314 )
22722315}
22732316
2317+/// Dedupe CSS declarations within a single rule body: for any property
2318+/// declared more than once, keep only the last occurrence. Unparseable
2319+/// segments (no `:`) are preserved as-is. Semicolons inside parentheses are
2320+/// treated as part of a value, not as declaration separators.
2321+fn dedupe_declarations(css: &str) -> String {
2322+ if css.is_empty() {
2323+ return String::new();
2324+ }
2325+ // Quick path: no chance of duplicates if there's fewer than 2 declarations.
2326+ if css.matches(';').count() < 2 {
2327+ return css.to_string();
2328+ }
2329+
2330+ // Split into (property_name_opt, full_decl_with_semi) preserving whatever
2331+ // terminator the input used. We split on `;` at depth 0 (ignoring parens).
2332+ let mut decls: Vec<(Option<String>, String)> = Vec::new();
2333+ let mut current = String::new();
2334+ let mut depth: i32 = 0;
2335+ for ch in css.chars() {
2336+ if ch == '(' {
2337+ depth += 1;
2338+ current.push(ch);
2339+ } else if ch == ')' {
2340+ depth -= 1;
2341+ current.push(ch);
2342+ } else if ch == ';' && depth == 0 {
2343+ current.push(';');
2344+ let trimmed = current.trim();
2345+ if !trimmed.is_empty() && trimmed != ";" {
2346+ let prop = trimmed
2347+ .trim_end_matches(';')
2348+ .split_once(':')
2349+ .map(|(p, _)| p.trim().to_ascii_lowercase());
2350+ decls.push((prop, std::mem::take(&mut current)));
2351+ } else {
2352+ current.clear();
2353+ }
2354+ } else {
2355+ current.push(ch);
2356+ }
2357+ }
2358+ if !current.trim().is_empty() {
2359+ let prop = current
2360+ .split_once(':')
2361+ .map(|(p, _)| p.trim().to_ascii_lowercase());
2362+ decls.push((prop, std::mem::take(&mut current)));
2363+ }
2364+
2365+ if decls.len() < 2 {
2366+ return css.to_string();
2367+ }
2368+
2369+ // Find index of last occurrence of each property.
2370+ use std::collections::HashMap;
2371+ let mut last: HashMap<String, usize> = HashMap::new();
2372+ for (i, (prop, _)) in decls.iter().enumerate() {
2373+ if let Some(p) = prop {
2374+ last.insert(p.clone(), i);
2375+ }
2376+ }
2377+
2378+ let mut out = String::with_capacity(css.len());
2379+ for (i, (prop, raw)) in decls.iter().enumerate() {
2380+ let keep = match prop {
2381+ Some(p) => last.get(p) == Some(&i),
2382+ None => true,
2383+ };
2384+ if keep {
2385+ out.push_str(raw);
2386+ }
2387+ }
2388+ out
2389+}
2390+
22742391fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
22752392 match (gen_class, user_class) {
22762393 (Some(g), Some(u)) => {
@@ -2618,7 +2735,7 @@ fn attrs_to_css(
26182735 }
26192736 "line-height" => {
26202737 if let Some(v) = val {
2621− push_css(&mut css, "line-height", v);
2738+ push_css(&mut css, "line-height", &css_line_height(v));
26222739 }
26232740 }
26242741 "letter-spacing" => {
@@ -3530,6 +3647,110 @@ fn css_px(value: &str) -> String {
35303647 format!("{}px", v)
35313648}
35323649
3650+/// Rewrite literal values that match a declared CSS custom property (from
3651+/// `@theme` or `@let --name value`) to `var(--name)` references. Matches are
3652+/// anchored to CSS value boundaries so e.g. `#3b82f6` inside a longer hex or
3653+/// inside an identifier is not replaced. The `:root` block is emitted before
3654+/// this call runs, so its declarations are not affected.
3655+fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
3656+ if css.is_empty() || vars.is_empty() {
3657+ return css.to_string();
3658+ }
3659+ // Prefer longer values first so that if two vars share a prefix, the
3660+ // longer (more specific) match wins.
3661+ let mut pairs: Vec<(&str, String)> = vars
3662+ .iter()
3663+ .filter(|(name, value)| name.starts_with("--") && !value.is_empty())
3664+ .map(|(name, value)| (value.as_str(), format!("var({})", name)))
3665+ .collect();
3666+ pairs.sort_by_key(|(v, _)| std::cmp::Reverse(v.len()));
3667+ if pairs.is_empty() {
3668+ return css.to_string();
3669+ }
3670+
3671+ let is_boundary_before = |b: Option<u8>| match b {
3672+ None => true,
3673+ Some(c) => matches!(
3674+ c,
3675+ b':' | b' ' | b',' | b'(' | b';' | b'{' | b'\n' | b'\t'
3676+ ),
3677+ };
3678+ let is_boundary_after = |b: Option<u8>| match b {
3679+ None => true,
3680+ Some(c) => matches!(
3681+ c,
3682+ b';' | b'}' | b',' | b' ' | b')' | b'\n' | b'\t'
3683+ ),
3684+ };
3685+
3686+ let bytes = css.as_bytes();
3687+ let mut out = String::with_capacity(css.len());
3688+ let mut i = 0;
3689+ let mut prev: Option<u8> = None;
3690+ while i < bytes.len() {
3691+ if is_boundary_before(prev) {
3692+ let mut matched = false;
3693+ for (val, repl) in &pairs {
3694+ let vb = val.as_bytes();
3695+ if i + vb.len() <= bytes.len() && &bytes[i..i + vb.len()] == vb {
3696+ let next = bytes.get(i + vb.len()).copied();
3697+ if is_boundary_after(next) {
3698+ out.push_str(repl);
3699+ prev = repl.as_bytes().last().copied();
3700+ i += vb.len();
3701+ matched = true;
3702+ break;
3703+ }
3704+ }
3705+ }
3706+ if matched {
3707+ continue;
3708+ }
3709+ }
3710+ // Advance by one UTF-8 code point.
3711+ let start = i;
3712+ i += 1;
3713+ while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
3714+ i += 1;
3715+ }
3716+ out.push_str(&css[start..i]);
3717+ prev = bytes.get(i - 1).copied();
3718+ }
3719+ out
3720+}
3721+
3722+/// Format a `line-height` value. CSS accepts either a unitless multiplier
3723+/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
3724+/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which
3725+/// CSS interprets as 24× font-size — almost never what anyone wants). Treat
3726+/// integers ≥ 2 as pixel lengths; anything with a decimal, an existing unit,
3727+/// or the value `0`/`1` passes through unchanged.
3728+fn css_line_height(value: &str) -> String {
3729+ let v = value.trim();
3730+ if v == "0" || v == "1" {
3731+ return v.to_string();
3732+ }
3733+ if v.contains('.') {
3734+ return v.to_string();
3735+ }
3736+ if CSS_UNITS.iter().any(|u| v.ends_with(u)) || v.ends_with("px") {
3737+ return v.to_string();
3738+ }
3739+ if v.starts_with("var(")
3740+ || v.starts_with("calc(")
3741+ || v.starts_with("clamp(")
3742+ || v.starts_with("min(")
3743+ || v.starts_with("max(")
3744+ {
3745+ return v.to_string();
3746+ }
3747+ // Plain integer — treat as pixel length.
3748+ if v.chars().all(|c| c.is_ascii_digit()) {
3749+ return format!("{}px", v);
3750+ }
3751+ v.to_string()
3752+}
3753+
35333754/// Format multiple space-separated values, each getting px if needed.
35343755fn css_px_multi(value: &str) -> String {
35353756 value