@tsrx/core 0.1.65 → 0.1.66

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 (52) hide show
  1. package/README.md +26 -1
  2. package/package.json +9 -5
  3. package/src/analyze/css-analyze.js +44 -6
  4. package/src/analyze/index.js +14 -1
  5. package/src/analyze/style-analyze.js +467 -0
  6. package/src/analyze/validation.js +57 -0
  7. package/src/diagnostics.js +22 -0
  8. package/src/index.js +17 -0
  9. package/src/parse/style.js +97 -7
  10. package/src/plugin.js +145 -47
  11. package/src/scope.js +1 -1
  12. package/src/transform/jsx/index.js +94 -417
  13. package/src/transform/jsx/style-scopes.js +842 -0
  14. package/src/transform/scoping.js +129 -79
  15. package/src/transform/segments.js +16 -4
  16. package/src/transform/style-ref.js +74 -13
  17. package/src/transform/stylesheet.js +2 -1
  18. package/src/utils/is-reference.js +59 -0
  19. package/tests/fixtures/scoped-styles/README.md +71 -0
  20. package/tests/fixtures/scoped-styles/apply-forms.expected.json +18 -0
  21. package/tests/fixtures/scoped-styles/apply-forms.tsrx +69 -0
  22. package/tests/fixtures/scoped-styles/assigned-positions.expected.json +29 -0
  23. package/tests/fixtures/scoped-styles/assigned-positions.tsrx +90 -0
  24. package/tests/fixtures/scoped-styles/class-opt-in.expected.json +13 -0
  25. package/tests/fixtures/scoped-styles/class-opt-in.tsrx +39 -0
  26. package/tests/fixtures/scoped-styles/control-flow-else-if.expected.json +11 -0
  27. package/tests/fixtures/scoped-styles/control-flow-else-if.tsrx +26 -0
  28. package/tests/fixtures/scoped-styles/control-flow.expected.json +17 -0
  29. package/tests/fixtures/scoped-styles/control-flow.tsrx +102 -0
  30. package/tests/fixtures/scoped-styles/cross-module-apply.expected.json +14 -0
  31. package/tests/fixtures/scoped-styles/cross-module-apply.tsrx +48 -0
  32. package/tests/fixtures/scoped-styles/element-rooted-templates.expected.json +12 -0
  33. package/tests/fixtures/scoped-styles/element-rooted-templates.tsrx +33 -0
  34. package/tests/fixtures/scoped-styles/precedence.expected.json +11 -0
  35. package/tests/fixtures/scoped-styles/precedence.tsrx +45 -0
  36. package/tests/fixtures/scoped-styles/rfc-opening-example/panel.expected.json +12 -0
  37. package/tests/fixtures/scoped-styles/rfc-opening-example/panel.tsrx +46 -0
  38. package/tests/fixtures/scoped-styles/rfc-opening-example/theme.expected.json +9 -0
  39. package/tests/fixtures/scoped-styles/rfc-opening-example/theme.tsrx +24 -0
  40. package/tests/fixtures/scoped-styles/search-panel.expected.json +11 -0
  41. package/tests/fixtures/scoped-styles/search-panel.tsrx +48 -0
  42. package/tests/fixtures/scoped-styles/sibling-scope.expected.json +11 -0
  43. package/tests/fixtures/scoped-styles/sibling-scope.tsrx +44 -0
  44. package/tests/fixtures/scoped-styles/sibling-scopes.expected.json +12 -0
  45. package/tests/fixtures/scoped-styles/sibling-scopes.tsrx +51 -0
  46. package/tests/fixtures/scoped-styles/theme-composition.expected.json +14 -0
  47. package/tests/fixtures/scoped-styles/theme-composition.tsrx +45 -0
  48. package/tests/fixtures/scoped-styles/theme-diamond.expected.json +10 -0
  49. package/tests/fixtures/scoped-styles/theme-diamond.tsrx +18 -0
  50. package/tests/shared/scoped-styles-fixtures.js +67 -0
  51. package/tests/utils/fixtures/style-syntax.js +519 -0
  52. package/types/index.d.ts +85 -0
@@ -7,7 +7,7 @@
7
7
 
8
8
  /** @import * as AST from 'estree' */
9
9
  /** @import * as ESTreeJSX from 'estree-jsx' */
10
- /** @import { Visitors } from '../../types/index' */
10
+ /** @import { ScopeClassParts, StyleRenderMode, Visitors } from '../../types/index' */
11
11
 
12
12
  import { walk } from 'zimmerframe';
13
13
  import * as b from '../utils/builders.js';
@@ -18,27 +18,35 @@ export { is_style_element };
18
18
 
19
19
  /**
20
20
  * Mark selectors inside the stylesheet as "used" so `renderStylesheets` does
21
- * not comment them out.
21
+ * not comment them out, per render mode (D4):
22
22
  *
23
- * For a free-standing `<style>` block every selector is marked: we skip
24
- * selector-pruning because component boundaries can be dynamic — any selector
25
- * authored inside the component's `<style>` block is considered intentional.
23
+ * - `scope`: a free-standing `<style>` block. Every selector is marked; we
24
+ * skip selector-pruning because component boundaries can be dynamic — any
25
+ * selector authored inside a scope's `<style>` block is considered
26
+ * intentional.
27
+ * - `class-map`: a block assigned to a local, unexported, unapplied variable.
28
+ * The only selectors reachable through the generated class map are
29
+ * standalone class selectors — scoped (`.x`) or global-wrapped
30
+ * (`:global(.x)`). Anything else at the top level — element selectors,
31
+ * compound selectors, descendant chains, global tag selectors — never ends
32
+ * up in the class map and is marked unused for `renderStylesheets` to
33
+ * comment out. Selectors of nested rules ride along with their parent: they
34
+ * apply where the parent's class matched, and the whole rule is pruned when
35
+ * the parent itself is unreachable.
36
+ * - `theme`: an exported or applied block (D5). Every selector is kept and
37
+ * hash-scoped, because appliers stamp `$class` on arbitrary elements.
26
38
  *
27
- * When the `<style>` block is assigned to a variable (`is_style_expression`),
28
- * the only selectors reachable through the generated class map are standalone
29
- * class selectors — scoped (`.x`) or global-wrapped (`:global(.x)`). Anything
30
- * else at the top level — element selectors, compound selectors, descendant
31
- * chains, global tag selectors — never ends up in the class map and is marked
32
- * unused for `renderStylesheets` to comment out. Selectors of nested rules ride
33
- * along with their parent: they apply where the parent's class matched, and the
34
- * whole rule is pruned when the parent itself is unreachable.
39
+ * The boolean form (`true` `class-map`, `false` `scope`) is kept for one
40
+ * release for consumers compiled against the previous signature.
35
41
  *
36
42
  * @param {AST.CSS.StyleSheet} stylesheet
37
- * @param {boolean} [is_style_expression]
43
+ * @param {StyleRenderMode | boolean} [mode]
38
44
  * @returns {AST.CSS.StyleSheet}
39
45
  */
40
- export function prepare_stylesheet_for_render(stylesheet, is_style_expression = false) {
41
- if (is_style_expression) {
46
+ export function prepare_stylesheet_for_render(stylesheet, mode = 'scope') {
47
+ const render_mode = mode === true ? 'class-map' : mode === false ? 'scope' : mode;
48
+ const is_class_map = render_mode === 'class-map';
49
+ if (is_class_map) {
42
50
  mark_class_map_selectors(stylesheet);
43
51
  }
44
52
  walk(
@@ -47,7 +55,7 @@ export function prepare_stylesheet_for_render(stylesheet, is_style_expression =
47
55
  /** @type {Visitors<AST.CSS.Node, null>} */ ({
48
56
  _(node, { next, path }) {
49
57
  if (node.type === 'ComplexSelector') {
50
- if (is_style_expression && is_unreachable_via_class_map(node, path)) {
58
+ if (is_class_map && is_unreachable_via_class_map(node, path)) {
51
59
  // Not in the generated class map. The analyzer pre-marks global
52
60
  // selectors as used, so reset, and leave the subtree untouched —
53
61
  // no `scoped` marks that would splice the hash into pruned output.
@@ -149,7 +157,7 @@ export function annotate_with_hash(
149
157
  if (node.type === 'JSXElement') {
150
158
  const element = /** @type {AST.TSRXJSXElement} */ (node);
151
159
  if (!is_composite_jsx_element(element) || element.metadata?.dynamicElement) {
152
- add_hash_class_to_jsx_element(element, hash, jsx_class_attr_name);
160
+ add_hash_class(element, hash, jsx_class_attr_name);
153
161
  }
154
162
  element.children = element.children
155
163
  .map((child) => annotate_with_hash(child, hash, jsx_class_attr_name, preserve_style_elements))
@@ -221,85 +229,127 @@ function is_class_attribute(attr) {
221
229
  }
222
230
 
223
231
  /**
224
- * Merge the hash into an existing class attribute value. Returns `false` when
225
- * the value is not a string literal, so the caller must wrap it instead.
232
+ * The authored class value of an attribute as an expression, or `null` when
233
+ * the attribute has no usable value (`<div class>`, `class={}`).
226
234
  *
227
- * @param {ESTreeJSX.JSXAttribute['value'] | AST.Expression | ESTreeJSX.JSXEmptyExpression} value
228
- * @param {string} hash
229
- * @returns {boolean} whether the hash was merged into the literal in place
235
+ * @param {ESTreeJSX.JSXAttribute | undefined} attr
236
+ * @returns {AST.Expression | null}
230
237
  */
231
- function merge_hash_into_literal(value, hash) {
232
- if (value?.type !== 'Literal' || typeof value.value !== 'string') return false;
233
- const merged = `${value.value} ${hash}`;
234
- value.value = merged;
235
- value.raw = JSON.stringify(merged);
236
- return true;
238
+ function class_attribute_base(attr) {
239
+ const value = attr?.value;
240
+ if (!value) return null;
241
+ const expression = value.type === 'JSXExpressionContainer' ? value.expression : value;
242
+ if (expression.type === 'JSXEmptyExpression') return null;
243
+ return /** @type {AST.Expression} */ (expression);
237
244
  }
238
245
 
239
246
  /**
240
- * Ensure the element carries a class attribute containing the scoping hash.
247
+ * Build one attribute value from the accumulated parts `authored hashes…
248
+ * applied…` — as a single string literal when everything is static,
249
+ * otherwise as one template literal (never a template literal nested in
250
+ * another across repeated stamping).
241
251
  *
242
- * @param {AST.TSRXJSXElement} element
243
- * @param {string} hash
244
- * @param {'class' | 'className'} [class_attr_name='class']
245
- * @returns {void}
252
+ * @param {ScopeClassParts} parts
253
+ * @returns {AST.Expression}
246
254
  */
247
- export function add_hash_class(element, hash, class_attr_name = 'class') {
248
- const attrs = element.openingElement.attributes;
249
- const existing = attrs.find(is_class_attribute);
255
+ function build_scope_class_value(parts) {
256
+ const { base, hashes, applied } = parts;
257
+ const base_literal = base?.type === 'Literal' && typeof base.value === 'string' ? base : null;
258
+ /** @type {Array<string | AST.Expression>} */
259
+ const sequence = [];
260
+ if (base_literal) sequence.push(/** @type {string} */ (base_literal.value));
261
+ else if (base) sequence.push(base);
262
+ sequence.push(...hashes, ...applied);
250
263
 
251
- if (!existing) {
252
- attrs.push(b.jsx_attribute(b.jsx_id(class_attr_name), b.literal(hash)));
253
- return;
264
+ /** @type {AST.TemplateElement[]} */
265
+ const quasis = [];
266
+ /** @type {AST.Expression[]} */
267
+ const expressions = [];
268
+ /** @type {string} */
269
+ let text = '';
270
+ for (const part of sequence) {
271
+ if (typeof part === 'string') {
272
+ if (part) text = text ? `${text} ${part}` : part;
273
+ continue;
274
+ }
275
+ const between = expressions.length > 0;
276
+ quasis.push(b.quasi(text ? (between ? ` ${text} ` : `${text} `) : between ? ' ' : '', false));
277
+ expressions.push(part);
278
+ text = '';
254
279
  }
255
280
 
256
- const value =
257
- existing.value?.type === 'JSXExpressionContainer' ? existing.value.expression : existing.value;
258
- if (!value || value.type === 'JSXEmptyExpression') {
259
- existing.value = b.literal(hash, JSON.stringify(hash));
260
- return;
281
+ if (expressions.length === 0) {
282
+ // Keep the authored literal's position so editor mappings survive.
283
+ return base_literal
284
+ ? { ...base_literal, value: text, raw: JSON.stringify(text) }
285
+ : b.literal(text, JSON.stringify(text));
261
286
  }
262
-
263
- if (merge_hash_into_literal(value, hash)) return;
264
-
265
- // Dynamic expression. Concatenate at runtime via template literal.
266
- existing.value = b.jsx_expression_container(
267
- b.template([b.quasi('', false), b.quasi(` ${hash}`, true)], [value]),
268
- );
287
+ quasis.push(b.quasi(text ? ` ${text}` : '', true));
288
+ return b.template(quasis, expressions);
269
289
  }
270
290
 
271
291
  /**
272
- * @param {AST.TSRXJSXElement} element
273
- * @param {string} hash
274
- * @param {'class' | 'className'} jsx_class_attr_name
275
- * @returns {void}
292
+ * Stamp a scope's classes on an element, copy-on-write. The parts live on the
293
+ * element's (shared) metadata so an enclosing scope's stamp and a nested
294
+ * scope's stamp accumulate into one value: `authored hashes… applied…`, with
295
+ * every applied theme after every scope hash regardless of which scope
296
+ * applied it.
297
+ *
298
+ * @template {AST.TSRXJSXElement} T
299
+ * @param {T} element
300
+ * @param {string[]} hashes scope hashes to add
301
+ * @param {Array<string | AST.Expression>} applied theme classes: literals or `theme.$class` reads
302
+ * @param {'class' | 'className'} [class_attr_name='class']
303
+ * @returns {T}
276
304
  */
277
- function add_hash_class_to_jsx_element(element, hash, jsx_class_attr_name) {
278
- const attrs = (element.openingElement.attributes ??= []);
279
- const existing = attrs.find(is_class_attribute);
280
-
281
- if (!existing) {
282
- attrs.push(
283
- b.jsx_attribute(b.jsx_id(jsx_class_attr_name), b.literal(hash, JSON.stringify(hash))),
284
- );
285
- return;
305
+ export function add_scope_classes(element, hashes, applied, class_attr_name = 'class') {
306
+ if (hashes.length === 0 && applied.length === 0) return element;
307
+ const attrs = element.openingElement.attributes ?? [];
308
+ const index = attrs.findIndex(is_class_attribute);
309
+ const existing = index === -1 ? undefined : /** @type {ESTreeJSX.JSXAttribute} */ (attrs[index]);
310
+ const metadata = element.metadata || (element.metadata = { path: [] });
311
+ const parts =
312
+ metadata.tsrx_scope_class ||
313
+ (metadata.tsrx_scope_class = {
314
+ base: class_attribute_base(existing),
315
+ hashes: [],
316
+ applied: [],
317
+ });
318
+ for (const hash of hashes) {
319
+ if (!parts.hashes.includes(hash)) parts.hashes.push(hash);
286
320
  }
287
-
288
- const value = existing.value;
289
- if (!value) {
290
- existing.value = b.literal(hash, JSON.stringify(hash));
291
- return;
321
+ for (const part of applied) {
322
+ if (typeof part !== 'string' || !parts.applied.includes(part)) parts.applied.push(part);
292
323
  }
293
324
 
294
- if (merge_hash_into_literal(value, hash)) return;
295
-
296
- const expression = value.type === 'JSXExpressionContainer' ? value.expression : value;
297
- if (expression.type === 'JSXEmptyExpression') {
298
- existing.value = b.literal(hash, JSON.stringify(hash));
299
- return;
325
+ const value = build_scope_class_value(parts);
326
+ const attr_value =
327
+ value.type === 'Literal' && typeof value.value === 'string'
328
+ ? value
329
+ : b.jsx_expression_container(value);
330
+ const next_attrs = attrs.slice();
331
+ if (existing) {
332
+ next_attrs[index] = { ...existing, value: attr_value };
333
+ } else {
334
+ next_attrs.push(b.jsx_attribute(b.jsx_id(class_attr_name), attr_value));
300
335
  }
336
+ return {
337
+ ...element,
338
+ openingElement: { ...element.openingElement, attributes: next_attrs },
339
+ };
340
+ }
301
341
 
302
- existing.value = b.jsx_expression_container(
303
- b.template([b.quasi('', false), b.quasi(` ${hash}`, true)], [expression]),
304
- );
342
+ /**
343
+ * Ensure the element carries a class attribute containing the scoping hash,
344
+ * in place. Kept for consumers that stamp one hash at a time; the scope
345
+ * pre-pass uses {@link add_scope_classes}.
346
+ *
347
+ * @param {AST.TSRXJSXElement} element
348
+ * @param {string} hash
349
+ * @param {'class' | 'className'} [class_attr_name='class']
350
+ * @returns {void}
351
+ */
352
+ export function add_hash_class(element, hash, class_attr_name = 'class') {
353
+ const stamped = add_scope_classes(element, [hash], [], class_attr_name);
354
+ element.openingElement = stamped.openingElement;
305
355
  }
@@ -158,10 +158,15 @@ function visit_source_ast(ast, src_line_offsets, { regions, css_element_info, sc
158
158
  ).loc;
159
159
  const cssStart = loc_to_offset(openLoc.end.line, openLoc.end.column, src_line_offsets);
160
160
 
161
- const closeLoc = /** @type {ESTreeJSX.JSXClosingElement & AST.NodeWithLocation} */ (
162
- node.closingElement
163
- ).loc;
164
- const cssEnd = loc_to_offset(closeLoc.start.line, closeLoc.start.column, src_line_offsets);
161
+ const closeLoc =
162
+ /** @type {(ESTreeJSX.JSXClosingElement & AST.NodeWithLocation) | null} */ (
163
+ node.closingElement
164
+ )?.loc;
165
+ // An unclosed block recovered in loose mode has no closing tag; its
166
+ // body ends where the captured CSS ends.
167
+ const cssEnd = closeLoc
168
+ ? loc_to_offset(closeLoc.start.line, closeLoc.start.column, src_line_offsets)
169
+ : cssStart + node.css.length;
165
170
 
166
171
  regions.push({
167
172
  start: cssStart,
@@ -659,6 +664,13 @@ export function convert_source_map_to_mappings(
659
664
  if (node.metadata?.disable_verification) {
660
665
  token.mappingData = { ...mapping_data, verification: false };
661
666
  }
667
+ // A synthesized identifier that borrows an authored span so
668
+ // diagnostics land on it (e.g. the `$class` read of a type-only
669
+ // `apply` target): map for verification only, so hover and
670
+ // navigation on the authored token are not polluted by it.
671
+ if (node.metadata?.verify_only) {
672
+ token.mappingData = mapping_data_verify_only;
673
+ }
662
674
  // A generated identifier whose source span sits inside a string
663
675
  // literal (e.g. a server-module lowering's namespace reference
664
676
  // carrying the authored `'server'` import specifier): serve
@@ -1,6 +1,6 @@
1
1
  /** @import * as AST from 'estree' */
2
2
  /** @import * as ESTreeJSX from 'estree-jsx' */
3
- /** @import { ClassMapCollectionState, StyleRefOptions, TopScopedClasses, Visitors } from '../../types/index' */
3
+ /** @import { ClassMapCollectionState, StyleClassMapOptions, StyleRefOptions, TopScopedClasses, Visitors } from '../../types/index' */
4
4
 
5
5
  import { walk } from 'zimmerframe';
6
6
  import * as b from '../utils/builders.js';
@@ -14,40 +14,101 @@ import { clone_ast_node, clone_identifier } from './jsx/ast-builders.js';
14
14
  const regex_backslash_and_following_character = /\\(.)/g;
15
15
 
16
16
  /**
17
- * @param {AST.Node} component
18
- * @param {AST.CSS.StyleSheet} css
17
+ * @param {AST.Node} component the node whose metadata carries the scope's `topScopedClasses`
18
+ * @param {AST.CSS.StyleSheet | null} css
19
+ * @param {StyleClassMapOptions} [options]
19
20
  * @returns {AST.ObjectExpression}
20
21
  */
21
- export function create_style_class_map(component, css) {
22
+ export function create_style_class_map(component, css, options = {}) {
22
23
  return build_style_class_map(
23
- component.metadata?.topScopedClasses ?? collect_style_class_map_entries(css),
24
- css.hash,
24
+ component.metadata?.topScopedClasses ??
25
+ (css ? collect_style_class_map_entries(css) : new Map()),
26
+ options.hash ?? css?.hash ?? null,
27
+ options,
25
28
  );
26
29
  }
27
30
 
28
31
  /**
29
32
  * @param {AST.CSS.StyleSheet} css
33
+ * @param {StyleClassMapOptions} [options]
30
34
  * @returns {AST.ObjectExpression}
31
35
  */
32
- export function create_style_class_map_from_stylesheet(css) {
33
- return build_style_class_map(collect_style_class_map_entries(css), css.hash);
36
+ export function create_style_class_map_from_stylesheet(css, options = {}) {
37
+ return build_style_class_map(collect_style_class_map_entries(css), css.hash, options);
34
38
  }
35
39
 
36
40
  /**
37
- * `{ foo: 'hash foo', … }` for every class the style expression exposes.
41
+ * `{ $class: '<applied…> <hash>', foo: 'hash foo', … }` for every class the
42
+ * style expression exposes. `$class` comes first and is the block's own scope
43
+ * hash preceded by the `$class` of every applied theme (D6): adjacent static
44
+ * parts fold into one literal, runtime parts join with `+`.
38
45
  *
39
46
  * @param {TopScopedClasses} top_scoped_classes
40
47
  * @param {string | null} hash
48
+ * @param {StyleClassMapOptions} [options]
41
49
  * @returns {AST.ObjectExpression}
42
50
  */
43
- function build_style_class_map(top_scoped_classes, hash) {
51
+ export function build_style_class_map(top_scoped_classes, hash, options = {}) {
44
52
  const class_names = [...top_scoped_classes.keys()].sort();
53
+ /** @type {Array<string | AST.Expression>} */
54
+ const parts = [...(options.applied ?? [])];
55
+ if (hash) parts.push(hash);
45
56
 
46
- return b.object(
47
- class_names.map((class_name) =>
57
+ return b.object([
58
+ b.prop('init', b.literal('$class'), build_class_expression(parts)),
59
+ ...class_names.map((class_name) =>
48
60
  b.prop('init', b.literal(class_name), b.literal(hash ? `${hash} ${class_name}` : class_name)),
49
61
  ),
50
- );
62
+ ]);
63
+ }
64
+
65
+ /**
66
+ * Join class parts into one expression: a literal when every part is
67
+ * static, else a `+` chain with static runs folded together.
68
+ *
69
+ * @param {Array<string | AST.Expression>} parts
70
+ * @returns {AST.Expression}
71
+ */
72
+ function build_class_expression(parts) {
73
+ /** @type {AST.Expression | null} */
74
+ let result = null;
75
+ let pending = '';
76
+ /** @type {Set<string>} */
77
+ const seen = new Set();
78
+ /** @param {AST.Expression} expression */
79
+ const append = (expression) => {
80
+ result = result ? b.binary('+', result, expression) : expression;
81
+ };
82
+ for (const part of parts) {
83
+ if (typeof part === 'string') {
84
+ // Statically known hashes appear once even when applied through
85
+ // several themes (D12, static dedupe).
86
+ if (seen.has(part)) continue;
87
+ seen.add(part);
88
+ pending = pending ? `${pending} ${part}` : part;
89
+ continue;
90
+ }
91
+ if (result) {
92
+ append(b.literal(pending ? ` ${pending} ` : ' '));
93
+ } else if (pending) {
94
+ append(b.literal(`${pending} `));
95
+ }
96
+ pending = '';
97
+ append(clone_ast_node(part, false));
98
+ }
99
+ if (!result) return b.literal(pending);
100
+ if (pending) append(b.literal(` ${pending}`));
101
+ return result;
102
+ }
103
+
104
+ /**
105
+ * The class names an assigned block's class map exposes, in source order.
106
+ *
107
+ * @param {AST.CSS.StyleSheet} css
108
+ * @returns {string[]}
109
+ */
110
+ export function get_style_class_map_names(css) {
111
+ return [...collect_style_class_map_entries(css).keys()];
51
112
  }
52
113
 
53
114
  /**
@@ -543,6 +543,7 @@ export function render_css_result(stylesheets, minify = false) {
543
543
  }
544
544
  return {
545
545
  css: render_stylesheets(stylesheets, minify),
546
- cssHash: stylesheets.map((s) => s.hash).join(' '),
546
+ // One hash per scope: every sheet of a scope carries the scope's hash.
547
+ cssHash: [...new Set(stylesheets.map((s) => s.hash))].join(' '),
547
548
  };
548
549
  }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Whether an identifier (or a non-computed member chain rooted at one) is a
3
+ * reference to a binding rather than a property name, label, or specifier
4
+ * alias. Inlined from `is-reference@3.0.3` (MIT, Rich Harris,
5
+ * https://github.com/Rich-Harris/is-reference) so consumers that bundle the
6
+ * analyzer need no extra license notice for a 40-line helper.
7
+ *
8
+ * @import * as AST from 'estree'
9
+ */
10
+
11
+ /**
12
+ * @param {AST.Node} node
13
+ * @param {AST.Node | null | undefined} parent
14
+ * @returns {boolean}
15
+ */
16
+ export function is_reference(node, parent) {
17
+ if (node.type === 'MemberExpression') {
18
+ return !node.computed && is_reference(node.object, node);
19
+ }
20
+
21
+ if (node.type !== 'Identifier') return false;
22
+
23
+ switch (parent?.type) {
24
+ // disregard `bar` in `foo.bar`
25
+ case 'MemberExpression':
26
+ return parent.computed || node === parent.object;
27
+
28
+ // disregard the `foo` in `class {foo(){}}` but keep it in `class {[foo](){}}`
29
+ case 'MethodDefinition':
30
+ return parent.computed;
31
+
32
+ // disregard the `meta` in `import.meta`
33
+ case 'MetaProperty':
34
+ return parent.meta === node;
35
+
36
+ // disregard the `foo` in `class {foo=bar}` but keep it in `class {[foo]=bar}` and `class {bar=foo}`
37
+ case 'PropertyDefinition':
38
+ return parent.computed || node === parent.value;
39
+
40
+ // disregard the `bar` in `{ bar: foo }`, but keep it in `{ [bar]: foo }`
41
+ case 'Property':
42
+ return parent.computed || node === parent.value;
43
+
44
+ // disregard the `bar` in `export { foo as bar }` or
45
+ // the foo in `import { foo as bar }`
46
+ case 'ExportSpecifier':
47
+ case 'ImportSpecifier':
48
+ return node === parent.local;
49
+
50
+ // disregard the `foo` in `foo: while (...) { ... break foo; ... continue foo;}`
51
+ case 'LabeledStatement':
52
+ case 'BreakStatement':
53
+ case 'ContinueStatement':
54
+ return false;
55
+
56
+ default:
57
+ return true;
58
+ }
59
+ }
@@ -0,0 +1,71 @@
1
+ # Scoped style conformance fixtures
2
+
3
+ Target-neutral fixtures for sibling-scoped `<style>` blocks, `$class`, and `apply`
4
+ ([RFC tsrx-org/RFCs#1](https://github.com/tsrx-org/RFCs/discussions/1)). Each
5
+ `<name>.tsrx` compiles as one module (imports are never resolved) and its sibling
6
+ `<name>.expected.json` states what the output must contain. A subdirectory groups
7
+ the modules of a multi-file example; every `.tsrx` inside is still compiled on its
8
+ own.
9
+
10
+ The TSRX runner lives in
11
+ `packages/tsrx/tests/shared/scoped-styles-conformance.js`. Other compilers vendor
12
+ this directory and implement the same checks.
13
+
14
+ ## Labels
15
+
16
+ Hashes are position-derived and never appear in the JSON. Every scope and every
17
+ assigned block is named by a **label**, and the label resolves to a hash by
18
+ reading the marker selector `.<label>.<hash>` back from the compiled CSS.
19
+
20
+ Fixture conventions that make this work:
21
+
22
+ - Every `<style>` block with a body **starts with its label's marker rule**,
23
+ `.<label> { --label: <label>; }`. Blocks that share a scope repeat the scope's
24
+ label, so the sequence of marker rules in the CSS is the sheet emission order.
25
+ - A standalone block's marker must match an element, or it is pruned and has no
26
+ hash: put the label in the class list of some element the scope reaches
27
+ (`<div class="e1 scopeA">`). Assigned blocks keep their class selectors, so they
28
+ need no element.
29
+ - An assigned block's label is its variable name.
30
+ - A label of the form `import:<expr>` is not resolved; it stands for the runtime
31
+ read `<expr>.$class` of a block the compiler cannot see (an import, or any block
32
+ whose `$class` is not fully static).
33
+
34
+ ## `expected.json`
35
+
36
+ ```json
37
+ {
38
+ "elements": {
39
+ "e1 scopeA": ["scopeA", "import:theme"],
40
+ "{theme.dark}": ["scopeA"]
41
+ },
42
+ "cssOrder": ["theme", "scopeA", "scopeA", "scopeB"],
43
+ "pruned": ["p", "div"],
44
+ "classMaps": { "theme": ["base", "theme"], "bundle": ["import:a", "import:b"] },
45
+ "knownFailure": "optional: why the compiler currently disagrees"
46
+ }
47
+ ```
48
+
49
+ - **`elements`** — authored `class` value → labels the element must carry, in
50
+ order: enclosing scope hashes outer → inner, then applied themes. Static labels
51
+ are appended to the authored literal (`class="e1 scopeA <hashA>"`). A runtime
52
+ label makes it a template literal (``class={`e1 <hashA> ${theme.$class}`}``). A
53
+ key wrapped in braces is an expression-valued authored class and matches
54
+ ``class={`${theme.dark} <hashA>`}`` (or the untouched `class={theme.dark}` when
55
+ the chain is empty). Both `class=` and `className=` are accepted.
56
+ - **`cssOrder`** — labels of the marker rules in emission order: a scope's sheets
57
+ contiguous in source order, nested scopes after their parent, siblings in source
58
+ order, assigned blocks at their declaration position. `cssHash` must equal the
59
+ distinct hashes of this list, in order.
60
+ - **`pruned`** — selector texts expected inside `/* (unused) … */` comments, in
61
+ order. Standalone blocks prune selectors that match nothing in their reach;
62
+ assigned blocks that are neither exported nor applied prune every non-class
63
+ selector; exported or applied blocks prune nothing.
64
+ - **`classMaps`** — assigned-block variable → `$class` composition: applied
65
+ blocks' compositions first (transitively), the block's own label last. `own` may
66
+ stand for the variable's label. Adjacent static hashes share one string literal
67
+ and runtime labels are concatenated (`'<a> ' + x.$class + ' <own>'`). When the
68
+ composition includes the block's own label, the map must also carry
69
+ `'<label>': '<own hash> <label>'`.
70
+ - **`knownFailure`** — when present, the fixture documents a discrepancy between
71
+ the RFC and the compiler and is expected to fail until it is fixed.
@@ -0,0 +1,18 @@
1
+ {
2
+ "elements": {
3
+ "a1": ["one"],
4
+ "a2": ["one"],
5
+ "a3": ["one"],
6
+ "b1": ["one", "two"],
7
+ "c1 withBody": ["withBody", "one"],
8
+ "c2": ["withBody", "one"],
9
+ "d1": ["one", "two"]
10
+ },
11
+ "cssOrder": ["one", "two", "withBody", "withBody"],
12
+ "pruned": [],
13
+ "classMaps": {
14
+ "one": ["one"],
15
+ "two": ["two"],
16
+ "both": ["one", "two"]
17
+ }
18
+ }
@@ -0,0 +1,69 @@
1
+ // Every `apply` shape: self-closed, array, with a body, two blocks in one
2
+ // scope, and a body-less assigned bundle.
3
+ const one = <style>
4
+ .one {
5
+ --label: one;
6
+ }
7
+ div {
8
+ color: red;
9
+ }
10
+ </style>;
11
+
12
+ const two = <style>
13
+ .two {
14
+ --label: two;
15
+ }
16
+ div {
17
+ color: blue;
18
+ }
19
+ </style>;
20
+
21
+ export const both = <style apply={[one, two]} />;
22
+
23
+ export function SelfClosed() @{
24
+ <>
25
+ <style apply={one} />
26
+ <div class="a1">{'a'}</div>
27
+ <p class="a2">
28
+ <span class="a3">{'c'}</span>
29
+ </p>
30
+ </>
31
+ }
32
+
33
+ export function ArrayForm() @{
34
+ <>
35
+ <style apply={[one, two]} />
36
+ <div class="b1">{'b'}</div>
37
+ </>
38
+ }
39
+
40
+ export function WithBody() @{
41
+ <>
42
+ <style apply={one}>
43
+ .withBody {
44
+ --label: withBody;
45
+ }
46
+ div {
47
+ color: black;
48
+ }
49
+ </style>
50
+ <style>
51
+ .withBody {
52
+ --label: withBody;
53
+ }
54
+ p {
55
+ margin: 0;
56
+ }
57
+ </style>
58
+ <div class="c1 withBody">{'c'}</div>
59
+ <p class="c2">{'p'}</p>
60
+ </>
61
+ }
62
+
63
+ export function TwoApplies() @{
64
+ <>
65
+ <style apply={one} />
66
+ <style apply={two} />
67
+ <div class="d1">{'d'}</div>
68
+ </>
69
+ }