on master help sign in
Keep @grid, drop the grid attribute

Both emitted display: grid. htmlang upgrade rewrites the attribute to display grid (identical output); a bare grid attribute now gets a hint even though grid is also the name of the CSS shorthand.

commitb469524b5e32b32624a7a6669c7d2ef37cae5b64 authoriko <mail@iko.soy> dateSat 26 Sep 2026, 02:30 (2 weeks ago) parente28a74b
11 files changed+29−25
DESIGN.md+1 crates/htmlang-core/src/codegen.rs−3 crates/htmlang-core/src/parser.rs+17−13 editors/vscode/snippets/htmlang.json+1−1 examples/demo.hl+1−1 src/upgrade.rs+2 tests/snapshots.rs+3−3 tests/snapshots/extra_css.hl+1−1 tests/snapshots/grid_areas.hl+1−1 tests/snapshots/new_css_properties.hl+1−1 tests/snapshots/new_css_properties_2.hl+1−1
DESIGN.md+1
@@ -466,6 +466,7 @@ removed form with its replacement.
466466| `@fn`, `@define`, `@mixin`, `@component` | `@let` (function, bundle; `@style` in a function body is scoped) |
467467| `@unless COND` | `@if not COND` |
468468| `@for $i in A..B`, `@repeat N` | `@each $i in A..B`, `@each $_ in 1..N` |
469+| `grid` (attribute) | `@grid`, or `display grid` |
469470| `@switch`, `@match` | `@if $x == a` / `@else if` / `@else` |
470471| `@use "file" names`, `@import file` | `@include file` |
471472| `@collection`, `@env`, `@translations`, `@fetch` | `@data $name SOURCE` (glob, `env:NAME`, or a JSON file per locale) |
crates/htmlang-core/src/codegen.rs−3
@@ -1973,9 +1973,6 @@ fn attrs_to_css(
19731973 "wrap" => push_css(&mut css, "flex-wrap", "wrap"),
19741974
19751975 // Grid
1976− "grid" => {
1977− push_css(&mut css, "display", "grid");
1978− }
19791976 "grid-cols" => {
19801977 if let Some(v) = val {
19811978 if let Ok(n) = v.parse::<u32>() {
crates/htmlang-core/src/parser.rs+17−13
@@ -2324,6 +2324,7 @@ fn removed_attribute_hint(name: &str) -> Option<&'static str> {
23242324 "backdrop-blur" => Some("use `backdrop-filter blur(...)`"),
23252325 "truncate" => Some("use the `$truncate` bundle"),
23262326 "critical" => Some("remove it"),
2327+ "grid" => Some("use `@grid`, or `display grid`"),
23272328 _ => None,
23282329 }
23292330}
@@ -3038,7 +3039,22 @@ fn parse_attr_list(
30383039 let base_key = crate::vocab::base_attribute(attr.key.as_str());
30393040 let is_boolean_html =
30403041 attr.value.is_none() && crate::vocab::BOOLEAN_HTML_ATTRS.contains(&base_key);
3041− if is_boolean_html || crate::vocab::is_style_attribute(base_key) {
3042+ // A removed attribute that shares its name with a CSS property
3043+ // (bare `grid`) still gets its hint.
3044+ let removed = removed_attribute_hint(base_key)
3045+ .filter(|_| attr.value.is_none() || !crate::vocab::is_css_property(base_key));
3046+ if let Some(hint) = removed {
3047+ ctx.diagnostics.push(Diagnostic {
3048+ line: line_num,
3049+ column: None,
3050+ message: format!(
3051+ "attribute '{}' was removed: {} (run `htmlang upgrade`)",
3052+ base_key, hint
3053+ ),
3054+ severity: Severity::Warning,
3055+ source_line: None,
3056+ });
3057+ } else if is_boolean_html || crate::vocab::is_style_attribute(base_key) {
30423058 validate_attr_value(&attr, line_num, ctx);
30433059 } else if crate::vocab::HTML_ATTRIBUTES.contains(&base_key)
30443060 || base_key.starts_with("aria-")
@@ -3056,17 +3072,6 @@ fn parse_attr_list(
30563072 severity: Severity::Warning,
30573073 source_line: None,
30583074 });
3059− } else if let Some(hint) = removed_attribute_hint(base_key) {
3060− ctx.diagnostics.push(Diagnostic {
3061− line: line_num,
3062− column: None,
3063− message: format!(
3064− "attribute '{}' was removed: {} (run `htmlang upgrade`)",
3065− base_key, hint
3066− ),
3067− severity: Severity::Warning,
3068− source_line: None,
3069− });
30703075 } else {
30713076 let suggestion = suggest_closest(base_key, &crate::vocab::all_attributes());
30723077 let msg = match suggestion {
@@ -3356,7 +3361,6 @@ const CONTAINER_ONLY_ATTRS: &[&str] = &[
33563361 "spacing",
33573362 "gap",
33583363 "wrap",
3359− "grid",
33603364 "grid-cols",
33613365 "grid-rows",
33623366 "container",
editors/vscode/snippets/htmlang.json+1−1
@@ -111,7 +111,7 @@
111111 "Grid with named areas": {
112112 "prefix": "@grid-areas",
113113 "body": [
114− "@el [grid, grid-template-areas \"${1:header header}\" \"${2:sidebar main}\" \"${3:footer footer}\", gap ${4:20}]",
114+ "@grid [grid-template-areas \"${1:header header}\" \"${2:sidebar main}\" \"${3:footer footer}\", gap ${4:20}]",
115115 " @el [grid-area ${5:header}]",
116116 " ${6:Header}",
117117 " @el [grid-area ${7:sidebar}]",
examples/demo.hl+1−1
@@ -280,7 +280,7 @@
280280
281281 -- Implicit @el via bare [attrs] line
282282 [
283− grid,
283+ display grid,
284284 grid-template-columns 1fr 2fr,
285285 grid-template-areas "h h" "s m" "f f",
286286 gap $base,
src/upgrade.rs+2
@@ -876,6 +876,7 @@ fn rewrite_attr_list(list: &str, element_attrs: bool) -> String {
876876 // Built-in style attributes that became standard-library bundles
877877 let body = match body.trim_end() {
878878 "skeleton" | "no-scrollbar" | "truncate" => format!("${}", body),
879+ "grid" => "display grid".to_string(),
879880 _ => body,
880881 };
881882 // `blur N` / `backdrop-blur N` → the filter they generated
@@ -1336,6 +1337,7 @@ mod tests {
13361337 "@el [column-gap 4, hover:box-shadow 0 1px red, filter blur(4px), backdrop-filter blur(2px), $truncate]"
13371338 );
13381339 assert_eq!(up("@el [critical, padding 4]"), "@el [padding 4]");
1340+ assert_eq!(up("@el [grid, grid-cols 3]"), "@el [display grid, grid-cols 3]");
13391341 }
13401342
13411343 #[test]
tests/snapshots.rs+3−3
@@ -1714,7 +1714,7 @@ fn css_text_decoration_full() {
17141714
17151715#[test]
17161716fn css_place_items() {
1717− let output = compile("@page T\n@el [grid, place-items center]");
1717+ let output = compile("@page T\n@el [display grid, place-items center]");
17181718 assert!(output.contains("place-items:center"));
17191719}
17201720
@@ -2015,7 +2015,7 @@ fn css_isolation() {
20152015
20162016#[test]
20172017fn css_place_content() {
2018− let output = compile("@page T\n@el [grid, place-content center]");
2018+ let output = compile("@page T\n@el [display grid, place-content center]");
20192019 assert!(output.contains("place-content:center"));
20202020}
20212021
@@ -3375,7 +3375,7 @@ fn snapshot_layer_wrapping() {
33753375#[test]
33763376fn grid_template_areas_passthrough() {
33773377 let output =
3378− compile("@page T\n@el [grid, grid-template-areas \"a b\"]\n @el [grid-area a]\n A");
3378+ compile("@page T\n@el [display grid, grid-template-areas \"a b\"]\n @el [grid-area a]\n A");
33793379 assert!(
33803380 output.contains("grid-template-areas:\"a b\""),
33813381 "grid-template-areas should pass through: {}",
tests/snapshots/extra_css.hl+1−1
@@ -9,7 +9,7 @@
99 Typography
1010 @el [border-bottom 1 #ccc, border-top 2 red]
1111 Borders
12− @el [grid, grid-cols 3, gap 10]
12+ @el [display grid, grid-cols 3, gap 10]
1313 @el [col-span 2]
1414 Wide
1515 @el
tests/snapshots/grid_areas.hl+1−1
@@ -1,5 +1,5 @@
11@page Grid Areas
2−@el [grid, grid-template-areas "header header" "sidebar main" "footer footer", gap 20]
2+@el [display grid, grid-template-areas "header header" "sidebar main" "footer footer", gap 20]
33 @el [grid-area header, padding 10, background #3b82f6, color white]
44 Header
55 @el [grid-area sidebar, padding 10, background #e5e7eb]
tests/snapshots/new_css_properties.hl+1−1
@@ -20,7 +20,7 @@
2020
2121@text [text-decoration underline, text-decoration-color red, text-decoration-thickness 2, text-decoration-style wavy] Decorated
2222
23−@el [grid, place-items center]
23+@el [display grid, place-items center]
2424 @text Centered
2525
2626@el [place-self center]
tests/snapshots/new_css_properties_2.hl+1−1
@@ -29,7 +29,7 @@
2929@el [isolation isolate]
3030 @text Isolated stacking
3131
32−@el [grid, place-content center]
32+@el [display grid, place-content center]
3333 @text Centered grid content
3434
3535@el [background-image url(bg.png), background-size cover]