@tsrx/core 0.1.28 → 0.1.29

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": "Core compiler infrastructure for TSRX syntax",
4
4
  "license": "MIT",
5
5
  "author": "Dominic Gannaway",
6
- "version": "0.1.28",
6
+ "version": "0.1.29",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
@@ -27,6 +27,9 @@
27
27
  "./types/acorn": {
28
28
  "types": "./types/acorn.d.ts"
29
29
  },
30
+ "./types/helpers": {
31
+ "types": "./types/helpers.d.ts"
32
+ },
30
33
  "./runtime/ref": {
31
34
  "types": "./types/runtime/ref.d.ts",
32
35
  "default": "./src/runtime/ref.js"
@@ -756,11 +756,13 @@ function attribute_matches(node, name, expected_value, operator, case_insensitiv
756
756
  if (attribute.type !== 'JSXAttribute') continue;
757
757
 
758
758
  const lowerCaseName = name.toLowerCase();
759
+ const accepted_names = [lowerCaseName, `$${lowerCaseName}`];
760
+ if (lowerCaseName === 'class') {
761
+ // React-style targets author the class attribute as `className`.
762
+ accepted_names.push('classname');
763
+ }
759
764
  const attributeName = get_attribute_name(attribute);
760
- if (
761
- !attributeName ||
762
- ![lowerCaseName, `$${lowerCaseName}`].includes(attributeName.toLowerCase())
763
- ) {
765
+ if (!attributeName || !accepted_names.includes(attributeName.toLowerCase())) {
764
766
  continue;
765
767
  }
766
768
 
@@ -2,6 +2,7 @@
2
2
  @import * as AST from 'estree'
3
3
  @import * as ESTreeJSX from 'estree-jsx'
4
4
  @import { Parse } from '../../types/parse'
5
+ @import { NonEmptyString } from '../../types/helpers'
5
6
  */
6
7
 
7
8
  import * as acorn from 'acorn';
@@ -189,7 +190,7 @@ function elementTemplateClosingTagPlugin(Base) {
189
190
  * extend the base parser with framework-specific syntax.
190
191
  *
191
192
  * @param {...(AcornPlugin | Function)} plugins - Framework parser plugins to compose
192
- * @returns {(source: string, filename?: string, options?: any) => AST.Program} A parse function
193
+ * @returns {<T extends string>(source: string, filename: NonEmptyString<T>, options?: any) => AST.Program} A parse function
193
194
  */
194
195
  export function createParser(...plugins) {
195
196
  const parser = /** @type {Parse.ParserConstructor} */ (
@@ -204,7 +205,7 @@ export function createParser(...plugins) {
204
205
 
205
206
  /**
206
207
  * @param {string} source
207
- * @param {string} [filename]
208
+ * @param {string} filename
208
209
  * @param {any} [options]
209
210
  * @returns {AST.Program}
210
211
  */
@@ -1,5 +1,6 @@
1
1
  /** @import * as AST from 'estree' */
2
2
  /** @import { ParseOptions } from '../../types/index' */
3
+ /** @import { NonEmptyString } from '../../types/helpers' */
3
4
 
4
5
  import { createParser } from './index.js';
5
6
  import { TSRXPlugin } from '../plugin.js';
@@ -8,8 +9,9 @@ const parse = createParser(TSRXPlugin());
8
9
 
9
10
  /**
10
11
  * Parse source code to an ESTree AST using the TSRX parser.
12
+ * @template {string} T
11
13
  * @param {string} source
12
- * @param {string} [filename]
14
+ * @param {NonEmptyString<T>} filename
13
15
  * @param {ParseOptions} [options]
14
16
  * @returns {AST.Program}
15
17
  */
@@ -1,6 +1,7 @@
1
1
  /** @import * as AST from 'estree' */
2
+ /** @import { NonEmptyString } from '../../types/helpers' */
2
3
 
3
- import { simple_hash } from '../utils/hashing.js';
4
+ import { strong_hash } from '../utils/hashing.js';
4
5
 
5
6
  const REGEX_MATCHER = /^[~^$*|]?=/;
6
7
  const REGEX_ATTRIBUTE_FLAGS = /^[a-zA-Z]+/;
@@ -110,16 +111,24 @@ class Parser {
110
111
  }
111
112
 
112
113
  /**
114
+ * @template {string} T
113
115
  * @param {string} content
116
+ * @param {{ filename: NonEmptyString<T>, line: number, column: number }} location position of the `<style>` tag in the source file
114
117
  * @param {{ loose?: boolean }} options
115
118
  * @returns {AST.CSS.StyleSheet}
116
119
  */
117
- export function parse_style(content, options) {
120
+ export function parse_style(content, location, options) {
118
121
  const parser = new Parser(content, options.loose || false);
119
122
 
123
+ // The filename and the position of the `<style>` tag keep the hash unique
124
+ // when identical content appears in multiple style blocks within a file or
125
+ // across files. The filename may be an absolute path, so this must be a
126
+ // pre-image-resistant hash to avoid leaking file structure into the bundle.
127
+ const hash_source = `${location.filename}:${location.line}:${location.column}:${content}`;
128
+
120
129
  return {
121
130
  source: content,
122
- hash: `tsrx-${simple_hash(content)}`,
131
+ hash: `tsrx-${strong_hash(hash_source)}`,
123
132
  type: 'StyleSheet',
124
133
  children: read_body(parser),
125
134
  start: 0,
package/src/plugin.js CHANGED
@@ -1995,11 +1995,25 @@ export function TSRXPlugin(config) {
1995
1995
  * @param {boolean} insideHead
1996
1996
  */
1997
1997
  #parseStyleElement(open, node, insideHead) {
1998
+ const filename = this.#filename;
1999
+ if (!filename) {
2000
+ throw new Error(
2001
+ '<style> elements require a filename: pass one to parse so style scope hashes are unique per file.',
2002
+ );
2003
+ }
1998
2004
  const contentStart = open.end;
1999
2005
  const input = this.input.slice(contentStart);
2000
2006
  const relativeCloseStart = input.indexOf('</style>');
2001
2007
  const content = relativeCloseStart === -1 ? input : input.slice(0, relativeCloseStart);
2002
- const parsedCss = parse_style(content, { loose: this.#loose });
2008
+ const parsedCss = parse_style(
2009
+ content,
2010
+ {
2011
+ filename,
2012
+ line: open.loc.start.line,
2013
+ column: open.loc.start.column,
2014
+ },
2015
+ { loose: this.#loose },
2016
+ );
2003
2017
 
2004
2018
  if (!insideHead) {
2005
2019
  node.metadata.styleScopeHash = parsedCss.hash;
@@ -441,15 +441,6 @@ export function createJsxTransform(platform) {
441
441
  return next() ?? node;
442
442
  }
443
443
 
444
- if (is_style_element(node) && is_style_expression_position(path)) {
445
- const stylesheet = get_style_element_stylesheet(node);
446
- if (stylesheet) {
447
- analyze_css(stylesheet);
448
- state.stylesheets.push(stylesheet);
449
- return /** @type {any} */ (create_style_expression_value(node, stylesheet, state));
450
- }
451
- }
452
-
453
444
  // Capture raw children BEFORE the walker transforms them so platform
454
445
  // hooks can inspect the original JSX child shape.
455
446
  const raw_children = /** @type {any} */ (node.children || []).map(
@@ -487,16 +478,14 @@ export function createJsxTransform(platform) {
487
478
  const stylesheet = get_style_element_stylesheet(node);
488
479
  if (stylesheet) {
489
480
  analyze_css(stylesheet);
490
- state.stylesheets.push(stylesheet);
481
+ state.stylesheets.push(prepare_stylesheet_for_render(stylesheet, true));
491
482
  return /** @type {any} */ (create_style_expression_value(node, stylesheet, state));
492
483
  }
493
484
  }
494
- return /** @type {any} */ (
495
- b.jsx_element(
496
- /** @type {ESTreeJSX.JSXElement} */ ({ ...node, type: 'JSXElement', children: [] }),
497
- node.openingElement?.attributes ?? [],
498
- [],
499
- )
485
+ return b.jsx_element(
486
+ /** @type {ESTreeJSX.JSXElement} */ ({ ...node, type: 'JSXElement', children: [] }),
487
+ node.openingElement?.attributes ?? [],
488
+ [],
500
489
  );
501
490
  },
502
491
 
@@ -561,9 +550,7 @@ export function createJsxTransform(platform) {
561
550
  sourceMapContent: source,
562
551
  });
563
552
 
564
- const { css, cssHash } = render_css_result(
565
- /** @type {any} */ (stylesheets.map(prepare_stylesheet_for_render)),
566
- );
553
+ const { css, cssHash } = render_css_result(/** @type {any} */ (stylesheets));
567
554
 
568
555
  return { ast: final_program, code: result.code, map: result.map, css, cssHash };
569
556
  }
@@ -1378,10 +1365,10 @@ function transform_return_statement(node, { next, visit, state, path }) {
1378
1365
 
1379
1366
  /**
1380
1367
  * @param {any} node
1381
- * @param {{ state: TransformContext, path: AST.Node[] }} context
1368
+ * @param {{ state: TransformContext, path: AST.Node[], visit: (node: any, state?: TransformContext) => any }} context
1382
1369
  * @returns {any}
1383
1370
  */
1384
- function transform_jsx_code_block(node, { state, path }) {
1371
+ function transform_jsx_code_block(node, { state, path, visit }) {
1385
1372
  const body_nodes = get_jsx_code_block_body_nodes(node, state);
1386
1373
  const parent = /** @type {any} */ (path.at(-1));
1387
1374
 
@@ -1398,10 +1385,22 @@ function transform_jsx_code_block(node, { state, path }) {
1398
1385
  }
1399
1386
 
1400
1387
  const expression = b.call(
1401
- b.arrow([], b.block(build_render_statements(body_nodes, true, state), node)),
1388
+ b.arrow(
1389
+ [],
1390
+ b.block(
1391
+ mark_native_pretransformed_jsx(build_render_statements(body_nodes, true, state)),
1392
+ node,
1393
+ ),
1394
+ ),
1402
1395
  );
1403
1396
 
1404
- return in_jsx_child_context(path) ? to_jsx_expression_container(expression, node) : expression;
1397
+ // Setup statements were carried over verbatim, so re-visit the lowered
1398
+ // scope: TSRX-only nodes they contain (style elements, nested `@{ … }`
1399
+ // blocks) still need their own lowering before printing.
1400
+ const result = in_jsx_child_context(path)
1401
+ ? to_jsx_expression_container(expression, node)
1402
+ : expression;
1403
+ return visit(result, state);
1405
1404
  }
1406
1405
 
1407
1406
  /**
@@ -1922,6 +1921,8 @@ function prepare_tsrx_fragment_styles(node, transform_context) {
1922
1921
  if (!css) return null;
1923
1922
 
1924
1923
  const style_refs = collect_style_ref_attributes(node);
1924
+ // `prune_css` inside marks the matching selectors as used/scoped; selectors
1925
+ // that match no element render commented out, like the Ripple target.
1925
1926
  apply_css_definition_metadata(node, css, transform_context, style_refs.length > 0);
1926
1927
  transform_context.stylesheets.push(css);
1927
1928
  const fragment = annotate_tsrx_with_hash(
@@ -7,20 +7,47 @@
7
7
 
8
8
  import { walk } from 'zimmerframe';
9
9
  import * as b from '../utils/builders.js';
10
+ import { mark_class_map_selectors } from './style-ref.js';
10
11
 
11
12
  /**
12
- * Mark every selector inside the stylesheet as "used" so `renderStylesheets`
13
- * does not comment it out. We skip selector-pruning because component
14
- * boundaries can be dynamic — any selector authored inside the component's
15
- * `<style>` block is considered intentional.
13
+ * Mark selectors inside the stylesheet as "used" so `renderStylesheets` does
14
+ * not comment them out.
15
+ *
16
+ * For a free-standing `<style>` block every selector is marked: we skip
17
+ * selector-pruning because component boundaries can be dynamic — any selector
18
+ * authored inside the component's `<style>` block is considered intentional.
19
+ *
20
+ * When the `<style>` block is assigned to a variable (`is_style_expression`),
21
+ * the only selectors reachable through the generated class map are standalone
22
+ * class selectors — scoped (`.x`) or global-wrapped (`:global(.x)`). Anything
23
+ * else at the top level — element selectors, compound selectors, descendant
24
+ * chains, global tag selectors — never ends up in the class map and is marked
25
+ * unused for `renderStylesheets` to comment out. Selectors of nested rules ride
26
+ * along with their parent: they apply where the parent's class matched, and the
27
+ * whole rule is pruned when the parent itself is unreachable.
16
28
  *
17
29
  * @param {any} stylesheet
30
+ * @param {boolean} [is_style_expression]
18
31
  * @returns {any}
19
32
  */
20
- export function prepare_stylesheet_for_render(stylesheet) {
33
+ export function prepare_stylesheet_for_render(stylesheet, is_style_expression = false) {
34
+ if (is_style_expression) {
35
+ mark_class_map_selectors(stylesheet);
36
+ }
21
37
  walk(stylesheet, null, {
22
- _(node, { next }) {
38
+ _(node, { next, path }) {
23
39
  if (node && node.metadata && typeof node.metadata === 'object') {
40
+ if (
41
+ is_style_expression &&
42
+ node.type === 'ComplexSelector' &&
43
+ is_unreachable_via_class_map(node, path)
44
+ ) {
45
+ // Not in the generated class map. The analyzer pre-marks global
46
+ // selectors as used, so reset, and leave the subtree untouched —
47
+ // no `scoped` marks that would splice the hash into pruned output.
48
+ node.metadata.used = false;
49
+ return;
50
+ }
24
51
  node.metadata.used = true;
25
52
  if (node.type === 'RelativeSelector' && !node.metadata.is_global) {
26
53
  node.metadata.scoped = true;
@@ -32,6 +59,37 @@ export function prepare_stylesheet_for_render(stylesheet) {
32
59
  return stylesheet;
33
60
  }
34
61
 
62
+ /**
63
+ * True when a selector of a style expression should be pruned because nothing
64
+ * reachable through the generated class map can match it. The class map
65
+ * collection in `style-ref.js` is the single decider of what the map exposes:
66
+ * it marks the carrying prelude-level selectors with `class_map_selector`.
67
+ * The remaining cases are structural, not class-shaped: selectors of nested
68
+ * rules ride along with their parent (the whole rule is pruned when the parent
69
+ * is unreachable), selectors inside another selector's arguments belong to
70
+ * their enclosing prelude-level selector, and a bare `:global` block prelude
71
+ * is kept because its contents render unscoped as authored and cannot be
72
+ * pruned selector-by-selector.
73
+ *
74
+ * @param {any} complex_selector
75
+ * @param {any[]} path
76
+ * @returns {boolean}
77
+ */
78
+ function is_unreachable_via_class_map(complex_selector, path) {
79
+ if (complex_selector.metadata.class_map_selector) return false;
80
+ if (complex_selector.metadata.rule?.metadata?.parent_rule != null) return false;
81
+ if (path.some((parent) => parent.type === 'ComplexSelector')) return false;
82
+
83
+ if (complex_selector.children?.length === 1) {
84
+ const first = complex_selector.children[0]?.selectors?.[0];
85
+ if (first?.type === 'PseudoClassSelector' && first.name === 'global' && first.args === null) {
86
+ return false;
87
+ }
88
+ }
89
+
90
+ return true;
91
+ }
92
+
35
93
  /**
36
94
  * @param {any} node
37
95
  * @returns {boolean}
@@ -260,22 +260,43 @@ function collect_style_class_map_entries(css) {
260
260
  return entries;
261
261
  }
262
262
 
263
+ /**
264
+ * Stamp `class_map_selector` on the prelude-level selectors whose classes the
265
+ * class map exposes, without building the map. Runs the same collection as
266
+ * `create_style_class_map_from_stylesheet`, so marking and the generated map
267
+ * always agree; calling both is harmless.
268
+ *
269
+ * @param {any} css
270
+ * @returns {void}
271
+ */
272
+ export function mark_class_map_selectors(css) {
273
+ collect_rule_class_map_entries(css, new Map());
274
+ }
275
+
263
276
  /**
264
277
  * @param {any} node
265
278
  * @param {Map<string, any>} entries
279
+ * @param {any} [enclosing_selector] the nearest prelude-level selector; classes
280
+ * found inside another selector (e.g. in `:global(...)` args) mark it as the
281
+ * selector that carries their class map entry
266
282
  * @returns {void}
267
283
  */
268
- function collect_rule_class_map_entries(node, entries) {
284
+ function collect_rule_class_map_entries(node, entries, enclosing_selector = null) {
269
285
  if (!node || typeof node !== 'object') return;
270
286
 
271
287
  if (Array.isArray(node)) {
272
- for (const child of node) collect_rule_class_map_entries(child, entries);
288
+ for (const child of node) collect_rule_class_map_entries(child, entries, enclosing_selector);
273
289
  return;
274
290
  }
275
291
 
276
292
  if (node.type === 'ComplexSelector') {
293
+ enclosing_selector ??= node;
277
294
  const class_selector = get_standalone_class_selector(node);
278
295
  if (class_selector) {
296
+ // Mark the prelude-level selector for every occurrence (not just the
297
+ // deduped first) so the render preparation of style expressions keeps
298
+ // exactly the selectors whose classes the map exposes.
299
+ (enclosing_selector.metadata ??= {}).class_map_selector = true;
279
300
  const name = class_selector.name.replace(regex_backslash_and_following_character, '$1');
280
301
  if (!entries.has(name)) {
281
302
  entries.set(name, {
@@ -295,7 +316,7 @@ function collect_rule_class_map_entries(node, entries) {
295
316
  if (key === 'loc' || key === 'start' || key === 'end' || key === 'metadata') {
296
317
  continue;
297
318
  }
298
- collect_rule_class_map_entries(node[key], entries);
319
+ collect_rule_class_map_entries(node[key], entries, enclosing_selector);
299
320
  }
300
321
  }
301
322
 
@@ -9,3 +9,5 @@ export type Nullable<T> = T | null;
9
9
  export type Nullish<T> = T | null | undefined;
10
10
 
11
11
  export type NestedArray<T> = (T | NestedArray<T>)[];
12
+
13
+ export type NonEmptyString<T extends string> = T extends '' ? never : T;
package/types/index.d.ts CHANGED
@@ -4,7 +4,7 @@ import type { TSESTree } from '@typescript-eslint/types';
4
4
  import type { Parse } from './parse.js';
5
5
  import type * as ESRap from 'esrap';
6
6
  import type { Position } from 'acorn';
7
- import type { RequireAllOrNone } from '../src/helpers.js';
7
+ import type { RequireAllOrNone } from './helpers';
8
8
  import type {
9
9
  JsxPlatform,
10
10
  JsxPlatformHooks,