@tsrx/core 0.1.27 → 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.27",
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
@@ -547,7 +547,7 @@ export function TSRXPlugin(config) {
547
547
  let index = start;
548
548
  let value = '';
549
549
  while (index < this.input.length) {
550
- if (this.#isTemplateLineCommentStart(index)) {
550
+ if (this.#isTemplateLineCommentStart(index, start)) {
551
551
  const comment_start = index;
552
552
  const comment_start_loc = acorn.getLineInfo(this.input, comment_start);
553
553
  index += 2;
@@ -600,6 +600,7 @@ export function TSRXPlugin(config) {
600
600
  ch === CharCode.lessThan ||
601
601
  ch === CharCode.openBrace ||
602
602
  ch === CharCode.closeBrace ||
603
+ this.#isCodeBlockStart(index) ||
603
604
  this.#isJSXControlFlowDirectiveAt(index)
604
605
  ) {
605
606
  break;
@@ -961,14 +962,29 @@ export function TSRXPlugin(config) {
961
962
  }
962
963
 
963
964
  /**
965
+ * A `//` is a comment only when nothing but whitespace precedes it on its
966
+ * line, or — given `run_start`, the position where the current text run
967
+ * began (right after a sibling element, code block, or expression
968
+ * container) — since that boundary. Once real text has begun, `//` is
969
+ * literal so inline text like `https://…` stays text.
964
970
  * @param {number} index
971
+ * @param {number} [run_start]
965
972
  */
966
- #isTemplateLineCommentStart(index) {
967
- return (
968
- this.input.charCodeAt(index) === CharCode.slash &&
969
- this.input.charCodeAt(index + 1) === CharCode.slash &&
970
- this.#isLineStartPosition(index)
971
- );
973
+ #isTemplateLineCommentStart(index, run_start = -1) {
974
+ if (
975
+ this.input.charCodeAt(index) !== CharCode.slash ||
976
+ this.input.charCodeAt(index + 1) !== CharCode.slash
977
+ ) {
978
+ return false;
979
+ }
980
+ if (this.#isLineStartPosition(index)) return true;
981
+ if (run_start < 0) return false;
982
+ for (let i = index - 1; i >= run_start; i--) {
983
+ const ch = this.input.charCodeAt(i);
984
+ if (ch === CharCode.lineFeed || ch === CharCode.carriageReturn) return false;
985
+ if (ch !== CharCode.space && ch !== CharCode.tab) return false;
986
+ }
987
+ return true;
972
988
  }
973
989
 
974
990
  /**
@@ -996,7 +1012,7 @@ export function TSRXPlugin(config) {
996
1012
  ch === CharCode.openBrace ||
997
1013
  ch === CharCode.closeBrace ||
998
1014
  this.#isJSXControlFlowDirectiveAt(index) ||
999
- this.#isTemplateLineCommentStart(index) ||
1015
+ this.#isTemplateLineCommentStart(index, start) ||
1000
1016
  this.#isTemplateBlockCommentStart(index)
1001
1017
  ) {
1002
1018
  break;
@@ -1979,11 +1995,25 @@ export function TSRXPlugin(config) {
1979
1995
  * @param {boolean} insideHead
1980
1996
  */
1981
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
+ }
1982
2004
  const contentStart = open.end;
1983
2005
  const input = this.input.slice(contentStart);
1984
2006
  const relativeCloseStart = input.indexOf('</style>');
1985
2007
  const content = relativeCloseStart === -1 ? input : input.slice(0, relativeCloseStart);
1986
- 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
+ );
1987
2017
 
1988
2018
  if (!insideHead) {
1989
2019
  node.metadata.styleScopeHash = parsedCss.hash;
@@ -2714,7 +2744,10 @@ export function TSRXPlugin(config) {
2714
2744
  this.pos++;
2715
2745
  return this.finishToken(tt.arrow);
2716
2746
  }
2717
- if (code === CharCode.lessThan) {
2747
+ if (code === CharCode.lessThan && this.type !== tstt.jsxText) {
2748
+ // After a JSX text token a `<` can only open a tag; without this guard
2749
+ // text ending in an identifier character (`hello<div>`) would read as
2750
+ // the start of a type argument list (`hello<T>`).
2718
2751
  const next = this.input.charCodeAt(this.pos + 1);
2719
2752
  if (
2720
2753
  next !== CharCode.slash &&
@@ -4,20 +4,19 @@
4
4
  import tsx from 'esrap/languages/tsx';
5
5
 
6
6
  /**
7
- * Zimmerframe provides `path` as the ancestor chain. A native template node whose
8
- * parent is another native template node renders as a JSX child; anywhere else it
9
- * renders as a standalone expression (e.g. a return value).
7
+ * Zimmerframe provides `path` as the ancestor chain. A native template node in
8
+ * the children list of any JSX element/fragment renders as a JSX child;
9
+ * anywhere else it renders as a standalone expression (e.g. a return value).
10
+ * The parent may be a parsed native template node or a synthetic fragment the
11
+ * transform built around render children — either way a bare expression in a
12
+ * child slot would print as JSX text.
10
13
  *
11
14
  * @param {any[]} path
12
15
  * @returns {boolean}
13
16
  */
14
17
  export function in_jsx_child_context(path) {
15
18
  const parent = path[path.length - 1];
16
- return (
17
- !!parent &&
18
- (parent.type === 'JSXElement' || parent.type === 'JSXFragment') &&
19
- parent.metadata?.native_tsrx
20
- );
19
+ return !!parent && (parent.type === 'JSXElement' || parent.type === 'JSXFragment');
21
20
  }
22
21
 
23
22
  /**
@@ -146,10 +146,57 @@ function mark_nested_function_return_jsx(node, inside_function = false, seen = n
146
146
  }
147
147
 
148
148
  /**
149
- * Flatten a `@{ … }` code block that appears as an element/fragment child into
150
- * the element's children list: its setup statements followed by its single
151
- * render output. The render pipeline already handles interleaved setup
152
- * statements and JSX children. This is the element-scoped equivalent of
149
+ * Lower a `@{ … }` code block that appears as an element/fragment child,
150
+ * paying only for what the block uses while keeping each block its own
151
+ * lexical scope:
152
+ *
153
+ * - no setup code: the scope is unobservable, so the render output merges
154
+ * directly into the children list (template-only chains collapse to the
155
+ * innermost output, empty chains to nothing);
156
+ * - code-only: a plain `{ … }` statement block — statements run in source
157
+ * order, scoped, and render nothing (the render pipeline already handles
158
+ * statements interleaved with JSX children);
159
+ * - setup code + render output: kept as a `JSXCodeBlock` (with any nested
160
+ * chain simplified) for the context-aware lowering into a scoped IIFE
161
+ * (`transform_jsx_code_block` / `build_render_statements`).
162
+ *
163
+ * Always returns zero or one node.
164
+ * @param {any} block
165
+ * @returns {any[]}
166
+ */
167
+ function lower_code_block_child(block) {
168
+ const body = block.body || [];
169
+ const render = block.render ?? null;
170
+
171
+ if (body.length === 0) {
172
+ if (render == null) return [];
173
+ if (render.type === 'JSXCodeBlock') return lower_code_block_child(render);
174
+ return [render];
175
+ }
176
+
177
+ if (render?.type === 'JSXCodeBlock') {
178
+ const inner = lower_code_block_child(render);
179
+ if (inner.length === 0) {
180
+ return [b.block(body, block)];
181
+ }
182
+ if (inner[0].type === 'BlockStatement') {
183
+ return [b.block([...body, inner[0]], block)];
184
+ }
185
+ // The chain still renders — simplify the render to the lowered inner
186
+ // node and leave the block for the context-aware lowering.
187
+ return [{ ...block, render: inner[0] }];
188
+ }
189
+
190
+ if (render == null) {
191
+ return [b.block(body, block)];
192
+ }
193
+
194
+ return [block];
195
+ }
196
+
197
+ /**
198
+ * Lower `@{ … }` code blocks that appear as element/fragment children (see
199
+ * `lower_code_block_child`). This is the element-scoped equivalent of
153
200
  * `transform_function`'s body lowering — function and arrow bodies are never
154
201
  * element children, so they are untouched here.
155
202
  * @param {any} node
@@ -170,9 +217,7 @@ function expand_child_code_blocks(node, seen = new Set()) {
170
217
  node.children.some((/** @type {any} */ c) => c?.type === 'JSXCodeBlock')
171
218
  ) {
172
219
  node.children = node.children.flatMap((/** @type {any} */ child) =>
173
- child?.type === 'JSXCodeBlock'
174
- ? [...child.body, ...(child.render != null ? [child.render] : [])]
175
- : [child],
220
+ child?.type === 'JSXCodeBlock' ? lower_code_block_child(child) : [child],
176
221
  );
177
222
  }
178
223
 
@@ -396,15 +441,6 @@ export function createJsxTransform(platform) {
396
441
  return next() ?? node;
397
442
  }
398
443
 
399
- if (is_style_element(node) && is_style_expression_position(path)) {
400
- const stylesheet = get_style_element_stylesheet(node);
401
- if (stylesheet) {
402
- analyze_css(stylesheet);
403
- state.stylesheets.push(stylesheet);
404
- return /** @type {any} */ (create_style_expression_value(node, stylesheet, state));
405
- }
406
- }
407
-
408
444
  // Capture raw children BEFORE the walker transforms them so platform
409
445
  // hooks can inspect the original JSX child shape.
410
446
  const raw_children = /** @type {any} */ (node.children || []).map(
@@ -442,16 +478,14 @@ export function createJsxTransform(platform) {
442
478
  const stylesheet = get_style_element_stylesheet(node);
443
479
  if (stylesheet) {
444
480
  analyze_css(stylesheet);
445
- state.stylesheets.push(stylesheet);
481
+ state.stylesheets.push(prepare_stylesheet_for_render(stylesheet, true));
446
482
  return /** @type {any} */ (create_style_expression_value(node, stylesheet, state));
447
483
  }
448
484
  }
449
- return /** @type {any} */ (
450
- b.jsx_element(
451
- /** @type {ESTreeJSX.JSXElement} */ ({ ...node, type: 'JSXElement', children: [] }),
452
- node.openingElement?.attributes ?? [],
453
- [],
454
- )
485
+ return b.jsx_element(
486
+ /** @type {ESTreeJSX.JSXElement} */ ({ ...node, type: 'JSXElement', children: [] }),
487
+ node.openingElement?.attributes ?? [],
488
+ [],
455
489
  );
456
490
  },
457
491
 
@@ -516,9 +550,7 @@ export function createJsxTransform(platform) {
516
550
  sourceMapContent: source,
517
551
  });
518
552
 
519
- const { css, cssHash } = render_css_result(
520
- /** @type {any} */ (stylesheets.map(prepare_stylesheet_for_render)),
521
- );
553
+ const { css, cssHash } = render_css_result(/** @type {any} */ (stylesheets));
522
554
 
523
555
  return { ast: final_program, code: result.code, map: result.map, css, cssHash };
524
556
  }
@@ -801,6 +833,79 @@ function build_component_statements(body_nodes, transform_context) {
801
833
  return build_render_statements(body_nodes, false, transform_context);
802
834
  }
803
835
 
836
+ /**
837
+ * Statements for one `@{ … }` scope level: the setup statements followed by
838
+ * the lowered chain continuation. A nested level that declares anything is
839
+ * kept in a nested plain `{ … }` block, so a whole chain shares a single
840
+ * closure while still scoping each level; the generated `return` exits that
841
+ * closure.
842
+ * @param {any} block
843
+ * @param {TransformContext} transform_context
844
+ * @returns {{ statements: any[], has_render: boolean }}
845
+ */
846
+ function code_block_scope_statements(block, transform_context) {
847
+ const statements = [...(block.body || [])];
848
+ const render = block.render ?? null;
849
+
850
+ if (render == null) {
851
+ return { statements, has_render: false };
852
+ }
853
+
854
+ if (render.type === 'JSXCodeBlock') {
855
+ const inner = code_block_scope_statements(render, transform_context);
856
+ if (inner.statements.length > 0) {
857
+ if ((render.body || []).length > 0) {
858
+ statements.push(b.block(inner.statements, render));
859
+ } else {
860
+ statements.push(...inner.statements);
861
+ }
862
+ }
863
+ return { statements, has_render: inner.has_render };
864
+ }
865
+
866
+ return {
867
+ statements: [...statements, ...build_render_statements([render], true, transform_context)],
868
+ has_render: true,
869
+ };
870
+ }
871
+
872
+ /**
873
+ * Lower a `@{ … }` code block that appears in a component/IIFE statement
874
+ * stream, keeping each block its own lexical scope:
875
+ *
876
+ * - no setup code: the scope is unobservable, so the render output (if any)
877
+ * merges directly into the stream;
878
+ * - code-only: a plain `{ … }` statement block;
879
+ * - setup code + render output: a scoped IIFE expression child whose value is
880
+ * the render output, with nested chains folded into the one closure.
881
+ *
882
+ * Always returns zero or one node.
883
+ * @param {any} block
884
+ * @param {TransformContext} transform_context
885
+ * @returns {any[]}
886
+ */
887
+ function lower_code_block_stream_node(block, transform_context) {
888
+ const body = block.body || [];
889
+ const render = block.render ?? null;
890
+
891
+ if (body.length === 0) {
892
+ if (render == null) return [];
893
+ if (render.type === 'JSXCodeBlock') {
894
+ return lower_code_block_stream_node(render, transform_context);
895
+ }
896
+ return [render];
897
+ }
898
+
899
+ const { statements, has_render } = code_block_scope_statements(block, transform_context);
900
+
901
+ if (!has_render) {
902
+ return [b.block(statements, block)];
903
+ }
904
+
905
+ const iife = b.call(b.arrow([], b.block(statements, block)));
906
+ return [to_jsx_expression_container(iife, block)];
907
+ }
908
+
804
909
  /**
805
910
  * @param {any[]} body_nodes
806
911
  * @param {boolean} return_null_when_empty
@@ -809,9 +914,7 @@ function build_component_statements(body_nodes, transform_context) {
809
914
  */
810
915
  function build_render_statements(body_nodes, return_null_when_empty, transform_context) {
811
916
  body_nodes = body_nodes.flatMap((node) =>
812
- node?.type === 'JSXCodeBlock'
813
- ? [...node.body, ...(node.render != null ? [node.render] : [])]
814
- : [node],
917
+ node?.type === 'JSXCodeBlock' ? lower_code_block_stream_node(node, transform_context) : [node],
815
918
  );
816
919
 
817
920
  const statements = [];
@@ -1262,10 +1365,10 @@ function transform_return_statement(node, { next, visit, state, path }) {
1262
1365
 
1263
1366
  /**
1264
1367
  * @param {any} node
1265
- * @param {{ state: TransformContext, path: AST.Node[] }} context
1368
+ * @param {{ state: TransformContext, path: AST.Node[], visit: (node: any, state?: TransformContext) => any }} context
1266
1369
  * @returns {any}
1267
1370
  */
1268
- function transform_jsx_code_block(node, { state, path }) {
1371
+ function transform_jsx_code_block(node, { state, path, visit }) {
1269
1372
  const body_nodes = get_jsx_code_block_body_nodes(node, state);
1270
1373
  const parent = /** @type {any} */ (path.at(-1));
1271
1374
 
@@ -1282,10 +1385,22 @@ function transform_jsx_code_block(node, { state, path }) {
1282
1385
  }
1283
1386
 
1284
1387
  const expression = b.call(
1285
- 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
+ ),
1286
1395
  );
1287
1396
 
1288
- 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);
1289
1404
  }
1290
1405
 
1291
1406
  /**
@@ -1806,6 +1921,8 @@ function prepare_tsrx_fragment_styles(node, transform_context) {
1806
1921
  if (!css) return null;
1807
1922
 
1808
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.
1809
1926
  apply_css_definition_metadata(node, css, transform_context, style_refs.length > 0);
1810
1927
  transform_context.stylesheets.push(css);
1811
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
 
@@ -1249,6 +1249,44 @@ export function jsx_fragment(children = [], attributes = []) {
1249
1249
  };
1250
1250
  }
1251
1251
 
1252
+ /**
1253
+ * Ripple's internal fragment template node (the normalized form of a
1254
+ * `JSXFragment`).
1255
+ * @param {AST.Node[]} [children]
1256
+ * @param {AST.NodeWithLocation} [loc_info]
1257
+ * @returns {AST.TsrxFragment}
1258
+ */
1259
+ export function tsrx_fragment(children = [], loc_info) {
1260
+ const node = /** @type {AST.TsrxFragment} */ (
1261
+ /** @type {unknown} */ ({
1262
+ type: 'TsrxFragment',
1263
+ children,
1264
+ attributes: [],
1265
+ selfClosing: false,
1266
+ metadata: { path: [] },
1267
+ })
1268
+ );
1269
+
1270
+ return set_location(node, loc_info);
1271
+ }
1272
+
1273
+ /**
1274
+ * Ripple's internal expression template child (the normalized form of a
1275
+ * `JSXExpressionContainer` child).
1276
+ * @param {AST.Expression} expression
1277
+ * @param {AST.NodeWithLocation} [loc_info]
1278
+ * @returns {AST.TSRXExpression}
1279
+ */
1280
+ export function tsrx_expression(expression, loc_info) {
1281
+ const node = /** @type {AST.TSRXExpression} */ ({
1282
+ type: 'TSRXExpression',
1283
+ expression,
1284
+ metadata: { path: [] },
1285
+ });
1286
+
1287
+ return set_location(node, loc_info);
1288
+ }
1289
+
1252
1290
  /**
1253
1291
  * @param {AST.Expression | ESTreeJSX.JSXEmptyExpression} expression
1254
1292
  * @param {AST.NodeWithLocation} [loc_info]
@@ -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,
@@ -170,6 +170,17 @@ declare module 'estree' {
170
170
  interface SimpleCallExpression {
171
171
  metadata: BaseNodeMetaData & {
172
172
  hash?: string;
173
+ /**
174
+ * A generated `(() => @{ … })()` inline-component IIFE for a code
175
+ * block; collapsible once the block's statements lower into the
176
+ * component callback.
177
+ */
178
+ tsrx_code_block_component?: boolean;
179
+ /**
180
+ * A generated zero-argument scope IIFE for a `@{ … }` code-block
181
+ * chain level; runs synchronously inside its `with_scope` wrapper.
182
+ */
183
+ tsrx_code_block_scope?: boolean;
173
184
  };
174
185
  }
175
186
 
@@ -323,7 +334,14 @@ declare module 'estree' {
323
334
  closingElement?: ESTreeJSX.JSXClosingFragment | null;
324
335
  selfClosing?: boolean;
325
336
  attributes?: Array<Attribute | SpreadAttribute>;
326
- metadata: BaseNodeMetaData;
337
+ metadata: BaseNodeMetaData & {
338
+ /**
339
+ * A synthetic wrapper for a nested code-block render chain
340
+ * (`@{ @{ … } }`), so render-slot consumers see a template node;
341
+ * template-children lowering unwraps it.
342
+ */
343
+ tsrx_code_block_chain?: boolean;
344
+ };
327
345
  start: number;
328
346
  end: number;
329
347
  }