1mod accuracy;
2
3use noxid_css_syntax::{AtRuleBlock, Declaration, ParsedStyle, Rule, parse};
4use noxid_ir::{SemanticId, SemanticProgram};
5use noxid_source::{Diagnostic, Span, json_escape};
6use std::collections::{BTreeMap, BTreeSet};
7
8#[derive(Clone, Debug, Default)]
9pub struct StyleProgram {
10 pub sheets: Vec<StyleSheet>,
11}
12
13#[derive(Clone, Debug)]
14pub struct StyleSheet {
15 pub id: SemanticId,
16 pub component: SemanticId,
17 pub component_name: String,
18 pub scope: String,
19 pub rules: Vec<StyleRule>,
20 pub span: Span,
21 pub cst_token_count: usize,
22}
23
24#[derive(Clone, Debug)]
25pub enum StyleRule {
26 Qualified {
27 id: SemanticId,
28 selector: String,
29 scoped_selector: String,
30 declarations: Vec<StyleDeclaration>,
31 span: Span,
32 },
33 AtRule {
34 id: SemanticId,
35 name: String,
36 prelude: String,
37 block: Option<StyleAtRuleBlock>,
38 span: Span,
39 },
40 Keyframes {
41 id: SemanticId,
42 name: String,
43 scoped_name: String,
44 vendor_prefix: Option<String>,
45 frames: Vec<Keyframe>,
46 span: Span,
47 },
48}
49
50#[derive(Clone, Debug)]
51pub enum StyleAtRuleBlock {
52 Rules(Vec<StyleRule>),
53 Declarations(Vec<StyleDeclaration>),
54}
55
56#[derive(Clone, Debug)]
57pub struct Keyframe {
58 pub selector: String,
59 pub declarations: Vec<StyleDeclaration>,
60 pub span: Span,
61}
62
63#[derive(Clone, Debug)]
64pub struct StyleDeclaration {
65 pub id: SemanticId,
66 pub name: String,
67 pub value: String,
68 pub important: bool,
69 pub span: Span,
70}
71
72#[derive(Clone, Debug)]
73pub struct LoweredStyles {
74 pub program: StyleProgram,
75 pub diagnostics: Vec<Diagnostic>,
76}
77
78#[derive(Clone, Copy, Debug)]
80pub struct StyleOptions {
81 pub check_accuracy: bool,
89}
90
91impl Default for StyleOptions {
92 fn default() -> Self {
93 Self {
94 check_accuracy: true,
95 }
96 }
97}
98
99pub fn lower(program: &SemanticProgram) -> LoweredStyles {
100 lower_with_options(
101 program,
102 &noxid_design_ir::DesignProgram::default(),
103 StyleOptions::default(),
104 )
105}
106
107pub fn lower_with_options(
108 program: &SemanticProgram,
109 design: &noxid_design_ir::DesignProgram,
110 options: StyleOptions,
111) -> LoweredStyles {
112 let vocabulary = accuracy::Vocabulary::from_design(design);
113 let mut sheets = Vec::new();
114 let mut diagnostics = Vec::new();
115 for component in &program.components {
116 let Some(style) = &component.style else {
117 continue;
118 };
119 let parsed = parse(&style.source, style.span);
120 diagnostics.extend(parsed.diagnostics.clone());
121 let scope = scope_id(component.id.as_str());
122 let mut keyframes = BTreeMap::new();
123 collect_keyframes(
124 &parsed,
125 &component.name,
126 &scope,
127 &mut keyframes,
128 &mut diagnostics,
129 );
130 let rules = {
131 let mut lowerer = Lowerer {
132 component: &component.name,
133 scope: &scope,
134 keyframes: &keyframes,
135 design,
136 vocabulary: &vocabulary,
137 check_accuracy: options.check_accuracy,
138 rule_ordinal: 0,
139 declaration_ordinal: 0,
140 diagnostics: &mut diagnostics,
141 };
142 lowerer.rules(&parsed.ast.rules)
143 };
144 if options.check_accuracy {
147 accuracy::check(component, &rules, &vocabulary, &mut diagnostics);
148 }
149 sheets.push(StyleSheet {
150 id: SemanticId::style(&component.name),
151 component: component.id.clone(),
152 component_name: component.name.clone(),
153 scope,
154 rules,
155 span: style.span,
156 cst_token_count: parsed.cst.tokens.len(),
157 });
158 }
159 LoweredStyles {
160 program: StyleProgram { sheets },
161 diagnostics,
162 }
163}
164
165struct Lowerer<'a> {
166 component: &'a str,
167 scope: &'a str,
168 keyframes: &'a BTreeMap<String, String>,
169 design: &'a noxid_design_ir::DesignProgram,
170 vocabulary: &'a accuracy::Vocabulary,
171 check_accuracy: bool,
174 rule_ordinal: usize,
175 declaration_ordinal: usize,
176 diagnostics: &'a mut Vec<Diagnostic>,
177}
178
179impl Lowerer<'_> {
180 fn rules(&mut self, rules: &[Rule]) -> Vec<StyleRule> {
181 rules.iter().map(|rule| self.rule(rule)).collect()
182 }
183
184 fn rule(&mut self, rule: &Rule) -> StyleRule {
185 match rule {
186 Rule::Qualified(rule) => {
187 self.rule_ordinal += 1;
188 let rule_ordinal = self.rule_ordinal;
189 let scoped_selector = match scope_selector_list(&rule.selector, self.scope) {
190 Ok(selector) => selector,
191 Err(message) => {
192 self.diagnostics.push(
193 Diagnostic::error(
194 "CSS_INVALID_GLOBAL_SELECTOR",
195 message,
196 rule.selector_span,
197 )
198 .with_symbol(
199 SemanticId::css_rule(self.component, rule_ordinal).to_string(),
200 ),
201 );
202 rule.selector.clone()
203 }
204 };
205 StyleRule::Qualified {
206 id: SemanticId::css_rule(self.component, rule_ordinal),
207 selector: rule.selector.clone(),
208 scoped_selector,
209 declarations: self.declarations(&rule.declarations, rule_ordinal),
210 span: rule.span,
211 }
212 }
213 Rule::At(rule) if matches!(rule.name.as_str(), "keyframes" | "-webkit-keyframes") => {
214 let name = rule.prelude.trim().to_string();
215 let scoped_name = self
216 .keyframes
217 .get(&name)
218 .cloned()
219 .unwrap_or_else(|| format!("{name}--{}", self.scope));
220 let frames = match &rule.block {
221 Some(AtRuleBlock::Keyframes(frames)) => frames
222 .iter()
223 .map(|frame| {
224 self.rule_ordinal += 1;
225 let ordinal = self.rule_ordinal;
226 Keyframe {
227 selector: frame.selector.clone(),
228 declarations: self.declarations(&frame.declarations, ordinal),
229 span: frame.span,
230 }
231 })
232 .collect(),
233 _ => vec![],
234 };
235 let id_name = if rule.name == "-webkit-keyframes" {
236 format!("-webkit-{name}")
237 } else {
238 name.clone()
239 };
240 StyleRule::Keyframes {
241 id: SemanticId::css_keyframes(self.component, &id_name),
242 name,
243 scoped_name,
244 vendor_prefix: (rule.name == "-webkit-keyframes").then(|| "-webkit-".into()),
245 frames,
246 span: rule.span,
247 }
248 }
249 Rule::At(rule) => {
250 self.rule_ordinal += 1;
251 let ordinal = self.rule_ordinal;
252 let block = match &rule.block {
253 Some(AtRuleBlock::Rules(rules)) => {
254 Some(StyleAtRuleBlock::Rules(self.rules(rules)))
255 }
256 Some(AtRuleBlock::Declarations(declarations)) => Some(
257 StyleAtRuleBlock::Declarations(self.declarations(declarations, ordinal)),
258 ),
259 Some(AtRuleBlock::Keyframes(_)) | None => None,
260 };
261 if self.check_accuracy {
264 accuracy::check_strict_media_prelude(
265 self.vocabulary,
266 &rule.name,
267 &rule.prelude,
268 rule.span,
269 &SemanticId::css_at_rule(self.component, &rule.name, ordinal),
270 self.diagnostics,
271 );
272 }
273 StyleRule::AtRule {
274 id: SemanticId::css_at_rule(self.component, &rule.name, ordinal),
275 name: rule.name.clone(),
276 prelude: self.resolve_prelude_tokens(&rule.prelude),
281 block,
282 span: rule.span,
283 }
284 }
285 }
286 }
287
288 fn resolve_prelude_tokens(&self, prelude: &str) -> String {
292 let mut output = prelude.to_string();
293 for system in &self.design.systems {
294 for token in &system.tokens {
295 output = output.replace(&format!("token({})", token.name), &token.value);
296 }
297 }
298 output
299 }
300
301 fn declarations(
302 &mut self,
303 declarations: &[Declaration],
304 rule_ordinal: usize,
305 ) -> Vec<StyleDeclaration> {
306 declarations
307 .iter()
308 .map(|declaration| {
309 self.declaration_ordinal += 1;
310 let mut value = declaration.value.clone();
311 if matches!(
312 declaration.name.to_ascii_lowercase().as_str(),
313 "animation" | "animation-name" | "-webkit-animation" | "-webkit-animation-name"
314 ) {
315 for (name, scoped) in self.keyframes {
316 value = replace_identifier(&value, name, scoped);
317 }
318 }
319 StyleDeclaration {
320 id: SemanticId::css_declaration(
321 self.component,
322 rule_ordinal,
323 self.declaration_ordinal,
324 ),
325 name: declaration.name.clone(),
326 value,
327 important: declaration.important,
328 span: declaration.span,
329 }
330 })
331 .collect()
332 }
333}
334
335fn collect_keyframes(
336 parsed: &ParsedStyle,
337 component: &str,
338 scope: &str,
339 output: &mut BTreeMap<String, String>,
340 diagnostics: &mut Vec<Diagnostic>,
341) {
342 fn visit(
343 rules: &[Rule],
344 component: &str,
345 scope: &str,
346 output: &mut BTreeMap<String, String>,
347 seen: &mut BTreeSet<String>,
348 diagnostics: &mut Vec<Diagnostic>,
349 ) {
350 for rule in rules {
351 let Rule::At(rule) = rule else {
352 continue;
353 };
354 if matches!(rule.name.as_str(), "keyframes" | "-webkit-keyframes") {
355 let name = rule.prelude.trim();
356 if name.is_empty() || name.chars().any(char::is_whitespace) {
357 diagnostics.push(
358 Diagnostic::error(
359 "CSS_INVALID_KEYFRAMES_NAME",
360 "keyframes require one identifier name",
361 rule.span,
362 )
363 .with_symbol(SemanticId::style(component).to_string()),
364 );
365 } else if !seen.insert(format!("{}:{name}", rule.name)) {
366 diagnostics.push(
367 Diagnostic::error(
368 "CSS_DUPLICATE_KEYFRAMES",
369 format!("duplicate keyframes `{name}`"),
370 rule.span,
371 )
372 .with_symbol(SemanticId::css_keyframes(component, name).to_string()),
373 );
374 } else {
375 output
376 .entry(name.to_string())
377 .or_insert_with(|| format!("{name}--{scope}"));
378 }
379 }
380 if let Some(AtRuleBlock::Rules(children)) = &rule.block {
381 visit(children, component, scope, output, seen, diagnostics);
382 }
383 }
384 }
385
386 visit(
387 &parsed.ast.rules,
388 component,
389 scope,
390 output,
391 &mut BTreeSet::new(),
392 diagnostics,
393 );
394}
395
396pub fn scope_id(component_id: &str) -> String {
397 format!("noxid-{:08x}", fnv1a(component_id) as u32)
398}
399
400fn fnv1a(value: &str) -> u64 {
401 value.bytes().fold(0xcbf29ce484222325, |hash, byte| {
402 (hash ^ byte as u64).wrapping_mul(0x100000001b3)
403 })
404}
405
406fn scope_selector_list(selector: &str, scope: &str) -> Result<String, String> {
407 split_top_level(selector, ',')
408 .into_iter()
409 .map(|selector| scope_selector(selector.trim(), scope))
410 .collect::<Result<Vec<_>, _>>()
411 .map(|selectors| selectors.join(", "))
412}
413
414fn scope_selector(selector: &str, scope: &str) -> Result<String, String> {
415 let (protected, globals) = protect_globals(selector)?;
416 let mut output = String::new();
417 let mut compound = String::new();
418 let mut paren = 0usize;
419 let mut bracket = 0usize;
420 let mut quote = None;
421 let chars = protected.chars().collect::<Vec<_>>();
422 let mut index = 0;
423 while index < chars.len() {
424 let ch = chars[index];
425 if let Some(active) = quote {
426 compound.push(ch);
427 if ch == '\\' && index + 1 < chars.len() {
428 index += 1;
429 compound.push(chars[index]);
430 } else if ch == active {
431 quote = None;
432 }
433 index += 1;
434 continue;
435 }
436 match ch {
437 '\'' | '"' => {
438 quote = Some(ch);
439 compound.push(ch);
440 }
441 '[' => {
442 bracket += 1;
443 compound.push(ch);
444 }
445 ']' => {
446 bracket = bracket.saturating_sub(1);
447 compound.push(ch);
448 }
449 '(' => {
450 paren += 1;
451 compound.push(ch);
452 }
453 ')' => {
454 paren = paren.saturating_sub(1);
455 compound.push(ch);
456 }
457 '>' | '+' | '~' if paren == 0 && bracket == 0 => {
458 output.push_str(&scope_compound(&compound, scope, &globals));
459 compound.clear();
460 output.push(ch);
461 }
462 value if value.is_whitespace() && paren == 0 && bracket == 0 => {
463 output.push_str(&scope_compound(&compound, scope, &globals));
464 compound.clear();
465 output.push(value);
466 }
467 _ => compound.push(ch),
468 }
469 index += 1;
470 }
471 output.push_str(&scope_compound(&compound, scope, &globals));
472 Ok(restore_globals(&output, &globals))
473}
474
475fn scope_compound(compound: &str, scope: &str, globals: &[String]) -> String {
476 let trimmed = compound.trim();
477 if trimmed.is_empty() {
478 return compound.to_string();
479 }
480 let local = globals
481 .iter()
482 .enumerate()
483 .fold(trimmed.to_string(), |value, (index, _)| {
484 value.replace(&global_marker(index), "")
485 });
486 if local.trim().is_empty() {
487 return compound.to_string();
488 }
489 let insert = first_pseudo_index(trimmed).unwrap_or(trimmed.len());
490 format!(
491 "{}[data-noxid-scope=\"{}\"]{}",
492 &trimmed[..insert],
493 scope,
494 &trimmed[insert..]
495 )
496}
497
498fn first_pseudo_index(selector: &str) -> Option<usize> {
499 let mut bracket = 0usize;
500 let mut quote = None;
501 let mut escaped = false;
502 for (index, ch) in selector.char_indices() {
503 if let Some(active) = quote {
504 if escaped {
505 escaped = false;
506 } else if ch == '\\' {
507 escaped = true;
508 } else if ch == active {
509 quote = None;
510 }
511 continue;
512 }
513 match ch {
514 '\'' | '"' => quote = Some(ch),
515 '[' => bracket += 1,
516 ']' => bracket = bracket.saturating_sub(1),
517 ':' if bracket == 0 => return Some(index),
518 _ => {}
519 }
520 }
521 None
522}
523
524fn protect_globals(selector: &str) -> Result<(String, Vec<String>), String> {
525 let mut output = String::new();
526 let mut globals = Vec::new();
527 let mut index = 0;
528 while let Some(relative) = selector[index..].find(":global(") {
529 let start = index + relative;
530 output.push_str(&selector[index..start]);
531 let content_start = start + ":global(".len();
532 let Some(end) = matching_paren(selector, content_start) else {
533 return Err("unterminated :global(...) selector".into());
534 };
535 let value = selector[content_start..end].trim();
536 if value.is_empty() {
537 return Err(":global(...) cannot be empty".into());
538 }
539 let marker = global_marker(globals.len());
540 globals.push(value.to_string());
541 output.push_str(&marker);
542 index = end + 1;
543 }
544 output.push_str(&selector[index..]);
545 Ok((output, globals))
546}
547
548fn matching_paren(value: &str, content_start: usize) -> Option<usize> {
549 let mut depth = 1usize;
550 let mut quote = None;
551 let mut escaped = false;
552 for (offset, ch) in value[content_start..].char_indices() {
553 if let Some(active) = quote {
554 if escaped {
555 escaped = false;
556 } else if ch == '\\' {
557 escaped = true;
558 } else if ch == active {
559 quote = None;
560 }
561 continue;
562 }
563 match ch {
564 '\'' | '"' => quote = Some(ch),
565 '(' => depth += 1,
566 ')' => {
567 depth -= 1;
568 if depth == 0 {
569 return Some(content_start + offset);
570 }
571 }
572 _ => {}
573 }
574 }
575 None
576}
577
578fn global_marker(index: usize) -> String {
579 format!("__NOXID_GLOBAL_{index}__")
580}
581
582fn restore_globals(value: &str, globals: &[String]) -> String {
583 globals
584 .iter()
585 .enumerate()
586 .fold(value.to_string(), |output, (index, global)| {
587 output.replace(&global_marker(index), global)
588 })
589}
590
591fn split_top_level(value: &str, delimiter: char) -> Vec<&str> {
592 let mut values = Vec::new();
593 let mut start = 0;
594 let mut paren = 0usize;
595 let mut bracket = 0usize;
596 let mut quote = None;
597 let mut escaped = false;
598 for (index, ch) in value.char_indices() {
599 if let Some(active) = quote {
600 if escaped {
601 escaped = false;
602 } else if ch == '\\' {
603 escaped = true;
604 } else if ch == active {
605 quote = None;
606 }
607 continue;
608 }
609 match ch {
610 '\'' | '"' => quote = Some(ch),
611 '(' => paren += 1,
612 ')' => paren = paren.saturating_sub(1),
613 '[' => bracket += 1,
614 ']' => bracket = bracket.saturating_sub(1),
615 current if current == delimiter && paren == 0 && bracket == 0 => {
616 values.push(&value[start..index]);
617 start = index + ch.len_utf8();
618 }
619 _ => {}
620 }
621 }
622 values.push(&value[start..]);
623 values
624}
625
626fn replace_identifier(value: &str, name: &str, replacement: &str) -> String {
627 let mut output = String::new();
628 let mut index = 0;
629 let mut quote = None;
630 while index < value.len() {
631 let ch = value[index..].chars().next().unwrap_or('\0');
632 if let Some(active) = quote {
633 output.push(ch);
634 index += ch.len_utf8();
635 if ch == '\\' && index < value.len() {
636 let escaped = value[index..].chars().next().unwrap_or('\0');
637 output.push(escaped);
638 index += escaped.len_utf8();
639 } else if ch == active {
640 quote = None;
641 }
642 continue;
643 }
644 if matches!(ch, '\'' | '"') {
645 quote = Some(ch);
646 output.push(ch);
647 index += ch.len_utf8();
648 continue;
649 }
650 if is_identifier_char(ch) {
651 let start = index;
652 index += ch.len_utf8();
653 while index < value.len() && is_identifier_char(value[index..].chars().next().unwrap())
654 {
655 index += value[index..].chars().next().unwrap().len_utf8();
656 }
657 let word = &value[start..index];
658 output.push_str(if word == name { replacement } else { word });
659 } else {
660 output.push(ch);
661 index += ch.len_utf8();
662 }
663 }
664 output
665}
666
667fn is_identifier_char(ch: char) -> bool {
668 ch.is_alphanumeric() || matches!(ch, '-' | '_') || !ch.is_ascii()
669}
670
671impl StyleProgram {
672 pub fn to_json(&self) -> String {
673 format!(
674 "{{\"schemaVersion\":1,\"sheets\":[{}]}}",
675 self.sheets
676 .iter()
677 .map(StyleSheet::to_json)
678 .collect::<Vec<_>>()
679 .join(",")
680 )
681 }
682}
683
684impl StyleSheet {
685 fn to_json(&self) -> String {
686 format!(
687 "{{\"id\":\"{}\",\"component\":\"{}\",\"scope\":\"{}\",\"cstTokenCount\":{},\"rules\":[{}],\"span\":{}}}",
688 self.id,
689 self.component,
690 json_escape(&self.scope),
691 self.cst_token_count,
692 self.rules
693 .iter()
694 .map(StyleRule::to_json)
695 .collect::<Vec<_>>()
696 .join(","),
697 span_json(self.span)
698 )
699 }
700}
701
702impl StyleRule {
703 fn to_json(&self) -> String {
704 match self {
705 Self::Qualified {
706 id,
707 selector,
708 scoped_selector,
709 declarations,
710 span,
711 } => format!(
712 "{{\"kind\":\"rule\",\"id\":\"{}\",\"selector\":\"{}\",\"scopedSelector\":\"{}\",\"declarations\":[{}],\"span\":{}}}",
713 id,
714 json_escape(selector),
715 json_escape(scoped_selector),
716 declarations
717 .iter()
718 .map(StyleDeclaration::to_json)
719 .collect::<Vec<_>>()
720 .join(","),
721 span_json(*span)
722 ),
723 Self::AtRule {
724 id,
725 name,
726 prelude,
727 block,
728 span,
729 } => format!(
730 "{{\"kind\":\"at-rule\",\"id\":\"{}\",\"name\":\"{}\",\"prelude\":\"{}\",\"block\":{},\"span\":{}}}",
731 id,
732 json_escape(name),
733 json_escape(prelude),
734 block
735 .as_ref()
736 .map(StyleAtRuleBlock::to_json)
737 .unwrap_or_else(|| "null".into()),
738 span_json(*span)
739 ),
740 Self::Keyframes {
741 id,
742 name,
743 scoped_name,
744 vendor_prefix,
745 frames,
746 span,
747 } => format!(
748 "{{\"kind\":\"keyframes\",\"id\":\"{}\",\"name\":\"{}\",\"scopedName\":\"{}\",\"vendorPrefix\":{},\"frames\":[{}],\"span\":{}}}",
749 id,
750 json_escape(name),
751 json_escape(scoped_name),
752 vendor_prefix
753 .as_ref()
754 .map(|value| format!("\"{}\"", json_escape(value)))
755 .unwrap_or_else(|| "null".into()),
756 frames
757 .iter()
758 .map(Keyframe::to_json)
759 .collect::<Vec<_>>()
760 .join(","),
761 span_json(*span)
762 ),
763 }
764 }
765}
766
767impl StyleAtRuleBlock {
768 fn to_json(&self) -> String {
769 match self {
770 Self::Rules(rules) => format!(
771 "{{\"kind\":\"rules\",\"rules\":[{}]}}",
772 rules
773 .iter()
774 .map(StyleRule::to_json)
775 .collect::<Vec<_>>()
776 .join(",")
777 ),
778 Self::Declarations(declarations) => format!(
779 "{{\"kind\":\"declarations\",\"declarations\":[{}]}}",
780 declarations
781 .iter()
782 .map(StyleDeclaration::to_json)
783 .collect::<Vec<_>>()
784 .join(",")
785 ),
786 }
787 }
788}
789
790impl Keyframe {
791 fn to_json(&self) -> String {
792 format!(
793 "{{\"selector\":\"{}\",\"declarations\":[{}],\"span\":{}}}",
794 json_escape(&self.selector),
795 self.declarations
796 .iter()
797 .map(StyleDeclaration::to_json)
798 .collect::<Vec<_>>()
799 .join(","),
800 span_json(self.span)
801 )
802 }
803}
804
805impl StyleDeclaration {
806 fn to_json(&self) -> String {
807 format!(
808 "{{\"id\":\"{}\",\"name\":\"{}\",\"value\":\"{}\",\"important\":{},\"span\":{}}}",
809 self.id,
810 json_escape(&self.name),
811 json_escape(&self.value),
812 self.important,
813 span_json(self.span)
814 )
815 }
816}
817
818fn span_json(span: Span) -> String {
819 format!("{{\"start\":{},\"end\":{}}}", span.start, span.end)
820}
821
822#[cfg(test)]
823mod tests {
824 use super::*;
825 use noxid_ir::{
826 ComponentDefinition, ComponentRenderMode, ComponentRenderPolicy, HydrationMode, StyleBlock,
827 };
828
829 #[test]
830 fn scopes_compounds_globals_nested_rules_and_keyframes() {
831 assert_eq!(
832 scope_selector_list(".panel > button:hover, :global(body) .panel", "noxid-test")
833 .unwrap(),
834 ".panel[data-noxid-scope=\"noxid-test\"] > button[data-noxid-scope=\"noxid-test\"]:hover, body .panel[data-noxid-scope=\"noxid-test\"]"
835 );
836 let source = "@media (min-width: 40rem) { .panel { animation: fade 1s; } } @keyframes fade { from { opacity: 0; } to { opacity: 1; } }";
837 let component = ComponentDefinition {
838 route_metadata: None,
839 route_render: None,
840 render: ComponentRenderPolicy {
841 id: SemanticId::component_render("Test"),
842 mode: ComponentRenderMode::Universal,
843 hydration: HydrationMode::Eager,
844 span: Span::new(0, 0),
845 },
846 route_query: vec![],
847 id: SemanticId::component("Panel"),
848 name: "Panel".into(),
849 middleware: vec![],
850 capabilities: vec![],
851 props: vec![],
852 events: vec![],
853 context_uses: vec![],
854 context_providers: vec![],
855 types: vec![],
856 distinct_types: vec![],
857 machines: vec![],
858 states: vec![],
859 computed: vec![],
860 loaders: vec![],
861 resources: vec![],
862 presence: None,
863 streams: vec![],
864 agents: vec![],
865 actions: vec![],
866 behaviors: vec![],
867 regions: vec![],
868 lifecycle: None,
869 effects: vec![],
870 intent: None,
871 invariants: vec![],
872 requirements: vec![],
873 scenarios: vec![],
874 view: vec![],
875 style: Some(StyleBlock {
876 source: source.into(),
877 span: Span::new(0, source.len()),
878 }),
879 span: Span::new(0, source.len()),
880 };
881 let lowered = lower_with_options(
884 &SemanticProgram {
885 imports: vec![],
886 functions: vec![],
887 external_modules: vec![],
888 contexts: vec![],
889 types: vec![],
890 distinct_types: vec![],
891 resources: vec![],
892 streams: vec![],
893 agents: vec![],
894 endpoints: vec![],
895 tasks: vec![],
896 queues: vec![],
897 models: vec![],
898 components: vec![component],
899 },
900 &noxid_design_ir::DesignProgram::default(),
901 StyleOptions {
902 check_accuracy: false,
903 },
904 );
905 assert!(lowered.diagnostics.is_empty(), "{:?}", lowered.diagnostics);
906 let json = lowered.program.to_json();
907 assert!(json.contains("css-keyframes:Panel.fade"));
908 assert!(json.contains("fade--noxid-"));
909 assert!(json.contains("animation"));
910 }
911
912 #[test]
913 fn rejects_unbalanced_global_selector() {
914 let error = scope_selector_list(".local :global(.open", "noxid-test").unwrap_err();
915 assert!(error.contains("unterminated"));
916 }
917}