on master help sign in
Drop attributes that only alias CSS

gap-x/gap-y (column-gap/row-gap), shadow (box-shadow), blur and backdrop-blur (filter / backdrop-filter blur()) now use the CSS names directly; truncate is the standard library's $truncate bundle; critical (inline styles) is gone. line-clamp stays: it emits the -webkit-box declarations browsers still need. htmlang upgrade rewrites all of them to the CSS they generated.

commitbbd920962bf8ad37e4b881c3d5627c4ba0f65a11 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 02:04 (2 weeks ago) parentcb6e458
12 files changed+83−84
DESIGN.md+4−6 crates/htmlang-core/src/codegen.rs+1−51 crates/htmlang-core/src/parser.rs+9−4 crates/htmlang-core/src/std.hl+3 crates/htmlang-core/src/vocab.rs+3−3 examples/blog.hl+1−1 examples/demo.hl+2−2 src/upgrade.rs+43−4 tests/snapshots.rs+11−7 tests/snapshots/attributes.hl+2−2 tests/snapshots/css_shorthands.hl+3−3 tests/snapshots/stress_large.hl+1−1
DESIGN.md+4−6
@@ -30,7 +30,7 @@ Children are indented under their parent. Attributes are comma-separated inside
3030 padding 20,
3131 background white,
3232 rounded 8,
33− shadow 0 2px 4px rgba(0,0,0,0.1)
33+ box-shadow 0 2px 4px rgba(0,0,0,0.1)
3434]
3535 Content
3636```
@@ -164,7 +164,7 @@ Use them like elements; your own `@let` with the same name takes precedence.
164164| `@tooltip [tip TEXT]` | Text that shows `TEXT` when hovered |
165165| `@carousel` | Horizontally scrolling row that snaps to each child |
166166| `@breadcrumb` | Breadcrumb trail of `@item`s |
167−| `$skeleton`, `$no-scrollbar` | Attribute bundles: loading placeholder, hidden scrollbars |
167+| `$skeleton`, `$truncate`, `$no-scrollbar` | Attribute bundles: loading placeholder, one-line ellipsis, hidden scrollbars |
168168
169169```
170170@row [spacing 8, align-items center]
@@ -184,7 +184,7 @@ Use them like elements; your own `@let` with the same name takes precedence.
184184
185185| Attribute | Effect |
186186|---|---|
187−| `spacing N` | Gap between children (also `gap-x N`, `gap-y N`) |
187+| `spacing N` | Gap between children |
188188| `padding N` / `padding Y X` / `padding T R B L` | Padding (also `padding-x`, `padding-y`) |
189189| `margin ...` | Margin, same forms as padding (also `margin-x`, `margin-y`) |
190190| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
@@ -201,12 +201,10 @@ Use them like elements; your own `@let` with the same name takes precedence.
201201|---|---|
202202| `border N COLOR` (also `border-top` etc.) | Solid border |
203203| `rounded N` | Border radius |
204−| `shadow VALUE` | Box shadow |
205204| `bold`, `italic`, `underline` | Text style |
206205| `size N` | Font size |
207206| `font NAME` | Font family |
208−| `truncate`, `line-clamp N` | Cut text off with an ellipsis |
209−| `blur N`, `backdrop-blur N` | Blur filters |
207+| `line-clamp N` | Cut text off after N lines |
210208
211209**Any standard CSS property** can also be used as an attribute, with the same
212210name and value: `background red`, `opacity 0.5`, `cursor pointer`,
crates/htmlang-core/src/codegen.rs+1−51
@@ -1532,24 +1532,7 @@ fn generate_element(
15321532
15331533 emit_argument_attr(out, elem);
15341534
1535− // Critical CSS: inline styles directly instead of using a class
1536− let is_critical = elem.attrs.iter().any(|a| a.key == "critical");
1537− if is_critical {
1538− let inline_css = attrs_to_css(&elem.attrs, "", &elem.kind, parent_kind, overlay_children);
1539− if !inline_css.is_empty() {
1540− out.push_str(" style=\"");
1541− out.push_str(&html_escape(&inline_css));
1542− out.push('"');
1543− }
1544− // Still use class for non-base styles (pseudo, responsive, etc.)
1545− if gen_class.as_deref().is_some_and(|c| !c.is_empty()) {
1546− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1547− } else if let Some(ref uc) = user_class {
1548− emit_class_attr(out, None, Some(uc));
1549− }
1550− } else {
1551− emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1552− }
1535+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
15531536
15541537 if let Some(id) = id {
15551538 out.push_str(" id=\"");
@@ -2308,24 +2291,9 @@ fn attrs_to_css(
23082291 "hidden" => push_css(&mut css, "display", "none"),
23092292
23102293 // Effects
2311− "shadow" => {
2312− if let Some(v) = val {
2313− push_css(&mut css, "box-shadow", v);
2314− }
2315− }
23162294
23172295 // Flow
23182296 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
2319− "gap-x" => {
2320− if let Some(v) = val {
2321− push_css(&mut css, "column-gap", &css_px(v));
2322− }
2323− }
2324− "gap-y" => {
2325− if let Some(v) = val {
2326− push_css(&mut css, "row-gap", &css_px(v));
2327− }
2328− }
23292297
23302298 // Grid
23312299 "grid" => {
@@ -2608,11 +2576,6 @@ fn attrs_to_css(
26082576 }
26092577
26102578 // --- CSS Shorthands ---
2611− "truncate" => {
2612− push_css(&mut css, "overflow", "hidden");
2613− push_css(&mut css, "text-overflow", "ellipsis");
2614− push_css(&mut css, "white-space", "nowrap");
2615− }
26162579 "line-clamp" => {
26172580 if let Some(v) = val {
26182581 push_css(&mut css, "display", "-webkit-box");
@@ -2621,19 +2584,6 @@ fn attrs_to_css(
26212584 push_css(&mut css, "overflow", "hidden");
26222585 }
26232586 }
2624− "blur" => {
2625− if let Some(v) = val {
2626− push_css(&mut css, "filter", &format!("blur({})", css_px(v)));
2627− }
2628− }
2629− "backdrop-blur" => {
2630− if let Some(v) = val {
2631− push_css(&mut css, "backdrop-filter", &format!("blur({})", css_px(v)));
2632− }
2633− }
2634−
2635− // Critical CSS hint — not CSS, handled elsewhere
2636− "critical" => {}
26372587
26382588 // Any other standard CSS property is copied through, with `px`
26392589 // added to bare numbers where the property takes a length.
crates/htmlang-core/src/parser.rs+9−4
@@ -2633,6 +2633,13 @@ fn removed_attribute_hint(name: &str) -> Option<&'static str> {
26332633 "gradient" => Some("use `background linear-gradient(...)`"),
26342634 "animate" => Some("use `animation`"),
26352635 "inset-area" => Some("use `position-area`"),
2636+ "gap-x" => Some("use `column-gap`"),
2637+ "gap-y" => Some("use `row-gap`"),
2638+ "shadow" => Some("use `box-shadow`"),
2639+ "blur" => Some("use `filter blur(...)`"),
2640+ "backdrop-blur" => Some("use `backdrop-filter blur(...)`"),
2641+ "truncate" => Some("use the `$truncate` bundle"),
2642+ "critical" => Some("remove it"),
26362643 _ => None,
26372644 }
26382645}
@@ -2841,8 +2848,8 @@ const NUMERIC_ATTRS: &[&str] = &[
28412848 "max-height",
28422849 "rounded",
28432850 "size",
2844− "gap-x",
2845− "gap-y",
2851+ "column-gap",
2852+ "row-gap",
28462853 "top",
28472854 "right",
28482855 "bottom",
@@ -3664,8 +3671,6 @@ fn split_if_args(input: &str) -> Vec<&str> {
36643671const CONTAINER_ONLY_ATTRS: &[&str] = &[
36653672 "spacing",
36663673 "gap",
3667− "gap-x",
3668− "gap-y",
36693674 "wrap",
36703675 "grid",
36713676 "grid-cols",
crates/htmlang-core/src/std.hl+3
@@ -46,5 +46,8 @@
4646-- Loading placeholder: [$skeleton]
4747@let skeleton [background linear-gradient(90deg,#e5e7eb 25%,#f3f4f6 50%,#e5e7eb 75%), background-size 200% 100%, animation hl-skeleton 1.5s ease-in-out infinite]
4848
49+-- Cut text off at one line with an ellipsis: [$truncate]
50+@let truncate [overflow hidden, text-overflow ellipsis, white-space nowrap]
51+
4952-- Hide scrollbars but keep scrolling: [$no-scrollbar]
5053@let no-scrollbar [scrollbar-width none]
crates/htmlang-core/src/vocab.rs+3−3
@@ -10,10 +10,10 @@
1010/// htmlang's own attributes (those that are neither a CSS property nor an
1111/// HTML attribute).
1212pub const HTMLANG_ATTRIBUTES: &[&str] = &[
13− "align-bottom", "align-left", "align-right", "align-top", "backdrop-blur", "blur", "bold",
14− "center-x", "center-y", "col-span", "critical", "gap-x", "gap-y", "grid-cols", "grid-rows",
13+ "align-bottom", "align-left", "align-right", "align-top", "bold",
14+ "center-x", "center-y", "col-span", "grid-cols", "grid-rows",
1515 "hidden", "inline", "italic", "margin-x", "margin-y", "ordered", "padding-x", "padding-y",
16− "responsive", "rounded", "row-span", "shadow", "size", "spacing", "truncate", "underline",
16+ "responsive", "rounded", "row-span", "size", "spacing", "underline",
1717 "wrap",
1818];
1919
examples/blog.hl+1−1
@@ -9,7 +9,7 @@
99 background white,
1010 rounded 10,
1111 border 1 #e5e7eb,
12− shadow 0 1px 3px rgba(0,0,0,0.05),
12+ box-shadow 0 1px 3px rgba(0,0,0,0.05),
1313 transition all 0.15s ease
1414]
1515
examples/demo.hl+2−2
@@ -22,13 +22,13 @@
2222 background white,
2323 rounded $base,
2424 border 1 #e5e7eb,
25− shadow 0 1px 3px rgba(0,0,0,0.05)
25+ box-shadow 0 1px 3px rgba(0,0,0,0.05)
2626]
2727
2828-- Composable style group (used with spread)
2929@let interactive [
3030 cursor pointer,
31− hover:shadow 0 4px 12px rgba(0,0,0,0.08),
31+ hover:box-shadow 0 4px 12px rgba(0,0,0,0.08),
3232 transition all 0.2s ease
3333]
3434
src/upgrade.rs+43−4
@@ -30,6 +30,9 @@ const ATTR_RENAMES: &[(&str, &str)] = &[
3030 ("animate", "animation"),
3131 ("inset-area", "position-area"),
3232 ("align-center", "center-x"),
33+ ("gap-x", "column-gap"),
34+ ("gap-y", "row-gap"),
35+ ("shadow", "box-shadow"),
3336];
3437
3538/// Variable filter aliases (`$x|upper`) and their canonical names.
@@ -718,21 +721,48 @@ fn rewrite_attr_list(list: &str, element_attrs: bool) -> String {
718721 }
719722 let mut out = String::with_capacity(list.len());
720723 let mut first = true;
724+ let mut dropped_leading = false;
721725 for segment in split_top_level_commas(list) {
726+ // `critical` (inline styles) was removed without a replacement
727+ if segment.trim() == "critical" {
728+ dropped_leading |= first;
729+ continue;
730+ }
731+ let lead = segment.len() - segment.trim_start().len();
732+ let (ws, body) = segment.split_at(lead);
722733 if !first {
723734 out.push(',');
735+ out.push_str(ws);
736+ } else if !dropped_leading || ws.contains('\n') {
737+ out.push_str(ws);
724738 }
725739 first = false;
726− let lead = segment.len() - segment.trim_start().len();
727− let (ws, body) = segment.split_at(lead);
728− out.push_str(ws);
729740 // `...$bundle` spread → `$bundle`
730741 let body = body.strip_prefix("...$").map_or(body.to_string(), |b| format!("${b}"));
731742 // Built-in style attributes that became standard-library bundles
732743 let body = match body.trim_end() {
733− "skeleton" | "no-scrollbar" => format!("${}", body),
744+ "skeleton" | "no-scrollbar" | "truncate" => format!("${}", body),
734745 _ => body,
735746 };
747+ // `blur N` / `backdrop-blur N` → the filter they generated
748+ let body = {
749+ let (key, value) = body.split_once(' ').unwrap_or((&body, ""));
750+ let (prefix, base) = key.rsplit_once(':').map_or(("", key), |(p, b)| (p, b));
751+ let prefix = if prefix.is_empty() { String::new() } else { format!("{prefix}:") };
752+ let px = |v: &str| {
753+ let v = v.trim();
754+ if !v.is_empty() && v.parse::<f64>().is_ok() && v != "0" {
755+ format!("{v}px")
756+ } else {
757+ v.to_string()
758+ }
759+ };
760+ match base {
761+ "blur" => format!("{prefix}filter blur({})", px(value)),
762+ "backdrop-blur" => format!("{prefix}backdrop-filter blur({})", px(value)),
763+ _ => body.clone(),
764+ }
765+ };
736766 // `gradient A B [ANGLE]` → the `background` it generated
737767 let body = match body.strip_prefix("gradient ") {
738768 Some(v) => {
@@ -1095,6 +1125,15 @@ mod tests {
10951125 assert_eq!(upgrade("@fetch $d http://x").manual.len(), 1);
10961126 }
10971127
1128+ #[test]
1129+ fn css_alias_attributes() {
1130+ assert_eq!(
1131+ up("@el [gap-x 4, hover:shadow 0 1px red, blur 4, backdrop-blur 2px, truncate, critical]"),
1132+ "@el [column-gap 4, hover:box-shadow 0 1px red, filter blur(4px), backdrop-filter blur(2px), $truncate]"
1133+ );
1134+ assert_eq!(up("@el [critical, padding 4]"), "@el [padding 4]");
1135+ }
1136+
10981137 #[test]
10991138 fn verbatim_regions_are_untouched() {
11001139 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
tests/snapshots.rs+11−7
@@ -3070,7 +3070,7 @@ fn test_new_elements_parse() {
30703070
30713071#[test]
30723072fn test_css_shorthands_output() {
3073− let result = htmlang::parser::parse("@text [truncate] Hello");
3073+ let result = htmlang::parser::parse("@text [$truncate] Hello");
30743074 let html = htmlang::codegen::generate(&result.document);
30753075 assert!(
30763076 html.contains("text-overflow:ellipsis"),
@@ -3090,7 +3090,7 @@ fn test_css_shorthands_output() {
30903090 html
30913091 );
30923092
3093− let result = htmlang::parser::parse("@el [blur 4] Content");
3093+ let result = htmlang::parser::parse("@el [filter blur(4px)] Content");
30943094 let html = htmlang::codegen::generate(&result.document);
30953095 assert!(
30963096 html.contains("filter:blur(4px)"),
@@ -3098,7 +3098,7 @@ fn test_css_shorthands_output() {
30983098 html
30993099 );
31003100
3101− let result = htmlang::parser::parse("@el [backdrop-blur 10] Content");
3101+ let result = htmlang::parser::parse("@el [backdrop-filter blur(10px)] Content");
31023102 let html = htmlang::codegen::generate(&result.document);
31033103 assert!(
31043104 html.contains("backdrop-filter:blur(10px)"),
@@ -3538,7 +3538,7 @@ fn fn_named_slot_default() {
35383538#[test]
35393539fn no_warning_new_attrs_batch6() {
35403540 let diags = parse_diagnostics(
3541− "@el [grid-template-areas \"a b\", grid-area a, view-transition-name hero, animation fade 1s, critical]",
3541+ "@el [grid-template-areas \"a b\", grid-area a, view-transition-name hero, animation fade 1s]",
35423542 );
35433543 assert!(
35443544 !diags
@@ -4111,9 +4111,13 @@ fn minified_strips_comments() {
41114111// -----------------------------------------------------------------------
41124112
41134113#[test]
4114−fn critical_attr_inlines_styles() {
4115− let html = compile("@el [critical, padding 20, background red]\n test\n");
4116− assert!(html.contains("style=\""));
4114+fn critical_attr_was_removed() {
4115+ let diags = parse_diagnostics("@el [critical, padding 20]\n test\n");
4116+ assert!(
4117+ diags.iter().any(|d| d.message.contains("'critical' was removed")),
4118+ "{:?}",
4119+ diags
4120+ );
41174121}
41184122
41194123// -----------------------------------------------------------------------
tests/snapshots/attributes.hl+2−2
@@ -1,8 +1,8 @@
11@page Attributes
22@el [width 200, height 100, padding 10 20, background #fff, color #333, border 1 #ccc, rounded 8, opacity 0.9]
33 @text [bold, italic, underline, size 16, font monospace] Styled
4−@row [wrap, gap-x 10, gap-y 5, spacing 8]
4+@row [wrap, column-gap 10, row-gap 5, spacing 8]
55 @el [min-width 50, max-width 200, min-height 30, max-height 100]
66@column [overflow hidden, position relative, z-index 10]
77 @text [text-align center, line-height 1.5] Aligned
8−@el [shadow 0 2px 4px rgba(0,0,0,0.1), transition all 0.2s, cursor pointer]
8+@el [box-shadow 0 2px 4px rgba(0,0,0,0.1), transition all 0.2s, cursor pointer]
tests/snapshots/css_shorthands.hl+3−3
@@ -2,18 +2,18 @@
22
33@column [spacing 20]
44 -- Truncate
5− @text [max-width 200, truncate] This text will be truncated with an ellipsis
5+ @text [max-width 200, $truncate] This text will be truncated with an ellipsis
66
77 -- Line clamp
88 @paragraph [line-clamp 3]
99 This is a long paragraph that spans multiple lines and should be clamped at three lines.
1010
1111 -- Blur
12− @el [blur 4, padding 20]
12+ @el [filter blur(4px), padding 20]
1313 Blurred content
1414
1515 -- Backdrop blur
16− @el [backdrop-blur 10, padding 20, background rgba(255,255,255,0.5)]
16+ @el [backdrop-filter blur(10px), padding 20, background rgba(255,255,255,0.5)]
1717 Frosted glass effect
1818
1919 -- No scrollbar
tests/snapshots/stress_large.hl+1−1
@@ -28,7 +28,7 @@
2828 @text $label
2929
3030@let card $title
31− @el [background white, dark:background #1e293b, rounded $--radius, padding $--gap, border 1 $--border, dark:border 1 #334155, hover:shadow 0 4 12 rgba(0,0,0,0.1), transition all 0.2s, animation fade-in 0.3s ease]
31+ @el [background white, dark:background #1e293b, rounded $--radius, padding $--gap, border 1 $--border, dark:border 1 #334155, hover:box-shadow 0 4 12 rgba(0,0,0,0.1), transition all 0.2s, animation fade-in 0.3s ease]
3232 @text [bold, size 18, color $--dark, dark:color white] $title
3333 @el [margin-top 8]
3434 @children