ripple 0.2.165 → 0.2.167

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.
Files changed (26) hide show
  1. package/package.json +2 -2
  2. package/src/compiler/phases/1-parse/index.js +30 -1
  3. package/src/compiler/phases/1-parse/style.js +36 -1
  4. package/src/compiler/phases/2-analyze/css-analyze.js +145 -0
  5. package/src/compiler/phases/2-analyze/index.js +7 -0
  6. package/src/compiler/phases/2-analyze/prune.js +165 -11
  7. package/src/compiler/phases/2-analyze/validation.js +156 -0
  8. package/src/compiler/phases/3-transform/client/index.js +62 -12
  9. package/src/compiler/phases/3-transform/stylesheet.js +102 -3
  10. package/src/runtime/internal/client/index.js +1 -0
  11. package/src/runtime/internal/client/operations.js +0 -6
  12. package/src/runtime/internal/client/render.js +22 -16
  13. package/tests/client/css/global-additional-cases.test.ripple +702 -0
  14. package/tests/client/css/global-advanced-selectors.test.ripple +229 -0
  15. package/tests/client/css/global-at-rules.test.ripple +126 -0
  16. package/tests/client/css/global-basic.test.ripple +165 -0
  17. package/tests/client/css/global-classes-ids.test.ripple +179 -0
  18. package/tests/client/css/global-combinators.test.ripple +124 -0
  19. package/tests/client/css/global-complex-nesting.test.ripple +221 -0
  20. package/tests/client/css/global-edge-cases.test.ripple +200 -0
  21. package/tests/client/css/global-keyframes.test.ripple +101 -0
  22. package/tests/client/css/global-nested.test.ripple +150 -0
  23. package/tests/client/css/global-pseudo.test.ripple +155 -0
  24. package/tests/client/css/global-scoping.test.ripple +229 -0
  25. package/tests/client/dynamic-elements.test.ripple +0 -1
  26. package/tests/server/streaming-ssr.test.ripple +9 -6
@@ -0,0 +1,156 @@
1
+ import { error } from '../../errors.js';
2
+
3
+ const invalid_nestings = {
4
+ // <p> cannot contain block-level elements
5
+ p: new Set([
6
+ 'address',
7
+ 'article',
8
+ 'aside',
9
+ 'blockquote',
10
+ 'details',
11
+ 'div',
12
+ 'dl',
13
+ 'fieldset',
14
+ 'figcaption',
15
+ 'figure',
16
+ 'footer',
17
+ 'form',
18
+ 'h1',
19
+ 'h2',
20
+ 'h3',
21
+ 'h4',
22
+ 'h5',
23
+ 'h6',
24
+ 'header',
25
+ 'hgroup',
26
+ 'hr',
27
+ 'main',
28
+ 'menu',
29
+ 'nav',
30
+ 'ol',
31
+ 'p',
32
+ 'pre',
33
+ 'section',
34
+ 'table',
35
+ 'ul',
36
+ ]),
37
+ // <span> cannot contain block-level elements
38
+ span: new Set([
39
+ 'address',
40
+ 'article',
41
+ 'aside',
42
+ 'blockquote',
43
+ 'details',
44
+ 'div',
45
+ 'dl',
46
+ 'fieldset',
47
+ 'figcaption',
48
+ 'figure',
49
+ 'footer',
50
+ 'form',
51
+ 'h1',
52
+ 'h2',
53
+ 'h3',
54
+ 'h4',
55
+ 'h5',
56
+ 'h6',
57
+ 'header',
58
+ 'hgroup',
59
+ 'hr',
60
+ 'main',
61
+ 'menu',
62
+ 'nav',
63
+ 'ol',
64
+ 'p',
65
+ 'pre',
66
+ 'section',
67
+ 'table',
68
+ 'ul',
69
+ ]),
70
+ // Interactive elements cannot be nested
71
+ a: new Set(['a', 'button']),
72
+ button: new Set(['a', 'button']),
73
+ // Form elements
74
+ label: new Set(['label']),
75
+ form: new Set(['form']),
76
+ // Headings cannot be nested within each other
77
+ h1: new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']),
78
+ h2: new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']),
79
+ h3: new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']),
80
+ h4: new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']),
81
+ h5: new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']),
82
+ h6: new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6']),
83
+ // Table structure
84
+ table: new Set(['table', 'tr', 'td', 'th']), // Can only contain caption, colgroup, thead, tbody, tfoot
85
+ thead: new Set(['caption', 'colgroup', 'thead', 'tbody', 'tfoot', 'td', 'th']), // Can only contain tr
86
+ tbody: new Set(['caption', 'colgroup', 'thead', 'tbody', 'tfoot', 'td', 'th']), // Can only contain tr
87
+ tfoot: new Set(['caption', 'colgroup', 'thead', 'tbody', 'tfoot', 'td', 'th']), // Can only contain tr
88
+ tr: new Set(['caption', 'colgroup', 'thead', 'tbody', 'tfoot', 'tr']), // Can only contain td and th
89
+ td: new Set(['td', 'th']), // Cannot nest td/th elements
90
+ th: new Set(['td', 'th']), // Cannot nest td/th elements
91
+ // Media elements
92
+ picture: new Set(['picture']),
93
+ // Main landmark - only one per document, cannot be nested
94
+ main: new Set(['main']),
95
+ // Other semantic restrictions
96
+ figcaption: new Set(['figcaption']),
97
+ dt: new Set([
98
+ 'header',
99
+ 'footer',
100
+ 'article',
101
+ 'aside',
102
+ 'nav',
103
+ 'section',
104
+ 'h1',
105
+ 'h2',
106
+ 'h3',
107
+ 'h4',
108
+ 'h5',
109
+ 'h6',
110
+ ]),
111
+ // No interactive content inside summary
112
+ summary: new Set(['summary']),
113
+ };
114
+
115
+ /**
116
+ * @param {any} element
117
+ * @returns {string | null}
118
+ */
119
+ function get_element_tag(element) {
120
+ return element.id.type === 'Identifier' ? element.id.name : null;
121
+ }
122
+
123
+ /**
124
+ * @param {any} element
125
+ * @param {any} state
126
+ * @param {any} context
127
+ */
128
+ export function validate_nesting(element, state, context) {
129
+ const tag = get_element_tag(element);
130
+
131
+ if (tag === null) {
132
+ return;
133
+ }
134
+
135
+ for (let i = context.path.length - 1; i >= 0; i--) {
136
+ const parent = context.path[i];
137
+ if (parent.type === 'Element') {
138
+ const parent_tag = get_element_tag(parent);
139
+ if (parent_tag === null) {
140
+ continue;
141
+ }
142
+
143
+ if (parent_tag in invalid_nestings) {
144
+ const validation_set =
145
+ invalid_nestings[/** @type {keyof typeof invalid_nestings} */ (parent_tag)];
146
+ if (validation_set.has(tag)) {
147
+ error(
148
+ `Invalid HTML nesting: <${tag}> cannot be a descendant of <${parent_tag}>.`,
149
+ state.analysis.module.filename,
150
+ context,
151
+ );
152
+ }
153
+ }
154
+ }
155
+ }
156
+ }
@@ -1,4 +1,4 @@
1
- /** @import {Expression, FunctionExpression, Node, Program} from 'estree' */
1
+ /** @import {Expression, FunctionExpression, Node, Program, Statement} from 'estree' */
2
2
 
3
3
  /** @typedef {Map<number, {offset: number, delta: number}>} PostProcessingChanges */
4
4
  /** @typedef {number[]} LineOffsets */
@@ -129,7 +129,7 @@ function visit_head_element(node, context) {
129
129
  }
130
130
  }
131
131
 
132
- function apply_updates(init, update) {
132
+ function apply_updates(init, update, state) {
133
133
  if (update.length === 1) {
134
134
  init.push(
135
135
  b.stmt(
@@ -155,8 +155,25 @@ function apply_updates(init, update) {
155
155
  const render_statements = [];
156
156
  let index = 0;
157
157
 
158
+ const grouped_updates = new Map();
159
+
158
160
  for (const u of update) {
159
161
  if (u.initial) {
162
+ const id =
163
+ u.identity.type === 'Identifier' ? state.scope.get(u.identity.name)?.initial : u.identity;
164
+ let updates = grouped_updates.get(id);
165
+
166
+ if (updates === undefined) {
167
+ updates = [];
168
+ grouped_updates.set(id, updates);
169
+ }
170
+ updates.push(u);
171
+ }
172
+ }
173
+
174
+ for (const [, updates] of grouped_updates) {
175
+ if (updates.length === 1) {
176
+ const u = updates[0];
160
177
  const key = index_to_key(index);
161
178
  index_map.set(u.operation, key);
162
179
  initial.push(b.prop('init', b.id(key), u.initial));
@@ -171,6 +188,29 @@ function apply_updates(init, update) {
171
188
  );
172
189
  index++;
173
190
  } else {
191
+ const key = index_to_key(index);
192
+ /** @type {Array<Statement>} */
193
+ const if_body = [
194
+ b.stmt(b.assignment('=', b.member(b.id('__prev'), b.id(key)), b.id('__' + key))),
195
+ ];
196
+ initial.push(b.prop('init', b.id(key), updates[0].initial));
197
+ render_statements.push(
198
+ b.var('__' + key, updates[0].expression),
199
+ b.if(
200
+ b.binary('!==', b.member(b.id('__prev'), b.id(key)), b.id('__' + key)),
201
+ b.block(if_body),
202
+ ),
203
+ );
204
+ for (const u of updates) {
205
+ index_map.set(u.operation, key);
206
+ if_body.push(u.operation(b.id('__' + key)));
207
+ index++;
208
+ }
209
+ }
210
+ }
211
+
212
+ for (const u of update) {
213
+ if (!u.initial) {
174
214
  render_statements.push(u.operation);
175
215
  }
176
216
  }
@@ -835,6 +875,7 @@ const visitors = {
835
875
  if (is_dom_element) {
836
876
  let class_attribute = null;
837
877
  let style_attribute = null;
878
+ /** @type {Array<Statement>} */
838
879
  const local_updates = [];
839
880
  const is_void = is_void_element(node.id.name);
840
881
 
@@ -997,9 +1038,11 @@ const visitors = {
997
1038
  });
998
1039
  } else {
999
1040
  local_updates.push({
1000
- operation: b.stmt(
1001
- b.call('_$_.set_attribute', id, b.literal(attribute), expression),
1002
- ),
1041
+ operation: (key) =>
1042
+ b.stmt(b.call('_$_.set_attribute', id, b.literal(attribute), key)),
1043
+ expression,
1044
+ identity: attr.value,
1045
+ initial: b.void0,
1003
1046
  });
1004
1047
  }
1005
1048
  } else {
@@ -1044,6 +1087,7 @@ const visitors = {
1044
1087
  operation: (key) =>
1045
1088
  b.stmt(b.call('_$_.set_class', id, key, hash_arg, b.literal(is_html))),
1046
1089
  expression,
1090
+ identity: class_attribute.value,
1047
1091
  initial: b.literal(''),
1048
1092
  });
1049
1093
  } else {
@@ -1063,14 +1107,16 @@ const visitors = {
1063
1107
  const id = state.flush_node();
1064
1108
  const metadata = { tracking: false, await: false };
1065
1109
  const expression = visit(style_attribute.value, { ...state, metadata });
1066
- const name = style_attribute.name.name;
1067
-
1068
- const statement = b.stmt(b.call('_$_.set_attribute', id, b.literal(name), expression));
1069
1110
 
1070
1111
  if (metadata.tracking) {
1071
- local_updates.push({ operation: statement });
1112
+ local_updates.push({
1113
+ operation: (key) => b.stmt(b.call('_$_.set_style', id, key)),
1114
+ identity: style_attribute.value,
1115
+ expression,
1116
+ initial: b.void0,
1117
+ });
1072
1118
  } else {
1073
- state.init.push(statement);
1119
+ state.init.push(b.stmt(b.call('_$_.set_style', id, expression)));
1074
1120
  }
1075
1121
  }
1076
1122
  }
@@ -1084,7 +1130,9 @@ const visitors = {
1084
1130
  );
1085
1131
  }
1086
1132
 
1133
+ /** @type {Array<Statement>} */
1087
1134
  const init = [];
1135
+ /** @type {Array<Statement>} */
1088
1136
  const update = [];
1089
1137
 
1090
1138
  if (!is_void) {
@@ -1100,7 +1148,7 @@ const visitors = {
1100
1148
 
1101
1149
  if (update.length > 0) {
1102
1150
  if (state.scope.parent.declarations.size > 0) {
1103
- apply_updates(init, update);
1151
+ apply_updates(init, update, state);
1104
1152
  } else {
1105
1153
  state.update.push(...update);
1106
1154
  }
@@ -2252,6 +2300,7 @@ function transform_children(children, context) {
2252
2300
  state.update.push({
2253
2301
  operation: (key) => b.stmt(b.call('_$_.set_text', id, key)),
2254
2302
  expression,
2303
+ identity: node.expression,
2255
2304
  initial: b.literal(' '),
2256
2305
  });
2257
2306
  if (metadata.await) {
@@ -2279,6 +2328,7 @@ function transform_children(children, context) {
2279
2328
  state.update.push({
2280
2329
  operation: (key) => b.stmt(b.call('_$_.set_text', id, key)),
2281
2330
  expression,
2331
+ identity: node.expression,
2282
2332
  initial: b.literal(' '),
2283
2333
  });
2284
2334
  if (metadata.await) {
@@ -2356,7 +2406,7 @@ function transform_body(body, { visit, state }) {
2356
2406
  // In TypeScript mode, just add the update statements directly
2357
2407
  body_state.init.push(...body_state.update);
2358
2408
  } else {
2359
- apply_updates(body_state.init, body_state.update);
2409
+ apply_updates(body_state.init, body_state.update, state);
2360
2410
  }
2361
2411
  }
2362
2412
 
@@ -24,6 +24,65 @@ function is_in_global_block(path) {
24
24
  return path.some((node) => node.type === 'Rule' && node.metadata.is_global_block);
25
25
  }
26
26
 
27
+ /**
28
+ * Check if we're inside a pseudo-class selector that's INSIDE a :global() wrapper
29
+ * or adjacent to a :global modifier
30
+ * @param {any[]} path
31
+ */
32
+ function is_in_global_pseudo(path) {
33
+ // Walk up the path to find if we're inside a :global() pseudo-class selector with args
34
+ // or if we're in a pseudo-class that's in the same RelativeSelector as a :global modifier
35
+ for (let i = path.length - 1; i >= 0; i--) {
36
+ const node = path[i];
37
+
38
+ // Case 1: :global(...) with args - we're inside it
39
+ if (node.type === 'PseudoClassSelector' && node.name === 'global' && node.args !== null) {
40
+ return true;
41
+ }
42
+
43
+ // Case 2: We're in a PseudoClassSelector (like :is, :where, :has, :not)
44
+ // Check if there's a :global modifier in the same RelativeSelector
45
+ if (
46
+ node.type === 'PseudoClassSelector' &&
47
+ (node.name === 'is' || node.name === 'where' || node.name === 'has' || node.name === 'not')
48
+ ) {
49
+ // Look for the parent RelativeSelector
50
+ for (let j = i - 1; j >= 0; j--) {
51
+ const ancestor = path[j];
52
+ if (ancestor.type === 'RelativeSelector') {
53
+ // Check if this RelativeSelector has a :global modifier (no args)
54
+ const hasGlobalModifier = ancestor.selectors.some(
55
+ (s) => s.type === 'PseudoClassSelector' && s.name === 'global' && s.args === null,
56
+ );
57
+ if (hasGlobalModifier) {
58
+ return true;
59
+ }
60
+ break;
61
+ }
62
+ }
63
+ }
64
+ }
65
+ return false;
66
+ }
67
+
68
+ /**
69
+ * Check if a rule has :global in the middle (like `div :global p`)
70
+ * These rules should treat nested selectors as global
71
+ * @param {AST.CSS.Rule} rule
72
+ */
73
+ function has_global_in_middle(rule) {
74
+ for (const complex_selector of rule.prelude.children) {
75
+ for (let i = 0; i < complex_selector.children.length; i++) {
76
+ const child = complex_selector.children[i];
77
+ // Check if this is a :global selector that's not at the start
78
+ if (i > 0 && child.metadata.is_global) {
79
+ return true;
80
+ }
81
+ }
82
+ }
83
+ return false;
84
+ }
85
+
27
86
  function remove_global_pseudo_class(selector, combinator, state) {
28
87
  if (selector.args === null) {
29
88
  let start = selector.start;
@@ -75,13 +134,19 @@ function is_empty(rule, is_in_global_block) {
75
134
  return rule.block.children.length === 0;
76
135
  }
77
136
 
137
+ // Rules with :global in the middle (like `div :global p`) should treat nested rules as global
138
+ const has_mid_global = has_global_in_middle(rule);
139
+
78
140
  for (const child of rule.block.children) {
79
141
  if (child.type === 'Declaration') {
80
142
  return false;
81
143
  }
82
144
 
83
145
  if (child.type === 'Rule') {
84
- if ((is_used(child) || is_in_global_block) && !is_empty(child, is_in_global_block)) {
146
+ if (
147
+ (is_used(child) || is_in_global_block || has_mid_global) &&
148
+ !is_empty(child, is_in_global_block || has_mid_global)
149
+ ) {
85
150
  return false;
86
151
  }
87
152
  }
@@ -196,8 +261,10 @@ const visitors = {
196
261
  },
197
262
  SelectorList(node, { state, next, path }) {
198
263
  // Only add comments if we're not inside a complex selector that itself is unused or a global block
264
+ // or inside a pseudo-class that's part of a global selector
199
265
  if (
200
266
  !is_in_global_block(path) &&
267
+ !is_in_global_pseudo(path) &&
201
268
  !path.find((n) => n.type === 'ComplexSelector' && !n.metadata.used)
202
269
  ) {
203
270
  const children = node.children;
@@ -280,8 +347,39 @@ const visitors = {
280
347
  ComplexSelector(node, context) {
281
348
  const before_bumped = context.state.specificity.bumped;
282
349
 
350
+ // Check if we're inside a :has/:is/:where/:not pseudo-class that's part of a global selector
351
+ // In that case, we should still scope the contents even though the parent is global
352
+ const parentPath = context.path;
353
+ let insideScopingPseudo = false;
354
+
355
+ // Walk up the path to find if we're inside args of :has/:is/:where/:not
356
+ for (let i = parentPath.length - 1; i >= 0; i--) {
357
+ const pathNode = parentPath[i];
358
+
359
+ // Check if we're inside a SelectorList that belongs to a scoping pseudo-class
360
+ if (pathNode.type === 'SelectorList' && i > 0) {
361
+ const parent = parentPath[i - 1];
362
+ if (
363
+ parent.type === 'PseudoClassSelector' &&
364
+ (parent.name === 'has' ||
365
+ parent.name === 'is' ||
366
+ parent.name === 'where' ||
367
+ parent.name === 'not')
368
+ ) {
369
+ // Now check if this pseudo-class is part of a global RelativeSelector
370
+ for (let j = i - 2; j >= 0; j--) {
371
+ if (parentPath[j].type === 'RelativeSelector' && parentPath[j].metadata?.is_global) {
372
+ insideScopingPseudo = true;
373
+ break;
374
+ }
375
+ }
376
+ break;
377
+ }
378
+ }
379
+ }
380
+
283
381
  for (const relative_selector of node.children) {
284
- if (relative_selector.metadata.is_global) {
382
+ if (relative_selector.metadata.is_global && !insideScopingPseudo) {
285
383
  const global = /** @type {AST.CSS.PseudoClassSelector} */ (relative_selector.selectors[0]);
286
384
  remove_global_pseudo_class(global, relative_selector.combinator, context.state);
287
385
 
@@ -303,7 +401,8 @@ const visitors = {
303
401
  }
304
402
  }
305
403
 
306
- if (relative_selector.metadata.scoped) {
404
+ // Skip scoping if we're inside a global block
405
+ if (relative_selector.metadata.scoped && !is_in_global_block(context.path)) {
307
406
  if (relative_selector.selectors.length === 1) {
308
407
  // skip standalone :is/:where/& selectors
309
408
  const selector = relative_selector.selectors[0];
@@ -9,6 +9,7 @@ export {
9
9
  export {
10
10
  set_text,
11
11
  set_class,
12
+ set_style,
12
13
  set_attribute,
13
14
  set_value,
14
15
  set_checked,
@@ -28,12 +28,6 @@ export function init_operations() {
28
28
  // @ts-expect-error
29
29
  element_prototype.__click = undefined;
30
30
  // @ts-expect-error
31
- element_prototype.__attributes = null;
32
- // @ts-expect-error
33
- element_prototype.__styles = null;
34
- // @ts-expect-error
35
- element_prototype.__e = undefined;
36
- // @ts-expect-error
37
31
  event_target_prototype.__root = undefined;
38
32
  }
39
33
 
@@ -1,7 +1,7 @@
1
1
  /** @import { Block } from '#client' */
2
2
 
3
3
  import { destroy_block, ref } from './blocks.js';
4
- import { REF_PROP, TRACKED, TRACKED_OBJECT } from './constants.js';
4
+ import { REF_PROP } from './constants.js';
5
5
  import {
6
6
  get_descriptors,
7
7
  get_own_property_symbols,
@@ -69,25 +69,29 @@ function get_setters(element) {
69
69
 
70
70
  /**
71
71
  * @param {Element} element
72
- * @param {string} attribute
73
72
  * @param {any} value
74
73
  * @returns {void}
75
74
  */
76
- export function set_attribute(element, attribute, value) {
77
- // @ts-expect-error
78
- var attributes = (element.__attributes ??= {});
79
-
80
- if (attributes[attribute] === (attributes[attribute] = value)) return;
81
-
82
- if (attribute === 'style' && '__styles' in element) {
83
- // reset styles to force style: directive to update
84
- element.__styles = {};
75
+ export function set_style(element, value) {
76
+ if (value == null) {
77
+ element.removeAttribute('style');
78
+ } else if (typeof value !== 'string') {
79
+ apply_styles(/** @type {HTMLElement} */ (element), value);
80
+ } else {
81
+ // @ts-ignore
82
+ element.style.cssText = value;
85
83
  }
84
+ }
86
85
 
86
+ /**
87
+ * @param {Element} element
88
+ * @param {string} attribute
89
+ * @param {any} value
90
+ * @returns {void}
91
+ */
92
+ export function set_attribute(element, attribute, value) {
87
93
  if (value == null) {
88
94
  element.removeAttribute(attribute);
89
- } else if (attribute === 'style' && typeof value !== 'string') {
90
- apply_styles(/** @type {HTMLElement} */ (element), value);
91
95
  } else if (typeof value !== 'string' && get_setters(element).includes(attribute)) {
92
96
  /** @type {any} */ (element)[attribute] = value;
93
97
  } else {
@@ -97,13 +101,13 @@ export function set_attribute(element, attribute, value) {
97
101
 
98
102
  /**
99
103
  * @param {HTMLElement} element
100
- * @param {HTMLElement['style']} newStyles
104
+ * @param {HTMLElement['style']} new_styles
101
105
  */
102
- export function apply_styles(element, newStyles) {
106
+ export function apply_styles(element, new_styles) {
103
107
  const style = element.style;
104
108
  const new_properties = new Set();
105
109
 
106
- for (const [property, value] of Object.entries(newStyles)) {
110
+ for (const [property, value] of Object.entries(new_styles)) {
107
111
  const normalized_property = normalize_css_property_name(property);
108
112
  const normalized_value = String(value);
109
113
 
@@ -132,6 +136,8 @@ function set_attribute_helper(element, key, value) {
132
136
  if (key === 'class') {
133
137
  const is_html = element.namespaceURI === 'http://www.w3.org/1999/xhtml';
134
138
  set_class(/** @type {HTMLElement} */ (element), value, undefined, is_html);
139
+ } else if (key === 'style') {
140
+ set_style(/** @type {HTMLElement} */ (element), value);
135
141
  } else if (key === '#class') {
136
142
  // Special case for static class when spreading props
137
143
  element.classList.add(value);