@tsrx/core 0.1.65 → 0.1.67

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 (55) 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/prune.js +36 -9
  6. package/src/analyze/style-analyze.js +467 -0
  7. package/src/analyze/validation.js +57 -0
  8. package/src/diagnostics.js +22 -0
  9. package/src/index.js +18 -0
  10. package/src/parse/style.js +97 -7
  11. package/src/plugin.js +145 -47
  12. package/src/scope.js +1 -1
  13. package/src/transform/jsx/index.js +94 -417
  14. package/src/transform/jsx/style-scopes.js +866 -0
  15. package/src/transform/scoping.js +129 -79
  16. package/src/transform/segments.js +16 -4
  17. package/src/transform/style-ref.js +74 -13
  18. package/src/transform/stylesheet.js +2 -1
  19. package/src/utils/is-reference.js +59 -0
  20. package/tests/fixtures/scoped-styles/README.md +71 -0
  21. package/tests/fixtures/scoped-styles/apply-forms.expected.json +18 -0
  22. package/tests/fixtures/scoped-styles/apply-forms.tsrx +69 -0
  23. package/tests/fixtures/scoped-styles/assigned-positions.expected.json +29 -0
  24. package/tests/fixtures/scoped-styles/assigned-positions.tsrx +90 -0
  25. package/tests/fixtures/scoped-styles/class-opt-in.expected.json +13 -0
  26. package/tests/fixtures/scoped-styles/class-opt-in.tsrx +39 -0
  27. package/tests/fixtures/scoped-styles/control-flow-else-if.expected.json +11 -0
  28. package/tests/fixtures/scoped-styles/control-flow-else-if.tsrx +26 -0
  29. package/tests/fixtures/scoped-styles/control-flow.expected.json +17 -0
  30. package/tests/fixtures/scoped-styles/control-flow.tsrx +102 -0
  31. package/tests/fixtures/scoped-styles/cross-module-apply.expected.json +14 -0
  32. package/tests/fixtures/scoped-styles/cross-module-apply.tsrx +48 -0
  33. package/tests/fixtures/scoped-styles/element-rooted-templates.expected.json +12 -0
  34. package/tests/fixtures/scoped-styles/element-rooted-templates.tsrx +33 -0
  35. package/tests/fixtures/scoped-styles/precedence.expected.json +11 -0
  36. package/tests/fixtures/scoped-styles/precedence.tsrx +45 -0
  37. package/tests/fixtures/scoped-styles/rfc-opening-example/panel.expected.json +12 -0
  38. package/tests/fixtures/scoped-styles/rfc-opening-example/panel.tsrx +46 -0
  39. package/tests/fixtures/scoped-styles/rfc-opening-example/theme.expected.json +9 -0
  40. package/tests/fixtures/scoped-styles/rfc-opening-example/theme.tsrx +24 -0
  41. package/tests/fixtures/scoped-styles/search-panel.expected.json +11 -0
  42. package/tests/fixtures/scoped-styles/search-panel.tsrx +48 -0
  43. package/tests/fixtures/scoped-styles/sibling-combinators.expected.json +16 -0
  44. package/tests/fixtures/scoped-styles/sibling-combinators.tsrx +42 -0
  45. package/tests/fixtures/scoped-styles/sibling-scope.expected.json +11 -0
  46. package/tests/fixtures/scoped-styles/sibling-scope.tsrx +44 -0
  47. package/tests/fixtures/scoped-styles/sibling-scopes.expected.json +12 -0
  48. package/tests/fixtures/scoped-styles/sibling-scopes.tsrx +51 -0
  49. package/tests/fixtures/scoped-styles/theme-composition.expected.json +14 -0
  50. package/tests/fixtures/scoped-styles/theme-composition.tsrx +45 -0
  51. package/tests/fixtures/scoped-styles/theme-diamond.expected.json +10 -0
  52. package/tests/fixtures/scoped-styles/theme-diamond.tsrx +18 -0
  53. package/tests/shared/scoped-styles-fixtures.js +67 -0
  54. package/tests/utils/fixtures/style-syntax.js +519 -0
  55. package/types/index.d.ts +85 -0
@@ -113,7 +113,15 @@ class Parser {
113
113
  /**
114
114
  * @template {string} T
115
115
  * @param {string} content
116
- * @param {{ filename: NonEmptyString<T>, line: number, column: number }} location position of the `<style>` tag in the source file
116
+ * @param {{
117
+ * filename: NonEmptyString<T>,
118
+ * line: number,
119
+ * column: number,
120
+ * body?: { start: number, line: number, column: number },
121
+ * }} location position of the `<style>` tag in the source file, and optionally
122
+ * `body`, the file offset and 1-based line / 0-based column of the style body's
123
+ * first character. With `body`, the sheet records `sourceStart` and a
124
+ * file-relative `loc`, and diagnostics on its nodes carry file positions.
117
125
  * @param {{ loose?: boolean }} options
118
126
  * @returns {AST.CSS.StyleSheet}
119
127
  */
@@ -126,14 +134,82 @@ export function parse_style(content, location, options) {
126
134
  // pre-image-resistant hash to avoid leaking file structure into the bundle.
127
135
  const hash_source = `${location.filename}:${location.line}:${location.column}:${content}`;
128
136
 
129
- return {
137
+ /** @type {AST.CSS.StyleSheet} */
138
+ const sheet = {
130
139
  source: content,
131
140
  hash: `tsrx-${strong_hash(hash_source)}`,
132
141
  type: 'StyleSheet',
133
142
  children: read_body(parser),
134
143
  start: 0,
135
144
  end: content.length,
145
+ filename: location.filename,
136
146
  };
147
+
148
+ if (location.body) {
149
+ sheet.sourceStart = location.body.start;
150
+ sheet.loc = {
151
+ start: { line: location.body.line, column: location.body.column },
152
+ end: css_position(sheet, content.length, location.body),
153
+ };
154
+ }
155
+
156
+ return sheet;
157
+ }
158
+
159
+ /**
160
+ * Map a style-body offset to a file line / column, given the file position of
161
+ * the body's first character.
162
+ *
163
+ * @param {AST.CSS.StyleSheet} sheet
164
+ * @param {number} offset
165
+ * @param {{ line: number, column: number }} origin
166
+ * @returns {{ line: number, column: number }}
167
+ */
168
+ function css_position(sheet, offset, origin) {
169
+ const source = sheet.source;
170
+ let line = origin.line;
171
+ let line_start = -1;
172
+ for (let i = 0; i < offset; i++) {
173
+ const ch = source.charCodeAt(i);
174
+ if (ch === 13 && source.charCodeAt(i + 1) === 10) {
175
+ i++;
176
+ } else if (ch !== 10 && ch !== 13 && ch !== 0x2028 && ch !== 0x2029) {
177
+ continue;
178
+ }
179
+ line++;
180
+ line_start = i;
181
+ }
182
+ return {
183
+ line,
184
+ column: line_start === -1 ? origin.column + offset : offset - line_start - 1,
185
+ };
186
+ }
187
+
188
+ /**
189
+ * The file-relative `start` / `end` / `loc` of a CSS node in a sheet parsed
190
+ * with a `body` origin; the node itself when the sheet has none. CSS node
191
+ * offsets are relative to the style body, so this is what a diagnostic on a
192
+ * CSS node has to be anchored on.
193
+ *
194
+ * @param {AST.CSS.StyleSheet | null | undefined} sheet
195
+ * @param {AST.CSS.Node} node
196
+ * @returns {AST.Node | AST.NodeWithLocation}
197
+ */
198
+ export function css_node_source_position(sheet, node) {
199
+ if (sheet?.sourceStart === undefined || !sheet.loc) {
200
+ return /** @type {AST.Node} */ (/** @type {unknown} */ (node));
201
+ }
202
+ const origin = sheet.loc.start;
203
+ return /** @type {AST.NodeWithLocation} */ (
204
+ /** @type {unknown} */ ({
205
+ start: node.start + sheet.sourceStart,
206
+ end: node.end + sheet.sourceStart,
207
+ loc: {
208
+ start: css_position(sheet, node.start, origin),
209
+ end: css_position(sheet, node.end, origin),
210
+ },
211
+ })
212
+ );
137
213
  }
138
214
 
139
215
  /** @param {Parser} parser */
@@ -163,12 +239,26 @@ function read_body(parser) {
163
239
  const children = [];
164
240
 
165
241
  while (parser.index < parser.template.length) {
166
- allow_comment_or_whitespace(parser);
242
+ try {
243
+ allow_comment_or_whitespace(parser);
167
244
 
168
- if (parser.match('@')) {
169
- children.push(read_at_rule(parser));
170
- } else {
171
- children.push(read_rule(parser));
245
+ if (parser.index >= parser.template.length) {
246
+ break;
247
+ }
248
+
249
+ if (parser.match('@')) {
250
+ children.push(read_at_rule(parser));
251
+ } else {
252
+ children.push(read_rule(parser));
253
+ }
254
+ } catch (error) {
255
+ if (!parser.loose) {
256
+ throw error;
257
+ }
258
+ // Partial CSS while typing (`.foo {`, `/*`), or whatever follows an
259
+ // unclosed module-scope `<style>`, must not take the whole file down in
260
+ // loose mode: keep the rules parsed so far and drop the rest.
261
+ break;
172
262
  }
173
263
  }
174
264
 
package/src/plugin.js CHANGED
@@ -2245,26 +2245,66 @@ export function TSRXPlugin(config) {
2245
2245
  * synthesize the closing element, and restore the tokenizer state past it.
2246
2246
  * Shared by `<style>` and `<script>`.
2247
2247
  *
2248
+ * Without a closing tag the element is unclosed and the rest of the input
2249
+ * is its body, except that in loose mode inside a template the body stops
2250
+ * at the next tag start (`<x`, `</x>`, `</>`): while `<style>` is being
2251
+ * typed, partial CSS reaches the loose CSS parser, and the tokenizer
2252
+ * resumes at the following sibling or closing tag so the rest of the file
2253
+ * keeps its mappings. Outside a template only JS follows, with no marker to
2254
+ * resume at, so the body runs to the end of the input.
2255
+ *
2248
2256
  * @param {ESTreeJSX.TSRXJSXOpeningElement & AST.NodeWithLocation} open
2249
2257
  * @param {AST.JSXStyleElement | AST.TSRXJSXElement} node
2250
2258
  * @param {'style' | 'script'} tagName
2259
+ * @param {number} [contextDepth] tokenizer context depth to restore before
2260
+ * reading the token after the closing tag when the element sits outside
2261
+ * a template (see parseElement)
2251
2262
  * @returns {string} The raw body text
2252
2263
  */
2253
- #parseRawTextElement(open, node, tagName) {
2264
+ #parseRawTextElement(open, node, tagName, contextDepth) {
2254
2265
  const closeTag = `</${tagName}>`;
2255
2266
  const contentStart = open.end;
2256
2267
  const input = this.input.slice(contentStart);
2257
- const relativeCloseStart = input.indexOf(closeTag);
2258
- const content = relativeCloseStart === -1 ? input : input.slice(0, relativeCloseStart);
2268
+ const parent = this.#path.at(-2);
2269
+ const insideTemplate = this.#isNativeTemplateNode(parent);
2270
+ let relativeCloseStart = input.indexOf(closeTag);
2271
+ const unclosed = relativeCloseStart === -1;
2259
2272
 
2273
+ if (unclosed) {
2274
+ this.#report_broken_markup_error(
2275
+ open.end,
2276
+ `Unclosed tag '<${tagName}>'. Expected '${closeTag}' before end of template.`,
2277
+ );
2278
+ node.unclosed = true;
2279
+ relativeCloseStart = input.length;
2280
+ if (!this.#loose) {
2281
+ const newLines = input.match(regex_newline_characters)?.length;
2282
+ if (newLines) {
2283
+ this.curLine = open.loc.end.line + newLines;
2284
+ this.lineStart = contentStart + input.lastIndexOf('\n') + 1;
2285
+ }
2286
+ return input;
2287
+ }
2288
+ if (insideTemplate) {
2289
+ // A `<` inside CSS text (`content: "<"`, `<!--`) is not a tag start.
2290
+ let lt = input.indexOf('<');
2291
+ while (lt !== -1 && !can_start_tag_after_lt(input, lt)) {
2292
+ lt = input.indexOf('<', lt + 1);
2293
+ }
2294
+ if (lt !== -1) relativeCloseStart = lt;
2295
+ }
2296
+ }
2297
+
2298
+ const content = input.slice(0, relativeCloseStart);
2260
2299
  const newLines = content.match(regex_newline_characters)?.length;
2261
2300
  if (newLines) {
2262
2301
  this.curLine = open.loc.end.line + newLines;
2263
2302
  this.lineStart = contentStart + content.lastIndexOf('\n') + 1;
2264
2303
  }
2265
2304
 
2266
- if (relativeCloseStart !== -1) {
2267
- const closingStart = contentStart + content.length;
2305
+ const closingStart = contentStart + content.length;
2306
+ let closingEnd = closingStart;
2307
+ if (!unclosed) {
2268
2308
  const closingLineInfo = get_line_info(this, closingStart);
2269
2309
  const closingStartLoc = new acorn.Position(closingLineInfo.line, closingLineInfo.column);
2270
2310
  const nameStart = closingStart + 2;
@@ -2284,7 +2324,7 @@ export function TSRXPlugin(config) {
2284
2324
  nameEnd,
2285
2325
  new acorn.Position(nameEndInfo.line, nameEndInfo.column),
2286
2326
  );
2287
- const closingEnd = closingStart + closeTag.length;
2327
+ closingEnd = closingStart + closeTag.length;
2288
2328
  const closingEndInfo = get_line_info(this, closingEnd);
2289
2329
  const closingElement =
2290
2330
  /** @type {ESTreeJSX.TSRXJSXClosingElement & AST.NodeWithLocation} */ (
@@ -2298,65 +2338,84 @@ export function TSRXPlugin(config) {
2298
2338
  new acorn.Position(closingEndInfo.line, closingEndInfo.column),
2299
2339
  );
2300
2340
  node.closingElement = closingElement;
2301
- const parent = this.#path.at(-2);
2302
- const insideTemplate = this.#isNativeTemplateNode(parent);
2303
- if (this.curContext() === tstc.tc_expr && !insideTemplate) {
2341
+ }
2342
+
2343
+ if (this.curContext() === tstc.tc_expr && !insideTemplate) {
2344
+ this.context.pop();
2345
+ }
2346
+ this.exprAllowed = false;
2347
+ this.pos = closingEnd;
2348
+ const closingEndInfo = get_line_info(this, closingEnd);
2349
+ this.curLine = closingEndInfo.line;
2350
+ this.lineStart = closingEnd - closingEndInfo.column;
2351
+ if (insideTemplate && relativeCloseStart === 0) {
2352
+ // Acorn has already tokenized the adjacent tag start (this element's
2353
+ // closing tag, or, when unclosed, the next sibling or parent close);
2354
+ // the element resumes there manually, so drop the stale tag context.
2355
+ if (this.curContext() === tstc.tc_oTag) {
2304
2356
  this.context.pop();
2305
2357
  }
2306
- this.exprAllowed = false;
2307
- this.pos = closingEnd;
2308
- this.curLine = closingEndInfo.line;
2309
- this.lineStart = closingEnd - closingEndInfo.column;
2310
- if (insideTemplate && relativeCloseStart === 0) {
2311
- // Acorn has already tokenized the adjacent closing tag; TSRX
2312
- // synthesizes that close manually, so drop the stale tag context.
2313
- if (this.curContext() === tstc.tc_oTag) {
2314
- this.context.pop();
2315
- }
2316
- if (this.curContext() === tstc.tc_expr) {
2317
- this.context.pop();
2318
- }
2358
+ if (this.curContext() === tstc.tc_expr) {
2359
+ this.context.pop();
2319
2360
  }
2320
- if (!insideTemplate && this.#path.at(-1) === node) {
2321
- this.#path.pop();
2322
- try {
2323
- this.next();
2324
- } finally {
2325
- this.#path.push(node);
2326
- }
2327
- } else {
2361
+ }
2362
+ if (!insideTemplate && this.#path.at(-1) === node) {
2363
+ // Outside a template (a `@{ … }` body, a `@case` body, a statement),
2364
+ // the element must leave the tokenizer context exactly where it
2365
+ // began — like a balanced element does after its closing tag — so
2366
+ // the following `}`, `@case`, or sibling tokenizes as code, not as
2367
+ // JSX text of a children context this element's `<` opened.
2368
+ if (contextDepth !== undefined && this.context.length > contextDepth) {
2369
+ this.context.length = contextDepth;
2370
+ }
2371
+ this.#path.pop();
2372
+ try {
2328
2373
  this.next();
2374
+ } finally {
2375
+ this.#path.push(node);
2329
2376
  }
2330
2377
  } else {
2331
- this.#report_broken_markup_error(
2332
- open.end,
2333
- `Unclosed tag '<${tagName}>'. Expected '${closeTag}' before end of template.`,
2334
- );
2335
- node.unclosed = true;
2378
+ this.next();
2336
2379
  }
2337
2380
 
2338
2381
  return content;
2339
2382
  }
2340
2383
 
2384
+ /**
2385
+ * Whether the first non-whitespace character after an opening tag is
2386
+ * `{`: the element's children start with an expression container.
2387
+ *
2388
+ * @param {ESTreeJSX.TSRXJSXOpeningElement & AST.NodeWithLocation} open
2389
+ * @returns {boolean}
2390
+ */
2391
+ #hasExpressionChildStart(open) {
2392
+ if (open.selfClosing) return false;
2393
+ const index = skip_whitespace_from(this.input, open.end);
2394
+ return this.input.charCodeAt(index) === CharCode.openBrace;
2395
+ }
2396
+
2341
2397
  /**
2342
2398
  * @param {ESTreeJSX.TSRXJSXOpeningElement & AST.NodeWithLocation} open
2343
2399
  * @param {AST.JSXStyleElement} node
2344
2400
  * @param {boolean} insideHead
2401
+ * @param {number} [contextDepth] see #parseRawTextElement
2345
2402
  */
2346
- #parseStyleElement(open, node, insideHead) {
2403
+ #parseStyleElement(open, node, insideHead, contextDepth) {
2347
2404
  const filename = this.#filename;
2348
2405
  if (!filename) {
2349
2406
  throw new Error(
2350
2407
  '<style> elements require a filename: pass one to parse so style scope hashes are unique per file.',
2351
2408
  );
2352
2409
  }
2353
- const content = this.#parseRawTextElement(open, node, 'style');
2410
+ const content = this.#parseRawTextElement(open, node, 'style', contextDepth);
2411
+ const bodyLoc = get_line_info(this, open.end);
2354
2412
  const parsedCss = parse_style(
2355
2413
  content,
2356
2414
  {
2357
2415
  filename,
2358
2416
  line: open.loc.start.line,
2359
2417
  column: open.loc.start.column,
2418
+ body: { start: open.end, line: bodyLoc.line, column: bodyLoc.column },
2360
2419
  },
2361
2420
  { loose: this.#loose },
2362
2421
  );
@@ -2384,9 +2443,10 @@ export function TSRXPlugin(config) {
2384
2443
  *
2385
2444
  * @param {ESTreeJSX.TSRXJSXOpeningElement & AST.NodeWithLocation} open
2386
2445
  * @param {AST.TSRXJSXElement} node
2446
+ * @param {number} [contextDepth] see #parseRawTextElement
2387
2447
  */
2388
- #parseScriptElement(open, node) {
2389
- const content = this.#parseRawTextElement(open, node, 'script');
2448
+ #parseScriptElement(open, node, contextDepth) {
2449
+ const content = this.#parseRawTextElement(open, node, 'script', contextDepth);
2390
2450
  node.content = content;
2391
2451
  node.children = [];
2392
2452
 
@@ -4740,7 +4800,10 @@ export function TSRXPlugin(config) {
4740
4800
  }
4741
4801
  const tag_name = open.name ? this.getElementName(open.name) : null;
4742
4802
  const is_dynamic = this.#isDynamicJSXElementName(open.name);
4743
- const is_style = tag_name === 'style';
4803
+ // `<style>` holds raw CSS text (a JSXStyleElement) unless its first
4804
+ // child is an expression container: `<style>{css}</style>` is the
4805
+ // ordinary TSX element, parsed like any other host element.
4806
+ const is_style = tag_name === 'style' && !this.#hasExpressionChildStart(open);
4744
4807
  const is_script = tag_name === 'script';
4745
4808
  const inside_head = this.#path.findLast((n) => this.#isNativeElementNamed(n, 'head'));
4746
4809
 
@@ -4792,12 +4855,28 @@ export function TSRXPlugin(config) {
4792
4855
  this.#path.push(node);
4793
4856
 
4794
4857
  if (!is_fragment && open.selfClosing) {
4858
+ if (is_style) {
4859
+ // `<style apply={theme} />` has no CSS body and no scope hash of
4860
+ // its own; it only contributes its `apply` targets to the scope.
4861
+ const style = /** @type {AST.JSXStyleElement} */ (node);
4862
+ style.css = '';
4863
+ style.children = [];
4864
+ }
4795
4865
  this.#path.pop();
4796
4866
  } else if (is_style) {
4797
- this.#parseStyleElement(open, /** @type {AST.JSXStyleElement} */ (node), !!inside_head);
4867
+ this.#parseStyleElement(
4868
+ open,
4869
+ /** @type {AST.JSXStyleElement} */ (node),
4870
+ !!inside_head,
4871
+ pre_element_context_depth,
4872
+ );
4798
4873
  this.#path.pop();
4799
4874
  } else if (is_script) {
4800
- this.#parseScriptElement(open, /** @type {AST.TSRXJSXElement} */ (node));
4875
+ this.#parseScriptElement(
4876
+ open,
4877
+ /** @type {AST.TSRXJSXElement} */ (node),
4878
+ pre_element_context_depth,
4879
+ );
4801
4880
  this.#path.pop();
4802
4881
  } else {
4803
4882
  this.#parseNativeTemplateBody(node, /** @type {AST.Node[]} */ (node.children), {
@@ -4842,11 +4921,30 @@ export function TSRXPlugin(config) {
4842
4921
  }
4843
4922
  }
4844
4923
 
4845
- if ((is_style || is_script) && /** @type {AST.JSXStyleElement} */ (node).closingElement) {
4846
- const closing = /** @type {ESTreeJSX.JSXClosingElement & AST.NodeWithLocation} */ (
4847
- /** @type {AST.JSXStyleElement} */ (node).closingElement
4848
- );
4849
- return this.finishNodeAt(node, node.type, closing.end, closing.loc.end);
4924
+ if (is_style || is_script) {
4925
+ const raw_text = /** @type {AST.JSXStyleElement} */ (node);
4926
+ if (raw_text.closingElement) {
4927
+ const closing = /** @type {ESTreeJSX.JSXClosingElement & AST.NodeWithLocation} */ (
4928
+ raw_text.closingElement
4929
+ );
4930
+ return this.finishNodeAt(node, node.type, closing.end, closing.loc.end);
4931
+ }
4932
+ if (raw_text.unclosed && this.#loose) {
4933
+ // Loose recovery captured a body without a closing tag: the element
4934
+ // ends where its body ends, not at whatever token was read next.
4935
+ const script = /** @type {AST.TSRXJSXElement} */ (/** @type {unknown} */ (node));
4936
+ const body = (is_style ? raw_text.css : script.content) ?? '';
4937
+ const body_end =
4938
+ /** @type {ESTreeJSX.TSRXJSXOpeningElement & AST.NodeWithLocation} */ (open).end +
4939
+ body.length;
4940
+ const body_end_info = get_line_info(this, body_end);
4941
+ return this.finishNodeAt(
4942
+ node,
4943
+ node.type,
4944
+ body_end,
4945
+ new acorn.Position(body_end_info.line, body_end_info.column),
4946
+ );
4947
+ }
4850
4948
  }
4851
4949
 
4852
4950
  return this.finishNode(node, node.type);
package/src/scope.js CHANGED
@@ -11,7 +11,7 @@
11
11
  @import * as AST from 'estree';
12
12
  */
13
13
 
14
- import is_reference from 'is-reference';
14
+ import { is_reference } from './utils/is-reference.js';
15
15
  import { extract_identifiers, object, unwrap_pattern } from './utils/ast.js';
16
16
  import { walk } from 'zimmerframe';
17
17
  import { is_reserved } from './utils.js';