Skip to content

Commit 9e6203e

Browse files
committed
align css hmr
1 parent ce33f68 commit 9e6203e

File tree

4 files changed

+298
-10
lines changed

4 files changed

+298
-10
lines changed

crates/oxc_angular_compiler/src/ast/r3.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ pub enum R3Node<'a> {
211211
Component(Box<'a, R3Component<'a>>),
212212
/// A directive reference.
213213
Directive(Box<'a, R3Directive<'a>>),
214+
/// A host element (for type checking only, cannot be visited).
215+
HostElement(Box<'a, R3HostElement<'a>>),
214216
}
215217

216218
impl<'a> R3Node<'a> {
@@ -240,6 +242,7 @@ impl<'a> R3Node<'a> {
240242
Self::LetDeclaration(n) => n.source_span,
241243
Self::Component(n) => n.source_span,
242244
Self::Directive(n) => n.source_span,
245+
Self::HostElement(n) => n.source_span,
243246
}
244247
}
245248

@@ -269,6 +272,8 @@ impl<'a> R3Node<'a> {
269272
Self::LetDeclaration(n) => visitor.visit_let_declaration(n),
270273
Self::Component(n) => visitor.visit_component(n),
271274
Self::Directive(n) => visitor.visit_directive(n),
275+
// HostElement cannot be visited (used only for type checking)
276+
Self::HostElement(_) => {}
272277
}
273278
}
274279
}
@@ -1029,6 +1034,21 @@ pub struct R3Directive<'a> {
10291034
pub i18n: Option<I18nMeta<'a>>,
10301035
}
10311036

1037+
/// Represents the host element of a directive.
1038+
/// This node is used only for type checking purposes and cannot be produced
1039+
/// from a user's template. HostElement nodes should NOT be visited.
1040+
#[derive(Debug)]
1041+
pub struct R3HostElement<'a> {
1042+
/// Possible tag names for the host element. Must have at least one.
1043+
pub tag_names: Vec<'a, Atom<'a>>,
1044+
/// Attribute and property bindings.
1045+
pub bindings: Vec<'a, R3BoundAttribute<'a>>,
1046+
/// Event listeners.
1047+
pub listeners: Vec<'a, R3BoundEvent<'a>>,
1048+
/// Source span.
1049+
pub source_span: Span,
1050+
}
1051+
10321052
/// An ICU message for internationalization.
10331053
#[derive(Debug)]
10341054
pub struct R3Icu<'a> {

crates/oxc_angular_compiler/src/pipeline/ingest.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -438,8 +438,8 @@ fn ingest_node<'a>(job: &mut ComponentCompilationJob<'a>, view_xref: XrefId, nod
438438
R3Node::IfBlockBranch(_) => {
439439
// If branches are handled by the parent if block
440440
}
441-
R3Node::Component(_) | R3Node::Directive(_) => {
442-
// Components and directives are resolved during binding
441+
R3Node::Component(_) | R3Node::Directive(_) | R3Node::HostElement(_) => {
442+
// Components, directives, and host elements are resolved during binding/type checking
443443
}
444444
}
445445
}

crates/oxc_angular_compiler/src/styles/encapsulation.rs

Lines changed: 266 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -65,26 +65,41 @@ pub fn shim_css_text(css: &str, content_attr: &str, host_attr: &str) -> String {
6565

6666
let mut result = result;
6767

68-
// Step 1: Handle :host-context() - must be done before :host
68+
// Step 1: Process polyfill directives (polyfill-next-selector, polyfill-rule)
69+
// These convert ShadowDOM rules to work with the CSS shim
70+
result = insert_polyfill_directives(&result);
71+
result = insert_polyfill_rules(&result);
72+
73+
// Step 2: Extract unscoped rules (polyfill-unscoped-rule)
74+
// These rules are added at the end WITHOUT scoping
75+
let unscoped_rules = extract_unscoped_rules(&result);
76+
result = remove_unscoped_rules(&result);
77+
78+
// Step 3: Handle :host-context() - must be done before :host
6979
result = convert_colon_host_context(&result, content_attr, host_attr);
7080

71-
// Step 2: Handle :host selectors
81+
// Step 4: Handle :host selectors
7282
result = convert_colon_host(&result, host_attr);
7383

74-
// Step 3: Handle ::ng-deep and other shadow DOM selectors
84+
// Step 5: Handle ::ng-deep and other shadow DOM selectors
7585
result = convert_shadow_dom_selectors(&result);
7686

77-
// Step 4: Scope keyframes and animation properties
87+
// Step 6: Scope keyframes and animation properties
7888
if !content_attr.is_empty() {
7989
result = scope_keyframes_related_css(&result, content_attr);
8090
}
8191

82-
// Step 5: Scope all selectors with the content attribute
92+
// Step 7: Scope all selectors with the content attribute
8393
if !content_attr.is_empty() {
8494
result = scope_selectors(&result, content_attr, host_attr);
8595
}
8696

87-
// Step 6: Restore comments
97+
// Step 8: Append unscoped rules (without scoping)
98+
if !unscoped_rules.is_empty() {
99+
result = result + "\n" + unscoped_rules.trim();
100+
}
101+
102+
// Step 9: Restore comments
88103
restore_comments(&result, &comments)
89104
}
90105

@@ -430,11 +445,16 @@ fn convert_colon_host_context(css: &str, content_attr: &str, host_attr: &str) ->
430445
/// `:host` becomes `[host_attr]`
431446
/// `:host(.active)` becomes `.active[host_attr]`
432447
/// `:host[attr]` becomes `[attr][host_attr]`
448+
/// `:host.class` becomes `.class[host_attr]`
449+
/// `:host#id` becomes `#id[host_attr]`
433450
fn convert_colon_host(css: &str, host_attr: &str) -> String {
434-
// Pattern: :host followed by optional (selector)
451+
// Pattern: :host followed by (selector) in parentheses
435452
let host_with_selector_re: &Regex = regex!(r":host\(([^)]+)\)");
436453
// Pattern: :host followed by attribute selector directly (no space)
437454
let host_with_attr_re: &Regex = regex!(r":host(\[[^\]]+\])");
455+
// Pattern: :host followed by class, id, or pseudo selector directly attached
456+
// Matches: :host.class, :host#id, :host:hover, :host.class:before, etc.
457+
let host_with_class_re: &Regex = regex!(r":host([.#:][^{\s,]+)");
438458
let host_plain_re: &Regex = regex!(r":host\b");
439459

440460
let attr = if host_attr.is_empty() { String::new() } else { format!("[{}]", host_attr) };
@@ -455,7 +475,15 @@ fn convert_colon_host(css: &str, host_attr: &str) -> String {
455475
})
456476
.to_string();
457477

458-
// Then handle plain :host
478+
// Then handle :host.class, :host#id, :host:pseudo directly attached
479+
let result = host_with_class_re
480+
.replace_all(&result, |caps: &regex::Captures| {
481+
let attached_selector = caps.get(1).map_or("", |m| m.as_str());
482+
format!("{}{}", attached_selector, attr)
483+
})
484+
.to_string();
485+
486+
// Finally handle plain :host
459487
host_plain_re.replace_all(&result, attr.as_str()).to_string()
460488
}
461489

@@ -985,6 +1013,130 @@ fn is_keyframe_selector(selector: &str) -> bool {
9851013
false
9861014
}
9871015

1016+
// ============================================================================
1017+
// Polyfill Directive Processing
1018+
// ============================================================================
1019+
1020+
/// Process styles to convert native ShadowDOM rules that will trip up the CSS parser.
1021+
///
1022+
/// Converts polyfill-next-selector rules like:
1023+
/// ```css
1024+
/// polyfill-next-selector { content: ':host menu-item'; }
1025+
/// ::content menu-item { ... }
1026+
/// ```
1027+
///
1028+
/// to:
1029+
/// ```css
1030+
/// :host menu-item { ... }
1031+
/// ```
1032+
fn insert_polyfill_directives(css: &str) -> String {
1033+
// Handle both single and double quotes separately since Rust regex doesn't support backreferences
1034+
let re_single: &Regex =
1035+
regex!(r#"(?im)polyfill-next-selector[^}]*content:[\s]*?'([^']*)'[;\s]*\}([^{]*?)\{"#);
1036+
let re_double: &Regex =
1037+
regex!(r#"(?im)polyfill-next-selector[^}]*content:[\s]*?"([^"]*)"[;\s]*\}([^{]*?)\{"#);
1038+
1039+
let result = re_single
1040+
.replace_all(css, |caps: &regex::Captures| {
1041+
// Replace with: selector {
1042+
format!("{} {{", &caps[1])
1043+
})
1044+
.into_owned();
1045+
1046+
re_double
1047+
.replace_all(&result, |caps: &regex::Captures| format!("{} {{", &caps[1]))
1048+
.into_owned()
1049+
}
1050+
1051+
/// Process styles to add rules which will only apply under the polyfill.
1052+
///
1053+
/// Converts polyfill-rule rules like:
1054+
/// ```css
1055+
/// polyfill-rule {
1056+
/// content: ':host menu-item';
1057+
/// background: blue;
1058+
/// }
1059+
/// ```
1060+
///
1061+
/// to:
1062+
/// ```css
1063+
/// :host menu-item { background: blue; }
1064+
/// ```
1065+
fn insert_polyfill_rules(css: &str) -> String {
1066+
// Handle both single and double quotes separately
1067+
let re_single: &Regex =
1068+
regex!(r#"(?im)polyfill-rule[^}]*content:[\s]*'([^']*)'[;\s]*([^}]*)\}"#);
1069+
let re_double: &Regex =
1070+
regex!(r#"(?im)polyfill-rule[^}]*content:[\s]*"([^"]*)"[;\s]*([^}]*)\}"#);
1071+
1072+
let replace_fn = |caps: &regex::Captures| {
1073+
let selector = &caps[1];
1074+
let rule_body = &caps[2];
1075+
let cleaned_body = rule_body.trim().trim_start_matches(';').trim();
1076+
format!("{} {{ {} }}", selector, cleaned_body)
1077+
};
1078+
1079+
let result = re_single.replace_all(css, replace_fn).into_owned();
1080+
re_double.replace_all(&result, replace_fn).into_owned()
1081+
}
1082+
1083+
/// Extract unscoped rules from CSS text.
1084+
///
1085+
/// These are rules that should apply under the polyfill but NOT be scoped.
1086+
///
1087+
/// Converts polyfill-unscoped-rule rules like:
1088+
/// ```css
1089+
/// @polyfill-unscoped-rule {
1090+
/// content: 'menu-item';
1091+
/// background: blue;
1092+
/// }
1093+
/// ```
1094+
///
1095+
/// to:
1096+
/// ```css
1097+
/// menu-item { background: blue; }
1098+
/// ```
1099+
///
1100+
/// Returns the extracted unscoped rules as a string.
1101+
fn extract_unscoped_rules(css: &str) -> String {
1102+
// Handle both single and double quotes separately
1103+
let re_single: &Regex =
1104+
regex!(r#"(?im)polyfill-unscoped-rule[^}]*content:[\s]*'([^']*)'[;\s]*([^}]*)\}"#);
1105+
let re_double: &Regex =
1106+
regex!(r#"(?im)polyfill-unscoped-rule[^}]*content:[\s]*"([^"]*)"[;\s]*([^}]*)\}"#);
1107+
1108+
let mut result = String::new();
1109+
1110+
for caps in re_single.captures_iter(css) {
1111+
let selector = &caps[1];
1112+
let rule_body = &caps[2];
1113+
let cleaned_body = rule_body.trim().trim_start_matches(';').trim();
1114+
result.push_str(&format!("{} {{ {} }}\n\n", selector, cleaned_body));
1115+
}
1116+
1117+
for caps in re_double.captures_iter(css) {
1118+
let selector = &caps[1];
1119+
let rule_body = &caps[2];
1120+
let cleaned_body = rule_body.trim().trim_start_matches(';').trim();
1121+
result.push_str(&format!("{} {{ {} }}\n\n", selector, cleaned_body));
1122+
}
1123+
1124+
result
1125+
}
1126+
1127+
/// Remove polyfill-unscoped-rule blocks from CSS text.
1128+
///
1129+
/// This is called after extracting the unscoped rules.
1130+
fn remove_unscoped_rules(css: &str) -> String {
1131+
let re_single: &Regex =
1132+
regex!(r#"(?im)polyfill-unscoped-rule[^}]*content:[\s]*'[^']*'[;\s]*[^}]*\}"#);
1133+
let re_double: &Regex =
1134+
regex!(r#"(?im)polyfill-unscoped-rule[^}]*content:[\s]*"[^"]*"[;\s]*[^}]*\}"#);
1135+
1136+
let result = re_single.replace_all(css, "").into_owned();
1137+
re_double.replace_all(&result, "").into_owned()
1138+
}
1139+
9881140
#[cfg(test)]
9891141
mod tests {
9901142
use super::*;
@@ -1050,4 +1202,110 @@ mod tests {
10501202
assert!(result.contains("@media"), "Got: {}", result);
10511203
assert!(result.contains(".button[contenta]"), "Got: {}", result);
10521204
}
1205+
1206+
// ============================================================================
1207+
// Polyfill Directive Tests
1208+
// ============================================================================
1209+
1210+
#[test]
1211+
fn test_polyfill_next_selector_single_quotes() {
1212+
let result = shim_css_text("polyfill-next-selector {content: 'x > y'} z {}", "contenta", "");
1213+
assert!(result.contains("x[contenta]"), "Got: {}", result);
1214+
assert!(result.contains("y[contenta]"), "Got: {}", result);
1215+
assert!(!result.contains("polyfill-next-selector"), "Got: {}", result);
1216+
assert!(!result.contains("z"), "Got: {}", result);
1217+
}
1218+
1219+
#[test]
1220+
fn test_polyfill_next_selector_double_quotes() {
1221+
let result =
1222+
shim_css_text("polyfill-next-selector {content: \"x > y\"} z {}", "contenta", "");
1223+
assert!(result.contains("x[contenta]"), "Got: {}", result);
1224+
assert!(result.contains("y[contenta]"), "Got: {}", result);
1225+
}
1226+
1227+
#[test]
1228+
fn test_polyfill_next_selector_with_attribute() {
1229+
let result = shim_css_text(
1230+
"polyfill-next-selector {content: 'button[priority=\"1\"]'} z {}",
1231+
"contenta",
1232+
"",
1233+
);
1234+
assert!(
1235+
result.contains("button[priority=\"1\"][contenta]"),
1236+
"Got: {}",
1237+
result
1238+
);
1239+
}
1240+
1241+
#[test]
1242+
fn test_polyfill_unscoped_rule_single_quotes() {
1243+
let result = shim_css_text(
1244+
"polyfill-unscoped-rule {content: '#menu > .bar';color: blue;}",
1245+
"contenta",
1246+
"",
1247+
);
1248+
// Unscoped rules should NOT have the content attribute
1249+
assert!(result.contains("#menu > .bar"), "Got: {}", result);
1250+
assert!(result.contains("color: blue"), "Got: {}", result);
1251+
assert!(!result.contains("[contenta]") || result.contains("#menu > .bar {"), "Unscoped rule should not have content attribute. Got: {}", result);
1252+
}
1253+
1254+
#[test]
1255+
fn test_polyfill_unscoped_rule_double_quotes() {
1256+
let result = shim_css_text(
1257+
"polyfill-unscoped-rule {content: \"#menu > .bar\";color: blue;}",
1258+
"contenta",
1259+
"",
1260+
);
1261+
assert!(result.contains("#menu > .bar"), "Got: {}", result);
1262+
assert!(result.contains("color: blue"), "Got: {}", result);
1263+
}
1264+
1265+
#[test]
1266+
fn test_polyfill_unscoped_rule_multiple() {
1267+
let result = shim_css_text(
1268+
"polyfill-unscoped-rule {content: 'foo';color: blue;}polyfill-unscoped-rule {content: 'bar';color: red;}",
1269+
"contenta",
1270+
"",
1271+
);
1272+
assert!(result.contains("foo {"), "Got: {}", result);
1273+
assert!(result.contains("bar {"), "Got: {}", result);
1274+
assert!(result.contains("color: blue"), "Got: {}", result);
1275+
assert!(result.contains("color: red"), "Got: {}", result);
1276+
}
1277+
1278+
#[test]
1279+
fn test_polyfill_rule_single_quotes() {
1280+
let result = shim_css_text(
1281+
"polyfill-rule {content: ':host.foo .bar';color: blue;}",
1282+
"contenta",
1283+
"a-host",
1284+
);
1285+
// polyfill-rule content gets scoped like a regular rule
1286+
assert!(result.contains(".foo[a-host]"), "Got: {}", result);
1287+
assert!(result.contains(".bar[contenta]"), "Got: {}", result);
1288+
assert!(result.contains("color: blue"), "Got: {}", result);
1289+
}
1290+
1291+
#[test]
1292+
fn test_polyfill_rule_double_quotes() {
1293+
let result = shim_css_text(
1294+
"polyfill-rule {content: \":host.foo .bar\";color: blue;}",
1295+
"contenta",
1296+
"a-host",
1297+
);
1298+
assert!(result.contains(".foo[a-host]"), "Got: {}", result);
1299+
assert!(result.contains(".bar[contenta]"), "Got: {}", result);
1300+
}
1301+
1302+
#[test]
1303+
fn test_polyfill_rule_with_attribute() {
1304+
let result = shim_css_text(
1305+
"polyfill-rule {content: 'button[priority=\"1\"]'}",
1306+
"contenta",
1307+
"a-host",
1308+
);
1309+
assert!(result.contains("button[priority=\"1\"][contenta]"), "Got: {}", result);
1310+
}
10531311
}

0 commit comments

Comments
 (0)