@tsrx/core 0.1.36 → 0.1.38

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.36",
6
+ "version": "0.1.38",
7
7
  "type": "module",
8
8
  "repository": {
9
9
  "type": "git",
package/src/plugin.js CHANGED
@@ -465,12 +465,17 @@ export function TSRXPlugin(config) {
465
465
  return !!node && node.metadata?.templateMode !== 'template';
466
466
  }
467
467
 
468
- #isStyleOpeningTagStart() {
468
+ /**
469
+ * Whether the `<` token at `this.start` opens the given raw-text element
470
+ * (`<style` or `<script` followed by `>`, `/`, or whitespace).
471
+ * @param {string} tagName
472
+ */
473
+ #isRawTextOpeningTagStart(tagName) {
469
474
  let index = this.start + 1;
470
475
  if (this.input.charCodeAt(index) === CharCode.slash) return false;
471
- if (this.input.slice(index, index + 'style'.length) !== 'style') return false;
476
+ if (this.input.slice(index, index + tagName.length) !== tagName) return false;
472
477
 
473
- const after = this.input.charCodeAt(index + 'style'.length);
478
+ const after = this.input.charCodeAt(index + tagName.length);
474
479
  return (
475
480
  after === CharCode.greaterThan ||
476
481
  after === CharCode.slash ||
@@ -2051,34 +2056,22 @@ export function TSRXPlugin(config) {
2051
2056
  }
2052
2057
 
2053
2058
  /**
2059
+ * Read a raw-text element body: capture everything between the opening `>`
2060
+ * and the literal `</tagName>` verbatim (never as template markup),
2061
+ * synthesize the closing element, and restore the tokenizer state past it.
2062
+ * Shared by `<style>` and `<script>`.
2063
+ *
2054
2064
  * @param {ESTreeJSX.JSXOpeningElement & AST.NodeWithLocation} open
2055
- * @param {AST.JSXStyleElement} node
2056
- * @param {boolean} insideHead
2065
+ * @param {AST.JSXStyleElement | AST.TSRXJSXElement} node
2066
+ * @param {'style' | 'script'} tagName
2067
+ * @returns {string} The raw body text
2057
2068
  */
2058
- #parseStyleElement(open, node, insideHead) {
2059
- const filename = this.#filename;
2060
- if (!filename) {
2061
- throw new Error(
2062
- '<style> elements require a filename: pass one to parse so style scope hashes are unique per file.',
2063
- );
2064
- }
2069
+ #parseRawTextElement(open, node, tagName) {
2070
+ const closeTag = `</${tagName}>`;
2065
2071
  const contentStart = open.end;
2066
2072
  const input = this.input.slice(contentStart);
2067
- const relativeCloseStart = input.indexOf('</style>');
2073
+ const relativeCloseStart = input.indexOf(closeTag);
2068
2074
  const content = relativeCloseStart === -1 ? input : input.slice(0, relativeCloseStart);
2069
- const parsedCss = parse_style(
2070
- content,
2071
- {
2072
- filename,
2073
- line: open.loc.start.line,
2074
- column: open.loc.start.column,
2075
- },
2076
- { loose: this.#loose },
2077
- );
2078
-
2079
- if (!insideHead) {
2080
- node.metadata.styleScopeHash = parsedCss.hash;
2081
- }
2082
2075
 
2083
2076
  const newLines = content.match(regex_newline_characters)?.length;
2084
2077
  if (newLines) {
@@ -2091,7 +2084,7 @@ export function TSRXPlugin(config) {
2091
2084
  const closingLineInfo = acorn.getLineInfo(this.input, closingStart);
2092
2085
  const closingStartLoc = new acorn.Position(closingLineInfo.line, closingLineInfo.column);
2093
2086
  const nameStart = closingStart + 2;
2094
- const nameEnd = nameStart + 'style'.length;
2087
+ const nameEnd = nameStart + tagName.length;
2095
2088
  const nameStartInfo = acorn.getLineInfo(this.input, nameStart);
2096
2089
  const nameEndInfo = acorn.getLineInfo(this.input, nameEnd);
2097
2090
  const name = /** @type {ESTreeJSX.JSXIdentifier} */ (
@@ -2100,14 +2093,14 @@ export function TSRXPlugin(config) {
2100
2093
  new acorn.Position(nameStartInfo.line, nameStartInfo.column),
2101
2094
  )
2102
2095
  );
2103
- name.name = 'style';
2096
+ name.name = tagName;
2104
2097
  this.finishNodeAt(
2105
2098
  name,
2106
2099
  'JSXIdentifier',
2107
2100
  nameEnd,
2108
2101
  new acorn.Position(nameEndInfo.line, nameEndInfo.column),
2109
2102
  );
2110
- const closingEnd = closingStart + '</style>'.length;
2103
+ const closingEnd = closingStart + closeTag.length;
2111
2104
  const closingEndInfo = acorn.getLineInfo(this.input, closingEnd);
2112
2105
  const closingElement =
2113
2106
  /** @type {ESTreeJSX.TSRXJSXClosingElement & AST.NodeWithLocation} */ (
@@ -2131,8 +2124,8 @@ export function TSRXPlugin(config) {
2131
2124
  this.curLine = closingEndInfo.line;
2132
2125
  this.lineStart = closingEnd - closingEndInfo.column;
2133
2126
  if (insideTemplate && relativeCloseStart === 0) {
2134
- // Acorn has already tokenized the adjacent </style>; TSRX synthesizes
2135
- // that close manually, so drop the stale style tag context.
2127
+ // Acorn has already tokenized the adjacent closing tag; TSRX
2128
+ // synthesizes that close manually, so drop the stale tag context.
2136
2129
  if (this.curContext() === tstc.tc_oTag) {
2137
2130
  this.context.pop();
2138
2131
  }
@@ -2153,15 +2146,85 @@ export function TSRXPlugin(config) {
2153
2146
  } else {
2154
2147
  this.#report_broken_markup_error(
2155
2148
  open.end,
2156
- "Unclosed tag '<style>'. Expected '</style>' before end of template.",
2149
+ `Unclosed tag '<${tagName}>'. Expected '${closeTag}' before end of template.`,
2157
2150
  );
2158
2151
  node.unclosed = true;
2159
2152
  }
2160
2153
 
2154
+ return content;
2155
+ }
2156
+
2157
+ /**
2158
+ * @param {ESTreeJSX.JSXOpeningElement & AST.NodeWithLocation} open
2159
+ * @param {AST.JSXStyleElement} node
2160
+ * @param {boolean} insideHead
2161
+ */
2162
+ #parseStyleElement(open, node, insideHead) {
2163
+ const filename = this.#filename;
2164
+ if (!filename) {
2165
+ throw new Error(
2166
+ '<style> elements require a filename: pass one to parse so style scope hashes are unique per file.',
2167
+ );
2168
+ }
2169
+ const content = this.#parseRawTextElement(open, node, 'style');
2170
+ const parsedCss = parse_style(
2171
+ content,
2172
+ {
2173
+ filename,
2174
+ line: open.loc.start.line,
2175
+ column: open.loc.start.column,
2176
+ },
2177
+ { loose: this.#loose },
2178
+ );
2179
+
2180
+ if (!insideHead) {
2181
+ node.metadata.styleScopeHash = parsedCss.hash;
2182
+ }
2183
+
2161
2184
  node.css = content;
2162
2185
  node.children = [parsedCss];
2163
2186
  }
2164
2187
 
2188
+ /**
2189
+ * Parse a `<script>` element as a raw-text element, exactly like `<style>`:
2190
+ * the body is captured verbatim as `node.content`, letting authors write real
2191
+ * JS/TS (with `<`, `{`, `}`) and letting the editor treat the body as an
2192
+ * embedded TypeScript/JavaScript document.
2193
+ *
2194
+ * Mirroring `JSXStyleElement` (raw `css` string + parsed children), the body
2195
+ * is exposed twice: verbatim on `content`, and as a single `JSXText` child so
2196
+ * generic element paths (factory targets, static hoisting, printers) emit the
2197
+ * body without knowing about raw-text elements. Consumers that handle
2198
+ * `content` directly (the Ripple transforms, the prettier plugin) must skip
2199
+ * the children instead of emitting both.
2200
+ *
2201
+ * @param {ESTreeJSX.JSXOpeningElement & AST.NodeWithLocation} open
2202
+ * @param {AST.TSRXJSXElement} node
2203
+ */
2204
+ #parseScriptElement(open, node) {
2205
+ const content = this.#parseRawTextElement(open, node, 'script');
2206
+ node.content = content;
2207
+ node.children = [];
2208
+
2209
+ if (content.length > 0) {
2210
+ const bodyStartInfo = acorn.getLineInfo(this.input, open.end);
2211
+ const text = /** @type {ESTreeJSX.JSXText} */ (
2212
+ this.startNodeAt(open.end, new acorn.Position(bodyStartInfo.line, bodyStartInfo.column))
2213
+ );
2214
+ text.value = content;
2215
+ text.raw = content;
2216
+ const bodyEnd = open.end + content.length;
2217
+ const bodyEndInfo = acorn.getLineInfo(this.input, bodyEnd);
2218
+ this.finishNodeAt(
2219
+ text,
2220
+ 'JSXText',
2221
+ bodyEnd,
2222
+ new acorn.Position(bodyEndInfo.line, bodyEndInfo.column),
2223
+ );
2224
+ node.children = [/** @type {AST.Node} */ (text)];
2225
+ }
2226
+ }
2227
+
2165
2228
  #parseNativeTemplateExpressionContainer() {
2166
2229
  const allow_trailing_semicolon = this.#allowExpressionContainerTrailingSemicolon;
2167
2230
  this.#allowExpressionContainerTrailingSemicolon = true;
@@ -2888,9 +2951,18 @@ export function TSRXPlugin(config) {
2888
2951
  this.exprAllowed = false;
2889
2952
  }
2890
2953
 
2954
+ // A `/` or `#` in template TEXT position joins the text run as a
2955
+ // literal character (`<div>5/2</div>`, `<div>#tag</div>`). This must
2956
+ // not fire in the JS positions that can sit under a template element
2957
+ // on the path: inside a `{ … }` expression container (an attribute or
2958
+ // child expression — `<rect x={a / 2}/>`, `{this.#x}`) or inside a
2959
+ // control-flow directive header (`@if (a / 2 > 1)`), where `/` is
2960
+ // division and `#` is a private-field access.
2891
2961
  if (
2892
2962
  (code === CharCode.numberSign || code === CharCode.slash) &&
2893
2963
  this.#functionBodyDepth === 0 &&
2964
+ this.#jsxExpressionContainerDepth === 0 &&
2965
+ !this.#readingJSXControlFlowHeader &&
2894
2966
  this.#isNativeTemplateNode(this.#path.at(-1)) &&
2895
2967
  !(
2896
2968
  code === CharCode.slash &&
@@ -4161,7 +4233,7 @@ export function TSRXPlugin(config) {
4161
4233
  */
4162
4234
  jsx_parseElement() {
4163
4235
  if (this.#forceScriptJSXElementDepth > 0 || this.#isInsideNativeTemplateScriptSection()) {
4164
- if (this.#isStyleOpeningTagStart()) {
4236
+ if (this.#isRawTextOpeningTagStart('style') || this.#isRawTextOpeningTagStart('script')) {
4165
4237
  this.next();
4166
4238
  return /** @type {ESTreeJSX.JSXElement | AST.JSXStyleElement} */ (
4167
4239
  /** @type {unknown} */ (this.parseElement())
@@ -4294,6 +4366,7 @@ export function TSRXPlugin(config) {
4294
4366
  const tag_name = open.name ? this.getElementName(open.name) : null;
4295
4367
  const is_dynamic = this.#isDynamicJSXElementName(open.name);
4296
4368
  const is_style = tag_name === 'style';
4369
+ const is_script = tag_name === 'script';
4297
4370
  const inside_head = this.#path.findLast((n) => this.#isNativeElementNamed(n, 'head'));
4298
4371
 
4299
4372
  // Fragments (<>) produce JSXOpeningFragment with no `name` property
@@ -4348,6 +4421,9 @@ export function TSRXPlugin(config) {
4348
4421
  } else if (is_style) {
4349
4422
  this.#parseStyleElement(open, /** @type {AST.JSXStyleElement} */ (node), !!inside_head);
4350
4423
  this.#path.pop();
4424
+ } else if (is_script) {
4425
+ this.#parseScriptElement(open, /** @type {AST.TSRXJSXElement} */ (node));
4426
+ this.#path.pop();
4351
4427
  } else {
4352
4428
  this.#parseNativeTemplateBody(node, /** @type {AST.Node[]} */ (node.children), {
4353
4429
  enterScope: true,
@@ -4391,7 +4467,7 @@ export function TSRXPlugin(config) {
4391
4467
  }
4392
4468
  }
4393
4469
 
4394
- if (is_style && /** @type {AST.JSXStyleElement} */ (node).closingElement) {
4470
+ if ((is_style || is_script) && /** @type {AST.JSXStyleElement} */ (node).closingElement) {
4395
4471
  const closing = /** @type {ESTreeJSX.JSXClosingElement & AST.NodeWithLocation} */ (
4396
4472
  /** @type {AST.JSXStyleElement} */ (node).closingElement
4397
4473
  );
@@ -3634,7 +3634,16 @@ function to_jsx_element(
3634
3634
  transform_context,
3635
3635
  node,
3636
3636
  );
3637
- const walked_children = node.children || [];
3637
+ let walked_children = node.children || [];
3638
+ // A raw-text `<script>` body (mirrored by the parser as a JSXText child of
3639
+ // `node.content`) must not appear in the type-only editor TSX: raw JS/TS
3640
+ // (`{`, `<`) doesn't lex as JSX text there and would surface bogus syntactic
3641
+ // diagnostics. The embedded TS document built from `scriptMappings` covers
3642
+ // the body in the editor; runtime output keeps the text child.
3643
+ if (transform_context.typeOnly && typeof node.content === 'string') {
3644
+ walked_children = [];
3645
+ raw_children = [];
3646
+ }
3638
3647
  let selfClosing = !!source_opening.selfClosing;
3639
3648
  let children;
3640
3649
  const child_transform = transform_context.platform.hooks?.transformElementChildren?.(
@@ -22,6 +22,12 @@
22
22
  content: string,
23
23
  id: string,
24
24
  }} CssSourceRegion;
25
+ @typedef {{
26
+ start: number,
27
+ end: number,
28
+ content: string,
29
+ id: string,
30
+ }} ScriptSourceRegion;
25
31
  @typedef {{
26
32
  source: string | null | undefined;
27
33
  generated: string;
@@ -130,12 +136,40 @@ function get_style_region_id(hash, fallback) {
130
136
  * @param {{
131
137
  * regions: CssSourceRegion[],
132
138
  * css_element_info: CssElementInfo,
139
+ * script_regions: ScriptSourceRegion[],
133
140
  * }} param2
134
141
  * @returns {void}
135
142
  */
136
- function visit_source_ast(ast, src_line_offsets, { regions, css_element_info }) {
143
+ function visit_source_ast(ast, src_line_offsets, { regions, css_element_info, script_regions }) {
137
144
  let region_id = 0;
145
+ let script_region_id = 0;
138
146
  walk(ast, null, {
147
+ JSXElement(node, context) {
148
+ // Raw-text `<script>` elements carry their body verbatim on `node.content`
149
+ // (see the parser's `#parseScriptElement`). Expose that body as an embedded
150
+ // TypeScript region so the editor can offer intellisense inside it,
151
+ // mirroring how `<style>` bodies become embedded CSS regions below. The
152
+ // editor treats every script body as TypeScript (a superset of JS, matching
153
+ // the TextMate/tree-sitter/prettier treatment); the `type` attribute only
154
+ // matters to the runtime transforms, which read it off the AST.
155
+ const element_name = node.openingElement?.name;
156
+ const content = node.content;
157
+ if (
158
+ element_name?.type === 'JSXIdentifier' &&
159
+ element_name.name === 'script' &&
160
+ typeof content === 'string'
161
+ ) {
162
+ const start = /** @type {AST.NodeWithLocation} */ (node.openingElement).end;
163
+ script_regions.push({
164
+ start,
165
+ end: start + content.length,
166
+ content,
167
+ id: `script_${script_region_id++}`,
168
+ });
169
+ }
170
+
171
+ context.next();
172
+ },
139
173
  JSXStyleElement(node, context) {
140
174
  if (node.css) {
141
175
  const openLoc = /** @type {ESTreeJSX.JSXOpeningElement & AST.NodeWithLocation} */ (
@@ -370,10 +404,13 @@ export function convert_source_map_to_mappings(
370
404
  const css_regions = [];
371
405
  /** @type {CssElementInfo} */
372
406
  const css_element_info = new Map();
407
+ /** @type {ScriptSourceRegion[]} */
408
+ const script_regions = [];
373
409
 
374
410
  visit_source_ast(ast_from_source, src_line_offsets, {
375
411
  regions: css_regions,
376
412
  css_element_info,
413
+ script_regions,
377
414
  });
378
415
 
379
416
  /** @type {Map<string, number>} */
@@ -2320,10 +2357,30 @@ export function convert_source_map_to_mappings(
2320
2357
  });
2321
2358
  }
2322
2359
 
2360
+ /** @type {CodeMapping[]} */
2361
+ const scriptMappings = [];
2362
+ for (let i = 0; i < script_regions.length; i++) {
2363
+ const region = script_regions[i];
2364
+ scriptMappings.push({
2365
+ sourceOffsets: [region.start],
2366
+ generatedOffsets: [0],
2367
+ lengths: [region.content.length],
2368
+ generatedLengths: [region.content.length],
2369
+ data: {
2370
+ ...mapping_data,
2371
+ customData: {
2372
+ embeddedId: region.id,
2373
+ content: region.content,
2374
+ },
2375
+ },
2376
+ });
2377
+ }
2378
+
2323
2379
  return {
2324
2380
  code: generated_code,
2325
2381
  mappings,
2326
2382
  cssMappings,
2383
+ scriptMappings,
2327
2384
  };
2328
2385
  }
2329
2386
 
package/types/index.d.ts CHANGED
@@ -327,6 +327,15 @@ declare module 'estree' {
327
327
  isDynamic?: boolean;
328
328
  /** Loose-mode recovery: the element was never closed. */
329
329
  unclosed?: boolean;
330
+ /**
331
+ * Raw-text `<script>` body captured verbatim by the parser's
332
+ * `#parseScriptElement` (analogous to {@link JSXStyleElement.css}). Present only
333
+ * on `<script>` elements that have a body. The parser also mirrors the body as
334
+ * a single `JSXText` child so generic element consumers emit it; consumers that
335
+ * handle `content` directly (the Ripple transforms, the prettier plugin, the
336
+ * type-only editor output) skip the children instead of emitting both.
337
+ */
338
+ content?: string;
330
339
  /**
331
340
  * The parser emits {@link TSRXJSXChild}; the compile pre-passes lower
332
341
  * template children in place (retyped directives, code-block IIFEs,
@@ -1687,6 +1696,14 @@ export interface VolarMappingsResult {
1687
1696
  code: string;
1688
1697
  mappings: CodeMapping[];
1689
1698
  cssMappings: CodeMapping[];
1699
+ /**
1700
+ * Embedded raw-text `<script>` body regions, each mapped to its source range so
1701
+ * the editor can treat the body as an embedded TypeScript document (TS is a
1702
+ * superset of JS, so every body is treated as TypeScript regardless of the
1703
+ * `type` attribute — that attribute only matters to the runtime transforms).
1704
+ * Mirrors {@link cssMappings} for `<style>` bodies.
1705
+ */
1706
+ scriptMappings: CodeMapping[];
1690
1707
  errors: CompileError[];
1691
1708
  sourceAst: AST.Program;
1692
1709
  }