on master help sign in
Optimize CSS codegen: dedupe rules, auto-extract repeated values, consolidate constants

- Group identical CSS rule bodies into selector lists (e.g., `.a,.b{...}`) across base, pseudo, media query, and container query sections - Auto-extract frequently-repeated literal values (colors, borders) to `--hN` custom properties in minified mode; skipped in dev for readability - Replace padding-x/y and margin-x/y with logical properties (padding-inline and margin-block), improving RTL support and reducing output - Extract FLEX_COLUMN, MONOSPACE_STACK, PILL_BASE constants to reduce duplication across 12+ element kind definitions - Standardize Fieldset padding from 0.5em to 8px for scale consistency - Share Badge/Tag default styles via PILL_BASE, reducing drift risk Reduces demo.html from 12754 to 12610 bytes (-144, -1.1%). All 538 tests pass.

commitc35fddb84cb44bda595bdfaa4dc20a76b3a8bf39 authoriko <mail@iko.soy> dateThu 16 Apr 2026, 12:23 (6 months ago) parentf0efd16
3 files changed+463−272
crates/htmlang-core/src/codegen.rs+458−267 examples/demo.html+1−1 tests/snapshots.rs+4−4
crates/htmlang-core/src/codegen.rs+458−267
@@ -153,7 +153,7 @@ impl StyleCollector {
153153
154154 fn to_css_formatted(&self, dev: bool, use_layer: bool) -> String {
155155 let mut css = String::new();
156− let (sp, nl) = if dev { (" ", "\n") } else { ("", "") };
156+ let inner_indent = if use_layer && dev { " " } else { "" };
157157
158158 // Wrap in @layer for specificity management
159159 if use_layer {
@@ -164,226 +164,178 @@ impl StyleCollector {
164164 }
165165 }
166166
167− // Non-responsive rules
167+ // Non-responsive rules. Base and pseudo rules are merged separately by
168+ // identical body so that e.g. `.a,.b{display:flex;flex-direction:column;}`
169+ // replaces two identical rules. Pseudo variants are grouped per selector
170+ // suffix (`:hover` with `:hover`, etc.) to keep each pseudo's cascade
171+ // position independent of others.
172+ let mut base_pairs: Vec<(&str, &str)> = Vec::with_capacity(self.entries.len());
168173 for e in &self.entries {
169174 if !e.base.is_empty() {
170− if use_layer && dev {
171− css.push_str(&format!(" .{}{sp}{{{}}}{nl}", e.class_name, e.base));
172− } else {
173− css.push_str(&format!(".{}{sp}{{{}}}{nl}", e.class_name, e.base));
174− }
175+ base_pairs.push((e.class_name.as_str(), e.base.as_str()));
175176 }
176− for (selector, pseudo_css) in &e.pseudo {
177− if !pseudo_css.is_empty() {
178− if use_layer && dev {
179− css.push_str(&format!(
180− " .{}{}{sp}{{{}}}{nl}",
181− e.class_name, selector, pseudo_css
182− ));
183− } else {
184− css.push_str(&format!(
185− ".{}{}{sp}{{{}}}{nl}",
186− e.class_name, selector, pseudo_css
187− ));
188− }
177+ }
178+ emit_grouped_rules(&mut css, &base_pairs, "", inner_indent, dev);
179+
180+ // Collect pseudo rules grouped by selector suffix, preserving the order
181+ // in which suffixes first appear across entries.
182+ let mut pseudo_order: Vec<&str> = Vec::new();
183+ let mut pseudo_buckets: HashMap<&str, Vec<(&str, &str)>> = HashMap::new();
184+ for e in &self.entries {
185+ for (selector, body) in &e.pseudo {
186+ if body.is_empty() {
187+ continue;
189188 }
189+ let key = selector.as_str();
190+ if !pseudo_buckets.contains_key(key) {
191+ pseudo_order.push(key);
192+ }
193+ pseudo_buckets
194+ .entry(key)
195+ .or_default()
196+ .push((e.class_name.as_str(), body.as_str()));
197+ }
198+ }
199+ for selector in &pseudo_order {
200+ if let Some(pairs) = pseudo_buckets.get(selector) {
201+ emit_grouped_rules(&mut css, pairs, selector, inner_indent, dev);
190202 }
191203 }
192204
193205 // Responsive rules grouped by breakpoint
194206 for &(bp_name, bp_width) in BREAKPOINTS {
195− let mut bp_css = String::new();
207+ let mut bp_pairs: Vec<(&str, &str)> = Vec::new();
196208 for e in &self.entries {
197209 for (bp, rule_css) in &e.responsive {
198210 if bp == bp_name && !rule_css.is_empty() {
199− if dev {
200− bp_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, rule_css));
201− } else {
202− bp_css.push_str(&format!(".{}{{{}}}", e.class_name, rule_css));
203− }
211+ bp_pairs.push((e.class_name.as_str(), rule_css.as_str()));
204212 }
205213 }
206214 }
207− if !bp_css.is_empty() {
208− if dev {
209− css.push_str(&format!(
210− "@media (min-width: {}) {{\n{}}}\n",
211− bp_width, bp_css
212− ));
213− } else {
214− css.push_str(&format!("@media(min-width:{}){{{}}}", bp_width, bp_css));
215− }
216− }
215+ emit_media_block(
216+ &mut css,
217+ &format!("@media (min-width: {})", bp_width),
218+ &format!("@media(min-width:{})", bp_width),
219+ &bp_pairs,
220+ "",
221+ dev,
222+ );
217223 }
218224
219225 // Dark mode rules
220− let mut dark_css = String::new();
221− for e in &self.entries {
222− if !e.dark.is_empty() {
223− if dev {
224− dark_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.dark));
225− } else {
226− dark_css.push_str(&format!(".{}{{{}}}", e.class_name, e.dark));
227− }
228− }
229− }
230− if !dark_css.is_empty() {
231− if dev {
232− css.push_str(&format!(
233− "@media (prefers-color-scheme: dark) {{\n{}}}\n",
234− dark_css
235− ));
236− } else {
237− css.push_str(&format!(
238− "@media(prefers-color-scheme:dark){{{}}}",
239− dark_css
240− ));
241− }
242− }
226+ let dark_pairs: Vec<(&str, &str)> = self
227+ .entries
228+ .iter()
229+ .filter(|e| !e.dark.is_empty())
230+ .map(|e| (e.class_name.as_str(), e.dark.as_str()))
231+ .collect();
232+ emit_media_block(
233+ &mut css,
234+ "@media (prefers-color-scheme: dark)",
235+ "@media(prefers-color-scheme:dark)",
236+ &dark_pairs,
237+ "",
238+ dev,
239+ );
243240
244241 // Print rules
245− let mut print_css = String::new();
246− for e in &self.entries {
247− if !e.print.is_empty() {
248− if dev {
249− print_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.print));
250− } else {
251− print_css.push_str(&format!(".{}{{{}}}", e.class_name, e.print));
252− }
253− }
254− }
255− if !print_css.is_empty() {
256− if dev {
257− css.push_str(&format!("@media print {{\n{}}}\n", print_css));
258− } else {
259− css.push_str(&format!("@media print{{{}}}", print_css));
260− }
261− }
242+ let print_pairs: Vec<(&str, &str)> = self
243+ .entries
244+ .iter()
245+ .filter(|e| !e.print.is_empty())
246+ .map(|e| (e.class_name.as_str(), e.print.as_str()))
247+ .collect();
248+ emit_media_block(
249+ &mut css,
250+ "@media print",
251+ "@media print",
252+ &print_pairs,
253+ "",
254+ dev,
255+ );
262256
263257 // Motion safe rules
264− let mut motion_safe_css = String::new();
265− for e in &self.entries {
266− if !e.motion_safe.is_empty() {
267− if dev {
268− motion_safe_css
269− .push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_safe));
270− } else {
271− motion_safe_css.push_str(&format!(".{}{{{}}}", e.class_name, e.motion_safe));
272− }
273− }
274− }
275− if !motion_safe_css.is_empty() {
276− if dev {
277− css.push_str(&format!(
278− "@media (prefers-reduced-motion: no-preference) {{\n{}}}\n",
279− motion_safe_css
280− ));
281− } else {
282− css.push_str(&format!(
283− "@media(prefers-reduced-motion:no-preference){{{}}}",
284− motion_safe_css
285− ));
286− }
287− }
258+ let motion_safe_pairs: Vec<(&str, &str)> = self
259+ .entries
260+ .iter()
261+ .filter(|e| !e.motion_safe.is_empty())
262+ .map(|e| (e.class_name.as_str(), e.motion_safe.as_str()))
263+ .collect();
264+ emit_media_block(
265+ &mut css,
266+ "@media (prefers-reduced-motion: no-preference)",
267+ "@media(prefers-reduced-motion:no-preference)",
268+ &motion_safe_pairs,
269+ "",
270+ dev,
271+ );
288272
289273 // Motion reduce rules
290− let mut motion_reduce_css = String::new();
291− for e in &self.entries {
292− if !e.motion_reduce.is_empty() {
293− if dev {
294− motion_reduce_css
295− .push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_reduce));
296− } else {
297− motion_reduce_css
298− .push_str(&format!(".{}{{{}}}", e.class_name, e.motion_reduce));
299− }
300− }
301− }
302− if !motion_reduce_css.is_empty() {
303− if dev {
304− css.push_str(&format!(
305− "@media (prefers-reduced-motion: reduce) {{\n{}}}\n",
306− motion_reduce_css
307− ));
308− } else {
309− css.push_str(&format!(
310− "@media(prefers-reduced-motion:reduce){{{}}}",
311− motion_reduce_css
312− ));
313− }
314− }
274+ let motion_reduce_pairs: Vec<(&str, &str)> = self
275+ .entries
276+ .iter()
277+ .filter(|e| !e.motion_reduce.is_empty())
278+ .map(|e| (e.class_name.as_str(), e.motion_reduce.as_str()))
279+ .collect();
280+ emit_media_block(
281+ &mut css,
282+ "@media (prefers-reduced-motion: reduce)",
283+ "@media(prefers-reduced-motion:reduce)",
284+ &motion_reduce_pairs,
285+ "",
286+ dev,
287+ );
315288
316289 // Landscape rules
317− let mut landscape_css = String::new();
318− for e in &self.entries {
319− if !e.landscape.is_empty() {
320− if dev {
321− landscape_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.landscape));
322− } else {
323− landscape_css.push_str(&format!(".{}{{{}}}", e.class_name, e.landscape));
324− }
325− }
326− }
327− if !landscape_css.is_empty() {
328− if dev {
329− css.push_str(&format!(
330− "@media (orientation: landscape) {{\n{}}}\n",
331− landscape_css
332− ));
333− } else {
334− css.push_str(&format!(
335− "@media(orientation:landscape){{{}}}",
336− landscape_css
337− ));
338− }
339− }
290+ let landscape_pairs: Vec<(&str, &str)> = self
291+ .entries
292+ .iter()
293+ .filter(|e| !e.landscape.is_empty())
294+ .map(|e| (e.class_name.as_str(), e.landscape.as_str()))
295+ .collect();
296+ emit_media_block(
297+ &mut css,
298+ "@media (orientation: landscape)",
299+ "@media(orientation:landscape)",
300+ &landscape_pairs,
301+ "",
302+ dev,
303+ );
340304
341305 // Portrait rules
342− let mut portrait_css = String::new();
343− for e in &self.entries {
344− if !e.portrait.is_empty() {
345− if dev {
346− portrait_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, e.portrait));
347− } else {
348− portrait_css.push_str(&format!(".{}{{{}}}", e.class_name, e.portrait));
349− }
350− }
351− }
352− if !portrait_css.is_empty() {
353− if dev {
354− css.push_str(&format!(
355− "@media (orientation: portrait) {{\n{}}}\n",
356− portrait_css
357− ));
358− } else {
359− css.push_str(&format!("@media(orientation:portrait){{{}}}", portrait_css));
360− }
361− }
306+ let portrait_pairs: Vec<(&str, &str)> = self
307+ .entries
308+ .iter()
309+ .filter(|e| !e.portrait.is_empty())
310+ .map(|e| (e.class_name.as_str(), e.portrait.as_str()))
311+ .collect();
312+ emit_media_block(
313+ &mut css,
314+ "@media (orientation: portrait)",
315+ "@media(orientation:portrait)",
316+ &portrait_pairs,
317+ "",
318+ dev,
319+ );
362320
363321 // Container query rules grouped by breakpoint
364322 for &(bp_name, bp_width) in BREAKPOINTS {
365− let mut cq_css = String::new();
323+ let mut cq_pairs: Vec<(&str, &str)> = Vec::new();
366324 for e in &self.entries {
367325 for (bp, rule_css) in &e.container {
368326 if bp == bp_name && !rule_css.is_empty() {
369− if dev {
370− cq_css.push_str(&format!(" .{} {{{}}}\n", e.class_name, rule_css));
371− } else {
372− cq_css.push_str(&format!(".{}{{{}}}", e.class_name, rule_css));
373− }
327+ cq_pairs.push((e.class_name.as_str(), rule_css.as_str()));
374328 }
375329 }
376330 }
377− if !cq_css.is_empty() {
378− if dev {
379− css.push_str(&format!(
380− "@container (min-width: {}) {{\n{}}}\n",
381− bp_width, cq_css
382− ));
383− } else {
384− css.push_str(&format!("@container(min-width:{}){{{}}}", bp_width, cq_css));
385− }
386− }
331+ emit_media_block(
332+ &mut css,
333+ &format!("@container (min-width: {})", bp_width),
334+ &format!("@container(min-width:{})", bp_width),
335+ &cq_pairs,
336+ "",
337+ dev,
338+ );
387339 }
388340
389341 // Close @layer
@@ -399,6 +351,80 @@ impl StyleCollector {
399351 }
400352}
401353
354+/// Emit CSS rules from `(class_name, body)` pairs, merging identical bodies
355+/// into a single selector-list rule (e.g. `.a,.b{body}`). The first occurrence
356+/// of each distinct body determines ordering, so output stays deterministic
357+/// across runs. `selector_suffix` is appended to each class (e.g. `":hover"`,
358+/// or `""` for plain class rules). `indent` is prepended to each rule line.
359+fn emit_grouped_rules(
360+ out: &mut String,
361+ pairs: &[(&str, &str)],
362+ selector_suffix: &str,
363+ indent: &str,
364+ dev: bool,
365+) {
366+ if pairs.is_empty() {
367+ return;
368+ }
369+ // Group in first-occurrence order.
370+ let mut order: Vec<&str> = Vec::new();
371+ let mut groups: HashMap<&str, Vec<&str>> = HashMap::new();
372+ for &(name, body) in pairs {
373+ if body.is_empty() {
374+ continue;
375+ }
376+ if !groups.contains_key(body) {
377+ order.push(body);
378+ }
379+ groups.entry(body).or_default().push(name);
380+ }
381+ let sp = if dev { " " } else { "" };
382+ let nl = if dev { "\n" } else { "" };
383+ for body in &order {
384+ let names = &groups[body];
385+ out.push_str(indent);
386+ for (i, n) in names.iter().enumerate() {
387+ if i > 0 {
388+ out.push(',');
389+ }
390+ out.push('.');
391+ out.push_str(n);
392+ out.push_str(selector_suffix);
393+ }
394+ out.push_str(sp);
395+ out.push('{');
396+ out.push_str(body);
397+ out.push('}');
398+ out.push_str(nl);
399+ }
400+}
401+
402+/// Emit an `@media` / `@container` block containing grouped class rules.
403+/// Skips the block entirely if no non-empty bodies are present.
404+fn emit_media_block(
405+ out: &mut String,
406+ header_dev: &str,
407+ header_min: &str,
408+ pairs: &[(&str, &str)],
409+ selector_suffix: &str,
410+ dev: bool,
411+) {
412+ if pairs.iter().all(|(_, body)| body.is_empty()) {
413+ return;
414+ }
415+ let mut inner = String::new();
416+ let inner_indent = if dev { " " } else { "" };
417+ emit_grouped_rules(&mut inner, pairs, selector_suffix, inner_indent, dev);
418+ if inner.is_empty() {
419+ return;
420+ }
421+ if dev {
422+ out.push_str(&format!("{} {{\n{}}}\n", header_dev, inner));
423+ } else {
424+ out.push_str(&format!("{}{{{}}}", header_min, inner));
425+ }
426+}
427+
402428struct GenContext {
403429 dev: bool,
404430 depth: usize,
@@ -747,51 +773,60 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
747773
748774 let mut element_css = String::new();
749775
750− // CSS custom properties (includes @theme runtime tokens)
751− let has_theme_tokens = !doc.theme_tokens.is_empty();
752− if !doc.css_vars.is_empty() || has_theme_tokens {
776+ // Collect all CSS custom properties (explicit `@let --name` / `@theme`
777+ // tokens, plus any auto-extracted repeats) so they can be emitted in a
778+ // single `:root` block below.
779+ let mut root_vars: Vec<(String, String)> = Vec::new();
780+ for (name, value) in &doc.css_vars {
781+ root_vars.push((name.clone(), value.clone()));
782+ }
783+ for (name, value) in &doc.theme_tokens {
784+ let css_name = format!("--{}", name);
785+ if !root_vars.iter().any(|(n, _)| *n == css_name) {
786+ root_vars.push((css_name, value.clone()));
787+ }
788+ }
789+
790+ let has_custom_css = !doc.custom_css.is_empty();
791+ let styles_css = styles.to_css_formatted(dev, !has_custom_css);
792+ // Fold literal values declared via `@theme` / `@let --name` back into
793+ // `var(--name)` references so the generated CSS actually uses the
794+ // custom properties emitted in `:root`. Saves bytes and makes runtime
795+ // theming take effect.
796+ let styles_css = substitute_css_vars(&styles_css, &root_vars);
797+ // Further compress the CSS by auto-extracting any remaining literal
798+ // values that appear often enough for `var(--hN)` references to come out
799+ // shorter overall. Disabled in dev mode to keep the generated CSS
800+ // readable.
801+ let styles_css = if dev {
802+ styles_css
803+ } else {
804+ let (new_css, auto_vars) = auto_extract_repeats(&styles_css, &root_vars);
805+ root_vars.extend(auto_vars);
806+ new_css
807+ };
808+
809+ // Emit the (possibly extended) :root block first so the cascade picks up
810+ // the custom properties before the class rules consume them.
811+ if !root_vars.is_empty() {
753812 if dev {
754813 element_css.push_str(":root {\n");
755− for (name, value) in &doc.css_vars {
814+ for (name, value) in &root_vars {
756815 element_css.push_str(&format!(" {}: {};\n", name, value));
757816 }
758− // @theme tokens as runtime CSS custom properties
759− for (name, value) in &doc.theme_tokens {
760− let css_name = format!("--{}", name);
761− // Only emit if not already in css_vars
762− if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
763− element_css.push_str(&format!(" {}: {};\n", css_name, value));
764− }
765− }
766817 element_css.push_str("}\n");
767818 } else {
768819 element_css.push_str(":root{");
769− for (name, value) in &doc.css_vars {
820+ for (name, value) in &root_vars {
770821 element_css.push_str(name);
771822 element_css.push(':');
772823 element_css.push_str(value);
773824 element_css.push(';');
774825 }
775− for (name, value) in &doc.theme_tokens {
776− let css_name = format!("--{}", name);
777− if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
778− element_css.push_str(&css_name);
779− element_css.push(':');
780− element_css.push_str(value);
781− element_css.push(';');
782− }
783− }
784826 element_css.push('}');
785827 }
786828 }
787829
788− let has_custom_css = !doc.custom_css.is_empty();
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);
795830 element_css.push_str(&styles_css);
796831
797832 // @keyframes
@@ -1266,43 +1301,46 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
12661301 let has_custom_css = !doc.custom_css.is_empty();
12671302 let mut element_css = String::new();
12681303
1269− // CSS custom properties
1270− if !doc.css_vars.is_empty() || !doc.theme_tokens.is_empty() {
1304+ let mut root_vars: Vec<(String, String)> = Vec::new();
1305+ for (name, value) in &doc.css_vars {
1306+ root_vars.push((name.clone(), value.clone()));
1307+ }
1308+ for (name, value) in &doc.theme_tokens {
1309+ let css_name = format!("--{}", name);
1310+ if !root_vars.iter().any(|(n, _)| *n == css_name) {
1311+ root_vars.push((css_name, value.clone()));
1312+ }
1313+ }
1314+
1315+ let styles_css = styles.to_css_formatted(dev, !has_custom_css);
1316+ let styles_css = substitute_css_vars(&styles_css, &root_vars);
1317+ let styles_css = if dev {
1318+ styles_css
1319+ } else {
1320+ let (new_css, auto_vars) = auto_extract_repeats(&styles_css, &root_vars);
1321+ root_vars.extend(auto_vars);
1322+ new_css
1323+ };
1324+
1325+ if !root_vars.is_empty() {
12711326 if dev {
12721327 element_css.push_str(":root {\n");
1273− for (name, value) in &doc.css_vars {
1328+ for (name, value) in &root_vars {
12741329 element_css.push_str(&format!(" {}: {};\n", name, value));
12751330 }
1276− for (name, value) in &doc.theme_tokens {
1277− let css_name = format!("--{}", name);
1278− if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
1279− element_css.push_str(&format!(" {}: {};\n", css_name, value));
1280− }
1281− }
12821331 element_css.push_str("}\n");
12831332 } else {
12841333 element_css.push_str(":root{");
1285− for (name, value) in &doc.css_vars {
1334+ for (name, value) in &root_vars {
12861335 element_css.push_str(name);
12871336 element_css.push(':');
12881337 element_css.push_str(value);
12891338 element_css.push(';');
12901339 }
1291− for (name, value) in &doc.theme_tokens {
1292− let css_name = format!("--{}", name);
1293− if !doc.css_vars.iter().any(|(n, _)| *n == css_name) {
1294− element_css.push_str(&css_name);
1295− element_css.push(':');
1296− element_css.push_str(value);
1297− element_css.push(';');
1298− }
1299− }
13001340 element_css.push('}');
13011341 }
13021342 }
13031343
1304− let styles_css = styles.to_css_formatted(dev, !has_custom_css);
1305− let styles_css = substitute_css_vars(&styles_css, &doc.css_vars);
13061344 element_css.push_str(&styles_css);
13071345
13081346 for (name, kf_body) in &doc.keyframes {
@@ -2458,6 +2496,19 @@ fn is_prefixed_attr(key: &str) -> bool {
24582496 || key.starts_with("has(")
24592497}
24602498
2499+/// `display:flex;flex-direction:column;` — base layout for `@el` and every
2500+/// semantic wrapper that behaves like a column.
2501+const FLEX_COLUMN: &str = "display:flex;flex-direction:column;";
2502+
2503+/// Font stack shared by `@pre`, `@code`, `@kbd`.
2504+const MONOSPACE_STACK: &str = "font-family:ui-monospace,monospace;";
2505+
2506+/// Declarations common to `@badge` and `@tag`. They differ only in
2507+/// `border-radius` (and `@badge` additionally centers content / pins
2508+/// line-height:1), so keeping the shared base here avoids drift.
2509+const PILL_BASE: &str =
2510+ "display:inline-flex;align-items:center;padding:2px 8px;font-size:0.75rem;font-weight:600;";
2511+
24612512fn attrs_to_css(
24622513 attrs: &[Attribute],
24632514 state_prefix: &str,
@@ -2470,43 +2521,61 @@ fn attrs_to_css(
24702521 if state_prefix.is_empty() {
24712522 match kind {
24722523 ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2473− ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
2474− ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
2475− ElementKind::Paragraph => css.push_str("margin:0;"),
2524+ ElementKind::Column
2525+ | ElementKind::El
24762526 // Semantic elements get flex column layout like @el
2477− ElementKind::Nav | ElementKind::Header | ElementKind::Footer
2478− | ElementKind::Main | ElementKind::Section | ElementKind::Article
2479− | ElementKind::Aside => css.push_str("display:flex;flex-direction:column;"),
2527+ | ElementKind::Nav
2528+ | ElementKind::Header
2529+ | ElementKind::Footer
2530+ | ElementKind::Main
2531+ | ElementKind::Section
2532+ | ElementKind::Article
2533+ | ElementKind::Aside
2534+ | ElementKind::ListItem
2535+ | ElementKind::Form
2536+ | ElementKind::Details
2537+ | ElementKind::Dialog => css.push_str(FLEX_COLUMN),
2538+ ElementKind::Paragraph => css.push_str("margin:0;"),
24802539 // Lists: reset browser defaults
24812540 ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
2482− ElementKind::ListItem => css.push_str("display:flex;flex-direction:column;"),
2483− // Form: flex column like @el
2484− ElementKind::Form => css.push_str("display:flex;flex-direction:column;"),
2485− // Details: flex column
2486− ElementKind::Details => css.push_str("display:flex;flex-direction:column;"),
2487− // Figure: flex column
2488− ElementKind::Figure => css.push_str("display:flex;flex-direction:column;margin:0;"),
2489− // Blockquote: flex column, reset browser margin
2490− ElementKind::Blockquote => css.push_str("display:flex;flex-direction:column;margin:0;"),
2541+ // Figure / Blockquote: flex column with browser margin reset
2542+ ElementKind::Figure | ElementKind::Blockquote => {
2543+ css.push_str(FLEX_COLUMN);
2544+ css.push_str("margin:0;");
2545+ }
24912546 // Pre: preserve whitespace
2492− ElementKind::Pre => css.push_str("margin:0;white-space:pre;font-family:ui-monospace,monospace;"),
2493− // Code: monospace font
2494− ElementKind::Code => css.push_str("font-family:ui-monospace,monospace;"),
2495− // New elements
2496− ElementKind::Dialog => css.push_str("display:flex;flex-direction:column;"),
2547+ ElementKind::Pre => {
2548+ css.push_str("margin:0;white-space:pre;");
2549+ css.push_str(MONOSPACE_STACK);
2550+ }
2551+ // Code / Kbd: monospace font
2552+ ElementKind::Code | ElementKind::Kbd => css.push_str(MONOSPACE_STACK),
24972553 ElementKind::DefinitionList => css.push_str("margin:0;"),
2498− ElementKind::DefinitionDescription => css.push_str("margin:0;display:flex;flex-direction:column;"),
2499− ElementKind::Fieldset => css.push_str("display:flex;flex-direction:column;border:1px solid currentColor;padding:0.5em;margin:0;"),
2500− ElementKind::Kbd => css.push_str("font-family:ui-monospace,monospace;"),
2554+ ElementKind::DefinitionDescription => {
2555+ css.push_str("margin:0;");
2556+ css.push_str(FLEX_COLUMN);
2557+ }
2558+ ElementKind::Fieldset => {
2559+ css.push_str(FLEX_COLUMN);
2560+ // Align padding to the px-based scale used by Badge/Tag/Chip
2561+ // rather than the old 0.5em (which depended on font-size).
2562+ css.push_str("border:1px solid currentColor;padding:8px;margin:0;");
2563+ }
25012564 ElementKind::Grid => css.push_str("display:grid;"),
25022565 ElementKind::Stack => css.push_str("position:relative;"),
2503− ElementKind::Badge => css.push_str("display:inline-flex;align-items:center;justify-content:center;padding:2px 8px;border-radius:9999px;font-size:0.75rem;font-weight:600;line-height:1;"),
2566+ ElementKind::Badge => {
2567+ css.push_str(PILL_BASE);
2568+ css.push_str("justify-content:center;border-radius:9999px;line-height:1;");
2569+ }
2570+ ElementKind::Tag => {
2571+ css.push_str(PILL_BASE);
2572+ css.push_str("border-radius:4px;");
2573+ }
25042574 ElementKind::Tooltip => css.push_str("position:relative;cursor:help;"),
25052575 ElementKind::Spacer => css.push_str("flex:1;"),
25062576 ElementKind::Avatar => css.push_str("display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;"),
25072577 ElementKind::Carousel => css.push_str("display:flex;flex-direction:row;overflow-x:auto;scroll-snap-type:x mandatory;-webkit-overflow-scrolling:touch;scrollbar-width:none;"),
25082578 ElementKind::Chip => css.push_str("display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:9999px;font-size:0.875rem;border:1px solid currentColor;"),
2509− ElementKind::Tag => css.push_str("display:inline-flex;align-items:center;padding:2px 8px;border-radius:4px;font-size:0.75rem;font-weight:600;"),
25102579 _ => {}
25112580 }
25122581 // Children of @carousel get scroll-snap-align and flex-shrink
@@ -2545,14 +2614,15 @@ fn attrs_to_css(
25452614 }
25462615 "padding-x" => {
25472616 if let Some(v) = val {
2548− push_css(&mut css, "padding-left", &css_px(v));
2549− push_css(&mut css, "padding-right", &css_px(v));
2617+ // Logical property covers both inline sides in one
2618+ // declaration; for symmetric values this is visually
2619+ // identical to padding-left/right in LTR and RTL.
2620+ push_css(&mut css, "padding-inline", &css_px(v));
25502621 }
25512622 }
25522623 "padding-y" => {
25532624 if let Some(v) = val {
2554− push_css(&mut css, "padding-top", &css_px(v));
2555− push_css(&mut css, "padding-bottom", &css_px(v));
2625+ push_css(&mut css, "padding-block", &css_px(v));
25562626 }
25572627 }
25582628
@@ -3035,14 +3105,12 @@ fn attrs_to_css(
30353105 }
30363106 "margin-x" => {
30373107 if let Some(v) = val {
3038− push_css(&mut css, "margin-left", &css_px(v));
3039− push_css(&mut css, "margin-right", &css_px(v));
3108+ push_css(&mut css, "margin-inline", &css_px(v));
30403109 }
30413110 }
30423111 "margin-y" => {
30433112 if let Some(v) = val {
3044− push_css(&mut css, "margin-top", &css_px(v));
3045− push_css(&mut css, "margin-bottom", &css_px(v));
3113+ push_css(&mut css, "margin-block", &css_px(v));
30463114 }
30473115 }
30483116
@@ -3719,6 +3787,129 @@ fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
37193787 out
37203788}
37213789
3790+/// Scan generated CSS for literal property values that repeat often enough
3791+/// that substituting them with an auto-named custom property would reduce
3792+/// total byte count. Returns the rewritten CSS and any new vars that should
3793+/// be appended to `:root`.
3794+///
3795+/// The algorithm only promotes a value if it strictly saves bytes after
3796+/// accounting for the `--xN:value;` declaration overhead plus the
3797+/// `var(--xN)` reference cost at each call site. Values that are already
3798+/// `var(...)` references or contain `var(` calls are skipped so we never
3799+/// nest references. Names avoid collisions with `existing_vars`.
3800+fn auto_extract_repeats(
3801+ css: &str,
3802+ existing_vars: &[(String, String)],
3803+) -> (String, Vec<(String, String)>) {
3804+ if css.is_empty() {
3805+ return (css.to_string(), Vec::new());
3806+ }
3807+ // Count occurrences of each distinct property value (the text between
3808+ // `:` and the next `;` or `}` at the top level of a declaration block,
3809+ // ignoring balanced parentheses so that e.g. `rgba(...)` stays intact).
3810+ let bytes = css.as_bytes();
3811+ let mut counts: HashMap<String, usize> = HashMap::new();
3812+ let mut depth: i32 = 0;
3813+ let mut i = 0;
3814+ while i < bytes.len() {
3815+ match bytes[i] {
3816+ b'{' => {
3817+ depth += 1;
3818+ i += 1;
3819+ continue;
3820+ }
3821+ b'}' => {
3822+ if depth > 0 {
3823+ depth -= 1;
3824+ }
3825+ i += 1;
3826+ continue;
3827+ }
3828+ _ => {}
3829+ }
3830+ if depth > 0 && bytes[i] == b':' {
3831+ // Skip past `:` then walk to the terminator.
3832+ let start = i + 1;
3833+ let mut end = start;
3834+ let mut pdepth = 0i32;
3835+ while end < bytes.len() {
3836+ match bytes[end] {
3837+ b'(' => pdepth += 1,
3838+ b')' => {
3839+ if pdepth > 0 {
3840+ pdepth -= 1;
3841+ }
3842+ }
3843+ b';' | b'}' if pdepth == 0 => break,
3844+ b'{' if pdepth == 0 => break,
3845+ _ => {}
3846+ }
3847+ end += 1;
3848+ }
3849+ let val = css[start..end].trim();
3850+ if !val.is_empty() && !val.starts_with("var(") && !val.contains("var(") {
3851+ *counts.entry(val.to_string()).or_default() += 1;
3852+ }
3853+ i = end;
3854+ continue;
3855+ }
3856+ i += 1;
3857+ }
3858+
3859+ // Decide which values are worth extracting. A value of length L appearing
3860+ // N times costs N*L bytes inline; promoting it costs (L + 6) for the
3861+ // `--hK:V;` declaration plus N * ref_len for the call sites. We estimate
3862+ // ref_len optimistically as `var(--h0)` (9 bytes) and fall back to 10 for
3863+ // two-digit indices, which only affects very large extraction counts.
3864+ let existing: std::collections::HashSet<&str> =
3865+ existing_vars.iter().map(|(n, _)| n.as_str()).collect();
3866+ let mut candidates: Vec<(String, usize)> = counts
3867+ .into_iter()
3868+ .filter(|(_, n)| *n >= 2)
3869+ .collect();
3870+ // Stable, deterministic ordering — longest values first, tiebreak by text.
3871+ candidates.sort_by(|a, b| {
3872+ b.0.len()
3873+ .cmp(&a.0.len())
3874+ .then_with(|| a.0.cmp(&b.0))
3875+ });
3876+
3877+ let mut extracted: Vec<(String, String)> = Vec::new();
3878+ let mut next_idx: usize = 0;
3879+ for (value, n) in candidates {
3880+ let l = value.len();
3881+ // Allocate the next unused `--hN` name.
3882+ let (name, ref_len) = loop {
3883+ let candidate = format!("--h{}", next_idx);
3884+ next_idx += 1;
3885+ let rl = candidate.len() + 6; // `var(` + name + `)`
3886+ if !existing.contains(candidate.as_str())
3887+ && !extracted.iter().any(|(n, _)| *n == candidate)
3888+ {
3889+ break (candidate, rl);
3890+ }
3891+ };
3892+ // Skip if promotion would not strictly reduce byte count.
3893+ // Before: n * l bytes. After: (l + name.len() + 2) for the :root
3894+ // declaration (`<name>:<value>;`) plus n * ref_len for the sites.
3895+ let decl_overhead = l + name.len() + 2;
3896+ let before = n * l;
3897+ let after = decl_overhead + n * ref_len;
3898+ if after < before {
3899+ extracted.push((name, value));
3900+ } else {
3901+ // Rewind the index so the next value can reuse this slot.
3902+ next_idx -= 1;
3903+ }
3904+ }
3905+
3906+ if extracted.is_empty() {
3907+ return (css.to_string(), Vec::new());
3908+ }
3909+ let new_css = substitute_css_vars(css, &extracted);
3910+ (new_css, extracted)
3911+}
3912+
37223913/// Format a `line-height` value. CSS accepts either a unitless multiplier
37233914/// (e.g. `1.5`) or a length (e.g. `24px`). Plain integers in htmlang source
37243915/// are ambiguous: `[line-height 24]` was historically emitted as `24` (which
examples/demo.html+1−1
@@ -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: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
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;--h0:1px solid #e5e7eb;}@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:var(--h0);}.o{display:flex;flex-direction:column;gap:12px;padding-block: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:var(--h0);box-shadow:0 1px 3px rgba(0,0,0,0.05);cursor:pointer;transition:all 0.2s ease;animation:fade-up 0.3s ease;}.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;}.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:var(--h0);border-radius:6px;transition:border 0.15s ease;}.as{padding:10px;border:var(--h0);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;}.av{padding:10px 16px;background:white;border:var(--h0);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:var(--h0);}.be{padding:8px;border-top:var(--h0);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:var(--h0);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:var(--h0);}.bo{display:flex;flex-direction:column;grid-area:m;padding:10px;background:white;border-radius:6px;border:var(--h0);}.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,.bx{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:var(--h0);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;}.by{display:flex;flex-direction:column;flex:1;min-width:0;}.bz{color:var(--muted);font-size:13px;}.r:hover{box-shadow:0 4px 12px rgba(0,0,0,0.08);}.au:hover,.bw:hover{background:#3575dd;}.bx:hover{text-decoration:underline;}.an::before{content:"→ ";font-weight:bold;}.ar:focus{border:1px solid var(--primary);}.ar:valid{border:1px solid #10b981;}.ar:invalid{border:1px solid #ef4444;}.au:active,.bw:active{background:#2f68c5;}.bx:visited{color:#8b5cf6;}@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]
33 @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−4
@@ -1297,16 +1297,16 @@ fn css_margin() {
12971297
12981298#[test]
12991299fn css_margin_x() {
1300+ // margin-x emits the `margin-inline` logical shorthand (single property,
1301+ // covers both inline sides, stays symmetric under RTL).
13001302 let output = compile("@page T\n@el [margin-x 10]");
1301− assert!(output.contains("margin-left:10px"));
1302− assert!(output.contains("margin-right:10px"));
1303+ assert!(output.contains("margin-inline:10px"));
13031304}
13041305
13051306#[test]
13061307fn css_margin_y() {
13071308 let output = compile("@page T\n@el [margin-y 10]");
1308− assert!(output.contains("margin-top:10px"));
1309− assert!(output.contains("margin-bottom:10px"));
1309+ assert!(output.contains("margin-block:10px"));
13101310}
13111311
13121312#[test]