on master help sign in
Scoped styles without a wrapper element
74bc0c3iko
DESIGN.md+2−1
@@ -294,7 +294,8 @@ A function is called like an element:
294 caller's `@slot name` block replaces `@slot name` (the slot's own children are
294 caller's `@slot name` block replaces `@slot name` (the slot's own children are
295 the default).
295 the default).
296- An `@style` block at the top of the body is scoped to the function: its rules
296- 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.
298
299
299```
300```
300@let note $kind=info
301@let note $kind=info
crates/htmlang-core/src/parser.rs+52−31
@@ -1144,48 +1144,69 @@ impl Evaluator {
1144 let mut result_nodes =
1144 let mut result_nodes =
1145 replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
1145 replace_children_and_slots(body_nodes, &caller_children, &slot_contents);
1146
1146
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));
1147− if !forwarded.is_empty() {
1150+ if !forwarded.is_empty() || scope_class.is_some() {
1148 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
1151 let mut roots = result_nodes.iter_mut().filter_map(|n| match n {
1149 Node::Element(e) => Some(e),
1152 Node::Element(e) => Some(e),
1150 _ => None,
1153 _ => None,
1151 });
1154 });
1152− match (roots.next(), roots.next()) {
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) => {
1153− (Some(root), None) => root.attrs.extend(forwarded),
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 {
1154− _ => ctx.diagnostics.push(Diagnostic {
1200+ ctx.diagnostics.push(Diagnostic {
1155 line: line_num,
1201 line: line_num,
1156 column: None,
1202 column: None,
1157− message: format!(
1203+ message,
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− ),
1167 severity: Severity::Warning,
1204 severity: Severity::Warning,
1168 source_line: Some(content.to_string()),
1205 source_line: Some(content.to_string()),
1169− }),
1206+ });
1170 }
1207 }
1171 }
1208 }
1172
1209
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−
1189 Ok(result_nodes)
1210 Ok(result_nodes)
1190 }
1211 }
1191
1212
tests/snapshots.rs+6−3
@@ -3750,11 +3750,14 @@ fn key_if_condition_was_removed() {
3750
3750
3751#[test]
3751#[test]
3752fn function_with_style_is_scoped() {
3752fn function_with_style_is_scoped() {
3753+ // The scope class goes on the root element: no wrapper
3753 let html = compile(
3754 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",
3755 );
3756 );
3756− assert!(html.contains("hl-card\"><span class=\"t\">Hello"), "{}", html);
3757+ assert!(html.contains("<div class=\"a box hl-card\"><span class=\"t\">Hello"), "{}", html);
3757− assert!(html.contains(".hl-card {.t { color: red; }}"), "{}", 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);
3758}
3761}
3759
3762
3760#[test]
3763#[test]