@tsrx/core 0.1.9 → 0.1.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1151 @@
1
+ /** @import * as AST from 'estree' */
2
+ /** @import { Visitors, TopScopedClasses, StyleClasses } from '../../types/index' */
3
+ /** @typedef {0 | 1} Direction */
4
+
5
+ import { walk } from 'zimmerframe';
6
+
7
+ const regex_backslash_and_following_character = /\\(.)/g;
8
+ /** @type {Direction} */
9
+ const FORWARD = 0;
10
+ /** @type {Direction} */
11
+ const BACKWARD = 1;
12
+
13
+ // this will be set for every prune_css call
14
+ // since the code is synchronous, this is safe
15
+ /** @type {string} */
16
+ let css_hash;
17
+ /** @type {StyleClasses} */
18
+ let style_identifier_classes;
19
+ /** @type {TopScopedClasses} */
20
+ let top_scoped_classes;
21
+
22
+ /**
23
+ * Returns true if node is a DOM element (not a component).
24
+ * @param {AST.Node} node
25
+ * @returns {boolean}
26
+ */
27
+ function is_element_dom_element(node) {
28
+ const id = /** @type {AST.Element} */ (node).id;
29
+ return (
30
+ id.type === 'Identifier' &&
31
+ id.name[0].toLowerCase() === id.name[0] &&
32
+ id.name !== 'children' &&
33
+ !id.tracked
34
+ );
35
+ }
36
+
37
+ /**
38
+ * Returns true if element is dynamic.
39
+ * @param {AST.Element} node
40
+ * @returns {boolean}
41
+ */
42
+ function is_element_dynamic(node) {
43
+ return node.id.type === 'Identifier' ? !!node.id.tracked : false;
44
+ }
45
+
46
+ // CSS selector constants
47
+ /**
48
+ * @param {number} start
49
+ * @param {number} end
50
+ * @returns {AST.CSS.Combinator}
51
+ */
52
+ function create_descendant_combinator(start, end) {
53
+ return { name: ' ', type: 'Combinator', start, end };
54
+ }
55
+
56
+ /**
57
+ * @param {AST.CSS.RelativeSelector} relative_selector
58
+ * @param {AST.CSS.ClassSelector} selector
59
+ * @returns {boolean}
60
+ */
61
+ function is_standalone_class_selector(relative_selector, selector) {
62
+ return relative_selector.selectors.length === 1 && relative_selector.selectors[0] === selector;
63
+ }
64
+
65
+ /**`
66
+ * @param {number} start
67
+ * @param {number} end
68
+ * @returns {AST.CSS.RelativeSelector}
69
+ */
70
+ function create_nesting_selector(start, end) {
71
+ return {
72
+ type: 'RelativeSelector',
73
+ selectors: [{ type: 'NestingSelector', name: '&', start, end }],
74
+ combinator: null,
75
+ metadata: { is_global: false, is_global_like: false, scoped: false },
76
+ start,
77
+ end,
78
+ };
79
+ }
80
+
81
+ /**
82
+ * @param {number} start
83
+ * @param {number} end
84
+ * @returns {AST.CSS.RelativeSelector}
85
+ */
86
+ function create_any_selector(start, end) {
87
+ return {
88
+ type: 'RelativeSelector',
89
+ selectors: [{ type: 'TypeSelector', name: '*', start, end }],
90
+ combinator: null,
91
+ metadata: { is_global: false, is_global_like: false, scoped: false },
92
+ start,
93
+ end,
94
+ };
95
+ }
96
+
97
+ // Whitelist for attribute selectors on specific elements
98
+ const whitelist_attribute_selector = new Map([
99
+ ['details', ['open']],
100
+ ['dialog', ['open']],
101
+ ['form', ['novalidate']],
102
+ ['iframe', ['allow', 'allowfullscreen', 'allowpaymentrequest', 'loading', 'referrerpolicy']],
103
+ ['img', ['loading']],
104
+ [
105
+ 'input',
106
+ [
107
+ 'accept',
108
+ 'autocomplete',
109
+ 'capture',
110
+ 'checked',
111
+ 'disabled',
112
+ 'max',
113
+ 'maxlength',
114
+ 'min',
115
+ 'minlength',
116
+ 'multiple',
117
+ 'pattern',
118
+ 'placeholder',
119
+ 'readonly',
120
+ 'required',
121
+ 'size',
122
+ 'step',
123
+ ],
124
+ ],
125
+ ['object', ['typemustmatch']],
126
+ ['ol', ['reversed', 'start', 'type']],
127
+ ['optgroup', ['disabled']],
128
+ ['option', ['disabled', 'selected']],
129
+ ['script', ['async', 'defer', 'nomodule', 'type']],
130
+ ['select', ['disabled', 'multiple', 'required', 'size']],
131
+ [
132
+ 'textarea',
133
+ [
134
+ 'autocomplete',
135
+ 'disabled',
136
+ 'maxlength',
137
+ 'minlength',
138
+ 'placeholder',
139
+ 'readonly',
140
+ 'required',
141
+ 'rows',
142
+ 'wrap',
143
+ ],
144
+ ],
145
+ ['video', ['autoplay', 'controls', 'loop', 'muted', 'playsinline']],
146
+ ]);
147
+
148
+ /**
149
+ * @param {AST.CSS.ComplexSelector} node
150
+ */
151
+ function get_relative_selectors(node) {
152
+ const selectors = truncate(node);
153
+
154
+ if (node.metadata.rule?.metadata.parent_rule && selectors.length > 0) {
155
+ let has_explicit_nesting_selector = false;
156
+
157
+ // nesting could be inside pseudo classes like :is, :has or :where
158
+ for (let selector of selectors) {
159
+ walk(
160
+ selector,
161
+ null,
162
+ /** @type {Visitors<AST.CSS.Node, null>} */ ({
163
+ NestingSelector() {
164
+ has_explicit_nesting_selector = true;
165
+ },
166
+ }),
167
+ );
168
+
169
+ // if we found one we can break from the others
170
+ if (has_explicit_nesting_selector) break;
171
+ }
172
+
173
+ if (!has_explicit_nesting_selector) {
174
+ if (selectors[0].combinator === null) {
175
+ selectors[0] = {
176
+ ...selectors[0],
177
+ combinator: create_descendant_combinator(selectors[0].start, selectors[0].end),
178
+ };
179
+ }
180
+
181
+ selectors.unshift(create_nesting_selector(selectors[0].start, selectors[0].end));
182
+ }
183
+ }
184
+
185
+ return selectors;
186
+ }
187
+
188
+ /**
189
+ *
190
+ * @param {AST.CSS.ComplexSelector} node
191
+ * @returns {AST.CSS.RelativeSelector[]}
192
+ */
193
+ function truncate(node) {
194
+ const i = node.children.findLastIndex(({ metadata, selectors }) => {
195
+ const first = selectors[0];
196
+ return (
197
+ // not after a :global selector
198
+ !metadata.is_global_like &&
199
+ !(first.type === 'PseudoClassSelector' && first.name === 'global' && first.args === null) &&
200
+ // not a :global(...) without a :has/is/where(...) modifier that is scoped
201
+ !metadata.is_global
202
+ );
203
+ });
204
+
205
+ return node.children.slice(0, i + 1).map((child) => {
206
+ // In case of `:root.y:has(...)`, `y` is unscoped, but everything in `:has(...)` should be scoped (if not global).
207
+ // To properly accomplish that, we gotta filter out all selector types except `:has`.
208
+ const root = child.selectors.find((s) => s.type === 'PseudoClassSelector' && s.name === 'root');
209
+ if (!root || child.metadata.is_global_like) return child;
210
+
211
+ return {
212
+ ...child,
213
+ selectors: child.selectors.filter(
214
+ (s) => s.type === 'PseudoClassSelector' && s.name === 'has',
215
+ ),
216
+ };
217
+ });
218
+ }
219
+
220
+ /**
221
+ * @param {AST.CSS.RelativeSelector[]} relative_selectors
222
+ * @param {AST.CSS.Rule} rule
223
+ * @param {AST.Element} element
224
+ * @param {Direction} direction
225
+ * @returns {boolean}
226
+ */
227
+ function apply_selector(relative_selectors, rule, element, direction) {
228
+ const rest_selectors = relative_selectors.slice();
229
+ const relative_selector = direction === FORWARD ? rest_selectors.shift() : rest_selectors.pop();
230
+
231
+ const matched =
232
+ !!relative_selector &&
233
+ relative_selector_might_apply_to_node(relative_selector, rule, element, direction) &&
234
+ apply_combinator(relative_selector, rest_selectors, rule, element, direction);
235
+
236
+ if (matched) {
237
+ if (!is_outer_global(relative_selector)) {
238
+ relative_selector.metadata.scoped = true;
239
+
240
+ // Store scoped class information on element for language server features
241
+ if (!relative_selector.metadata.is_global && !relative_selector.metadata.is_global_like) {
242
+ // Extract class selectors from the relative selector
243
+ for (const selector of relative_selector.selectors) {
244
+ if (selector.type === 'ClassSelector') {
245
+ const name = selector.name.replace(regex_backslash_and_following_character, '$1');
246
+
247
+ if (!element.metadata.css) {
248
+ element.metadata.css = {
249
+ scopedClasses: new Map(),
250
+ hash: css_hash,
251
+ };
252
+ }
253
+
254
+ // Store class name → CSS location in scopedClasses
255
+ if (!element.metadata.css.scopedClasses.has(name)) {
256
+ element.metadata.css.scopedClasses.set(name, {
257
+ start: selector.start,
258
+ end: selector.end,
259
+ selector: selector,
260
+ });
261
+ }
262
+ }
263
+ }
264
+ }
265
+ }
266
+
267
+ element.metadata.scoped = true;
268
+ }
269
+
270
+ return matched;
271
+ }
272
+
273
+ /**
274
+ * @param {AST.Element} node
275
+ * @param {boolean} adjacent_only
276
+ * @returns {AST.Element[]}
277
+ */
278
+ function get_ancestor_elements(node, adjacent_only) {
279
+ /** @type {AST.Element[]} */
280
+ const ancestors = [];
281
+
282
+ const path = node.metadata.path;
283
+ let i = path.length;
284
+
285
+ while (i--) {
286
+ const parent = path[i];
287
+
288
+ if (parent.type === 'Element') {
289
+ ancestors.push(parent);
290
+ if (adjacent_only) {
291
+ break;
292
+ }
293
+ }
294
+ }
295
+
296
+ return ancestors;
297
+ }
298
+
299
+ /**
300
+ * @param {AST.Element} node
301
+ * @param {boolean} adjacent_only
302
+ * @returns {AST.Element[]}
303
+ */
304
+ function get_descendant_elements(node, adjacent_only) {
305
+ /** @type {AST.Element[]} */
306
+ const descendants = [];
307
+
308
+ /**
309
+ * @param {AST.Node} current_node
310
+ * @param {number} depth
311
+ * @returns {void}
312
+ */
313
+ function visit(current_node, depth = 0) {
314
+ if (current_node.type === 'Element' && current_node !== node) {
315
+ descendants.push(current_node);
316
+ if (adjacent_only) return; // Only direct children for '>' combinator
317
+ }
318
+
319
+ // Visit children based on TSRX's template AST structure
320
+ if (/** @type {AST.Element} */ (current_node).children) {
321
+ for (const child of /** @type {AST.Element} */ (current_node).children) {
322
+ visit(child, depth + 1);
323
+ }
324
+ }
325
+
326
+ if (/** @type {AST.Component} */ (current_node).body) {
327
+ for (const child of /** @type {AST.Component} */ (current_node).body) {
328
+ visit(child, depth + 1);
329
+ }
330
+ }
331
+
332
+ // For template nodes and interpolation expressions
333
+ if (
334
+ (current_node.type === 'TSRXExpression' ||
335
+ current_node.type === 'Text' ||
336
+ current_node.type === 'Html') &&
337
+ /** @type {AST.TSRXExpression | AST.Html | AST.TextNode} */ (current_node).expression &&
338
+ typeof (
339
+ /** @type {AST.TSRXExpression | AST.Html | AST.TextNode} */ (current_node).expression
340
+ ) === 'object'
341
+ ) {
342
+ visit(
343
+ /** @type {AST.TSRXExpression | AST.Html | AST.TextNode} */ (current_node).expression,
344
+ depth + 1,
345
+ );
346
+ }
347
+ }
348
+
349
+ // Start from node's children
350
+ if (node.children) {
351
+ for (const child of node.children) {
352
+ visit(child);
353
+ }
354
+ }
355
+
356
+ return descendants;
357
+ }
358
+
359
+ /**
360
+ * Check if an element can render dynamic content that might affect CSS matching
361
+ * @param {AST.Node} element
362
+ * @param {boolean} check_classes - Whether to check for dynamic class attributes
363
+ * @returns {boolean}
364
+ */
365
+ function can_render_dynamic_content(element, check_classes = false) {
366
+ if (!is_element_dom_element(element)) {
367
+ return true;
368
+ }
369
+
370
+ // Either a dynamic element or component (only can tell at runtime)
371
+ // But dynamic elements should return false ideally
372
+ if (is_element_dynamic(/** @type {AST.Element} */ (element))) {
373
+ return true;
374
+ }
375
+
376
+ // Check for dynamic class attributes if requested (for class-based selectors)
377
+ if (check_classes && /** @type {AST.Element} */ (element).attributes) {
378
+ for (const attr of /** @type {AST.Element} */ (element).attributes) {
379
+ if (attr.type === 'Attribute' && attr.name.name === 'class') {
380
+ // Check if class value is an expression (not a static string)
381
+ if (attr.value && typeof attr.value === 'object') {
382
+ // If it's a CallExpression or other dynamic value, it's dynamic
383
+ if (attr.value.type !== 'Literal' && attr.value.type !== 'Text') {
384
+ return true;
385
+ }
386
+ }
387
+ }
388
+ }
389
+ }
390
+
391
+ return false;
392
+ }
393
+
394
+ /**
395
+ * @param {AST.Node} node
396
+ * @param {Direction} direction
397
+ * @param {boolean} adjacent_only
398
+ * @returns {Map<AST.Element, boolean>}
399
+ */
400
+ function get_possible_element_siblings(node, direction, adjacent_only) {
401
+ const siblings = new Map();
402
+ // Parent has to be an Element not a Component
403
+ const parent = get_element_parent(node);
404
+
405
+ if (!parent) {
406
+ return siblings;
407
+ }
408
+
409
+ // Get the container that holds the siblings
410
+ const container = parent.children || [];
411
+ const node_index = container.indexOf(node);
412
+
413
+ if (node_index === -1) return siblings;
414
+
415
+ // Determine which siblings to check based on direction
416
+ let start, end, step;
417
+ if (direction === FORWARD) {
418
+ start = node_index + 1;
419
+ end = container.length;
420
+ step = 1;
421
+ } else {
422
+ start = node_index - 1;
423
+ end = -1;
424
+ step = -1;
425
+ }
426
+
427
+ // Collect siblings
428
+ for (let i = start; i !== end; i += step) {
429
+ const sibling = container[i];
430
+
431
+ if (sibling.type === 'Element' || sibling.type === 'Component') {
432
+ siblings.set(sibling, true);
433
+ // Don't break for dynamic elements (children, Components, dynamic components)
434
+ // as they can render dynamic content or might render nothing
435
+ const isDynamic = can_render_dynamic_content(sibling, false);
436
+ if (adjacent_only && !isDynamic) {
437
+ break; // Only immediate sibling for '+' combinator
438
+ }
439
+ }
440
+ // Stop at non-whitespace text nodes for adjacent selectors
441
+ else if (
442
+ adjacent_only &&
443
+ (sibling.type === 'TSRXExpression' || sibling.type === 'Text') &&
444
+ sibling.expression.type === 'Literal' &&
445
+ typeof sibling.expression.value === 'string' &&
446
+ sibling.expression.value.trim()
447
+ ) {
448
+ break;
449
+ }
450
+ }
451
+
452
+ return siblings;
453
+ }
454
+
455
+ /**
456
+ * @param {AST.CSS.RelativeSelector} relative_selector
457
+ * @param {AST.CSS.RelativeSelector[]} rest_selectors
458
+ * @param {AST.CSS.Rule} rule
459
+ * @param {AST.Element} node
460
+ * @param {Direction} direction
461
+ * @returns {boolean}
462
+ */
463
+ function apply_combinator(relative_selector, rest_selectors, rule, node, direction) {
464
+ const combinator =
465
+ direction == FORWARD ? rest_selectors[0]?.combinator : relative_selector.combinator;
466
+ if (!combinator) return true;
467
+
468
+ switch (combinator.name) {
469
+ case ' ':
470
+ case '>': {
471
+ const is_adjacent = combinator.name === '>';
472
+ const parents =
473
+ direction === FORWARD
474
+ ? get_descendant_elements(node, is_adjacent)
475
+ : get_ancestor_elements(node, is_adjacent);
476
+ let parent_matched = false;
477
+
478
+ for (const parent of parents) {
479
+ if (apply_selector(rest_selectors, rule, parent, direction)) {
480
+ parent_matched = true;
481
+ }
482
+ }
483
+
484
+ return (
485
+ parent_matched ||
486
+ (direction === BACKWARD &&
487
+ (!is_adjacent || parents.length === 0) &&
488
+ rest_selectors.every((selector) => is_global(selector, rule)))
489
+ );
490
+ }
491
+
492
+ case '+':
493
+ case '~': {
494
+ const siblings = get_possible_element_siblings(node, direction, combinator.name === '+');
495
+
496
+ let sibling_matched = false;
497
+
498
+ for (const possible_sibling of siblings.keys()) {
499
+ // Check if this sibling can render dynamic content
500
+ // For class selectors, also check if element has dynamic classes
501
+ const has_class_selector = rest_selectors.some((sel) =>
502
+ sel.selectors?.some((s) => s.type === 'ClassSelector'),
503
+ );
504
+ const is_dynamic = can_render_dynamic_content(possible_sibling, has_class_selector);
505
+
506
+ if (is_dynamic) {
507
+ if (rest_selectors.length > 0) {
508
+ // Check if the first selector in the rest is global
509
+ const first_rest_selector = rest_selectors[0];
510
+ if (is_global(first_rest_selector, rule)) {
511
+ // Global selector followed by possibly more selectors
512
+ // Check if remaining selectors could match elements after this component
513
+ const remaining = rest_selectors.slice(1);
514
+ if (remaining.length === 0) {
515
+ // Just a global selector, mark as matched
516
+ sibling_matched = true;
517
+ } else {
518
+ // Check if there are any elements after this component that could match the remaining selectors
519
+ const parent = get_element_parent(node);
520
+ if (parent) {
521
+ const container = parent.children || [];
522
+ const component_index = container.indexOf(possible_sibling);
523
+
524
+ // For adjacent combinator, only check immediate next element
525
+ // For general sibling, check all following elements
526
+ const search_start = component_index + 1;
527
+ const search_end = combinator.name === '+' ? search_start + 1 : container.length;
528
+
529
+ for (let i = search_start; i < search_end; i++) {
530
+ const subsequent = container[i];
531
+ if (subsequent.type === 'Element') {
532
+ if (apply_selector(remaining, rule, subsequent, direction)) {
533
+ sibling_matched = true;
534
+ break;
535
+ }
536
+ if (combinator.name === '+') break; // For adjacent, only check first element
537
+ } else if (subsequent.type === 'Component') {
538
+ // Skip components when looking for the target element
539
+ if (combinator.name === '+') {
540
+ // For adjacent, continue looking
541
+ continue;
542
+ }
543
+ }
544
+ }
545
+ }
546
+ }
547
+ }
548
+ }
549
+ // Don't apply_selector for dynamic elements - they won't match regular element selectors
550
+ } else if (
551
+ possible_sibling.type === 'Element' &&
552
+ apply_selector(rest_selectors, rule, possible_sibling, direction)
553
+ ) {
554
+ sibling_matched = true;
555
+ }
556
+ }
557
+
558
+ return (
559
+ sibling_matched ||
560
+ (direction === BACKWARD &&
561
+ get_element_parent(node) === null &&
562
+ rest_selectors.every((selector) => is_global(selector, rule)))
563
+ );
564
+ }
565
+
566
+ default:
567
+ // TODO other combinators
568
+ return true;
569
+ }
570
+ }
571
+ /**
572
+ * @param {AST.Node} node
573
+ * @returns {AST.Element | null}
574
+ */
575
+ function get_element_parent(node) {
576
+ // Check if metadata and path exist
577
+ if (!node.metadata || !node.metadata.path || !node.metadata.path.length) {
578
+ return null;
579
+ }
580
+
581
+ let path = node.metadata.path;
582
+ let i = path.length;
583
+
584
+ while (i--) {
585
+ const parent = path[i];
586
+
587
+ if (parent.type === 'Element') {
588
+ return parent;
589
+ }
590
+ }
591
+
592
+ return null;
593
+ }
594
+
595
+ /**
596
+ * `true` if is a pseudo class that cannot be or is not scoped
597
+ * @param {AST.CSS.SimpleSelector} selector
598
+ * @returns {boolean}
599
+ */
600
+ function is_unscoped_pseudo_class(selector) {
601
+ return (
602
+ selector.type === 'PseudoClassSelector' &&
603
+ // These make the selector scoped
604
+ ((selector.name !== 'has' &&
605
+ selector.name !== 'is' &&
606
+ selector.name !== 'where' &&
607
+ // :not is special because we want to scope as specific as possible, but because :not
608
+ // inverses the result, we want to leave the unscoped, too. The exception is more than
609
+ // one selector in the :not (.e.g :not(.x .y)), then .x and .y should be scoped
610
+ (selector.name !== 'not' ||
611
+ selector.args === null ||
612
+ selector.args.children.every((c) => c.children.length === 1))) ||
613
+ // selectors with has/is/where/not can also be global if all their children are global
614
+ selector.args === null ||
615
+ selector.args.children.every((c) => c.children.every((r) => is_global_simple(r))))
616
+ );
617
+ }
618
+
619
+ /**
620
+ * True if is `:global(...)` or `:global` and no pseudo class that is scoped.
621
+ * @param {AST.CSS.RelativeSelector} relative_selector
622
+ */
623
+ function is_global_simple(relative_selector) {
624
+ const first = relative_selector.selectors[0];
625
+
626
+ return (
627
+ first.type === 'PseudoClassSelector' &&
628
+ first.name === 'global' &&
629
+ (first.args === null ||
630
+ // Only these two selector types keep the whole selector global, because e.g.
631
+ // :global(button).x means that the selector is still scoped because of the .x
632
+ relative_selector.selectors.every(
633
+ (selector) =>
634
+ is_unscoped_pseudo_class(selector) || selector.type === 'PseudoElementSelector',
635
+ ))
636
+ );
637
+ }
638
+
639
+ /**
640
+ * @param {AST.CSS.RelativeSelector} selector
641
+ * @param {AST.CSS.Rule} rule
642
+ * @return {boolean}
643
+ */
644
+ function is_global(selector, rule) {
645
+ if (selector.metadata.is_global || selector.metadata.is_global_like) {
646
+ return true;
647
+ }
648
+
649
+ let explicitly_global = false;
650
+
651
+ for (const s of selector.selectors) {
652
+ /** @type {AST.CSS.SelectorList | null} */
653
+ let selector_list = null;
654
+ let can_be_global = false;
655
+ let owner = rule;
656
+
657
+ if (s.type === 'PseudoClassSelector') {
658
+ if ((s.name === 'is' || s.name === 'where') && s.args) {
659
+ selector_list = s.args;
660
+ } else {
661
+ can_be_global = is_unscoped_pseudo_class(s);
662
+ }
663
+ }
664
+
665
+ if (s.type === 'NestingSelector') {
666
+ owner = /** @type {AST.CSS.Rule} */ (rule.metadata.parent_rule);
667
+ selector_list = owner.prelude;
668
+ }
669
+
670
+ const has_global_selectors = !!selector_list?.children.some((complex_selector) => {
671
+ return complex_selector.children.every((relative_selector) =>
672
+ is_global(relative_selector, owner),
673
+ );
674
+ });
675
+ explicitly_global ||= has_global_selectors;
676
+
677
+ if (!has_global_selectors && !can_be_global) {
678
+ return false;
679
+ }
680
+ }
681
+
682
+ return explicitly_global || selector.selectors.length === 0;
683
+ }
684
+
685
+ /**
686
+ * @param {AST.Attribute} attribute
687
+ * @returns {attribute is AST.Attribute & { value: AST.Literal & { value: string } }}
688
+ */
689
+ function is_text_attribute(attribute) {
690
+ return attribute.value?.type === 'Literal' && typeof attribute.value.value === 'string';
691
+ }
692
+
693
+ /**
694
+ * @param {string | null} operator
695
+ * @param {string} expected_value
696
+ * @param {boolean} case_insensitive
697
+ * @param {string} value
698
+ * @returns {boolean}
699
+ */
700
+ function test_attribute(operator, expected_value, case_insensitive, value) {
701
+ if (case_insensitive) {
702
+ expected_value = expected_value.toLowerCase();
703
+ value = value.toLowerCase();
704
+ }
705
+ switch (operator) {
706
+ case '=':
707
+ return value === expected_value;
708
+ case '~=':
709
+ return value.split(/\s/).includes(expected_value);
710
+ case '|=':
711
+ return `${value}-`.startsWith(`${expected_value}-`);
712
+ case '^=':
713
+ return value.startsWith(expected_value);
714
+ case '$=':
715
+ return value.endsWith(expected_value);
716
+ case '*=':
717
+ return value.includes(expected_value);
718
+ default:
719
+ throw new Error("this shouldn't happen");
720
+ }
721
+ }
722
+
723
+ /**
724
+ * @param {AST.Element} node
725
+ * @param {string} name
726
+ * @param {string | null} expected_value
727
+ * @param {string | null} operator
728
+ * @param {boolean} case_insensitive
729
+ * @returns {boolean}
730
+ */
731
+ function attribute_matches(node, name, expected_value, operator, case_insensitive) {
732
+ for (const attribute of node.attributes) {
733
+ if (attribute.type === 'SpreadAttribute') return true;
734
+
735
+ if (attribute.type !== 'Attribute') continue;
736
+
737
+ const lowerCaseName = name.toLowerCase();
738
+ if (![lowerCaseName, `$${lowerCaseName}`].includes(attribute.name.name.toLowerCase())) continue;
739
+
740
+ if (expected_value === null) return true;
741
+
742
+ if (is_text_attribute(attribute)) {
743
+ return test_attribute(operator, expected_value, case_insensitive, attribute.value.value);
744
+ } else {
745
+ return true;
746
+ }
747
+ }
748
+
749
+ return false;
750
+ }
751
+
752
+ /**
753
+ * @param {AST.CSS.RelativeSelector} relative_selector
754
+ * @returns {boolean}
755
+ */
756
+ function is_outer_global(relative_selector) {
757
+ const first = relative_selector.selectors[0];
758
+
759
+ return (
760
+ first &&
761
+ first.type === 'PseudoClassSelector' &&
762
+ first.name === 'global' &&
763
+ (first.args === null ||
764
+ // Only these two selector types can keep the whole selector global, because e.g.
765
+ // :global(button).x means that the selector is still scoped because of the .x
766
+ relative_selector.selectors.every(
767
+ (selector) =>
768
+ selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector',
769
+ ))
770
+ );
771
+ }
772
+
773
+ /**
774
+ * @param {AST.CSS.RelativeSelector} relative_selector
775
+ * @param {AST.CSS.Rule} rule
776
+ * @param {AST.Element} element
777
+ * @param {Direction} direction
778
+ * @return {boolean}
779
+ */
780
+ function relative_selector_might_apply_to_node(relative_selector, rule, element, direction) {
781
+ // Sort :has(...) selectors in one bucket and everything else into another
782
+ const has_selectors = [];
783
+ const other_selectors = [];
784
+
785
+ for (const selector of relative_selector.selectors) {
786
+ if (selector.type === 'PseudoClassSelector' && selector.name === 'has' && selector.args) {
787
+ has_selectors.push(selector);
788
+ } else {
789
+ other_selectors.push(selector);
790
+ }
791
+ }
792
+
793
+ // If we're called recursively from a :has(...) selector, we're on the way of checking if the other selectors match.
794
+ // In that case ignore this check (because we just came from this) to avoid an infinite loop.
795
+ if (has_selectors.length > 0) {
796
+ // If this is a :has inside a global selector, we gotta include the element itself, too,
797
+ // because the global selector might be for an element that's outside the component,
798
+ // e.g. :root:has(.scoped), :global(.foo):has(.scoped), or :root { &:has(.scoped) {} }
799
+ const rules = get_parent_rules(rule);
800
+ const include_self =
801
+ rules.some((r) => r.prelude.children.some((c) => c.children.some((s) => is_global(s, r)))) ||
802
+ rules[rules.length - 1].prelude.children.some((c) =>
803
+ c.children.some((r) =>
804
+ r.selectors.some(
805
+ (s) =>
806
+ s.type === 'PseudoClassSelector' &&
807
+ (s.name === 'root' || (s.name === 'global' && s.args)),
808
+ ),
809
+ ),
810
+ );
811
+
812
+ // :has(...) is special in that it means "look downwards in the CSS tree". Since our matching algorithm goes
813
+ // upwards and back-to-front, we need to first check the selectors inside :has(...), then check the rest of the
814
+ // selector in a way that is similar to ancestor matching. In a sense, we're treating `.x:has(.y)` as `.x .y`.
815
+ for (const has_selector of has_selectors) {
816
+ const complex_selectors = /** @type {AST.CSS.SelectorList} */ (has_selector.args).children;
817
+ let matched = false;
818
+
819
+ for (const complex_selector of complex_selectors) {
820
+ const [first, ...rest] = truncate(complex_selector);
821
+ // if it was just a :global(...)
822
+ if (!first) {
823
+ complex_selector.metadata.used = true;
824
+ matched = true;
825
+ continue;
826
+ }
827
+
828
+ if (include_self) {
829
+ const selector_including_self = [
830
+ first.combinator ? { ...first, combinator: null } : first,
831
+ ...rest,
832
+ ];
833
+ if (apply_selector(selector_including_self, rule, element, FORWARD)) {
834
+ complex_selector.metadata.used = true;
835
+ matched = true;
836
+ }
837
+ }
838
+
839
+ const selector_excluding_self = [
840
+ create_any_selector(first.start, first.end),
841
+ first.combinator
842
+ ? first
843
+ : { ...first, combinator: create_descendant_combinator(first.start, first.end) },
844
+ ...rest,
845
+ ];
846
+ if (apply_selector(selector_excluding_self, rule, element, FORWARD)) {
847
+ complex_selector.metadata.used = true;
848
+ matched = true;
849
+ }
850
+ }
851
+
852
+ if (!matched) {
853
+ return false;
854
+ }
855
+ }
856
+ }
857
+
858
+ for (const selector of other_selectors) {
859
+ if (selector.type === 'Percentage' || selector.type === 'Nth') continue;
860
+
861
+ const name = selector.name.replace(regex_backslash_and_following_character, '$1');
862
+
863
+ switch (selector.type) {
864
+ case 'PseudoClassSelector': {
865
+ if (name === 'host' || name === 'root') return false;
866
+
867
+ if (
868
+ name === 'global' &&
869
+ selector.args !== null &&
870
+ relative_selector.selectors.length === 1
871
+ ) {
872
+ const args = selector.args;
873
+ const complex_selector = args.children[0];
874
+ return apply_selector(complex_selector.children, rule, element, BACKWARD);
875
+ }
876
+
877
+ // We came across a :global, everything beyond it is global and therefore a potential match
878
+ if (name === 'global' && selector.args === null) return true;
879
+
880
+ // :not(...) contents should stay unscoped. Scoping them would achieve the opposite of what we want,
881
+ // because they are then _more_ likely to bleed out of the component. The exception is complex selectors
882
+ // with descendants, in which case we scope them all.
883
+ if (name === 'not' && selector.args) {
884
+ for (const complex_selector of selector.args.children) {
885
+ walk(complex_selector, null, {
886
+ ComplexSelector(node, context) {
887
+ node.metadata.used = true;
888
+ context.next();
889
+ },
890
+ });
891
+ const relative = truncate(complex_selector);
892
+
893
+ if (complex_selector.children.length > 1) {
894
+ // foo:not(bar foo) means that bar is an ancestor of foo (side note: ending with foo is the only way the selector make sense).
895
+ // We can't fully check if that actually matches with our current algorithm, so we just assume it does.
896
+ // The result may not match a real element, so the only drawback is the missing prune.
897
+ for (const selector of relative) {
898
+ selector.metadata.scoped = true;
899
+ }
900
+
901
+ /** @type {AST.Element | null} */
902
+ let el = element;
903
+ while (el) {
904
+ el.metadata.scoped = true;
905
+ el = get_element_parent(el);
906
+ }
907
+ }
908
+ }
909
+
910
+ break;
911
+ }
912
+
913
+ if ((name === 'is' || name === 'where') && selector.args) {
914
+ let matched = false;
915
+
916
+ for (const complex_selector of selector.args.children) {
917
+ const relative = truncate(complex_selector);
918
+ const is_global = relative.length === 0;
919
+
920
+ if (is_global) {
921
+ complex_selector.metadata.used = true;
922
+ matched = true;
923
+ } else if (apply_selector(relative, rule, element, BACKWARD)) {
924
+ complex_selector.metadata.used = true;
925
+ matched = true;
926
+ } else if (complex_selector.children.length > 1 && (name == 'is' || name == 'where')) {
927
+ // foo :is(bar baz) can also mean that bar is an ancestor of foo, and baz a descendant.
928
+ // We can't fully check if that actually matches with our current algorithm, so we just assume it does.
929
+ // The result may not match a real element, so the only drawback is the missing prune.
930
+ complex_selector.metadata.used = true;
931
+ matched = true;
932
+ for (const selector of relative) {
933
+ selector.metadata.scoped = true;
934
+ }
935
+ }
936
+ }
937
+
938
+ if (!matched) {
939
+ return false;
940
+ }
941
+ }
942
+
943
+ break;
944
+ }
945
+
946
+ case 'PseudoElementSelector': {
947
+ break;
948
+ }
949
+
950
+ case 'AttributeSelector': {
951
+ const whitelisted = whitelist_attribute_selector.get(
952
+ /** @type {AST.Identifier} */ (element.id).name.toLowerCase(),
953
+ );
954
+ if (
955
+ !whitelisted?.includes(selector.name.toLowerCase()) &&
956
+ !attribute_matches(
957
+ element,
958
+ selector.name,
959
+ selector.value && unquote(selector.value),
960
+ selector.matcher,
961
+ selector.flags?.includes('i') ?? false,
962
+ )
963
+ ) {
964
+ return false;
965
+ }
966
+ break;
967
+ }
968
+
969
+ case 'ClassSelector': {
970
+ if (
971
+ !attribute_matches(element, 'class', name, '~=', false) &&
972
+ (!style_identifier_classes.has(name) ||
973
+ !is_standalone_class_selector(relative_selector, selector))
974
+ ) {
975
+ return false;
976
+ }
977
+
978
+ break;
979
+ }
980
+
981
+ case 'IdSelector': {
982
+ if (!attribute_matches(element, 'id', name, '=', false)) {
983
+ return false;
984
+ }
985
+
986
+ break;
987
+ }
988
+
989
+ case 'TypeSelector': {
990
+ if (
991
+ element.id.type === 'Identifier' &&
992
+ element.id.name.toLowerCase() !== name.toLowerCase() &&
993
+ name !== '*'
994
+ ) {
995
+ return false;
996
+ }
997
+
998
+ break;
999
+ }
1000
+
1001
+ case 'NestingSelector': {
1002
+ let matched = false;
1003
+
1004
+ const parent = /** @type {AST.CSS.Rule} */ (rule.metadata.parent_rule);
1005
+
1006
+ for (const complex_selector of parent.prelude.children) {
1007
+ if (
1008
+ apply_selector(get_relative_selectors(complex_selector), parent, element, direction) ||
1009
+ complex_selector.children.every((s) => is_global(s, parent))
1010
+ ) {
1011
+ complex_selector.metadata.used = true;
1012
+ matched = true;
1013
+ }
1014
+ }
1015
+
1016
+ if (!matched) {
1017
+ return false;
1018
+ }
1019
+
1020
+ break;
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ // possible match
1026
+ return true;
1027
+ }
1028
+
1029
+ /**
1030
+ * @param {string} str
1031
+ * @returns {string}
1032
+ */
1033
+ function unquote(str) {
1034
+ if (
1035
+ (str[0] === '"' && str[str.length - 1] === '"') ||
1036
+ (str[0] === "'" && str[str.length - 1] === "'")
1037
+ ) {
1038
+ return str.slice(1, -1);
1039
+ }
1040
+ return str;
1041
+ }
1042
+
1043
+ /**
1044
+ * @param {AST.CSS.Rule} rule
1045
+ * @returns {AST.CSS.Rule[]}
1046
+ */
1047
+ function get_parent_rules(rule) {
1048
+ const rules = [rule];
1049
+ let current = rule;
1050
+
1051
+ while (current.metadata.parent_rule) {
1052
+ current = current.metadata.parent_rule;
1053
+ rules.unshift(current);
1054
+ }
1055
+
1056
+ return rules;
1057
+ }
1058
+
1059
+ /**
1060
+ * Check if a CSS rule contains animation or animation-name properties
1061
+ * @param {AST.CSS.Rule} rule
1062
+ * @returns {boolean}
1063
+ */
1064
+ function rule_has_animation(rule) {
1065
+ if (!rule.block) return false;
1066
+
1067
+ for (const child of rule.block.children) {
1068
+ if (child.type === 'Declaration') {
1069
+ const prop = child.property?.toLowerCase();
1070
+ if (prop === 'animation' || prop === 'animation-name') {
1071
+ return true;
1072
+ }
1073
+ }
1074
+ }
1075
+
1076
+ return false;
1077
+ }
1078
+
1079
+ /**
1080
+ * @param {AST.CSS.StyleSheet} css
1081
+ * @param {AST.Element} element
1082
+ * @param {StyleClasses} styleClasses
1083
+ * @param {TopScopedClasses} topScopedClasses
1084
+ * @return {void}
1085
+ */
1086
+ export function prune_css(css, element, styleClasses, topScopedClasses) {
1087
+ css_hash = css.hash;
1088
+ style_identifier_classes = styleClasses;
1089
+ top_scoped_classes = topScopedClasses;
1090
+
1091
+ /** @type {Visitors<AST.CSS.Node, null>} */
1092
+ const visitors = {
1093
+ Rule(node, context) {
1094
+ if (node.metadata.is_global_block) {
1095
+ context.visit(node.prelude);
1096
+ } else {
1097
+ context.next();
1098
+ }
1099
+ },
1100
+ ComplexSelector(node, context) {
1101
+ const selectors = get_relative_selectors(node);
1102
+
1103
+ const rule = /** @type {AST.CSS.Rule} */ (node.metadata.rule);
1104
+
1105
+ if (apply_selector(selectors, rule, element, BACKWARD) || rule_has_animation(rule)) {
1106
+ node.metadata.used = true;
1107
+ }
1108
+
1109
+ // Populate top_scoped_classes for truly standalone class selectors ({style} support).
1110
+ // A class is standalone only when the entire effective selector chain (after resolving
1111
+ // nesting and stripping :global) is a single RelativeSelector with a single ClassSelector.
1112
+ // This prevents classes from compound selectors like `.wrapper .nested` or selectors
1113
+ // inside :global() from being treated as valid {style} targets.
1114
+ if (selectors.length === 1) {
1115
+ const sole_selector = selectors[0];
1116
+ if (
1117
+ !sole_selector.metadata.is_global &&
1118
+ !sole_selector.metadata.is_global_like &&
1119
+ sole_selector.selectors.length === 1 &&
1120
+ sole_selector.selectors[0].type === 'ClassSelector'
1121
+ ) {
1122
+ const class_selector = sole_selector.selectors[0];
1123
+ const name = class_selector.name.replace(regex_backslash_and_following_character, '$1');
1124
+ if (!top_scoped_classes.has(name)) {
1125
+ top_scoped_classes.set(name, {
1126
+ start: class_selector.start,
1127
+ end: class_selector.end,
1128
+ selector: class_selector,
1129
+ });
1130
+ }
1131
+ }
1132
+ }
1133
+
1134
+ context.next();
1135
+ },
1136
+ PseudoClassSelector(node, context) {
1137
+ // Visit nested selectors inside :has(), :is(), :where(), and :not()
1138
+ if (
1139
+ (node.name === 'has' ||
1140
+ node.name === 'is' ||
1141
+ node.name === 'where' ||
1142
+ node.name === 'not') &&
1143
+ node.args
1144
+ ) {
1145
+ context.next();
1146
+ }
1147
+ },
1148
+ };
1149
+
1150
+ walk(css, null, visitors);
1151
+ }