on master help sign in
Optimize CSS codegen: dedupe rules, auto-extract repeated values, consolidate constants
c35fddbiko
crates/htmlang-core/src/codegen.rs+458−267
@@ -153,7 +153,7 @@ impl StyleCollector {
153
153
154 fn to_css_formatted(&self, dev: bool, use_layer: bool) -> String {
154 fn to_css_formatted(&self, dev: bool, use_layer: bool) -> String {
155 let mut css = String::new();
155 let mut css = String::new();
156− let (sp, nl) = if dev { (" ", "\n") } else { ("", "") };
156+ let inner_indent = if use_layer && dev { " " } else { "" };
157
157
158 // Wrap in @layer for specificity management
158 // Wrap in @layer for specificity management
159 if use_layer {
159 if use_layer {
@@ -164,226 +164,178 @@ impl StyleCollector {
164 }
164 }
165 }
165 }
166
166
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());
168 for e in &self.entries {
173 for e in &self.entries {
169 if !e.base.is_empty() {
174 if !e.base.is_empty() {
170− if use_layer && dev {
171− css.push_str(&format!(" .{}{sp}{{{}}}{nl}", e.class_name, e.base));
175+ base_pairs.push((e.class_name.as_str(), e.base.as_str()));
172− } else {
173− css.push_str(&format!(".{}{sp}{{{}}}{nl}", e.class_name, e.base));
174− }
175 }
176 }
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 {
176− for (selector, pseudo_css) in &e.pseudo {
185+ for (selector, body) in &e.pseudo {
177− if !pseudo_css.is_empty() {
186+ if body.is_empty() {
178− if use_layer && dev {
187+ continue;
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− }
189 }
188 }
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);
190 }
202 }
191 }
203 }
192
204
193 // Responsive rules grouped by breakpoint
205 // Responsive rules grouped by breakpoint
194 for &(bp_name, bp_width) in BREAKPOINTS {
206 for &(bp_name, bp_width) in BREAKPOINTS {
195− let mut bp_css = String::new();
207+ let mut bp_pairs: Vec<(&str, &str)> = Vec::new();
196 for e in &self.entries {
208 for e in &self.entries {
197 for (bp, rule_css) in &e.responsive {
209 for (bp, rule_css) in &e.responsive {
198 if bp == bp_name && !rule_css.is_empty() {
210 if bp == bp_name && !rule_css.is_empty() {
199− if dev {
211+ bp_pairs.push((e.class_name.as_str(), rule_css.as_str()));
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− }
204 }
212 }
205 }
213 }
206 }
214 }
207− if !bp_css.is_empty() {
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+ "",
208− if dev {
221+ dev,
209− css.push_str(&format!(
222+ );
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− }
217 }
223 }
218
224
219 // Dark mode rules
225 // Dark mode rules
220− let mut dark_css = String::new();
226+ let dark_pairs: Vec<(&str, &str)> = self
221− for e in &self.entries {
227+ .entries
222− if !e.dark.is_empty() {
228+ .iter()
223− if dev {
229+ .filter(|e| !e.dark.is_empty())
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));
230+ .map(|e| (e.class_name.as_str(), e.dark.as_str()))
227− }
231+ .collect();
228− }
232+ emit_media_block(
229− }
233+ &mut css,
230− if !dark_css.is_empty() {
231− if dev {
232− css.push_str(&format!(
233− "@media (prefers-color-scheme: dark) {{\n{}}}\n",
234+ "@media (prefers-color-scheme: dark)",
234− dark_css
235− ));
236− } else {
237− css.push_str(&format!(
238− "@media(prefers-color-scheme:dark){{{}}}",
235+ "@media(prefers-color-scheme:dark)",
239− dark_css
236+ &dark_pairs,
240− ));
237+ "",
241− }
238+ dev,
242− }
239+ );
243
240
244 // Print rules
241 // Print rules
245− let mut print_css = String::new();
242+ let print_pairs: Vec<(&str, &str)> = self
246− for e in &self.entries {
243+ .entries
247− if !e.print.is_empty() {
244+ .iter()
248− if dev {
245+ .filter(|e| !e.print.is_empty())
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));
246+ .map(|e| (e.class_name.as_str(), e.print.as_str()))
252− }
247+ .collect();
253− }
248+ emit_media_block(
254− }
249+ &mut css,
255− if !print_css.is_empty() {
256− if dev {
257− css.push_str(&format!("@media print {{\n{}}}\n", print_css));
250+ "@media print",
258− } else {
259− css.push_str(&format!("@media print{{{}}}", print_css));
251+ "@media print",
260− }
252+ &print_pairs,
261− }
253+ "",
254+ dev,
255+ );
262
256
263 // Motion safe rules
257 // Motion safe rules
264− let mut motion_safe_css = String::new();
258+ let motion_safe_pairs: Vec<(&str, &str)> = self
265− for e in &self.entries {
259+ .entries
266− if !e.motion_safe.is_empty() {
260+ .iter()
267− if dev {
268− motion_safe_css
269− .push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_safe));
261+ .filter(|e| !e.motion_safe.is_empty())
270− } else {
271− motion_safe_css.push_str(&format!(".{}{{{}}}", e.class_name, e.motion_safe));
262+ .map(|e| (e.class_name.as_str(), e.motion_safe.as_str()))
272− }
263+ .collect();
273− }
264+ emit_media_block(
274− }
265+ &mut css,
275− if !motion_safe_css.is_empty() {
276− if dev {
277− css.push_str(&format!(
278− "@media (prefers-reduced-motion: no-preference) {{\n{}}}\n",
266+ "@media (prefers-reduced-motion: no-preference)",
279− motion_safe_css
280− ));
281− } else {
282− css.push_str(&format!(
283− "@media(prefers-reduced-motion:no-preference){{{}}}",
267+ "@media(prefers-reduced-motion:no-preference)",
284− motion_safe_css
268+ &motion_safe_pairs,
285− ));
269+ "",
286− }
270+ dev,
287− }
271+ );
288
272
289 // Motion reduce rules
273 // Motion reduce rules
290− let mut motion_reduce_css = String::new();
274+ let motion_reduce_pairs: Vec<(&str, &str)> = self
291− for e in &self.entries {
275+ .entries
292− if !e.motion_reduce.is_empty() {
276+ .iter()
293− if dev {
294− motion_reduce_css
295− .push_str(&format!(" .{} {{{}}}\n", e.class_name, e.motion_reduce));
277+ .filter(|e| !e.motion_reduce.is_empty())
296− } else {
297− motion_reduce_css
298− .push_str(&format!(".{}{{{}}}", e.class_name, e.motion_reduce));
278+ .map(|e| (e.class_name.as_str(), e.motion_reduce.as_str()))
299− }
279+ .collect();
300− }
280+ emit_media_block(
301− }
281+ &mut css,
302− if !motion_reduce_css.is_empty() {
303− if dev {
304− css.push_str(&format!(
305− "@media (prefers-reduced-motion: reduce) {{\n{}}}\n",
282+ "@media (prefers-reduced-motion: reduce)",
306− motion_reduce_css
307− ));
308− } else {
309− css.push_str(&format!(
310− "@media(prefers-reduced-motion:reduce){{{}}}",
283+ "@media(prefers-reduced-motion:reduce)",
311− motion_reduce_css
284+ &motion_reduce_pairs,
312− ));
285+ "",
313− }
286+ dev,
314− }
287+ );
315
288
316 // Landscape rules
289 // Landscape rules
317− let mut landscape_css = String::new();
290+ let landscape_pairs: Vec<(&str, &str)> = self
318− for e in &self.entries {
291+ .entries
319− if !e.landscape.is_empty() {
292+ .iter()
320− if dev {
293+ .filter(|e| !e.landscape.is_empty())
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));
294+ .map(|e| (e.class_name.as_str(), e.landscape.as_str()))
324− }
295+ .collect();
325− }
296+ emit_media_block(
326− }
297+ &mut css,
327− if !landscape_css.is_empty() {
328− if dev {
329− css.push_str(&format!(
330− "@media (orientation: landscape) {{\n{}}}\n",
298+ "@media (orientation: landscape)",
331− landscape_css
332− ));
333− } else {
334− css.push_str(&format!(
335− "@media(orientation:landscape){{{}}}",
299+ "@media(orientation:landscape)",
336− landscape_css
300+ &landscape_pairs,
337− ));
301+ "",
338− }
302+ dev,
339− }
303+ );
340
304
341 // Portrait rules
305 // Portrait rules
342− let mut portrait_css = String::new();
306+ let portrait_pairs: Vec<(&str, &str)> = self
343− for e in &self.entries {
307+ .entries
344− if !e.portrait.is_empty() {
308+ .iter()
345− if dev {
309+ .filter(|e| !e.portrait.is_empty())
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));
310+ .map(|e| (e.class_name.as_str(), e.portrait.as_str()))
349− }
311+ .collect();
350− }
312+ emit_media_block(
351− }
313+ &mut css,
352− if !portrait_css.is_empty() {
353− if dev {
354− css.push_str(&format!(
355− "@media (orientation: portrait) {{\n{}}}\n",
314+ "@media (orientation: portrait)",
356− portrait_css
357− ));
358− } else {
359− css.push_str(&format!("@media(orientation:portrait){{{}}}", portrait_css));
315+ "@media(orientation:portrait)",
360− }
316+ &portrait_pairs,
361− }
317+ "",
318+ dev,
319+ );
362
320
363 // Container query rules grouped by breakpoint
321 // Container query rules grouped by breakpoint
364 for &(bp_name, bp_width) in BREAKPOINTS {
322 for &(bp_name, bp_width) in BREAKPOINTS {
365− let mut cq_css = String::new();
323+ let mut cq_pairs: Vec<(&str, &str)> = Vec::new();
366 for e in &self.entries {
324 for e in &self.entries {
367 for (bp, rule_css) in &e.container {
325 for (bp, rule_css) in &e.container {
368 if bp == bp_name && !rule_css.is_empty() {
326 if bp == bp_name && !rule_css.is_empty() {
369− if dev {
327+ cq_pairs.push((e.class_name.as_str(), rule_css.as_str()));
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− }
374 }
328 }
375 }
329 }
376 }
330 }
377− if !cq_css.is_empty() {
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+ "",
378− if dev {
337+ dev,
379− css.push_str(&format!(
338+ );
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− }
387 }
339 }
388
340
389 // Close @layer
341 // Close @layer
@@ -399,6 +351,80 @@ impl StyleCollector {
399 }
351 }
400}
352}
401
353
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+
402struct GenContext {
428struct GenContext {
403 dev: bool,
429 dev: bool,
404 depth: usize,
430 depth: usize,
@@ -747,51 +773,60 @@ fn generate_full_inner(doc: &Document, dev: bool) -> String {
747
773
748 let mut element_css = String::new();
774 let mut element_css = String::new();
749
775
750− // CSS custom properties (includes @theme runtime 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+ }
751− let has_theme_tokens = !doc.theme_tokens.is_empty();
783+ for (name, value) in &doc.theme_tokens {
752− if !doc.css_vars.is_empty() || has_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() {
753 if dev {
812 if dev {
754 element_css.push_str(":root {\n");
813 element_css.push_str(":root {\n");
755− for (name, value) in &doc.css_vars {
814+ for (name, value) in &root_vars {
756 element_css.push_str(&format!(" {}: {};\n", name, value));
815 element_css.push_str(&format!(" {}: {};\n", name, value));
757 }
816 }
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− }
766 element_css.push_str("}\n");
817 element_css.push_str("}\n");
767 } else {
818 } else {
768 element_css.push_str(":root{");
819 element_css.push_str(":root{");
769− for (name, value) in &doc.css_vars {
820+ for (name, value) in &root_vars {
770 element_css.push_str(name);
821 element_css.push_str(name);
771 element_css.push(':');
822 element_css.push(':');
772 element_css.push_str(value);
823 element_css.push_str(value);
773 element_css.push(';');
824 element_css.push(';');
774 }
825 }
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− }
784 element_css.push('}');
826 element_css.push('}');
785 }
827 }
786 }
828 }
787
829
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);
795 element_css.push_str(&styles_css);
830 element_css.push_str(&styles_css);
796
831
797 // @keyframes
832 // @keyframes
@@ -1266,43 +1301,46 @@ fn generate_partial_inner(doc: &Document, dev: bool) -> String {
1266 let has_custom_css = !doc.custom_css.is_empty();
1301 let has_custom_css = !doc.custom_css.is_empty();
1267 let mut element_css = String::new();
1302 let mut element_css = String::new();
1268
1303
1269− // CSS custom properties
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+ }
1270− if !doc.css_vars.is_empty() || !doc.theme_tokens.is_empty() {
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() {
1271 if dev {
1326 if dev {
1272 element_css.push_str(":root {\n");
1327 element_css.push_str(":root {\n");
1273− for (name, value) in &doc.css_vars {
1328+ for (name, value) in &root_vars {
1274 element_css.push_str(&format!(" {}: {};\n", name, value));
1329 element_css.push_str(&format!(" {}: {};\n", name, value));
1275 }
1330 }
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− }
1282 element_css.push_str("}\n");
1331 element_css.push_str("}\n");
1283 } else {
1332 } else {
1284 element_css.push_str(":root{");
1333 element_css.push_str(":root{");
1285− for (name, value) in &doc.css_vars {
1334+ for (name, value) in &root_vars {
1286 element_css.push_str(name);
1335 element_css.push_str(name);
1287 element_css.push(':');
1336 element_css.push(':');
1288 element_css.push_str(value);
1337 element_css.push_str(value);
1289 element_css.push(';');
1338 element_css.push(';');
1290 }
1339 }
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− }
1300 element_css.push('}');
1340 element_css.push('}');
1301 }
1341 }
1302 }
1342 }
1303
1343
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);
1344 element_css.push_str(&styles_css);
1307
1345
1308 for (name, kf_body) in &doc.keyframes {
1346 for (name, kf_body) in &doc.keyframes {
@@ -2458,6 +2496,19 @@ fn is_prefixed_attr(key: &str) -> bool {
2458 || key.starts_with("has(")
2496 || key.starts_with("has(")
2459}
2497}
2460
2498
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+
2461fn attrs_to_css(
2512fn attrs_to_css(
2462 attrs: &[Attribute],
2513 attrs: &[Attribute],
2463 state_prefix: &str,
2514 state_prefix: &str,
@@ -2470,43 +2521,61 @@ fn attrs_to_css(
2470 if state_prefix.is_empty() {
2521 if state_prefix.is_empty() {
2471 match kind {
2522 match kind {
2472 ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2523 ElementKind::Row => css.push_str("display:flex;flex-direction:row;"),
2473− ElementKind::Column => css.push_str("display:flex;flex-direction:column;"),
2524+ ElementKind::Column
2474− ElementKind::El => css.push_str("display:flex;flex-direction:column;"),
2525+ | ElementKind::El
2475− ElementKind::Paragraph => css.push_str("margin:0;"),
2476 // Semantic elements get flex column layout like @el
2526 // Semantic elements get flex column layout like @el
2527+ | ElementKind::Nav
2477− ElementKind::Nav | ElementKind::Header | ElementKind::Footer
2528+ | ElementKind::Header
2529+ | ElementKind::Footer
2530+ | ElementKind::Main
2478− | ElementKind::Main | ElementKind::Section | ElementKind::Article
2531+ | ElementKind::Section
2532+ | ElementKind::Article
2479− | ElementKind::Aside => css.push_str("display:flex;flex-direction:column;"),
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;"),
2480 // Lists: reset browser defaults
2539 // Lists: reset browser defaults
2481 ElementKind::List => css.push_str("margin:0;padding-left:0;list-style:none;"),
2540 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
2541+ // Figure / Blockquote: flex column with browser margin reset
2490− ElementKind::Blockquote => css.push_str("display:flex;flex-direction:column;margin:0;"),
2542+ ElementKind::Figure | ElementKind::Blockquote => {
2543+ css.push_str(FLEX_COLUMN);
2544+ css.push_str("margin:0;");
2545+ }
2491 // Pre: preserve whitespace
2546 // Pre: preserve whitespace
2547+ ElementKind::Pre => {
2492− ElementKind::Pre => css.push_str("margin:0;white-space:pre;font-family:ui-monospace,monospace;"),
2548+ css.push_str("margin:0;white-space:pre;");
2549+ css.push_str(MONOSPACE_STACK);
2550+ }
2493− // Code: monospace font
2551+ // Code / Kbd: monospace font
2494− ElementKind::Code => css.push_str("font-family:ui-monospace,monospace;"),
2552+ ElementKind::Code | ElementKind::Kbd => css.push_str(MONOSPACE_STACK),
2495− // New elements
2496− ElementKind::Dialog => css.push_str("display:flex;flex-direction:column;"),
2497 ElementKind::DefinitionList => css.push_str("margin:0;"),
2553 ElementKind::DefinitionList => css.push_str("margin:0;"),
2498− ElementKind::DefinitionDescription => css.push_str("margin:0;display:flex;flex-direction:column;"),
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).
2499− ElementKind::Fieldset => css.push_str("display:flex;flex-direction:column;border:1px solid currentColor;padding:0.5em;margin:0;"),
2562+ css.push_str("border:1px solid currentColor;padding:8px;margin:0;");
2500− ElementKind::Kbd => css.push_str("font-family:ui-monospace,monospace;"),
2563+ }
2501 ElementKind::Grid => css.push_str("display:grid;"),
2564 ElementKind::Grid => css.push_str("display:grid;"),
2502 ElementKind::Stack => css.push_str("position:relative;"),
2565 ElementKind::Stack => css.push_str("position:relative;"),
2566+ ElementKind::Badge => {
2567+ css.push_str(PILL_BASE);
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;"),
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+ }
2504 ElementKind::Tooltip => css.push_str("position:relative;cursor:help;"),
2574 ElementKind::Tooltip => css.push_str("position:relative;cursor:help;"),
2505 ElementKind::Spacer => css.push_str("flex:1;"),
2575 ElementKind::Spacer => css.push_str("flex:1;"),
2506 ElementKind::Avatar => css.push_str("display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;"),
2576 ElementKind::Avatar => css.push_str("display:inline-flex;align-items:center;justify-content:center;border-radius:9999px;overflow:hidden;flex-shrink:0;"),
2507 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;"),
2577 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;"),
2508 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;"),
2578 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;"),
2510 _ => {}
2579 _ => {}
2511 }
2580 }
2512 // Children of @carousel get scroll-snap-align and flex-shrink
2581 // Children of @carousel get scroll-snap-align and flex-shrink
@@ -2545,14 +2614,15 @@ fn attrs_to_css(
2545 }
2614 }
2546 "padding-x" => {
2615 "padding-x" => {
2547 if let Some(v) = val {
2616 if let Some(v) = val {
2548− push_css(&mut css, "padding-left", &css_px(v));
2617+ // Logical property covers both inline sides in one
2549− push_css(&mut css, "padding-right", &css_px(v));
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));
2550 }
2621 }
2551 }
2622 }
2552 "padding-y" => {
2623 "padding-y" => {
2553 if let Some(v) = val {
2624 if let Some(v) = val {
2554− push_css(&mut css, "padding-top", &css_px(v));
2625+ push_css(&mut css, "padding-block", &css_px(v));
2555− push_css(&mut css, "padding-bottom", &css_px(v));
2556 }
2626 }
2557 }
2627 }
2558
2628
@@ -3035,14 +3105,12 @@ fn attrs_to_css(
3035 }
3105 }
3036 "margin-x" => {
3106 "margin-x" => {
3037 if let Some(v) = val {
3107 if let Some(v) = val {
3038− push_css(&mut css, "margin-left", &css_px(v));
3108+ push_css(&mut css, "margin-inline", &css_px(v));
3039− push_css(&mut css, "margin-right", &css_px(v));
3040 }
3109 }
3041 }
3110 }
3042 "margin-y" => {
3111 "margin-y" => {
3043 if let Some(v) = val {
3112 if let Some(v) = val {
3044− push_css(&mut css, "margin-top", &css_px(v));
3113+ push_css(&mut css, "margin-block", &css_px(v));
3045− push_css(&mut css, "margin-bottom", &css_px(v));
3046 }
3114 }
3047 }
3115 }
3048
3116
@@ -3719,6 +3787,129 @@ fn substitute_css_vars(css: &str, vars: &[(String, String)]) -> String {
3719 out
3787 out
3720}
3788}
3721
3789
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+
3722/// Format a `line-height` value. CSS accepts either a unitless multiplier
3913/// 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
3914/// (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
3915/// 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
2 @el [padding 12, background blue]
2 @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="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>
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−4
@@ -1297,16 +1297,16 @@ fn css_margin() {
1297
1297
1298#[test]
1298#[test]
1299fn css_margin_x() {
1299fn css_margin_x() {
1300+ // margin-x emits the `margin-inline` logical shorthand (single property,
1301+ // covers both inline sides, stays symmetric under RTL).
1300 let output = compile("@page T\n@el [margin-x 10]");
1302 let output = compile("@page T\n@el [margin-x 10]");
1301− assert!(output.contains("margin-left:10px"));
1303+ assert!(output.contains("margin-inline:10px"));
1302− assert!(output.contains("margin-right:10px"));
1303}
1304}
1304
1305
1305#[test]
1306#[test]
1306fn css_margin_y() {
1307fn css_margin_y() {
1307 let output = compile("@page T\n@el [margin-y 10]");
1308 let output = compile("@page T\n@el [margin-y 10]");
1308− assert!(output.contains("margin-top:10px"));
1309+ assert!(output.contains("margin-block:10px"));
1309− assert!(output.contains("margin-bottom:10px"));
1310}
1310}
1311
1311
1312#[test]
1312#[test]