on master help sign in
Improve CSS output and HTML correctness

- Dedupe duplicate CSS declarations within each rule (last-write-wins) - Fix line-height: plain integers now emit px units (e.g. 24px not 24) - Remove duplicate datetime attribute emission on @time elements - Strip wrapping quotes from string literal attributes (alt, title, etc.) - Substitute literal color/spacing values with var(--name) references for runtime theming Reduces demo.html size and enables actual CSS custom property overrides.

commitf0efd1612de7da134994cd0c60fce0520087b6eb authoriko <mail@iko.soy> dateThu 16 Apr 2026, 12:07 (6 months ago) parentf851c72
3 files changed+241−18
crates/htmlang-core/src/codegen.rs+235−14 examples/demo.html+2−2 tests/snapshots.rs+4−2
crates/htmlang-core/src/codegen.rs+235−14
@@ -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
examples/demo.html+2−2
@@ -1,3 +1,3 @@
1−<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>htmlang Demo</title><meta name="theme-color" content="#3b82f6"><link rel="preload" href="https://picsum.photos/72" as="image"><link rel="preload" href="https://picsum.photos/640/200" as="image"><link rel="dns-prefetch" href="//github.com"><link rel="dns-prefetch" href="//picsum.photos"><meta name="description" content="A one-page tour of htmlang."><meta property="og:title" content="htmlang"><meta property="og:type" content="website"><style>*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}:root{--primary:#3b82f6;--ink:#0f172a;--muted:#64748b;--subtle:#f8fafc;}@layer htmlang{.a{display:flex;flex-direction:column;max-width:960px;margin-left:auto;margin-right:auto;padding:32px;gap:24px;background:white;}.b{display:flex;flex-direction:column;gap:8px;}.c{display:flex;flex-direction:column;gap:6px;color:#64748b;font-size:13px;}.d{display:flex;flex-direction:row;gap:6px;align-items:center;}.e{color:#64748b;}.f{color:#0f172a;}.g{display:flex;flex-direction:row;gap:16px;align-items:center;}.h{display:flex;flex-direction:column;flex:1;min-width:0;gap:6px;}.i{font-weight:bold;font-size:40px;color:#0f172a;}.j{margin:0;line-height:1.6;color:#64748b;}.k{font-weight:bold;color:#3b82f6;}.l{color:#3b82f6;text-decoration:underline;}.m{width:72px;height:72px;border-radius:36px;}.n{border-top:1px solid #e5e7eb;}.o{display:flex;flex-direction:column;gap:12px;padding-top:8px;padding-bottom:8px;}.p{font-weight:bold;font-size:22px;color:#0f172a;}.q{display:flex;flex-direction:row;flex-wrap:wrap;gap:8px;}.r{display:flex;flex-direction:column;padding:16px;background:white;border-radius:8px;border:1px solid #e5e7eb;box-shadow:0 1px 3px rgba(0,0,0,0.05);cursor:pointer;transition:all 0.2s ease;animation:fade-up 0.3s ease;}.r:hover{box-shadow:0 4px 12px rgba(0,0,0,0.08);}.s{display:flex;flex-direction:row;gap:10px;align-items:center;}.t{font-size:20px;color:#3b82f6;}.u{font-weight:bold;font-size:16px;color:#0f172a;}.v{margin:0;line-height:1.7;}.w{font-weight:bold;}.x{font-style:italic;}.y{font-family:ui-monospace,monospace;padding:2px 6px;background:#f1f5f9;border-radius:4px;font-family:monospace;font-size:13px;}.z{background:#fef08a;}.aa{display:flex;flex-direction:column;margin:0;padding-inline-start:16px;border-left:3px solid #3b82f6;color:#64748b;font-style:italic;}.ab{margin:0;white-space:pre;font-family:ui-monospace,monospace;background:#0f172a;color:#e2e8f0;padding:16px;border-radius:8px;font-family:monospace;font-size:13px;overflow-x:auto;}.ac{display:flex;flex-direction:column;gap:6px;}.ad{display:flex;flex-direction:row;gap:10px;align-items:baseline;}.ae{font-weight:bold;min-width:80px;}.af{color:#64748b;font-style:italic;}.ag{display:flex;flex-direction:row;gap:6px;}.ah{display:flex;flex-direction:column;width:18px;height:18px;border-radius:9px;background:#3b82f6;opacity:0.4;}.ai{display:flex;flex-direction:row;gap:4px;}.aj{display:flex;flex-direction:column;width:24px;height:24px;border-radius:4px;background:#3b82f6;color:white;text-align:center;font-size:12px;font-weight:bold;line-height:24;}.ak{font-weight:bold;min-width:64px;}.al{color:#3b82f6;font-weight:bold;}.am{color:#10b981;font-weight:bold;}.an{display:flex;flex-direction:column;padding:12px;border-radius:6px;background:#3b82f6;color:white;}.an::before{content:"→ ";font-weight:bold;}.ao{font-family:ui-monospace,monospace;}.ap{display:flex;flex-direction:column;gap:10px;max-width:420px;}.aq{font-weight:bold;font-size:13px;}.ar{padding:10px;border:1px solid #e5e7eb;border-radius:6px;transition:border 0.15s ease;}.ar:focus{border:1px solid #3b82f6;}.ar:valid{border:1px solid #10b981;}.ar:invalid{border:1px solid #ef4444;}.as{padding:10px;border:1px solid #e5e7eb;border-radius:6px;}.at{display:flex;flex-direction:row;gap:8px;}.au{padding:10px 16px;background:#3b82f6;color:white;font-weight:bold;border-radius:6px;border:0 solid currentColor;transition:background 0.15s ease;cursor:pointer;}.au:hover{background:#3575dd;}.au:active{background:#2f68c5;}.av{padding:10px 16px;background:white;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;}.aw{display:flex;flex-direction:row;gap:16px;flex-wrap:wrap;}.ax{display:flex;flex-direction:column;flex:1;min-width:0;gap:8px;min-width:240px;}.ay{margin:0;padding-left:0;list-style:none;gap:4px;list-style:disc;padding-inline-start:20px;}.az{display:flex;flex-direction:column;display:list-item;}.ba{margin:0;padding-left:0;list-style:none;list-style:decimal;padding-inline-start:20px;}.bb{text-align:left;padding:8px;}.bc{text-align:right;padding:8px;}.bd{padding:8px;border-top:1px solid #e5e7eb;}.be{padding:8px;border-top:1px solid #e5e7eb;text-align:right;}.bf{display:flex;flex-direction:column;margin:0;gap:6px;}.bg{border-radius:8px;}.bh{color:#64748b;font-size:13px;text-align:center;}.bi{display:flex;flex-direction:column;padding:12px;border:1px solid #e5e7eb;border-radius:6px;background:#f8fafc;}.bj{font-weight:bold;cursor:pointer;}.bk{margin:0;color:#64748b;line-height:1.6;padding-block-start:6px;}.bl{display:flex;flex-direction:column;display:grid;grid-template-columns:1fr 2fr;grid-template-areas:"h h" "s m" "f f";gap:8px;padding:8px;background:#f8fafc;border-radius:8px;}.bm{display:flex;flex-direction:column;grid-area:h;padding:10px;background:#3b82f6;color:white;border-radius:6px;text-align:center;}.bn{display:flex;flex-direction:column;grid-area:s;padding:10px;background:white;border-radius:6px;border:1px solid #e5e7eb;}.bo{display:flex;flex-direction:column;grid-area:m;padding:10px;background:white;border-radius:6px;border:1px solid #e5e7eb;}.bp{display:flex;flex-direction:column;grid-area:f;padding:10px;background:#0f172a;color:white;border-radius:6px;text-align:center;}.bq{gap:4px;font-style:normal;}.br{color:#3b82f6;}.bs{font-size:13px;color:#64748b;}.bt{font-size:13px;}.bu{display:flex;flex-direction:column;padding-block-start:24px;margin-block-start:24px;border-top:1px solid #e5e7eb;gap:8px;}.bv{display:flex;flex-direction:row;gap:8px;align-items:center;}.bw{display:flex;flex-direction:column;padding:12px 20px;background:#3b82f6;color:white;font-weight:bold;border-radius:8px;transition:all 0.15s ease;cursor:pointer;}.bw:hover{background:#3575dd;}.bw:active{background:#2f68c5;}.bx{color:#3b82f6;}.bx:hover{text-decoration:underline;}.bx:visited{color:#8b5cf6;}.by{display:flex;flex-direction:column;flex:1;min-width:0;}.bz{color:#64748b;font-size:13px;}@media(prefers-color-scheme:dark){.a{background:#0b1220;color:#e2e8f0;}.i{color:white;}}@media print{.bu{display:none;}}}@keyframes fade-up{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:translateY(0);}}</style></head><body><noscript><style>.needs-js{display:none}</style></noscript><div class="a"><header class="b"><nav class="c"><div class="d"><a href="/" class="e">Home</a><span>/</span><a href="/demos" class="e">Demos</a><span>/</span><span class="f">htmlang</span></div></nav><div class="g"><div class="h"><span class="i">Welcome to htmlang</span><p class="j">A minimalist layout language that compiles to <span class="k">static HTML</span> — no JS, no build step. Visit the <a href="https://github.com" rel="noopener noreferrer" target="_blank" class="l">repo</a> for more.</p></div><img src="https://picsum.photos/72" class="m" alt="&quot;Avatar&quot;" fetchpriority="high"></div></header><hr class="n"><section class="o"><span class="p">Features</span><div class="q"><article class="r"><div class="s"><span class="t">✦</span><span class="u">Simple</span></div><span>Write layouts without CSS classes.</span></article><article class="r"><div class="s"><span class="t">⚡</span><span class="u">Fast</span></div><span>Compiles to a single self-contained HTML file.</span></article><article class="r"><div class="s"><span class="t">○</span><span class="u">Minimal</span></div><span>Zero runtime JavaScript by default.</span></article><article class="r"><div class="s"><span class="t">◆</span><span class="u">Typed</span></div><span>LSP with diagnostics and go-to-definition.</span></article></div></section><section class="o"><span class="p">Typography</span><p class="v">A quick brown fox jumps over <span class="w">the lazy</span> <span class="x">dog</span>. Press <kbd class="y">Ctrl+K</kbd> to search. <mark class="z">Highlighted</mark> uses mark. <abbr title="HyperText Markup Language + language">htmlang</abbr> is an abbreviation.</p><blockquote class="aa"><span>Layout is code. Style is code. Everything should compose.</span><cite>— Anonymous</cite></blockquote><pre class="ab">@fn button $label
1+<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>htmlang Demo</title><meta name="theme-color" content="#3b82f6"><link rel="preload" href="https://picsum.photos/72" as="image"><link rel="preload" href="https://picsum.photos/640/200" as="image"><link rel="dns-prefetch" href="//github.com"><link rel="dns-prefetch" href="//picsum.photos"><meta name="description" content="A one-page tour of htmlang."><meta property="og:title" content="htmlang"><meta property="og:type" content="website"><style>*,*::before,*::after{box-sizing:border-box}body{margin:0;font-family:system-ui,-apple-system,sans-serif}img{display:block}a:focus-visible,button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid currentColor;outline-offset:2px}:root{--primary:#3b82f6;--ink:#0f172a;--muted:#64748b;--subtle:#f8fafc;}@layer htmlang{.a{display:flex;flex-direction:column;max-width:960px;margin-left:auto;margin-right:auto;padding:32px;gap:24px;background:white;}.b{display:flex;flex-direction:column;gap:8px;}.c{display:flex;flex-direction:column;gap:6px;color:var(--muted);font-size:13px;}.d{display:flex;flex-direction:row;gap:6px;align-items:center;}.e{color:var(--muted);}.f{color:var(--ink);}.g{display:flex;flex-direction:row;gap:16px;align-items:center;}.h{display:flex;flex-direction:column;flex:1;min-width:0;gap:6px;}.i{font-weight:bold;font-size:40px;color:var(--ink);}.j{margin:0;line-height:1.6;color:var(--muted);}.k{font-weight:bold;color:var(--primary);}.l{color:var(--primary);text-decoration:underline;}.m{width:72px;height:72px;border-radius:36px;}.n{border-top:1px solid #e5e7eb;}.o{display:flex;flex-direction:column;gap:12px;padding-top:8px;padding-bottom:8px;}.p{font-weight:bold;font-size:22px;color:var(--ink);}.q{display:flex;flex-direction:row;flex-wrap:wrap;gap:8px;}.r{display:flex;flex-direction:column;padding:16px;background:white;border-radius:8px;border:1px solid #e5e7eb;box-shadow:0 1px 3px rgba(0,0,0,0.05);cursor:pointer;transition:all 0.2s ease;animation:fade-up 0.3s ease;}.r:hover{box-shadow:0 4px 12px rgba(0,0,0,0.08);}.s{display:flex;flex-direction:row;gap:10px;align-items:center;}.t{font-size:20px;color:var(--primary);}.u{font-weight:bold;font-size:16px;color:var(--ink);}.v{margin:0;line-height:1.7;}.w{font-weight:bold;}.x{font-style:italic;}.y{padding:2px 6px;background:#f1f5f9;border-radius:4px;font-family:monospace;font-size:13px;}.z{background:#fef08a;}.aa{display:flex;flex-direction:column;margin:0;padding-inline-start:16px;border-left:3px solid var(--primary);color:var(--muted);font-style:italic;}.ab{margin:0;white-space:pre;background:var(--ink);color:#e2e8f0;padding:16px;border-radius:8px;font-family:monospace;font-size:13px;overflow-x:auto;}.ac{display:flex;flex-direction:column;gap:6px;}.ad{display:flex;flex-direction:row;gap:10px;align-items:baseline;}.ae{font-weight:bold;min-width:80px;}.af{color:var(--muted);font-style:italic;}.ag{display:flex;flex-direction:row;gap:6px;}.ah{display:flex;flex-direction:column;width:18px;height:18px;border-radius:9px;background:var(--primary);opacity:0.4;}.ai{display:flex;flex-direction:row;gap:4px;}.aj{display:flex;flex-direction:column;width:24px;height:24px;border-radius:4px;background:var(--primary);color:white;text-align:center;font-size:12px;font-weight:bold;line-height:24px;}.ak{font-weight:bold;min-width:64px;}.al{color:var(--primary);font-weight:bold;}.am{color:#10b981;font-weight:bold;}.an{display:flex;flex-direction:column;padding:12px;border-radius:6px;background:var(--primary);color:white;}.an::before{content:"→ ";font-weight:bold;}.ao{font-family:ui-monospace,monospace;}.ap{display:flex;flex-direction:column;gap:10px;max-width:420px;}.aq{font-weight:bold;font-size:13px;}.ar{padding:10px;border:1px solid #e5e7eb;border-radius:6px;transition:border 0.15s ease;}.ar:focus{border:1px solid var(--primary);}.ar:valid{border:1px solid #10b981;}.ar:invalid{border:1px solid #ef4444;}.as{padding:10px;border:1px solid #e5e7eb;border-radius:6px;}.at{display:flex;flex-direction:row;gap:8px;}.au{padding:10px 16px;background:var(--primary);color:white;font-weight:bold;border-radius:6px;border:0 solid currentColor;transition:background 0.15s ease;cursor:pointer;}.au:hover{background:#3575dd;}.au:active{background:#2f68c5;}.av{padding:10px 16px;background:white;border:1px solid #e5e7eb;border-radius:6px;cursor:pointer;}.aw{display:flex;flex-direction:row;gap:16px;flex-wrap:wrap;}.ax{display:flex;flex-direction:column;flex:1;gap:8px;min-width:240px;}.ay{margin:0;padding-left:0;gap:4px;list-style:disc;padding-inline-start:20px;}.az{flex-direction:column;display:list-item;}.ba{margin:0;padding-left:0;list-style:decimal;padding-inline-start:20px;}.bb{text-align:left;padding:8px;}.bc{text-align:right;padding:8px;}.bd{padding:8px;border-top:1px solid #e5e7eb;}.be{padding:8px;border-top:1px solid #e5e7eb;text-align:right;}.bf{display:flex;flex-direction:column;margin:0;gap:6px;}.bg{border-radius:8px;}.bh{color:var(--muted);font-size:13px;text-align:center;}.bi{display:flex;flex-direction:column;padding:12px;border:1px solid #e5e7eb;border-radius:6px;background:var(--subtle);}.bj{font-weight:bold;cursor:pointer;}.bk{margin:0;color:var(--muted);line-height:1.6;padding-block-start:6px;}.bl{flex-direction:column;display:grid;grid-template-columns:1fr 2fr;grid-template-areas:"h h" "s m" "f f";gap:8px;padding:8px;background:var(--subtle);border-radius:8px;}.bm{display:flex;flex-direction:column;grid-area:h;padding:10px;background:var(--primary);color:white;border-radius:6px;text-align:center;}.bn{display:flex;flex-direction:column;grid-area:s;padding:10px;background:white;border-radius:6px;border:1px solid #e5e7eb;}.bo{display:flex;flex-direction:column;grid-area:m;padding:10px;background:white;border-radius:6px;border:1px solid #e5e7eb;}.bp{display:flex;flex-direction:column;grid-area:f;padding:10px;background:var(--ink);color:white;border-radius:6px;text-align:center;}.bq{gap:4px;font-style:normal;}.br{color:var(--primary);}.bs{font-size:13px;color:var(--muted);}.bt{font-size:13px;}.bu{display:flex;flex-direction:column;padding-block-start:24px;margin-block-start:24px;border-top:1px solid #e5e7eb;gap:8px;}.bv{display:flex;flex-direction:row;gap:8px;align-items:center;}.bw{display:flex;flex-direction:column;padding:12px 20px;background:var(--primary);color:white;font-weight:bold;border-radius:8px;transition:all 0.15s ease;cursor:pointer;}.bw:hover{background:#3575dd;}.bw:active{background:#2f68c5;}.bx{color:var(--primary);}.bx:hover{text-decoration:underline;}.bx:visited{color:#8b5cf6;}.by{display:flex;flex-direction:column;flex:1;min-width:0;}.bz{color:var(--muted);font-size:13px;}@media(prefers-color-scheme:dark){.a{background:#0b1220;color:#e2e8f0;}.i{color:white;}}@media print{.bu{display:none;}}}@keyframes fade-up{from{opacity:0;transform:translateY(8px);}to{opacity:1;transform:translateY(0);}}</style></head><body><noscript><style>.needs-js{display:none}</style></noscript><div class="a"><header class="b"><nav class="c"><div class="d"><a href="/" class="e">Home</a><span>/</span><a href="/demos" class="e">Demos</a><span>/</span><span class="f">htmlang</span></div></nav><div class="g"><div class="h"><span class="i">Welcome to htmlang</span><p class="j">A minimalist layout language that compiles to <span class="k">static HTML</span> — no JS, no build step. Visit the <a href="https://github.com" rel="noopener noreferrer" target="_blank" class="l">repo</a> for more.</p></div><img src="https://picsum.photos/72" class="m" alt="Avatar" fetchpriority="high"></div></header><hr class="n"><section class="o"><span class="p">Features</span><div class="q"><article class="r"><div class="s"><span class="t">✦</span><span class="u">Simple</span></div><span>Write layouts without CSS classes.</span></article><article class="r"><div class="s"><span class="t">⚡</span><span class="u">Fast</span></div><span>Compiles to a single self-contained HTML file.</span></article><article class="r"><div class="s"><span class="t">○</span><span class="u">Minimal</span></div><span>Zero runtime JavaScript by default.</span></article><article class="r"><div class="s"><span class="t">◆</span><span class="u">Typed</span></div><span>LSP with diagnostics and go-to-definition.</span></article></div></section><section class="o"><span class="p">Typography</span><p class="v">A quick brown fox jumps over <span class="w">the lazy</span> <span class="x">dog</span>. Press <kbd class="y">Ctrl+K</kbd> to search. <mark class="z">Highlighted</mark> uses mark. <abbr title="HyperText Markup Language + language">htmlang</abbr> is an abbreviation.</p><blockquote class="aa"><span>Layout is code. Style is code. Everything should compose.</span><cite>— Anonymous</cite></blockquote><pre class="ab">@fn button $label
22 @el [padding 12, background blue]
3− @text $label</pre></section><section class="o"><span class="p">Control flow</span><div class="ac"><div class="ad"><span class="ae">Alice</span><span class="e">Engineer</span></div><div class="ad"><span class="ae">Bob</span><span class="e">Designer</span></div><div class="ad"><span class="ae">Carol</span><span class="e">PM</span></div></div><span class="af" data-state="">(Nothing — @each ... @else fallback)</span><div class="ag"><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div></div><div class="ai"><div class="aj"><span>1</span></div><div class="aj"><span>2</span></div><div class="aj"><span>3</span></div><div class="aj"><span>4</span></div><div class="aj"><span>5</span></div><div class="aj"><span>6</span></div></div><div class="d"><span class="ak">Count:</span><span class="al">3 items</span></div><span class="am">✓ Ready</span><div class="an"><span>Conditional styling via <code class="ao">if(cond, a, b)</code>.</span></div></section><section class="o"><span class="p">Forms</span><form action="/subscribe" class="ap"><label class="aq" for="email">Email</label><input class="ar" id="email" type="email" name="email" placeholder="you@example.com" required><label class="aq" for="role">Role</label><select class="as" id="role" name="role"><option value="dev">Developer</option><option value="designer">Designer</option><option value="other">Other</option></select><label class="aq" for="msg">Message</label><textarea class="as" id="msg" name="msg" rows="3"></textarea><div class="at"><button class="au" type="submit">Subscribe</button><button class="av" type="reset">Reset</button></div><progress value="60" max="100"></progress></form></section><section class="o"><span class="p">Data</span><div class="aw"><div class="ax"><span class="w">Unordered</span><ul class="ay"><li class="az">Composable with @each</li><li class="az">Indent to nest children</li><li class="az">Bold list items work too</li></ul><span class="w">Ordered</span><ol class="ba"><li class="az">First</li><li class="az">Second</li><li class="az">Third</li></ol></div><div class="ax"><span class="w">Table</span><table><thead><tr><th class="bb">Item</th><th class="bc">Qty</th></tr></thead><tbody><tr><td class="bd">Widgets</td><td class="be">12</td></tr><tr><td class="bd">Gadgets</td><td class="be">7</td></tr></tbody></table></div></div><figure class="bf"><img src="https://picsum.photos/640/200" class="bg" alt="&quot;A random placeholder&quot;" fetchpriority="high"><figcaption class="bh">A placeholder image.</figcaption></figure></section><section class="o"><span class="p">Disclosure + grid areas</span><details class="bi"><summary class="bj">What is htmlang?</summary><p class="bk">htmlang is a layout-first language that compiles to a single, self-contained HTML file with embedded CSS.</p></details><div class="bl"><div class="bm"><span>Header</span></div><div class="bn"><span>Sidebar</span></div><div class="bo"><span>Main</span></div><div class="bp"><span>Footer</span></div></div></section><section class="o"><span class="p">Misc</span><address class="bq"><span class="w">htmlang HQ</span><span>123 Flexbox Avenue</span><a href="mailto:hi@htmlang.dev" class="br">hi@htmlang.dev</a></address><div class="s"><span class="bs">Published</span><time datetime="2026-04-16" class="bt" datetime="2026-04-16">April 16 2026</time></div><div class="s"><span class="bs">Confidence</span><meter value="8" min="0" max="10" low="3" high="7"></meter></div></section><footer class="bu"><div class="bv"><div class="bw"><a href="https://github.com" rel="noopener noreferrer" target="_blank">Get Started</a></div><a href="https://github.com" rel="noopener noreferrer" target="_blank" class="bx">Browse source</a><div class="by"></div><span class="bz">© 2026 htmlang</span></div></footer></div></body></html>
3+ @text $label</pre></section><section class="o"><span class="p">Control flow</span><div class="ac"><div class="ad"><span class="ae">Alice</span><span class="e">Engineer</span></div><div class="ad"><span class="ae">Bob</span><span class="e">Designer</span></div><div class="ad"><span class="ae">Carol</span><span class="e">PM</span></div></div><span class="af" data-state="">(Nothing — @each ... @else fallback)</span><div class="ag"><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div><div class="ah"></div></div><div class="ai"><div class="aj"><span>1</span></div><div class="aj"><span>2</span></div><div class="aj"><span>3</span></div><div class="aj"><span>4</span></div><div class="aj"><span>5</span></div><div class="aj"><span>6</span></div></div><div class="d"><span class="ak">Count:</span><span class="al">3 items</span></div><span class="am">✓ Ready</span><div class="an"><span>Conditional styling via <code class="ao">if(cond, a, b)</code>.</span></div></section><section class="o"><span class="p">Forms</span><form action="/subscribe" class="ap"><label class="aq" for="email">Email</label><input class="ar" id="email" type="email" name="email" placeholder="you@example.com" required><label class="aq" for="role">Role</label><select class="as" id="role" name="role"><option value="dev">Developer</option><option value="designer">Designer</option><option value="other">Other</option></select><label class="aq" for="msg">Message</label><textarea class="as" id="msg" name="msg" rows="3"></textarea><div class="at"><button class="au" type="submit">Subscribe</button><button class="av" type="reset">Reset</button></div><progress value="60" max="100"></progress></form></section><section class="o"><span class="p">Data</span><div class="aw"><div class="ax"><span class="w">Unordered</span><ul class="ay"><li class="az">Composable with @each</li><li class="az">Indent to nest children</li><li class="az">Bold list items work too</li></ul><span class="w">Ordered</span><ol class="ba"><li class="az">First</li><li class="az">Second</li><li class="az">Third</li></ol></div><div class="ax"><span class="w">Table</span><table><thead><tr><th class="bb">Item</th><th class="bc">Qty</th></tr></thead><tbody><tr><td class="bd">Widgets</td><td class="be">12</td></tr><tr><td class="bd">Gadgets</td><td class="be">7</td></tr></tbody></table></div></div><figure class="bf"><img src="https://picsum.photos/640/200" class="bg" alt="A random placeholder" fetchpriority="high"><figcaption class="bh">A placeholder image.</figcaption></figure></section><section class="o"><span class="p">Disclosure + grid areas</span><details class="bi"><summary class="bj">What is htmlang?</summary><p class="bk">htmlang is a layout-first language that compiles to a single, self-contained HTML file with embedded CSS.</p></details><div class="bl"><div class="bm"><span>Header</span></div><div class="bn"><span>Sidebar</span></div><div class="bo"><span>Main</span></div><div class="bp"><span>Footer</span></div></div></section><section class="o"><span class="p">Misc</span><address class="bq"><span class="w">htmlang HQ</span><span>123 Flexbox Avenue</span><a href="mailto:hi@htmlang.dev" class="br">hi@htmlang.dev</a></address><div class="s"><span class="bs">Published</span><time class="bt" datetime="2026-04-16">April 16 2026</time></div><div class="s"><span class="bs">Confidence</span><meter value="8" min="0" max="10" low="3" high="7"></meter></div></section><footer class="bu"><div class="bv"><div class="bw"><a href="https://github.com" rel="noopener noreferrer" target="_blank">Get Started</a></div><a href="https://github.com" rel="noopener noreferrer" target="_blank" class="bx">Browse source</a><div class="by"></div><span class="bz">© 2026 htmlang</span></div></footer></div></body></html>
tests/snapshots.rs+4−2
@@ -3082,9 +3082,11 @@ fn test_theme_directive() {
30823082 "theme should emit spacing var, got: {}",
30833083 html
30843084 );
3085+ // Theme tokens collapse to `var(--name)` references so runtime theming
3086+ // actually takes effect (users can override `--primary` with CSS).
30853087 assert!(
3086− html.contains("background:#3b82f6"),
3087− "theme var should resolve in attrs, got: {}",
3088+ html.contains("background:var(--primary)"),
3089+ "theme var should resolve to var(--primary), got: {}",
30883090 html
30893091 );
30903092}