@@ -786,7 +786,13 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
788 let has_custom_css = !doc.custom_css.is_empty();
788 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);
791 // @keyframes
797 // @keyframes
792 for (name, kf_body) in &doc.keyframes {
798 for (name, kf_body) in &doc.keyframes {
@@ -1295,7 +1301,9 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
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);
1300 for (name, kf_body) in &doc.keyframes {
1308 for (name, kf_body) in &doc.keyframes {
1301 if dev {
1309 if dev {
@@ -1495,12 +1503,33 @@ fn emit_html_passthrough_attrs(out: &mut String, attrs: &[Attribute]) {
1495 out.push(' ');
1503 out.push(' ');
1496 out.push_str(key);
1504 out.push_str(key);
1497 out.push_str("=\"");
1505 out.push_str("=\"");
1498− out.push_str(&html_escape(val));
1506+ out.push_str(&html_escape(strip_string_quotes(val)));
1499 out.push('"');
1507 out.push('"');
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 {
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) {
1504fn generate_element(
1533fn generate_element(
1505 elem: &Element,
1534 elem: &Element,
1506 parent_kind: Option<&ElementKind>,
1535 parent_kind: Option<&ElementKind>,
@@ -1867,16 +1896,6 @@ fn generate_element(
1867 out.push('"');
1896 out.push('"');
1871− if elem.kind == ElementKind::Time
1872− && let Some(dt) = elem.attrs.iter().find(|a| a.key == "datetime")
1873− && let Some(val) = &dt.value
1875− out.push_str(" datetime=\"");
1876− out.push_str(&html_escape(val));
1880 // Critical CSS: inline styles directly instead of using a class
1899 // Critical CSS: inline styles directly instead of using a class
1881 let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
1900 let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
1882 if is_critical {
1901 if is_critical {
@@ -2257,6 +2276,30 @@ fn compute_class(
2257 let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind);
2276 let landscape = attrs_to_css(attrs, "landscape:", kind, parent_kind);
2258 let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind);
2277 let portrait = attrs_to_css(attrs, "portrait:", kind, parent_kind);
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
2286+ .map(|(sel, css)| (sel, dedupe_declarations(&css)))
2288+ let responsive: Vec<(String, String)> = responsive
2290+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
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
2300+ .map(|(bp, css)| (bp, dedupe_declarations(&css)))
2260 styles.get_class(
2303 styles.get_class(
@@ -2271,6 +2314,80 @@ fn compute_class(
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();
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();
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() {
2339+ } else if ch == ')' {
2342+ } else if ch == ';' && depth == 0 {
2344+ let trimmed = current.trim();
2345+ if !trimmed.is_empty() && trimmed != ";" {
2347+ .trim_end_matches(';')
2349+ .map(|(p, _)| p.trim().to_ascii_lowercase());
2350+ decls.push((prop, std::mem::take(&mut current)));
2358+ if !current.trim().is_empty() {
2361+ .map(|(p, _)| p.trim().to_ascii_lowercase());
2362+ decls.push((prop, std::mem::take(&mut current)));
2365+ if decls.len() < 2 {
2366+ return css.to_string();
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);
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),
2274fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
2391fn emit_class_attr(out: &mut String, gen_class: Option<&str>, user_class: Option<&str>) {
2275 match (gen_class, user_class) {
2392 match (gen_class, user_class) {
2276 (Some(g), Some(u)) => {
2393 (Some(g), Some(u)) => {
@@ -2618,7 +2735,7 @@ fn attrs_to_css(
2619 "line-height" => {
2736 "line-height" => {
2620 if let Some(v) = val {
2737 if let Some(v) = val {
2621− push_css(&mut css, "line-height", v);
2738+ push_css(&mut css, "line-height", &css_line_height(v));
2624 "letter-spacing" => {
2741 "letter-spacing" => {
@@ -3530,6 +3647,110 @@ fn css_px(value: &str) -> String {
3530 format!("{}px", v)
3647 format!("{}px", v)
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();
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
3663+ .filter(|(name, value)| name.starts_with("--") && !value.is_empty())
3664+ .map(|(name, value)| (value.as_str(), format!("var({})", name)))
3666+ pairs.sort_by_key(|(v, _)| std::cmp::Reverse(v.len()));
3667+ if pairs.is_empty() {
3668+ return css.to_string();
3671+ let is_boundary_before = |b: Option<u8>| match b {
3673+ Some(c) => matches!(
3675+ b':' | b' ' | b',' | b'(' | b';' | b'{' | b'\n' | b'\t'
3678+ let is_boundary_after = |b: Option<u8>| match b {
3680+ Some(c) => matches!(
3682+ b';' | b'}' | b',' | b' ' | b')' | b'\n' | b'\t'
3686+ let bytes = css.as_bytes();
3687+ let mut out = String::with_capacity(css.len());
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();
3710+ // Advance by one UTF-8 code point.
3713+ while i < bytes.len() && (bytes[i] & 0xC0) == 0x80 {
3716+ out.push_str(&css[start..i]);
3717+ prev = bytes.get(i - 1).copied();
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();
3733+ if v.contains('.') {
3734+ return v.to_string();
3736+ if CSS_UNITS.iter().any(|u| v.ends_with(u)) || v.ends_with("px") {
3737+ return v.to_string();
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(")
3745+ return v.to_string();
3747+ // Plain integer — treat as pixel length.
3748+ if v.chars().all(|c| c.is_ascii_digit()) {
3749+ return format!("{}px", v);
3533/// Format multiple space-separated values, each getting px if needed.
3754/// Format multiple space-separated values, each getting px if needed.
3534fn css_px_multi(value: &str) -> String {
3755fn css_px_multi(value: &str) -> String {