on master help sign in
Scoped styles without a wrapper element

A function with a scoped @style put its output inside an extra div, which changed layout. The scope class now goes on the function's root element, like forwarded attributes; its rules apply inside the root and & is the root itself. A body without a single root gets a warning.

commit74bc0c30ea5df77e370ffef8aaa529c3e49df49b authoriko <mail@iko.soy> dateSat 26 Sep 2026, 03:42 (2 weeks ago) parent992bdcd
3 files changed+60−35
DESIGN.md+2−1 crates/htmlang-core/src/parser.rs+52−31 tests/snapshots.rs+6−3
DESIGN.md+2−1
@@ -294,7 +294,8 @@ A function is called like an element:
294294 caller's `@slot name` block replaces `@slot name` (the slot's own children are
295295 the default).
296296- An `@style` block at the top of the body is scoped to the function: its rules
297− only apply inside the function's output.
297+ apply inside the function's root element (`&` is the root itself). The
298+ body needs a single root element.
298299
299300```
300301@let note $kind=info
crates/htmlang-core/src/parser.rs+52−31
@@ -1144,48 +1144,69 @@ impl Evaluator {
11441144 let mut result_nodes =
11451145 replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
11461146
1147− if !forwarded.is_empty() {
1147+ // Attributes that aren't parameters style the function's root
1148+ // element, and a scoped @style's class goes on it too.
1149+ let scope_class = ctx.scoped_functions.contains(name).then(|| format!("hl-{}", name));
1150+ if !forwarded.is_empty() || scope_class.is_some() {
11481151 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
11491152 Node::Element(e) => Some(e),
11501153 _ => None,
11511154 });
1152− match (roots.next(), roots.next()) {
1153− (Some(root), None) => root.attrs.extend(forwarded),
1154− _ => ctx.diagnostics.push(Diagnostic {
1155+ let root = match (roots.next(), roots.next()) {
1156+ (Some(root), None) => Some(root),
1157+ _ => None,
1158+ };
1159+ let mut problems = Vec::new();
1160+ match root {
1161+ Some(root) => {
1162+ root.attrs.extend(forwarded);
1163+ if let Some(class) = scope_class {
1164+ match root.attrs.iter_mut().find(|a| a.html && a.key == "class") {
1165+ Some(attr) => {
1166+ let existing = attr.value.take().unwrap_or_default();
1167+ attr.value = Some(format!("{} {}", existing, class).trim().to_string());
1168+ }
1169+ None => root.attrs.push(Attribute {
1170+ key: "class".to_string(),
1171+ value: Some(class),
1172+ html: true,
1173+ }),
1174+ }
1175+ }
1176+ }
1177+ None => {
1178+ if !forwarded.is_empty() {
1179+ problems.push(format!(
1180+ "attributes {} on @{} are not parameters, and its body has no single \
1181+ root element to receive them",
1182+ forwarded
1183+ .iter()
1184+ .map(|a| format!("'{}'", a.key))
1185+ .collect::<Vec<_>>()
1186+ .join(", "),
1187+ name
1188+ ));
1189+ }
1190+ if scope_class.is_some() {
1191+ problems.push(format!(
1192+ "@{} has a scoped @style, but its body has no single root element \
1193+ to scope it to",
1194+ name
1195+ ));
1196+ }
1197+ }
1198+ }
1199+ for message in problems {
1200+ ctx.diagnostics.push(Diagnostic {
11551201 line: line_num,
11561202 column: None,
1157− message: format!(
1158− "attributes {} on @{} are not parameters, and its body has no single \
1159− root element to receive them",
1160− forwarded
1161− .iter()
1162− .map(|a| format!("'{}'", a.key))
1163− .collect::<Vec<_>>()
1164− .join(", "),
1165− name
1166− ),
1203+ message,
11671204 severity: Severity::Warning,
11681205 source_line: Some(content.to_string()),
1169− }),
1206+ });
11701207 }
11711208 }
11721209
1173− // A function with a scoped @style wraps its output in the scope class
1174− if ctx.scoped_functions.contains(name) {
1175− let wrapper = Element {
1176− kind: ElementKind::El,
1177− attrs: vec![Attribute {
1178− key: "class".to_string(),
1179− value: Some(format!("hl-{}", name)),
1180− html: true,
1181− }],
1182− argument: None,
1183− children: result_nodes,
1184− line_num,
1185− };
1186− result_nodes = vec![Node::Element(wrapper)];
1187− }
1188−
11891210 Ok(result_nodes)
11901211 }
11911212
tests/snapshots.rs+6−3
@@ -3750,11 +3750,14 @@ fn key_if_condition_was_removed() {
37503750
37513751#[test]
37523752fn function_with_style_is_scoped() {
3753+ // The scope class goes on the root element: no wrapper
37533754 let html = compile(
3754− "@let card $title\n @style\n .t { color: red; }\n @text [class=t] $title\n@card [title Hello]\n",
3755+ "@let card $title\n @style\n & { padding: 4px; }\n .t { color: red; }\n @el [class=box]\n @text [class=t] $title\n@card [title Hello]\n",
37553756 );
3756− assert!(html.contains("hl-card\"><span class=\"t\">Hello"), "{}", html);
3757− assert!(html.contains(".hl-card {.t { color: red; }}"), "{}", html);
3757+ assert!(html.contains("<div class=\"a box hl-card\"><span class=\"t\">Hello"), "{}", html);
3758+ assert!(html.contains(".hl-card {& { padding: 4px; }.t { color: red; }}"), "{}", html);
3759+ let diags = parse_diagnostics("@let pair\n @style\n p { color: red; }\n @text A\n @text B\n@pair\n");
3760+ assert!(diags.iter().any(|d| d.message.contains("no single root element to scope it to")), "{:?}", diags);
37583761}
37593762
37603763#[test]