on master help sign in
Drop attributes that only alias CSS
bbd9209iko
DESIGN.md+4−6
@@ -30,7 +30,7 @@ Children are indented under their parent. Attributes are comma-separated inside
30 padding 20,
30 padding 20,
31 background white,
31 background white,
32 rounded 8,
32 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)
34]
34]
35 Content
35 Content
36```
36```
@@ -164,7 +164,7 @@ Use them like elements; your own `@let` with the same name takes precedence.
164| `@tooltip [tip TEXT]` | Text that shows `TEXT` when hovered |
164| `@tooltip [tip TEXT]` | Text that shows `TEXT` when hovered |
165| `@carousel` | Horizontally scrolling row that snaps to each child |
165| `@carousel` | Horizontally scrolling row that snaps to each child |
166| `@breadcrumb` | Breadcrumb trail of `@item`s |
166| `@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 |
168
168
169```
169```
170@row [spacing 8, align-items center]
170@row [spacing 8, align-items center]
@@ -184,7 +184,7 @@ Use them like elements; your own `@let` with the same name takes precedence.
184
184
185| Attribute | Effect |
185| Attribute | Effect |
186|---|---|
186|---|---|
187−| `spacing N` | Gap between children (also `gap-x N`, `gap-y N`) |
187+| `spacing N` | Gap between children |
188| `padding N` / `padding Y X` / `padding T R B L` | Padding (also `padding-x`, `padding-y`) |
188| `padding N` / `padding Y X` / `padding T R B L` | Padding (also `padding-x`, `padding-y`) |
189| `margin ...` | Margin, same forms as padding (also `margin-x`, `margin-y`) |
189| `margin ...` | Margin, same forms as padding (also `margin-x`, `margin-y`) |
190| `width fill` / `width N` / `width shrink` | Take remaining space, exact size, or fit content |
190| `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.
201|---|---|
201|---|---|
202| `border N COLOR` (also `border-top` etc.) | Solid border |
202| `border N COLOR` (also `border-top` etc.) | Solid border |
203| `rounded N` | Border radius |
203| `rounded N` | Border radius |
204−| `shadow VALUE` | Box shadow |
205| `bold`, `italic`, `underline` | Text style |
204| `bold`, `italic`, `underline` | Text style |
206| `size N` | Font size |
205| `size N` | Font size |
207| `font NAME` | Font family |
206| `font NAME` | Font family |
208−| `truncate`, `line-clamp N` | Cut text off with an ellipsis |
207+| `line-clamp N` | Cut text off after N lines |
209−| `blur N`, `backdrop-blur N` | Blur filters |
210
208
211**Any standard CSS property** can also be used as an attribute, with the same
209**Any standard CSS property** can also be used as an attribute, with the same
212name and value: `background red`, `opacity 0.5`, `cursor pointer`,
210name and value: `background red`, `opacity 0.5`, `cursor pointer`,
crates/htmlang-core/src/codegen.rs+1−51
@@ -1532,24 +1532,7 @@ fn generate_element(
1532
1532
1533 emit_argument_attr(out, elem);
1533 emit_argument_attr(out, elem);
1534
1534
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());
1535+ emit_class_attr(out, gen_class.as_deref(), user_class.as_deref());
1552− }
1553
1536
1554 if let Some(id) = id {
1537 if let Some(id) = id {
1555 out.push_str(" id=\"");
1538 out.push_str(" id=\"");
@@ -2308,24 +2291,9 @@ fn attrs_to_css(
2308 "hidden" => push_css(&mut css, "display", "none"),
2291 "hidden" => push_css(&mut css, "display", "none"),
2309
2292
2310 // Effects
2293 // Effects
2311− "shadow" => {
2312− if let Some(v) = val {
2313− push_css(&mut css, "box-shadow", v);
2314− }
2315− }
2316
2294
2317 // Flow
2295 // Flow
2318 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
2296 "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− }
2329
2297
2330 // Grid
2298 // Grid
2331 "grid" => {
2299 "grid" => {
@@ -2608,11 +2576,6 @@ fn attrs_to_css(
2608 }
2576 }
2609
2577
2610 // --- CSS Shorthands ---
2578 // --- 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− }
2616 "line-clamp" => {
2579 "line-clamp" => {
2617 if let Some(v) = val {
2580 if let Some(v) = val {
2618 push_css(&mut css, "display", "-webkit-box");
2581 push_css(&mut css, "display", "-webkit-box");
@@ -2621,19 +2584,6 @@ fn attrs_to_css(
2621 push_css(&mut css, "overflow", "hidden");
2584 push_css(&mut css, "overflow", "hidden");
2622 }
2585 }
2623 }
2586 }
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" => {}
2637
2587
2638 // Any other standard CSS property is copied through, with `px`
2588 // Any other standard CSS property is copied through, with `px`
2639 // added to bare numbers where the property takes a length.
2589 // 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> {
2633 "gradient" => Some("use `background linear-gradient(...)`"),
2633 "gradient" => Some("use `background linear-gradient(...)`"),
2634 "animate" => Some("use `animation`"),
2634 "animate" => Some("use `animation`"),
2635 "inset-area" => Some("use `position-area`"),
2635 "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"),
2636 _ => None,
2643 _ => None,
2637 }
2644 }
2638}
2645}
@@ -2841,8 +2848,8 @@ const NUMERIC_ATTRS: &[&str] = &[
2841 "max-height",
2848 "max-height",
2842 "rounded",
2849 "rounded",
2843 "size",
2850 "size",
2844− "gap-x",
2851+ "column-gap",
2845− "gap-y",
2852+ "row-gap",
2846 "top",
2853 "top",
2847 "right",
2854 "right",
2848 "bottom",
2855 "bottom",
@@ -3664,8 +3671,6 @@ fn split_if_args(input: &str) -> Vec<&str> {
3664const CONTAINER_ONLY_ATTRS: &[&str] = &[
3671const CONTAINER_ONLY_ATTRS: &[&str] = &[
3665 "spacing",
3672 "spacing",
3666 "gap",
3673 "gap",
3667− "gap-x",
3668− "gap-y",
3669 "wrap",
3674 "wrap",
3670 "grid",
3675 "grid",
3671 "grid-cols",
3676 "grid-cols",
crates/htmlang-core/src/std.hl+3
@@ -46,5 +46,8 @@
46-- Loading placeholder: [$skeleton]
46-- Loading placeholder: [$skeleton]
47@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]
47@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]
48
48
49+-- Cut text off at one line with an ellipsis: [$truncate]
50+@let truncate [overflow hidden, text-overflow ellipsis, white-space nowrap]
51+
49-- Hide scrollbars but keep scrolling: [$no-scrollbar]
52-- Hide scrollbars but keep scrolling: [$no-scrollbar]
50@let no-scrollbar [scrollbar-width none]
53@let no-scrollbar [scrollbar-width none]
crates/htmlang-core/src/vocab.rs+3−3
@@ -10,10 +10,10 @@
10/// htmlang's own attributes (those that are neither a CSS property nor an
10/// htmlang's own attributes (those that are neither a CSS property nor an
11/// HTML attribute).
11/// HTML attribute).
12pub const HTMLANG_ATTRIBUTES: &[&str] = &[
12pub const HTMLANG_ATTRIBUTES: &[&str] = &[
13− "align-bottom", "align-left", "align-right", "align-top", "backdrop-blur", "blur", "bold",
13+ "align-bottom", "align-left", "align-right", "align-top", "bold",
14− "center-x", "center-y", "col-span", "critical", "gap-x", "gap-y", "grid-cols", "grid-rows",
14+ "center-x", "center-y", "col-span", "grid-cols", "grid-rows",
15 "hidden", "inline", "italic", "margin-x", "margin-y", "ordered", "padding-x", "padding-y",
15 "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",
17 "wrap",
17 "wrap",
18];
18];
19
19
examples/blog.hl+1−1
@@ -9,7 +9,7 @@
9 background white,
9 background white,
10 rounded 10,
10 rounded 10,
11 border 1 #e5e7eb,
11 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),
13 transition all 0.15s ease
13 transition all 0.15s ease
14]
14]
15
15
examples/demo.hl+2−2
@@ -22,13 +22,13 @@
22 background white,
22 background white,
23 rounded $base,
23 rounded $base,
24 border 1 #e5e7eb,
24 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)
26]
26]
27
27
28-- Composable style group (used with spread)
28-- Composable style group (used with spread)
29@let interactive [
29@let interactive [
30 cursor pointer,
30 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),
32 transition all 0.2s ease
32 transition all 0.2s ease
33]
33]
34
34
src/upgrade.rs+43−4
@@ -30,6 +30,9 @@ const ATTR_RENAMES: &[(&str, &str)] = &[
30 ("animate", "animation"),
30 ("animate", "animation"),
31 ("inset-area", "position-area"),
31 ("inset-area", "position-area"),
32 ("align-center", "center-x"),
32 ("align-center", "center-x"),
33+ ("gap-x", "column-gap"),
34+ ("gap-y", "row-gap"),
35+ ("shadow", "box-shadow"),
33];
36];
34
37
35/// Variable filter aliases (`$x|upper`) and their canonical names.
38/// Variable filter aliases (`$x|upper`) and their canonical names.
@@ -718,21 +721,48 @@ fn rewrite_attr_list(list: &str, element_attrs: bool) -> String {
718 }
721 }
719 let mut out = String::with_capacity(list.len());
722 let mut out = String::with_capacity(list.len());
720 let mut first = true;
723 let mut first = true;
724+ let mut dropped_leading = false;
721 for segment in split_top_level_commas(list) {
725 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);
722 if !first {
733 if !first {
723 out.push(',');
734 out.push(',');
735+ out.push_str(ws);
736+ } else if !dropped_leading || ws.contains('\n') {
737+ out.push_str(ws);
724 }
738 }
725 first = false;
739 first = false;
726− let lead = segment.len() - segment.trim_start().len();
727− let (ws, body) = segment.split_at(lead);
728− out.push_str(ws);
729 // `...$bundle` spread → `$bundle`
740 // `...$bundle` spread → `$bundle`
730 let body = body.strip_prefix("...$").map_or(body.to_string(), |b| format!("${b}"));
741 let body = body.strip_prefix("...$").map_or(body.to_string(), |b| format!("${b}"));
731 // Built-in style attributes that became standard-library bundles
742 // Built-in style attributes that became standard-library bundles
732 let body = match body.trim_end() {
743 let body = match body.trim_end() {
733− "skeleton" | "no-scrollbar" => format!("${}", body),
744+ "skeleton" | "no-scrollbar" | "truncate" => format!("${}", body),
734 _ => body,
745 _ => body,
735 };
746 };
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+ };
736 // `gradient A B [ANGLE]` → the `background` it generated
766 // `gradient A B [ANGLE]` → the `background` it generated
737 let body = match body.strip_prefix("gradient ") {
767 let body = match body.strip_prefix("gradient ") {
738 Some(v) => {
768 Some(v) => {
@@ -1095,6 +1125,15 @@ mod tests {
1095 assert_eq!(upgrade("@fetch $d http://x").manual.len(), 1);
1125 assert_eq!(upgrade("@fetch $d http://x").manual.len(), 1);
1096 }
1126 }
1097
1127
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+
1098 #[test]
1137 #[test]
1099 fn verbatim_regions_are_untouched() {
1138 fn verbatim_regions_are_untouched() {
1100 let src = "@script\n for (x of y) {}\n@raw \"\"\"\n@unless\n\"\"\"\n@style\n .p { }";
1139 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() {
3070
3070
3071#[test]
3071#[test]
3072fn test_css_shorthands_output() {
3072fn test_css_shorthands_output() {
3073− let result = htmlang::parser::parse("@text [truncate] Hello");
3073+ let result = htmlang::parser::parse("@text [$truncate] Hello");
3074 let html = htmlang::codegen::generate(&result.document);
3074 let html = htmlang::codegen::generate(&result.document);
3075 assert!(
3075 assert!(
3076 html.contains("text-overflow:ellipsis"),
3076 html.contains("text-overflow:ellipsis"),
@@ -3090,7 +3090,7 @@ fn test_css_shorthands_output() {
3090 html
3090 html
3091 );
3091 );
3092
3092
3093− let result = htmlang::parser::parse("@el [blur 4] Content");
3093+ let result = htmlang::parser::parse("@el [filter blur(4px)] Content");
3094 let html = htmlang::codegen::generate(&result.document);
3094 let html = htmlang::codegen::generate(&result.document);
3095 assert!(
3095 assert!(
3096 html.contains("filter:blur(4px)"),
3096 html.contains("filter:blur(4px)"),
@@ -3098,7 +3098,7 @@ fn test_css_shorthands_output() {
3098 html
3098 html
3099 );
3099 );
3100
3100
3101− let result = htmlang::parser::parse("@el [backdrop-blur 10] Content");
3101+ let result = htmlang::parser::parse("@el [backdrop-filter blur(10px)] Content");
3102 let html = htmlang::codegen::generate(&result.document);
3102 let html = htmlang::codegen::generate(&result.document);
3103 assert!(
3103 assert!(
3104 html.contains("backdrop-filter:blur(10px)"),
3104 html.contains("backdrop-filter:blur(10px)"),
@@ -3538,7 +3538,7 @@ fn fn_named_slot_default() {
3538#[test]
3538#[test]
3539fn no_warning_new_attrs_batch6() {
3539fn no_warning_new_attrs_batch6() {
3540 let diags = parse_diagnostics(
3540 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]",
3542 );
3542 );
3543 assert!(
3543 assert!(
3544 !diags
3544 !diags
@@ -4111,9 +4111,13 @@ fn minified_strips_comments() {
4111// -----------------------------------------------------------------------
4111// -----------------------------------------------------------------------
4112
4112
4113#[test]
4113#[test]
4114−fn critical_attr_inlines_styles() {
4114+fn critical_attr_was_removed() {
4115− let html = compile("@el [critical, padding 20, background red]\n test\n");
4115+ let diags = parse_diagnostics("@el [critical, padding 20]\n test\n");
4116− assert!(html.contains("style=\""));
4116+ assert!(
4117+ diags.iter().any(|d| d.message.contains("'critical' was removed")),
4118+ "{:?}",
4119+ diags
4120+ );
4117}
4121}
4118
4122
4119// -----------------------------------------------------------------------
4123// -----------------------------------------------------------------------
tests/snapshots/attributes.hl+2−2
@@ -1,8 +1,8 @@
1@page Attributes
1@page Attributes
2@el [width 200, height 100, padding 10 20, background #fff, color #333, border 1 #ccc, rounded 8, opacity 0.9]
2@el [width 200, height 100, padding 10 20, background #fff, color #333, border 1 #ccc, rounded 8, opacity 0.9]
3 @text [bold, italic, underline, size 16, font monospace] Styled
3 @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]
5 @el [min-width 50, max-width 200, min-height 30, max-height 100]
5 @el [min-width 50, max-width 200, min-height 30, max-height 100]
6@column [overflow hidden, position relative, z-index 10]
6@column [overflow hidden, position relative, z-index 10]
7 @text [text-align center, line-height 1.5] Aligned
7 @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 @@
2
2
3@column [spacing 20]
3@column [spacing 20]
4 -- Truncate
4 -- 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
6
6
7 -- Line clamp
7 -- Line clamp
8 @paragraph [line-clamp 3]
8 @paragraph [line-clamp 3]
9 This is a long paragraph that spans multiple lines and should be clamped at three lines.
9 This is a long paragraph that spans multiple lines and should be clamped at three lines.
10
10
11 -- Blur
11 -- Blur
12− @el [blur 4, padding 20]
12+ @el [filter blur(4px), padding 20]
13 Blurred content
13 Blurred content
14
14
15 -- Backdrop blur
15 -- 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)]
17 Frosted glass effect
17 Frosted glass effect
18
18
19 -- No scrollbar
19 -- No scrollbar
tests/snapshots/stress_large.hl+1−1
@@ -28,7 +28,7 @@
28 @text $label
28 @text $label
29
29
30@let card $title
30@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]
32 @text [bold, size 18, color $--dark, dark:color white] $title
32 @text [bold, size 18, color $--dark, dark:color white] $title
33 @el [margin-top 8]
33 @el [margin-top 8]
34 @children
34 @children