ripple 0.2.166 → 0.2.168

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.
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "description": "Ripple is an elegant TypeScript UI framework",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.2.166",
6
+ "version": "0.2.168",
7
7
  "type": "module",
8
8
  "module": "src/runtime/index-client.js",
9
9
  "main": "src/runtime/index-client.js",
@@ -81,6 +81,6 @@
81
81
  "typescript": "^5.9.2"
82
82
  },
83
83
  "peerDependencies": {
84
- "ripple": "0.2.166"
84
+ "ripple": "0.2.168"
85
85
  }
86
86
  }
@@ -1382,7 +1382,36 @@ function RipplePlugin(config) {
1382
1382
  element.loc.start = position;
1383
1383
  element.metadata = {};
1384
1384
  element.children = [];
1385
- const open = this.jsx_parseOpeningElementAt();
1385
+
1386
+ // Check if this is a <script> or <style> tag
1387
+ const tagName = this.value;
1388
+ const isScriptOrStyle = tagName === 'script' || tagName === 'style';
1389
+
1390
+ let open;
1391
+ if (isScriptOrStyle) {
1392
+ // Manually parse opening tag to avoid jsx_parseOpeningElementAt consuming content
1393
+ const tagStart = this.start;
1394
+ const tagEndPos = this.input.indexOf('>', tagStart);
1395
+
1396
+ open = {
1397
+ type: 'JSXOpeningElement',
1398
+ name: { type: 'JSXIdentifier', name: tagName },
1399
+ attributes: [],
1400
+ selfClosing: false,
1401
+ end: tagEndPos + 1,
1402
+ loc: {
1403
+ end: {
1404
+ line: this.curLine,
1405
+ column: tagEndPos + 1,
1406
+ },
1407
+ },
1408
+ };
1409
+
1410
+ // Position after the '>'
1411
+ this.pos = tagEndPos + 1;
1412
+ } else {
1413
+ open = this.jsx_parseOpeningElementAt();
1414
+ }
1386
1415
 
1387
1416
  // Check if this is a namespaced element (tsx:react)
1388
1417
  const is_tsx_compat = open.name.type === 'JSXNamespacedName';
@@ -1,5 +1,7 @@
1
1
  import { hash } from '../../utils.js';
2
2
 
3
+ const REGEX_MATCHER = /^[~^$*|]?=/;
4
+ const REGEX_ATTRIBUTE_FLAGS = /^[a-zA-Z]+/;
3
5
  const REGEX_COMMENT_CLOSE = /\*\//;
4
6
  const REGEX_HTML_COMMENT_CLOSE = /-->/;
5
7
  const REGEX_PERCENTAGE = /^\d+(\.\d+)?%/;
@@ -155,7 +157,7 @@ function read_at_rule(parser) {
155
157
  end: parser.index,
156
158
  name,
157
159
  prelude,
158
- block
160
+ block,
159
161
  };
160
162
  }
161
163
 
@@ -553,6 +555,39 @@ function read_selector(parser, inside_pseudo_class = false) {
553
555
  throw new Error('Unexpected end of input');
554
556
  }
555
557
 
558
+ /**
559
+ * Read a property that may or may not be quoted, e.g.
560
+ * `foo` or `'foo bar'` or `"foo bar"`
561
+ * @param {Parser} parser
562
+ */
563
+ function read_attribute_value(parser) {
564
+ let value = '';
565
+ let escaped = false;
566
+ const quote_mark = parser.eat('"') ? '"' : parser.eat("'") ? "'" : null;
567
+
568
+ while (parser.index < parser.template.length) {
569
+ const char = parser.template[parser.index];
570
+ if (escaped) {
571
+ value += '\\' + char;
572
+ escaped = false;
573
+ } else if (char === '\\') {
574
+ escaped = true;
575
+ } else if (quote_mark ? char === quote_mark : /[\s\]]/.test(char)) {
576
+ if (quote_mark) {
577
+ parser.eat(quote_mark, true);
578
+ }
579
+
580
+ return value.trim();
581
+ } else {
582
+ value += char;
583
+ }
584
+
585
+ parser.index++;
586
+ }
587
+
588
+ throw new Error('Unexpected end of input');
589
+ }
590
+
556
591
  function read_identifier(parser) {
557
592
  const start = parser.index;
558
593
 
@@ -0,0 +1,145 @@
1
+ import { walk } from 'zimmerframe';
2
+
3
+ /**
4
+ * True if is `:global` without arguments
5
+ * @param {any} simple_selector
6
+ */
7
+ function is_global_block_selector(simple_selector) {
8
+ return (
9
+ simple_selector.type === 'PseudoClassSelector' &&
10
+ simple_selector.name === 'global' &&
11
+ simple_selector.args === null
12
+ );
13
+ }
14
+
15
+ /**
16
+ * True if is `:global(...)` or `:global` and no pseudo class that is scoped.
17
+ * @param {any} relative_selector
18
+ */
19
+ function is_global(relative_selector) {
20
+ const first = relative_selector.selectors[0];
21
+
22
+ return (
23
+ first?.type === 'PseudoClassSelector' &&
24
+ first.name === 'global' &&
25
+ (first.args === null ||
26
+ // Only these two selector types keep the whole selector global, because e.g.
27
+ // :global(button).x means that the selector is still scoped because of the .x
28
+ relative_selector.selectors.every(
29
+ (selector) =>
30
+ selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector',
31
+ ))
32
+ );
33
+ }
34
+
35
+ /**
36
+ * Analyze CSS and set metadata for global selectors
37
+ * @param {any} css - The CSS AST
38
+ */
39
+ export function analyze_css(css) {
40
+ walk(css, { rule: null }, {
41
+ Rule(node, context) {
42
+ node.metadata.parent_rule = context.state.rule;
43
+
44
+ // Check for :global blocks
45
+ // A global block is when the selector starts with :global and has no local selectors before it
46
+ for (const complex_selector of node.prelude.children) {
47
+ let is_global_block = false;
48
+
49
+ for (
50
+ let selector_idx = 0;
51
+ selector_idx < complex_selector.children.length;
52
+ selector_idx++
53
+ ) {
54
+ const child = complex_selector.children[selector_idx];
55
+ const idx = child.selectors.findIndex(is_global_block_selector);
56
+
57
+ if (is_global_block) {
58
+ // All selectors after :global are unscoped
59
+ child.metadata.is_global_like = true;
60
+ }
61
+
62
+ // Only set is_global_block if this is the FIRST RelativeSelector and it starts with :global
63
+ if (selector_idx === 0 && idx === 0) {
64
+ // `child` starts with `:global` and is the first selector in the chain
65
+ is_global_block = true;
66
+ node.metadata.is_global_block = is_global_block;
67
+ } else if (idx === 0) {
68
+ // :global appears later in the selector chain (e.g., `div :global p`)
69
+ // Set is_global_block for marking subsequent selectors as global-like
70
+ is_global_block = true;
71
+ } else if (idx !== -1) {
72
+ // `:global` is not at the start - this is invalid but we'll let it through for now
73
+ // The transform phase will handle removal
74
+ }
75
+ }
76
+ }
77
+
78
+ // Pass the current rule as state to nested nodes
79
+ const state = { rule: node };
80
+ context.visit(node.prelude, state);
81
+ context.visit(node.block, state);
82
+ },
83
+
84
+ ComplexSelector(node, context) {
85
+ // Set the rule metadata before analyzing children
86
+ node.metadata.rule = context.state.rule;
87
+
88
+ context.next(); // analyse relevant selectors first
89
+
90
+ {
91
+ const global = node.children.find(is_global);
92
+
93
+ if (global) {
94
+ const idx = node.children.indexOf(global);
95
+ if (global.selectors[0].args !== null && idx !== 0 && idx !== node.children.length - 1) {
96
+ // ensure `:global(...)` is not used in the middle of a selector (but multiple `global(...)` in sequence are ok)
97
+ for (let i = idx + 1; i < node.children.length; i++) {
98
+ if (!is_global(node.children[i])) {
99
+ throw new Error(
100
+ `:global(...) can be at the start or end of a selector sequence, but not in the middle`,
101
+ );
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+
108
+ // Set is_global metadata
109
+ node.metadata.is_global = node.children.every(
110
+ ({ metadata }) => metadata.is_global || metadata.is_global_like,
111
+ );
112
+
113
+ node.metadata.used ||= node.metadata.is_global;
114
+ },
115
+
116
+ PseudoClassSelector(node, context) {
117
+ // Walk into :is(), :where(), :has(), and :not() to initialize metadata for nested selectors
118
+ if (
119
+ (node.name === 'is' || node.name === 'where' || node.name === 'has' || node.name === 'not') &&
120
+ node.args
121
+ ) {
122
+ context.next();
123
+ }
124
+ }, RelativeSelector(node, context) {
125
+ // Check if this selector is a :global selector
126
+ node.metadata.is_global = node.selectors.length >= 1 && is_global(node);
127
+
128
+ // Check for :root and other global-like selectors
129
+ if (
130
+ node.selectors.length >= 1 &&
131
+ node.selectors.every(
132
+ (selector) =>
133
+ selector.type === 'PseudoClassSelector' || selector.type === 'PseudoElementSelector',
134
+ )
135
+ ) {
136
+ const first = node.selectors[0];
137
+ node.metadata.is_global_like ||=
138
+ (first.type === 'PseudoClassSelector' && first.name === 'host') ||
139
+ (first.type === 'PseudoClassSelector' && first.name === 'root');
140
+ }
141
+
142
+ context.next();
143
+ },
144
+ });
145
+ }
@@ -13,8 +13,10 @@ import {
13
13
  import { extract_paths } from '../../../utils/ast.js';
14
14
  import is_reference from 'is-reference';
15
15
  import { prune_css } from './prune.js';
16
+ import { analyze_css } from './css-analyze.js';
16
17
  import { error } from '../../errors.js';
17
18
  import { is_event_attribute } from '../../../utils/events.js';
19
+ import { validate_nesting } from './validation.js';
18
20
 
19
21
  const valid_in_head = new Set(['title', 'base', 'link', 'meta', 'style', 'script', 'noscript']);
20
22
 
@@ -349,6 +351,9 @@ const visitors = {
349
351
  const css = node.css;
350
352
 
351
353
  if (css !== null) {
354
+ // Analyze CSS to set global selector metadata
355
+ analyze_css(css);
356
+
352
357
  for (const node of elements) {
353
358
  prune_css(css, node);
354
359
  }
@@ -642,6 +647,8 @@ const visitors = {
642
647
 
643
648
  mark_control_flow_has_template(path);
644
649
 
650
+ validate_nesting(node, state, context);
651
+
645
652
  // Store capitalized name for dynamic components/elements
646
653
  if (node.id.tracked) {
647
654
  const original_name = node.id.name;
@@ -1,4 +1,5 @@
1
1
  import { walk } from 'zimmerframe';
2
+ import { is_element_dom_element } from '../../utils.js';
2
3
 
3
4
  const seen = new Set();
4
5
  const regex_backslash_and_following_character = /\\(.)/g;
@@ -216,6 +217,41 @@ function get_descendant_elements(node, adjacent_only) {
216
217
  return descendants;
217
218
  }
218
219
 
220
+ /**
221
+ * Check if an element can render dynamic content that might affect CSS matching
222
+ * @param {any} element
223
+ * @param {boolean} check_classes - Whether to check for dynamic class attributes
224
+ * @returns {boolean}
225
+ */
226
+ function can_render_dynamic_content(element, check_classes = false) {
227
+ if (!is_element_dom_element(element)) {
228
+ return true;
229
+ }
230
+
231
+ // Either a dynamic element or component (only can tell at runtime)
232
+ // But dynamic elements should return false ideally
233
+ if (element.id?.tracked) {
234
+ return true;
235
+ }
236
+
237
+ // Check for dynamic class attributes if requested (for class-based selectors)
238
+ if (check_classes && element.attributes) {
239
+ for (const attr of element.attributes) {
240
+ if (attr.type === 'Attribute' && attr.name?.name === 'class') {
241
+ // Check if class value is an expression (not a static string)
242
+ if (attr.value && typeof attr.value === 'object') {
243
+ // If it's a CallExpression or other dynamic value, it's dynamic
244
+ if (attr.value.type !== 'Literal' && attr.value.type !== 'Text') {
245
+ return true;
246
+ }
247
+ }
248
+ }
249
+ }
250
+ }
251
+
252
+ return false;
253
+ }
254
+
219
255
  function get_possible_element_siblings(node, direction, adjacent_only) {
220
256
  const siblings = new Map();
221
257
  const parent = get_element_parent(node);
@@ -248,7 +284,12 @@ function get_possible_element_siblings(node, direction, adjacent_only) {
248
284
 
249
285
  if (sibling.type === 'Element' || sibling.type === 'Component') {
250
286
  siblings.set(sibling, true);
251
- if (adjacent_only) break; // Only immediate sibling for '+' combinator
287
+ // Don't break for dynamic elements (children, Components, dynamic components)
288
+ // as they can render dynamic content or might render nothing
289
+ const isDynamic = can_render_dynamic_content(sibling, false);
290
+ if (adjacent_only && !isDynamic) {
291
+ break; // Only immediate sibling for '+' combinator
292
+ }
252
293
  }
253
294
  // Stop at non-whitespace text nodes for adjacent selectors
254
295
  else if (adjacent_only && sibling.type === 'Text' && sibling.value?.trim()) {
@@ -295,11 +336,64 @@ function apply_combinator(relative_selector, rest_selectors, rule, node, directi
295
336
  let sibling_matched = false;
296
337
 
297
338
  for (const possible_sibling of siblings.keys()) {
298
- if (possible_sibling.type === 'Component') {
299
- if (rest_selectors.length === 1 && rest_selectors[0].metadata.is_global) {
339
+ // Check if this sibling can render dynamic content
340
+ // For class selectors, also check if element has dynamic classes
341
+ const has_class_selector = rest_selectors.some((sel) =>
342
+ sel.selectors?.some((s) => s.type === 'ClassSelector'),
343
+ );
344
+ const is_dynamic = can_render_dynamic_content(possible_sibling, has_class_selector);
345
+
346
+ if (is_dynamic) {
347
+ if (rest_selectors.length > 0) {
348
+ // Check if the first selector in the rest is global
349
+ const first_rest_selector = rest_selectors[0];
350
+ if (is_global(first_rest_selector, rule)) {
351
+ // Global selector followed by possibly more selectors
352
+ // Check if remaining selectors could match elements after this component
353
+ const remaining = rest_selectors.slice(1);
354
+ if (remaining.length === 0) {
355
+ // Just a global selector, mark as matched
356
+ sibling_matched = true;
357
+ } else {
358
+ // Check if there are any elements after this component that could match the remaining selectors
359
+ const parent = get_element_parent(node);
360
+ if (parent) {
361
+ const container = parent.children || parent.body || [];
362
+ const component_index = container.indexOf(possible_sibling);
363
+
364
+ // For adjacent combinator, only check immediate next element
365
+ // For general sibling, check all following elements
366
+ const search_start = component_index + 1;
367
+ const search_end = combinator.name === '+' ? search_start + 1 : container.length;
368
+
369
+ for (let i = search_start; i < search_end; i++) {
370
+ const subsequent = container[i];
371
+ if (subsequent.type === 'Element') {
372
+ if (apply_selector(remaining, rule, subsequent, direction)) {
373
+ sibling_matched = true;
374
+ break;
375
+ }
376
+ if (combinator.name === '+') break; // For adjacent, only check first element
377
+ } else if (subsequent.type === 'Component') {
378
+ // Skip components when looking for the target element
379
+ if (combinator.name === '+') {
380
+ // For adjacent, continue looking
381
+ continue;
382
+ }
383
+ }
384
+ }
385
+ }
386
+ }
387
+ }
388
+ } else if (rest_selectors.length === 1 && rest_selectors[0].metadata.is_global) {
389
+ // Single global selector always matches
300
390
  sibling_matched = true;
301
391
  }
302
- } else if (apply_selector(rest_selectors, rule, possible_sibling, direction)) {
392
+ // Don't apply_selector for dynamic elements - they won't match regular element selectors
393
+ } else if (
394
+ possible_sibling.type === 'Element' &&
395
+ apply_selector(rest_selectors, rule, possible_sibling, direction)
396
+ ) {
303
397
  sibling_matched = true;
304
398
  }
305
399
  }
@@ -338,19 +432,67 @@ function get_element_parent(node) {
338
432
  return null;
339
433
  }
340
434
 
435
+ /**
436
+ * `true` if is a pseudo class that cannot be or is not scoped
437
+ * @param {Compiler.AST.CSS.SimpleSelector} selector
438
+ */
439
+ function is_unscoped_pseudo_class(selector) {
440
+ return (
441
+ selector.type === 'PseudoClassSelector' &&
442
+ // These make the selector scoped
443
+ ((selector.name !== 'has' &&
444
+ selector.name !== 'is' &&
445
+ selector.name !== 'where' &&
446
+ // :not is special because we want to scope as specific as possible, but because :not
447
+ // inverses the result, we want to leave the unscoped, too. The exception is more than
448
+ // one selector in the :not (.e.g :not(.x .y)), then .x and .y should be scoped
449
+ (selector.name !== 'not' ||
450
+ selector.args === null ||
451
+ selector.args.children.every((c) => c.children.length === 1))) ||
452
+ // selectors with has/is/where/not can also be global if all their children are global
453
+ selector.args === null ||
454
+ selector.args.children.every((c) => c.children.every((r) => is_global_simple(r))))
455
+ );
456
+ }
457
+
458
+ /**
459
+ * True if is `:global(...)` or `:global` and no pseudo class that is scoped.
460
+ * @param {Compiler.AST.CSS.RelativeSelector} relative_selector
461
+ */
462
+ function is_global_simple(relative_selector) {
463
+ const first = relative_selector.selectors[0];
464
+
465
+ return (
466
+ first.type === 'PseudoClassSelector' &&
467
+ first.name === 'global' &&
468
+ (first.args === null ||
469
+ // Only these two selector types keep the whole selector global, because e.g.
470
+ // :global(button).x means that the selector is still scoped because of the .x
471
+ relative_selector.selectors.every(
472
+ (selector) =>
473
+ is_unscoped_pseudo_class(selector) || selector.type === 'PseudoElementSelector',
474
+ ))
475
+ );
476
+ }
477
+
341
478
  function is_global(selector, rule) {
342
479
  if (selector.metadata.is_global || selector.metadata.is_global_like) {
343
480
  return true;
344
481
  }
345
482
 
483
+ let explicitly_global = false;
484
+
346
485
  for (const s of selector.selectors) {
347
486
  /** @type {Compiler.AST.CSS.SelectorList | null} */
348
487
  let selector_list = null;
488
+ let can_be_global = false;
349
489
  let owner = rule;
350
490
 
351
491
  if (s.type === 'PseudoClassSelector') {
352
492
  if ((s.name === 'is' || s.name === 'where') && s.args) {
353
493
  selector_list = s.args;
494
+ } else {
495
+ can_be_global = is_unscoped_pseudo_class(s);
354
496
  }
355
497
  }
356
498
 
@@ -359,18 +501,19 @@ function is_global(selector, rule) {
359
501
  selector_list = owner.prelude;
360
502
  }
361
503
 
362
- const has_global_selectors = selector_list?.children.some((complex_selector) => {
504
+ const has_global_selectors = !!selector_list?.children.some((complex_selector) => {
363
505
  return complex_selector.children.every((relative_selector) =>
364
506
  is_global(relative_selector, owner),
365
507
  );
366
508
  });
509
+ explicitly_global ||= has_global_selectors;
367
510
 
368
- if (!has_global_selectors) {
511
+ if (!has_global_selectors && !can_be_global) {
369
512
  return false;
370
513
  }
371
514
  }
372
515
 
373
- return true;
516
+ return explicitly_global || selector.selectors.length === 0;
374
517
  }
375
518
 
376
519
  function is_text_attribute(attribute) {
@@ -425,6 +568,7 @@ function is_outer_global(relative_selector) {
425
568
  const first = relative_selector.selectors[0];
426
569
 
427
570
  return (
571
+ first &&
428
572
  first.type === 'PseudoClassSelector' &&
429
573
  first.name === 'global' &&
430
574
  (first.args === null ||
@@ -710,7 +854,7 @@ export function prune_css(css, element) {
710
854
  context.next();
711
855
  }
712
856
  },
713
- ComplexSelector(node) {
857
+ ComplexSelector(node, context) {
714
858
  const selectors = get_relative_selectors(node);
715
859
 
716
860
  seen.clear();
@@ -726,9 +870,19 @@ export function prune_css(css, element) {
726
870
  node.metadata.used = true;
727
871
  }
728
872
 
729
- // note: we don't call context.next() here, we only recurse into
730
- // selectors that don't belong to rules (i.e. inside `:is(...)` etc)
731
- // when we encounter them below
873
+ context.next();
874
+ },
875
+ PseudoClassSelector(node, context) {
876
+ // Visit nested selectors inside :has(), :is(), :where(), and :not()
877
+ if (
878
+ (node.name === 'has' ||
879
+ node.name === 'is' ||
880
+ node.name === 'where' ||
881
+ node.name === 'not') &&
882
+ node.args
883
+ ) {
884
+ context.next();
885
+ }
732
886
  },
733
887
  });
734
888
  }