From c9c16cbb1900fece0af3efc2f9d7d09e53035be5 Mon Sep 17 00:00:00 2001
From: Ryan Carniato
Date: Tue, 25 Aug 2026 16:00:40 -0700
Subject: [PATCH 1/9] Add the experimental TSRX syntax frontend to both
compilers.
.tsrx sources desugar TSRX constructs into the shared Solid JSX lowering
in @solidjs/babel-plugin (via @tsrx/core) and @solidjs/compiler (via a
pinned oxc-tsrx text projection), with byte-parity enforced by the
fixture corpus and parity harness. Also fixes DOM insert markers to ride
the following sibling's declared walk variable, matching Babel.
Co-authored-by: Cursor
---
.changeset/fix-dom-insert-marker-anchors.md | 5 +
.changeset/tsrx-syntax-frontend.md | 6 +
documentation/tsrx/frontend-notes.md | 183 +++
.../tsrx/tsrx-specification-snapshot.md | 519 +++++++
packages/babel-plugin/README.md | 39 +-
packages/babel-plugin/package.json | 9 +-
packages/babel-plugin/rollup.config.js | 3 +-
packages/babel-plugin/src/config.ts | 6 +
packages/babel-plugin/src/index.ts | 17 +-
packages/babel-plugin/src/tsrx/desugar.ts | 892 +++++++++++
.../babel-plugin/src/tsrx/estree-to-babel.ts | 349 +++++
packages/babel-plugin/src/tsrx/index.ts | 90 ++
.../codeBlocks/code.tsrx | 22 +
.../codeBlocks/output.js | 32 +
.../conditionalChain/code.tsrx | 17 +
.../conditionalChain/output.js | 61 +
.../conditionals/code.tsrx | 24 +
.../conditionals/output.js | 49 +
.../dynamicTags/code.tsrx | 11 +
.../dynamicTags/output.js | 23 +
.../__tsrx_dom_fixtures__/forKeyed/code.tsrx | 21 +
.../__tsrx_dom_fixtures__/forKeyed/output.js | 54 +
.../__tsrx_dom_fixtures__/forLoop/code.tsrx | 17 +
.../__tsrx_dom_fixtures__/forLoop/output.js | 44 +
.../guardReturn/code.tsrx | 12 +
.../guardReturn/output.js | 17 +
.../lazyDestructuring/code.tsrx | 28 +
.../lazyDestructuring/output.js | 42 +
.../lazyShadowing/code.tsrx | 16 +
.../lazyShadowing/output.js | 33 +
.../multiSibling/code.tsrx | 10 +
.../multiSibling/output.js | 35 +
.../nestedControlFlow/code.tsrx | 19 +
.../nestedControlFlow/output.js | 57 +
.../propShorthand/code.tsrx | 11 +
.../propShorthand/output.js | 19 +
.../rawContent/code.tsrx | 21 +
.../rawContent/output.js | 57 +
.../simpleElement/code.tsrx | 9 +
.../simpleElement/output.js | 12 +
.../switchCase/code.tsrx | 24 +
.../switchCase/output.js | 62 +
.../tryCatchPending/code.tsrx | 24 +
.../tryCatchPending/output.js | 64 +
.../typedProps/code.tsrx | 15 +
.../typedProps/options.json | 3 +
.../typedProps/output.ts | 29 +
.../conditionals/code.tsrx | 13 +
.../conditionals/output.js | 41 +
.../__tsrx_ssr_fixtures__/forKeyed/code.tsrx | 9 +
.../__tsrx_ssr_fixtures__/forKeyed/output.js | 26 +
.../lazyDestructuring/code.tsrx | 14 +
.../lazyDestructuring/output.js | 15 +
.../rawContent/code.tsrx | 13 +
.../rawContent/output.js | 29 +
.../switchCase/code.tsrx | 11 +
.../switchCase/output.js | 28 +
.../tryCatchPending/code.tsrx | 12 +
.../tryCatchPending/output.js | 26 +
.../conditionals/code.tsrx | 5 +
.../conditionals/output.js | 19 +
.../dynamicTags/code.tsrx | 5 +
.../dynamicTags/output.js | 15 +
.../forLoop/code.tsrx | 7 +
.../forLoop/output.js | 21 +
packages/babel-plugin/test/fixtures.js | 11 +-
packages/babel-plugin/test/tsrx-dom.spec.js | 17 +
.../babel-plugin/test/tsrx-errors.spec.js | 93 ++
packages/babel-plugin/test/tsrx-ssr.spec.js | 17 +
.../babel-plugin/test/tsrx-universal.spec.js | 14 +
packages/compiler/Cargo.lock | 462 +++++-
packages/compiler/Cargo.toml | 9 +-
packages/compiler/README.md | 19 +-
.../dom-wrapperless/components/output.js | 4 +-
.../fixtures/dom/adjacentSlots/output.js | 2 +-
.../dom/attributeExpressions/output.js | 2 +-
.../fixtures/dom/components/output.js | 4 +-
.../fixtures/dom/textInterpolation/output.js | 8 +-
.../dynamic/attributeExpressions/output.js | 2 +-
.../fixtures/dynamic/components/output.js | 4 +-
.../dynamic/textInterpolation/output.js | 8 +-
...r_hydratable_fixtures--insertChildren.diff | 9 -
...r_hydratable_fixtures--insertChildren.diff | 9 -
...r_hydratable_fixtures--insertChildren.diff | 9 -
.../dom_fixtures--attributeExpressions.diff | 9 +
...versal_fixtures--attributeExpressions.diff | 27 +
.../dom_fixtures--attributeExpressions.diff | 1 +
...atable_fixtures--attributeExpressions.diff | 1 +
...ynamic_fixtures--attributeExpressions.diff | 1 +
.../ssr_fixtures--attributeExpressions.diff | 1 +
...atable_fixtures--attributeExpressions.diff | 1 +
packages/compiler/__tests__/parity/harness.js | 74 +-
packages/compiler/package.json | 2 +-
packages/compiler/src/compiler.rs | 62 +-
packages/compiler/src/config.rs | 4 +
packages/compiler/src/dom/children.rs | 118 +-
packages/compiler/src/dom/element.rs | 12 +-
packages/compiler/src/lib.rs | 4 +-
packages/compiler/src/node_adapter.rs | 10 +-
packages/compiler/src/shared/ast_builder.rs | 17 +
packages/compiler/src/tsrx/mod.rs | 223 +++
packages/compiler/src/tsrx/project.rs | 1311 +++++++++++++++++
packages/compiler/src/tsrx/rewrite.rs | 445 ++++++
packages/compiler/src/tsrx/tape.rs | 150 ++
packages/compiler/tests/tsrx_frontend.rs | 261 ++++
packages/compiler/types.d.ts | 7 +
pnpm-lock.yaml | 80 +
107 files changed, 6759 insertions(+), 186 deletions(-)
create mode 100644 .changeset/fix-dom-insert-marker-anchors.md
create mode 100644 .changeset/tsrx-syntax-frontend.md
create mode 100644 documentation/tsrx/frontend-notes.md
create mode 100644 documentation/tsrx/tsrx-specification-snapshot.md
create mode 100644 packages/babel-plugin/src/tsrx/desugar.ts
create mode 100644 packages/babel-plugin/src/tsrx/estree-to-babel.ts
create mode 100644 packages/babel-plugin/src/tsrx/index.ts
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/codeBlocks/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/codeBlocks/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/conditionalChain/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/conditionalChain/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/conditionals/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/conditionals/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/dynamicTags/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/dynamicTags/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/forKeyed/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/forKeyed/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/forLoop/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/forLoop/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/guardReturn/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/guardReturn/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/lazyDestructuring/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/lazyDestructuring/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/lazyShadowing/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/lazyShadowing/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/multiSibling/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/multiSibling/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/nestedControlFlow/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/nestedControlFlow/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/propShorthand/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/propShorthand/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/rawContent/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/rawContent/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/simpleElement/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/simpleElement/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/switchCase/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/switchCase/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/tryCatchPending/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/tryCatchPending/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/typedProps/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/typedProps/options.json
create mode 100644 packages/babel-plugin/test/__tsrx_dom_fixtures__/typedProps/output.ts
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/conditionals/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/conditionals/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/forKeyed/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/forKeyed/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/lazyDestructuring/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/lazyDestructuring/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/rawContent/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/rawContent/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/switchCase/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/switchCase/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/tryCatchPending/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_ssr_fixtures__/tryCatchPending/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_universal_fixtures__/conditionals/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_universal_fixtures__/conditionals/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_universal_fixtures__/dynamicTags/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_universal_fixtures__/dynamicTags/output.js
create mode 100644 packages/babel-plugin/test/__tsrx_universal_fixtures__/forLoop/code.tsrx
create mode 100644 packages/babel-plugin/test/__tsrx_universal_fixtures__/forLoop/output.js
create mode 100644 packages/babel-plugin/test/tsrx-dom.spec.js
create mode 100644 packages/babel-plugin/test/tsrx-errors.spec.js
create mode 100644 packages/babel-plugin/test/tsrx-ssr.spec.js
create mode 100644 packages/babel-plugin/test/tsrx-universal.spec.js
delete mode 100644 packages/compiler/__tests__/parity/expected-cross/dom-no-inline-styles/ssr_hydratable_fixtures--insertChildren.diff
delete mode 100644 packages/compiler/__tests__/parity/expected-cross/dom-wrapperless/ssr_hydratable_fixtures--insertChildren.diff
delete mode 100644 packages/compiler/__tests__/parity/expected-cross/dom/ssr_hydratable_fixtures--insertChildren.diff
create mode 100644 packages/compiler/__tests__/parity/expected-cross/tsrx-ssr/dom_fixtures--attributeExpressions.diff
create mode 100644 packages/compiler/__tests__/parity/expected-cross/tsrx-ssr/universal_fixtures--attributeExpressions.diff
create mode 100644 packages/compiler/__tests__/parity/expected-cross/tsrx-universal/dom_fixtures--attributeExpressions.diff
create mode 100644 packages/compiler/__tests__/parity/expected-cross/tsrx-universal/dom_hydratable_fixtures--attributeExpressions.diff
create mode 100644 packages/compiler/__tests__/parity/expected-cross/tsrx-universal/dynamic_fixtures--attributeExpressions.diff
create mode 100644 packages/compiler/__tests__/parity/expected-cross/tsrx-universal/ssr_fixtures--attributeExpressions.diff
create mode 100644 packages/compiler/__tests__/parity/expected-cross/tsrx-universal/ssr_hydratable_fixtures--attributeExpressions.diff
create mode 100644 packages/compiler/src/tsrx/mod.rs
create mode 100644 packages/compiler/src/tsrx/project.rs
create mode 100644 packages/compiler/src/tsrx/rewrite.rs
create mode 100644 packages/compiler/src/tsrx/tape.rs
create mode 100644 packages/compiler/tests/tsrx_frontend.rs
diff --git a/.changeset/fix-dom-insert-marker-anchors.md b/.changeset/fix-dom-insert-marker-anchors.md
new file mode 100644
index 000000000..4b4db7a04
--- /dev/null
+++ b/.changeset/fix-dom-insert-marker-anchors.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/compiler": patch
+---
+
+Fix DOM insert markers to reference the following sibling's declared walk variable (`_$insert(_el$, expr, _el$2)`) instead of re-deriving the walk inline (`_el$.firstChild`), matching babel-plugin output. Affects dynamic slots followed by static content in both single-slot and per-slot parents.
diff --git a/.changeset/tsrx-syntax-frontend.md b/.changeset/tsrx-syntax-frontend.md
new file mode 100644
index 000000000..b3191bb0b
--- /dev/null
+++ b/.changeset/tsrx-syntax-frontend.md
@@ -0,0 +1,6 @@
+---
+"@solidjs/babel-plugin": minor
+"@solidjs/compiler": minor
+---
+
+Add an experimental TSRX syntax frontend to both compilers. `.tsrx` sources (routed by filename with the new `syntax: "auto" | "jsx" | "tsrx"` option) desugar `@if`/`@else`, `@for … @empty`, `@switch`/`@case`, `@try`/`@catch`/`@pending`, `@{}` statement containers, and lazy destructuring (`&{}`/`&[]`) into the shared Solid JSX lowering, producing byte-identical output from both compilers. The Babel plugin loads the optional `@tsrx/core` peer dependency lazily; the native compiler ships the frontend behind the default-on `tsrx` cargo feature (statement containers in expression position are rejected with a structured diagnostic pending upstream oxc-tsrx support).
diff --git a/documentation/tsrx/frontend-notes.md b/documentation/tsrx/frontend-notes.md
new file mode 100644
index 000000000..cdd916eda
--- /dev/null
+++ b/documentation/tsrx/frontend-notes.md
@@ -0,0 +1,183 @@
+# TSRX Frontend — Stage 0 Findings
+
+Research notes for adding a TSRX syntax frontend to `@solidjs/babel-plugin` and
+`@solidjs/compiler`. The lowering rules below were verified by running
+`@tsrx/solid` (the official Solid target, our semantic oracle) against real
+samples, then cross-checked against this repo's current 2.0 RC APIs.
+
+## Version pins
+
+| Dependency | Pin | Notes |
+| --- | --- | --- |
+| TSRX specification | Draft / June 7, 2026 (first edition) | Snapshot in `tsrx-specification-snapshot.md` |
+| `@tsrx/core` | 0.1.61 | Official parser (acorn + `@sveltejs/acorn-typescript` + TSRXPlugin). ESTree AST + TSRX nodes. Babel-side parser. |
+| `@tsrx/solid` | 0.1.61 | Oracle only — we do not port it. Peer-deps `solid-js`/`@solidjs/web` `2.0.0-beta.15`. |
+| `oxc-tsrx` | rev `6be6a8c7773407c84f79fad0e3f7d192b72e8102` (v0.6.0, 2026-08-23) | Rust-side parser. Git dependency (not on crates.io): crates `tsrx_parser_engine`, `tsrx_syntax`, `tsrx_tape_schema`, `oxc_adapter` (feature `parser`). |
+
+### Oxc duplication
+
+`oxc-tsrx` pins oxc to git rev `8e0ed2eb…` (= oxc **0.140.0**); our compiler
+uses crates.io **0.144**. No oxc types cross the conversion boundary —
+`TsrxParseResult.program` is a `FlatTape` from `tsrx_tape_schema`, which is
+explicitly *revision-neutral and OXC-independent* — so the two copies coexist.
+Cost is compile time and binary size; revisit if `oxc-tsrx` upstreams into oxc.
+
+### Parse result shapes
+
+- **Babel side:** `parseModule(source, filename, options)` → ESTree `Program`
+ with TSRX nodes (`JSXCodeBlock`, `JSXIfExpression`, `JSXForExpression`,
+ `JSXSwitchExpression`, `JSXTryExpression`, `JSXStyleElement`);
+ `analyzeTsrx(ast, filename, options)` for target-neutral validation.
+- **Rust side:** `parse_tsrx(&TsrxParseRequest)` → `TsrxParseResult` whose
+ `program` is a `FlatTape` (flat record encoding mirroring the same ESTree+TSRX
+ shapes; built for `@tsrx/core` AST compatibility). Diagnostics come back in a
+ `DiagnosticTable`. The canonical route is ASCII-only; `parse_tsrx_utf16` is
+ the fallback route for non-ASCII sources.
+
+Both frontends therefore walk the **same logical AST**; the desugaring below is
+specified once and implemented twice.
+
+## Lowering contract (oracle-verified, adapted to 2.0 RC)
+
+All flow-control imports come from `solid-js`; `dynamic` from `@solidjs/web`.
+
+| TSRX | Lowering | Verified oracle output |
+| --- | --- | --- |
+| `@{ body; render }` as function body | Inline statements + `return render` | yes |
+| `@{ body; render }` in expression/child position | IIFE `(() => { body; return render; })()` | yes |
+| `@if (c) { A }` | `A` | yes |
+| `@if (c) { A } @else { B }` | `A` | yes |
+| `@if / @else if / … / @else` (chain) | `` + `` per branch | yes |
+| `@for (const x of expr; index i; key k(x))` | ` k(x)}>{(x, i) => …}` | yes — RC `For` has the `keyed: (item) => any` overload |
+| `@empty { F }` | `fallback={F}` on `For` | yes |
+| `@switch (v) { @case 'a': {A} @default: {D} }` | `A…` | yes |
+| `@try { C } @pending { P } @catch (e, reset) { E }` | ` E}>C` | yes, with one adaptation (below) |
+| `<{expr}>…{expr}>` | `const TsrxDynamic_N = dynamic(() => expr)` hoisted into scope, used as component | yes |
+| `{name}` prop shorthand | `name={name}` | yes |
+| `let/const &{ a, b } = expr` | `__lazyN = expr`; reads become `__lazyN.a` | yes |
+| `let &[a, b] = expr` | `__lazyN = expr`; reads become `__lazyN[0]` / `__lazyN[1]` — **no getter auto-calling** | yes (signal tuple: `__lazy0[0]` inserted as function child, unwrapped reactively at runtime) |
+| `&{ a }` in function-declaration params | `__lazyN` param (type annotation preserved), member reads | yes |
+| `
+```
+
+## 4.7 Host-defined Server Extensions
+
+Submodule declarations are documented in the first edition as a generic extension surface aligned with the TC39 module declarations proposal. Ripple currently defines one host profile for this surface: module server declarations and imports from server.
+
+```
+SubmoduleDeclaration :
+ module Identifier { ModuleItemListopt }
+
+SubmoduleImportDeclaration :
+ import ImportClause from Identifier ;
+
+```
+
+## 5 Static Semantics: Early Errors
+
+- A tag or fragment delimiter must not be split by intervening whitespace at the points described in 4.3.1.
+- Opening and closing tags for TSRX elements and fragments must match.
+- A JSXCodeBlock or template control-flow block that contains TypeScript setup statements and rendered output must place those statements before the output node and must have exactly one output node.
+- A statement-container function body follows the same structural rule as any other JSXCodeBlock.
+- A standalone`JSXExpressionContainer` is not a template output node. Use a JSX fragment when a statement container or control-flow block needs to render text, expression containers, or multiple siblings.
+- In hosts that enable server-oriented submodules, server exports must be imported before use, for example import { load } from server.
+- Host profiles may restrict which submodule names are supported and may impose additional restrictions on referenced bindings.
+- TSRX template children must not appear outside a JSXElement or JSXFragment body.
+
+## 6 Host-defined Semantics
+
+The purpose of the core specification is to make parsers, tooling, and language consumers agree on what TSRX source text means as syntax. The purpose of host documentation is to explain how that syntax is executed, lowered, or bound to runtime facilities.
+
+- How functions returning TSRX lower into executable host code.
+- How lazy destructuring is realized at runtime.
+- How style expressions expose generated or scoped class names.
+- How the dynamic tag syntax`<{expression}>` is lowered, and how the resolved value selects between string tags and component constructors at runtime.
+- How submodule declarations and imports from identifier sources are compiled or executed by a host profile that enables them.
+
+## Appendices
+
+The TSRX AST contract exposes ESTree-compatible function nodes and standard JSX-shaped nodes such as JSXElement, JSXFragment, JSXExpressionContainer, JSXText, JSXAttribute, and JSXSpreadAttribute. TSRX-specific additions are limited to JSXCodeBlock, JSXStyleElement, JSXIfExpression, JSXForExpression, JSXSwitchExpression, JSXTryExpression, TSModuleDeclaration, and TSModuleBlock. The grammar in sections 4.1 through 4.7 is normative; the node shapes in this appendix are informative and describe the parser contract exposed to tooling.
+
+### A.1 Grammar-to-node correspondence
+
+The reference parser follows the same broad editorial pattern used by the JSX specification: grammar productions define the accepted source forms, and a separate AST layer records those forms in a stable shape for downstream tools. The following correspondence summarizes the first-edition mappings.
+
+```
+Informative grammar-to-node correspondence
+
+FunctionDeclaration, FunctionExpression, ArrowFunctionExpression -> ESTree function nodes
+function ... @{ ... } -> ESTree function node with body: JSXCodeBlock
+return JSXElement -> ReturnStatement(argument: JSXElement)
+return JSXFragment -> ReturnStatement(argument: JSXFragment)
+return JSXCodeBlock -> ReturnStatement(argument: JSXCodeBlock)
+JSXElement -> ESTree JSXElement
+JSXFragment -> ESTree JSXFragment
+JSXText -> ESTree JSXText
+{ AssignmentExpression } in template position -> JSXExpressionContainer
+@{ StatementListItemListopt TemplateOutput } -> JSXCodeBlock
+JSXAttributeName JSXAttributeInitializeropt -> JSXAttribute
+{ ... AssignmentExpression } in attribute position -> JSXSpreadAttribute
+ -> JSXStyleElement
+@if -> JSXIfExpression
+@for -> JSXForExpression
+@switch -> JSXSwitchExpression
+@try -> JSXTryExpression
+module Identifier { ModuleItemListopt } -> TSModuleDeclaration
+import ImportClause from Identifier -> ImportDeclaration with Identifier source
+```
+
+### A.2 Function body and return nodes
+
+Functions remain ordinary ESTree function nodes. TSRX structure begins where a JSXElement, JSXFragment, JSXStyleElement, JSXCodeBlock, or JSX control-flow expression appears as an expression value, most commonly as a ReturnStatement argument. The statement-container function body shorthand stores a JSXCodeBlock directly in the function node's body field without introducing a separate component node kind.
+
+```
+interface FunctionDeclaration {
+ type: 'FunctionDeclaration';
+ id: Identifier | null;
+ params: Pattern[];
+ body: BlockStatement | JSXCodeBlock;
+ typeParameters?: TSTypeParameterDeclaration;
+}
+
+interface ReturnStatement {
+ type: 'ReturnStatement';
+ argument: Expression | null;
+}
+```
+
+- The function id and params fields preserve the ordinary TypeScript function surface, including type annotations and lazy patterns after parsing.
+- Ordinary function bodies remain BlockStatement nodes. A statement-container function body is represented as a JSXCodeBlock in the function body's place.
+- A returned or expression-position statement container is represented as a JSXCodeBlock expression.
+- A returned native fragment is represented by a JSXFragment node whose children store template children in source order.
+- Local template setup is represented by JSXCodeBlock nodes rather than by placing ordinary statement nodes directly in JSXElement or JSXFragment children.
+- Raw style elements inside a returned TSRX template carry parsed stylesheet metadata for downstream analysis and target-specific style emission.
+- Default exports are represented through the ordinary ESTree ExportDefaultDeclaration wrapping the function declaration or expression.
+- Implementation metadata may additionally record topScopedClasses for downstream style-ref analysis.
+
+### A.3 Template and attribute nodes
+
+Template children reuse the JSX AST shape. This appendix distinguishes the element node itself from the JSX opening-tag and attribute nodes that refine it.
+
+```
+interface JSXElement {
+ type: 'JSXElement';
+ openingElement: JSXOpeningElement;
+ closingElement: JSXClosingElement | null;
+ children: TemplateChild[];
+ metadata?: { native_tsrx?: true };
+}
+
+interface JSXOpeningElement {
+ type: 'JSXOpeningElement';
+ name: JSXIdentifier | JSXMemberExpression | JSXNamespacedName | JSXExpressionContainer;
+ attributes: Array;
+ selfClosing: boolean;
+}
+
+interface JSXExpressionContainer {
+ type: 'JSXExpressionContainer';
+ expression: Expression | JSXEmptyExpression;
+}
+
+interface JSXText {
+ type: 'JSXText';
+ value: string;
+}
+```
+
+- JSXOpeningElement.name is a JSXIdentifier for ordinary tag names, a JSXMemberExpression for dotted names, a JSXNamespacedName for namespaced names, and a JSXExpressionContainer for dynamic tags written as`<{expression}>`. Dynamic tags additionally mark the element, its opening element, and the name container with an`isDynamic` flag for downstream tooling.
+- openingElement and closingElement preserve the original tag delimiters so formatters and source-mapping tools can recover the authored shape.
+- JSXOpeningElement.selfClosing records self-closing syntax, while unclosed recovery metadata may be attached by the parser in loose scenarios.
+- JSXExpressionContainer wraps the embedded ECMAScript expression from the ordinary {expr} template form.
+- JSXText records a raw text child. Static text children decode JSX character references before the text value is stored.
+- JSXAttribute.value is null for boolean-style attributes with no initializer. Shorthand attributes are represented as JSXAttribute nodes with parser metadata.
+- JSXSpreadAttribute.argument preserves the original ECMAScript expression payload carried by the attribute form.
+- Current implementations may additionally attach style-element-specific details such as captured stylesheet source text or styleScopeHash when the element is a raw` ` tag, but those details are not part of the general JSXElement shape described here.
+
+### A.4 Expression value nodes
+
+Expression-position TSRX values use JSXElement, JSXFragment, JSXStyleElement, JSXCodeBlock, JSXIfExpression, JSXForExpression, JSXSwitchExpression, and JSXTryExpression nodes. Native fragments use the standard JSXFragment shape.
+
+```
+interface JSXFragment {
+ type: 'JSXFragment';
+ openingFragment: JSXOpeningFragment;
+ closingFragment: JSXClosingFragment;
+ children: TemplateChild[];
+ metadata?: { native_tsrx?: true };
+}
+
+type TSRXExpressionValue =
+ | JSXElement
+ | JSXFragment
+ | JSXStyleElement
+ | JSXCodeBlock
+ | JSXIfExpression
+ | JSXForExpression
+ | JSXSwitchExpression
+ | JSXTryExpression;
+
+type TemplateOutput =
+ | JSXElement
+ | JSXFragment
+ | JSXIfExpression
+ | JSXForExpression
+ | JSXSwitchExpression
+ | JSXTryExpression;
+
+type TemplateChild =
+ | JSXText
+ | JSXExpressionContainer
+ | JSXCodeBlock
+ | TemplateOutput
+ | JSXStyleElement;
+```
+
+- JSXFragment corresponds to <> ... > when that form appears in expression position. Its children array follows the TSRX template-child model.
+- openingFragment and closingFragment preserve fragment delimiters for formatter and source-mapping tools.
+
+### A.5 TSRX extension nodes
+
+The following nodes are the TSRX-specific additions. Statement containers are represented by JSXCodeBlock nodes in expression, child, and function-body positions. Style blocks are represented as JSXStyleElement nodes. Control-flow directives are represented directly by JSXIfExpression, JSXForExpression, JSXSwitchExpression, and JSXTryExpression nodes.
+
+```
+interface JSXCodeBlock {
+ type: 'JSXCodeBlock';
+ body: StatementListItem[];
+ render: TemplateOutput;
+}
+
+interface JSXStyleElement {
+ type: 'JSXStyleElement';
+ openingElement: JSXOpeningElement;
+ closingElement: JSXClosingElement | null;
+ children: StyleSheet[];
+ css?: string;
+}
+
+interface JSXIfExpression {
+ type: 'JSXIfExpression';
+ statementType: 'IfStatement';
+ test: Expression;
+ consequent: Statement;
+ alternate: Statement | null;
+}
+
+interface JSXForExpression {
+ type: 'JSXForExpression';
+ statementType: 'ForStatement' | 'ForInStatement' | 'ForOfStatement';
+ body: Statement;
+ init?: VariableDeclaration | Expression | null;
+ test?: Expression | null;
+ update?: Expression | null;
+ left?: VariableDeclaration | Pattern;
+ right?: Expression;
+ await?: boolean;
+ index?: Identifier | null;
+ key?: Expression | null;
+ empty?: BlockStatement | null;
+}
+
+interface JSXSwitchExpression {
+ type: 'JSXSwitchExpression';
+ statementType: 'SwitchStatement';
+ discriminant: Expression;
+ cases: SwitchCase[];
+}
+
+interface JSXTryExpression {
+ type: 'JSXTryExpression';
+ statementType: 'TryStatement';
+ block: BlockStatement;
+ handler: CatchClause | null;
+ finalizer: BlockStatement | null;
+ pending?: BlockStatement | null;
+}
+```
+
+- The AST contract emits`JSXIfExpression`,`JSXForExpression`,`JSXSwitchExpression`, and`JSXTryExpression` for template control flow.
+- `JSXCodeBlock` is a template child and expression value, not a general ECMAScript statement node. Its render field is the one node produced by the container after setup.
+
+### A.6 Submodules, special identifiers, and stylesheets
+
+TSRX reuses TypeScript-compatible module declaration node shapes for submodules and extends ImportDeclaration sources so a source may be an Identifier. These nodes are intentionally narrow: most of their semantics come from surrounding grammar or from host-defined analysis, not from a wide intrinsic property surface.
+
+```
+interface TSModuleDeclaration {
+ type: 'TSModuleDeclaration';
+ id: Identifier;
+ body: TSModuleBlock;
+}
+
+interface TSModuleBlock {
+ type: 'TSModuleBlock';
+ body: Array;
+}
+
+interface CSSStyleSheet {
+ type: 'StyleSheet';
+ children: Array;
+ source: string;
+ hash: string;
+}
+```
+
+- TSModuleDeclaration represents module Identifier { ... } submodules.
+- ImportDeclaration.source may be an Identifier for imports from a declared submodule.
+- Ripple records exported names from module server declarations for downstream RPC analysis.
+- CSSStyleSheet metadata is attached to raw style elements in returned TSRX templates and stores both the original stylesheet source text and the stable hash used by current host implementations for scoping.
+
+The reference parser is built on Acorn with @sveltejs/acorn-typescript and extended by a custom TSRXPlugin. This is why the grammar in this draft is framed as additive over a TypeScript-compatible baseline rather than as a language unrelated to TypeScript source syntax.
\ No newline at end of file
diff --git a/packages/babel-plugin/README.md b/packages/babel-plugin/README.md
index e0e69fddc..726451bb3 100644
--- a/packages/babel-plugin/README.md
+++ b/packages/babel-plugin/README.md
@@ -86,7 +86,7 @@ Omitted options are the Solid 2.0 defaults that used to live in `babel-preset-so
hydratable: true
}
]
- ]
+ ];
}
```
@@ -99,6 +99,13 @@ Omitted options are the Solid 2.0 defaults that used to live in `babel-preset-so
Runtime module the compiled output imports helpers from. Use the same module for SSR; switch `generate` instead.
+### syntax
+
+- Type: `'auto' | 'jsx' | 'tsrx'`
+- Default: `'auto'`
+
+Source syntax frontend. `"auto"` routes `.tsrx` files through the TSRX frontend and everything else through standard JSX; `"tsrx"` forces TSRX for every file; `"jsx"` disables TSRX routing entirely. See [TSRX](#tsrx-experimental).
+
### generate
- Type: `'dom' | 'ssr' | 'universal' | 'dynamic'`
@@ -206,12 +213,7 @@ Restrict JSX transformation to files whose `@jsxImportSource` pragma matches.
```js
{
- plugins: [
- [
- "@solidjs/babel-plugin",
- { requireImportSource: "@solidjs/web" }
- ]
- ]
+ plugins: [["@solidjs/babel-plugin", { requireImportSource: "@solidjs/web" }]];
}
```
@@ -234,6 +236,29 @@ Inline style attributes in templates when the value is a string or `Record
+ @for (const item of items; index i; key item.id) {
+ {i + 1}. {item.text}
+ } @empty {
+ No todos
+ }
+
+}
+```
+
+Requirements and behavior:
+
+- Compiling `.tsrx` sources requires the optional peer dependency `@tsrx/core` and Node.js >= 22.12. It loads lazily on first TSRX routing, so plain JSX users never pay for it.
+- Routing is filename-based by default (`syntax: "auto"`), so Babel must receive a `filename`.
+- Desugared constructs rely on the `builtIns` auto-imports, so those components must exist in `moduleName`.
+- The native compiler ([`@solidjs/compiler`](../compiler)) compiles the same sources to byte-identical output.
+
## Special Binding
### ref
diff --git a/packages/babel-plugin/package.json b/packages/babel-plugin/package.json
index 97160325a..88c72abb4 100644
--- a/packages/babel-plugin/package.json
+++ b/packages/babel-plugin/package.json
@@ -28,12 +28,19 @@
"validate-html-nesting": "^1.2.1"
},
"peerDependencies": {
- "@babel/core": "^7.20.12"
+ "@babel/core": "^7.20.12",
+ "@tsrx/core": "0.1.61"
+ },
+ "peerDependenciesMeta": {
+ "@tsrx/core": {
+ "optional": true
+ }
},
"devDependencies": {
"@babel/core": "^7.29.0",
"@solidjs/web": "workspace:*",
"@rollup/plugin-babel": "7.0.0",
+ "@tsrx/core": "0.1.61",
"@types/babel__core": "^7.20.5",
"@types/babel__traverse": "^7.28.0"
}
diff --git a/packages/babel-plugin/rollup.config.js b/packages/babel-plugin/rollup.config.js
index b82aa2613..f92f5061e 100644
--- a/packages/babel-plugin/rollup.config.js
+++ b/packages/babel-plugin/rollup.config.js
@@ -25,7 +25,8 @@ export default {
"@babel/plugin-syntax-jsx",
"@babel/helper-module-imports",
"@babel/types",
- "html-entities"
+ "html-entities",
+ "@tsrx/core"
],
output: {
file: "index.js",
diff --git a/packages/babel-plugin/src/config.ts b/packages/babel-plugin/src/config.ts
index 181a9c161..20d3b77f3 100644
--- a/packages/babel-plugin/src/config.ts
+++ b/packages/babel-plugin/src/config.ts
@@ -8,6 +8,11 @@ export interface RendererConfig {
export interface PluginConfig {
moduleName: string;
+ /** Source syntax frontend: "auto" routes `.tsrx` files through the TSRX
+ * parser and everything else through standard JSX; "tsrx" forces TSRX for
+ * every file; "jsx" disables TSRX routing entirely. TSRX support is
+ * experimental and requires the optional `@tsrx/core` peer dependency. */
+ syntax: "auto" | "jsx" | "tsrx";
generate: "dom" | "ssr" | "universal" | "dynamic";
hydratable: boolean;
dev: boolean;
@@ -41,6 +46,7 @@ export interface PluginConfig {
const config: PluginConfig = {
moduleName: "@solidjs/web",
+ syntax: "auto",
generate: "dom",
hydratable: false,
dev: false,
diff --git a/packages/babel-plugin/src/index.ts b/packages/babel-plugin/src/index.ts
index acf58dfb5..03a15fd3f 100644
--- a/packages/babel-plugin/src/index.ts
+++ b/packages/babel-plugin/src/index.ts
@@ -2,6 +2,8 @@ import SyntaxJSX from "@babel/plugin-syntax-jsx";
import { transformJSX } from "./shared/transform";
import postprocess from "./shared/postprocess";
import preprocess from "./shared/preprocess";
+import { isTsrxSource, parseTsrx, type SyntaxOption } from "./tsrx";
+import type * as t from "@babel/types";
import type { Visitor } from "@babel/traverse";
import type { PluginPass } from "./types";
@@ -9,14 +11,27 @@ type JSXPluginSyntax = {
manipulateOptions(opts: unknown, parserOpts: { plugins: string[] }): void;
};
-export default (): {
+type ParserOptions = { sourceFileName?: string };
+type ParseFn = (code: string, parserOpts: ParserOptions) => t.File;
+
+export default (
+ _api?: unknown,
+ options: { syntax?: SyntaxOption } = {}
+): {
name: string;
inherits: () => JSXPluginSyntax;
+ parserOverride: (code: string, parserOpts: ParserOptions, parse: ParseFn) => t.File;
visitor: Visitor;
} => {
return {
name: "@solidjs/babel-plugin",
inherits: SyntaxJSX.default,
+ parserOverride(code, parserOpts, parse) {
+ if (isTsrxSource(options.syntax, parserOpts?.sourceFileName)) {
+ return parseTsrx(code, parserOpts?.sourceFileName);
+ }
+ return parse(code, parserOpts);
+ },
visitor: {
JSXElement: transformJSX,
JSXFragment: transformJSX,
diff --git a/packages/babel-plugin/src/tsrx/desugar.ts b/packages/babel-plugin/src/tsrx/desugar.ts
new file mode 100644
index 000000000..b7acdbbe0
--- /dev/null
+++ b/packages/babel-plugin/src/tsrx/desugar.ts
@@ -0,0 +1,892 @@
+/**
+ * TSRX → Solid JSX desugaring.
+ *
+ * Walks the ESTree AST produced by `@tsrx/core` (after its lazy-destructuring
+ * transform has run) and lowers every TSRX construct to Solid 2.0 builtIn
+ * component JSX, in place. The result is a plain ESTree TSX program that
+ * `estree-to-babel` converts for the existing JSX pipeline. BuiltIns are
+ * referenced as bare identifiers (`Show`, `For`, …) so the plugin's normal
+ * `builtIns` auto-import machinery resolves them against `moduleName`.
+ *
+ * Lowering contract (mirrored byte-for-byte by the Oxc frontend):
+ * - `@{ body; render }` — expression position: `render` alone when there is no
+ * setup, otherwise `(() => { body; return render; })()`; function-body
+ * position: inline statements ending in `return render`.
+ * - `@if` — `Show` for a single branch, `Switch`/`Match` for chains; `@else`
+ * becomes `fallback`.
+ * - `@for (const x of expr; index i; key k)` — `For`; `key` present emits
+ * `keyed={(x) => k}`; `@empty` becomes `fallback`. RC `For` hands the
+ * callback an item *accessor* in keyed mode and an index accessor always,
+ * so reads of `x` (keyed only) and `i` rewrite to calls.
+ * - `@switch` — `Switch` with one `Match when={disc === test}` per `@case`;
+ * `@default` becomes `fallback`.
+ * - `@try/@pending/@catch (e, reset)` — `
+ * …}>{content}`; RC
+ * `Errored` passes an `ErrorAccessor`, so reads of `e` rewrite to calls.
+ * - `<{expr}>` — `` (deliberate adaptation from
+ * `@tsrx/solid`'s hoisted `dynamic()` factory; semantically equivalent).
+ * - `
+ hi
+
+}`)
+ ).rejects.toThrow(/scoped \n hi
\n \n}\n",
+ );
+ assert!(
+ message.to_lowercase().contains("scoped \n hi
\n \n}\n",
);
assert!(
- message.to_lowercase().contains("scoped
@for (const item of items; index i; key item.id) {
{i + 1}. {item.text}
} @empty {
@@ -257,6 +260,7 @@ Requirements and behavior:
- Compiling `.tsrx` sources requires the optional peer dependency `@tsrx/core` and Node.js >= 22.12. It loads lazily on first TSRX routing, so plain JSX users never pay for it.
- Routing is filename-based by default (`syntax: "auto"`), so Babel must receive a `filename`.
- Desugared constructs rely on the `builtIns` auto-imports, so those components must exist in `moduleName`.
+- Scoped `
- hi
-
+
+ hi
+ >
}`)
- ).rejects.toThrow(/scoped
+
+
+
+ <{Tag} />
+ >
+}`);
+
+ const hash = result.metadata.cssHash;
+ expect(hash).toMatch(/^tsrx-[0-9a-f]+$/);
+ expect(result.metadata.css).toContain(`div.${hash}`);
+ expect(result.metadata.css).toContain(`span.${hash}`);
+ expect(result.code).not.toContain("
+
+>;`;
+ const results = await Promise.all(
+ ["dom", "ssr", "universal"].map(generate =>
+ compile(source, "renderer-parity.tsrx", { generate })
+ )
+ );
+ const metadata = results.map(result => ({
+ css: result.metadata.css,
+ cssHash: result.metadata.cssHash
+ }));
+
+ expect(metadata[1]).toEqual(metadata[0]);
+ expect(metadata[2]).toEqual(metadata[0]);
+ for (const result of results) expect(result.code).not.toContain(">;`),
+ compile(`export const C = () => ;`, "plain.tsrx")
+ ]);
+
+ expect(styled.metadata.cssHash).toMatch(/^tsrx-[0-9a-f]+$/);
+ expect(styled.metadata.css).toContain(styled.metadata.cssHash);
+ expect(plain.metadata.css).toBe("");
+ expect(plain.metadata.cssHash).toBeNull();
+
+ const withAst = await babel.transformAsync(
+ `export const C = () => <>>;`,
+ {
+ babelrc: false,
+ configFile: false,
+ ast: true,
+ code: false,
+ plugins: [[plugin, pluginOptions]],
+ filename: "ast-cleanup.tsrx"
+ }
+ );
+ expect(withAst.ast.tsrxStyle).toBeUndefined();
+ expect(withAst.metadata.cssHash).toMatch(/^tsrx-[0-9a-f]+$/);
+ });
+
+ test("matches @tsrx/core pruning, nesting, global, and keyframe bytes", async () => {
+ const filename = path.join(__dirname, "style-parity.tsrx");
+ const source = `export function C() @{
+ <>
+
+
+ >
+}`;
+
+ const ast = tsrx.parseModule(source, filename);
+ tsrx.analyzeTsrx(ast, filename);
+ const fragment = ast.body[0].declaration.body.render;
+ const style = fragment.children.find(child => child.type === "JSXStyleElement");
+ const div = fragment.children.find(child => child.type === "JSXElement");
+ const span = div.children.find(child => child.type === "JSXElement");
+ const stylesheet = tsrx.getStyleElementStylesheet(style);
+ const styleClasses = new Map();
+ const topScopedClasses = new Map();
+ div.metadata.path = [];
+ span.metadata.path = [div];
+ tsrx.analyzeCss(stylesheet);
+ tsrx.pruneCss(stylesheet, div, styleClasses, topScopedClasses, stylesheet.hash);
+ tsrx.pruneCss(stylesheet, span, styleClasses, topScopedClasses, stylesheet.hash);
+ const expected = tsrx.renderCssResult([stylesheet]);
+
+ const result = await compile(source, filename);
+ expect({
+ css: result.metadata.css,
+ cssHash: result.metadata.cssHash
+ }).toEqual(expected);
+ expect(result.metadata.css).toContain("/* (unused) .unused");
+ expect(result.metadata.css).toContain("body { margin:0 }");
+ expect(result.metadata.css).toContain(`@keyframes ${expected.cssHash}-pulse`);
+ expect(result.metadata.css).toContain(`& > span.${expected.cssHash}`);
+ });
+
+ test("lowers expression-position styles to class maps", async () => {
+ const result = await compile(`export const styles = ;`);
+ const hash = result.metadata.cssHash;
+
+ expect(result.code).toContain(`"foo": "${hash} foo"`);
+ expect(result.code).not.toContain("`
+ },
+ {
+ name: "a block render statement",
+ source: `{ }`
+ },
+ {
+ name: "a switch-case render statement",
+ source: `switch (value) {
+ case 1:
+
+}`
+ },
+ {
+ name: "a native element child outside a TSRX render block",
+ source: `export const C = () =>
+
+
;`
+ }
+ ])("matches the oracle for unowned style in $name", async ({ source }) => {
+ const result = await compile(source, "unowned-position.tsrx");
+
+ expect({
+ css: result.metadata.css,
+ cssHash: result.metadata.cssHash
+ }).toEqual(tsrx.renderCssResult([]));
+ expect(result.code).toContain("
+ }
+
+ >
+}`);
+
+ expect({
+ css: result.metadata.css,
+ cssHash: result.metadata.cssHash
+ }).toEqual(tsrx.renderCssResult([]));
+ expect(result.code).toContain("
+ }
+ >
+}`);
+
+ expect({
+ css: result.metadata.css,
+ cssHash: result.metadata.cssHash
+ }).toEqual(tsrx.renderCssResult([]));
+ expect(result.code).toContain(">
+}`
+ },
+ {
+ name: "@switch",
+ directive: `@switch (value) {
+ @case 1: {
+ <>>
+ }
+}`
+ },
+ {
+ name: "@try",
+ directive: `@try {
+ <>>
+} @catch (error) {
+ {error}
+}`
+ }
+ ])("preserves outer fragment ownership through nested $name blocks", async ({ directive }) => {
+ const result = await compile(`export function C({ value }) @{
+ <>
+ ${directive}
+
+ >
+}`);
+ const hash = result.metadata.cssHash;
+
+ expect(hash).toMatch(/^tsrx-[0-9a-f]+$/);
+ expect(result.metadata.css).toContain(`.owned.${hash}`);
+ expect(result.code).toContain(`class="owned ${hash}"`);
+ });
+
+ test("accepts and ignores refs on expression-position styles like the oracle", async () => {
+ const result = await compile(`export const styles = ;`);
+ const hash = result.metadata.cssHash;
+
+ expect(result.code).toContain(`"referenced": "${hash} referenced"`);
+ expect(result.code).not.toContain("sideEffect");
+ expect(result.metadata.css).toContain(`.referenced.${hash}`);
+ });
+
+ test("uses the core style-ref helper before rendering", async () => {
+ const result = await compile(`export function C() @{
+ let styles;
+ <>
+
+
+ >
+}`);
+ const hash = result.metadata.cssHash;
+
+ expect(result.code).toContain(`styles = {\n "foo": "${hash} foo"\n };`);
+ expect(result.code).toContain(`class="foo ${hash}"`);
+ });
+});
diff --git a/packages/compiler/README.md b/packages/compiler/README.md
index 68fab3895..d46ffe5be 100644
--- a/packages/compiler/README.md
+++ b/packages/compiler/README.md
@@ -84,10 +84,15 @@ TSRX (TypeScript Render Extensions) is a syntax for declarative UI whose constru
```js
const result = transform(tsrxSource, { filename: "App.tsrx" });
+// TSRX
+
+
+
+ <{Tag} />
+ >
+}`);
+ const hash = result.cssHash;
+
+ expect(hash).toMatch(/^tsrx-[0-9a-f]{8}$/);
+ expect(result.code).not.toContain(";
+export const second = ;`,
+ "style-expression-order.tsrx"
+ );
+ const [firstHash, secondHash] = expression.cssHash.split(" ");
+ expect(expression.code).toContain(`"first": "${firstHash} first"`);
+ expect(expression.code).toContain(`"second": "${secondHash} second"`);
+ expect(expression.css.indexOf(firstHash)).toBeLessThan(expression.css.indexOf(secondHash));
+
+ const ref = compareStyleMetadata(
+ `export function C() @{
+ let styles;
+ <>
+
+
+ >
+}`,
+ "style-ref.tsrx"
+ );
+ expect(ref.code).toContain(`styles = { "foo": "${ref.cssHash} foo" };`);
+
+ compareStyleMetadata(
+ `const marker = "🚀"; export const unicode = ;`,
+ "unicode-style-location.tsrx"
+ );
+ });
+
+ test("exposes CSS fields only for TSRX routes and rejects duplicate runtime styles", () => {
+ const empty = transform("export const view = ;", {
+ ...options,
+ filename: "empty.tsrx"
+ });
+ expect(empty.css).toBe("");
+ expect(empty.cssHash).toBeNull();
+
+ const jsx = transform("export const view = ;", {
+ ...options,
+ filename: "plain.tsx"
+ });
+ expect(jsx.css).toBeUndefined();
+ expect(jsx.cssHash).toBeUndefined();
+
+ expect(() =>
+ transform("const view = <>>;", {
+ ...options,
+ filename: "duplicate.tsrx"
+ })
+ ).toThrow(/TSRX fragments can only have one style tag/);
+ });
+
+ test("matches core for control-flow pruning and whole-owner annotation", () => {
+ const { native, oracle } = compareCoreStyleMetadata(
+ `const Component = props => props.children;
+export const View = ({ visible, items, Tag }) => <>
+
+ @if (visible) { }
+ @for (const item of items) { }
+ <{Tag} />
+
+>;`,
+ "control-flow-style-oracle.tsrx"
+ );
+ expect(native.css).toContain(`span.${native.cssHash}`);
+ expect(native.css).not.toContain("/* (unused) span");
+ for (const tag of ["span", "i"]) {
+ expect(native.code).toContain(`<${tag} class=${native.cssHash}>`);
+ expect(oracle.code).toContain(`<${tag} class="${native.cssHash}"`);
+ }
+ expect(native.code).toMatch(new RegExp(`class: "${native.cssHash}"`));
+ expect(oracle.code).toContain(`class="${native.cssHash}"`);
+ expect(native.code).toContain(``);
+ expect(oracle.code).toContain(``);
+ });
+
+ test("matches core collection boundaries for for and try pending", () => {
+ const outer = compareCoreStyleMetadata(
+ `export const View = ({ items }) => <>
+
+ @for (const item of items) { <>> }
+ @try { } @pending { <>> }
+>;`,
+ "style-owner-boundaries-oracle.tsrx"
+ );
+ expect(outer.native.css).toContain(".outer");
+ expect(outer.native.css).not.toContain(".loop");
+ expect(outer.native.css).not.toContain(".pending");
+
+ for (const [basename, source] of [
+ [
+ "for-style-boundary-oracle.tsrx",
+ `export const view = ({ items }) =>
+ @for (const item of items) { <>> };`
+ ],
+ [
+ "pending-style-boundary-oracle.tsrx",
+ `export const view = () =>
+ @try { } @pending { <>> };`
+ ]
+ ]) {
+ const { native } = compareCoreStyleMetadata(source, basename);
+ expect(native.css).toBe("");
+ expect(native.cssHash).toBeNull();
+ }
+ });
+
+ test("matches core style-ref pruning and all supported ref forms", () => {
+ const { native } = compareCoreStyleMetadata(
+ `let styles;
+const holder = {};
+const callback = value => value;
+const getRef = () => ({ current: null });
+export const view = <>
+
+
+>;`,
+ "style-ref-pruning-oracle.tsrx"
+ );
+ expect(native.css).toContain(`.foo.${native.cssHash}`);
+ expect(native.css).not.toContain("/* (unused) .foo");
+ expect(native.code).toContain(`"foo": "${native.cssHash} foo"`);
+ expect(native.code).toContain("styles = {");
+ expect(native.code).toContain("holder.value = {");
+ expect(native.code).toContain("callback(value)");
+ expect(native.code.match(/_tsrx_style_ref_/g).length).toBeGreaterThanOrEqual(2);
+ });
+
+ test("matches core expression classification, ignored refs, and visitor order", () => {
+ const unowned = compareCoreStyleMetadata(
+ `;`,
+ "unowned-style-oracle.tsrx"
+ );
+ expect(unowned.native.css).toBe("");
+ expect(unowned.native.cssHash).toBeNull();
+
+ const expression = compareCoreStyleMetadata(
+ `const ignored = () => {};
+export const styles = ;`,
+ "expression-ref-oracle.tsrx"
+ );
+ expect(expression.native.code).toContain(`"foo": "${expression.native.cssHash} foo"`);
+
+ const ordered = compareCoreStyleMetadata(
+ `export function View() @{
+ const early = ;
+ <>
+
+
+ >
+}`,
+ "style-owner-order-oracle.tsrx"
+ );
+ expect(ordered.native.css.indexOf(".owner")).toBeLessThan(ordered.native.css.indexOf(".early"));
+ });
+
+ test("preserves authored skip output and safely omits TSRX source maps", () => {
+ const source = 'export const view = <>>;';
+ const filename = path.join(__dirname, "style-import-source-skip.tsrx");
+ const skipped = transform(source, {
+ ...options,
+ filename,
+ requireImportSource: "solid-js",
+ sourceMap: true
+ });
+ expect(skipped.code).toBe(source);
+ expect(skipped.css).toContain(".x.");
+ expect(skipped.cssHash).toMatch(/^tsrx-/);
+ expect(skipped.map).toBeNull();
+
+ const mapped = transform(source, {
+ ...options,
+ filename,
+ sourceMap: true
+ });
+ expect(mapped.map).toBeNull();
+ expect(
+ transform("export const view = ;", {
+ ...options,
+ filename: "mapped.tsx",
+ sourceMap: true
+ }).map
+ ).not.toBeNull();
+ });
+});
diff --git a/packages/compiler/index.js b/packages/compiler/index.js
index 2a9d41760..96b182465 100644
--- a/packages/compiler/index.js
+++ b/packages/compiler/index.js
@@ -12,10 +12,18 @@ function transform(code, options) {
const nativeOptions = validateOptions(code, options);
const result = native.transform(code, nativeOptions);
- return {
+ const output = {
code: result.code,
map: result.map ?? null
};
+ // Preserve the established JSX result shape. Native TSRX transforms always
+ // return a CSS string (including `""` when no styles are present), which
+ // makes the sidecar fields a route-specific extension.
+ if (result.css != null) {
+ output.css = result.css;
+ output.cssHash = result.cssHash ?? null;
+ }
+ return output;
}
function transformAsync(code, options) {
diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs
index 19e5c8e29..85a584351 100644
--- a/packages/compiler/src/compiler.rs
+++ b/packages/compiler/src/compiler.rs
@@ -141,6 +141,10 @@ impl Default for CompileOptions {
pub struct CompileOutput {
pub code: String,
pub source_map: Option,
+ /// Extracted TSRX stylesheet output. `None` for the JSX route.
+ pub css: Option,
+ /// Space-separated TSRX scope hashes, matching `@tsrx/core`.
+ pub css_hash: Option,
}
/// Compile one JavaScript or TypeScript module containing JSX.
@@ -167,6 +171,7 @@ pub(crate) fn compile_for_node_adapter(
}
fn compile_inner(source: &str, options: &CompileOptions) -> Result {
+ let authored_source = source;
let tsrx_route = match options.syntax {
Syntax::Jsx => false,
Syntax::Tsrx => true,
@@ -224,9 +229,21 @@ fn compile_inner(source: &str, options: &CompileOptions) -> Result Result;", &options).unwrap_err();
assert_eq!(configuration.kind(), crate::CompileErrorKind::Configuration);
}
+
+ #[cfg(feature = "tsrx")]
+ fn compile_tsrx(source: &str, filename: &str) -> CompileOutput {
+ compile(
+ source,
+ &CompileOptions {
+ filename: Some(filename.into()),
+ syntax: Syntax::Tsrx,
+ ..CompileOptions::default()
+ },
+ )
+ .expect("compile TSRX")
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn extracts_and_scopes_tsrx_styles_without_a_runtime_helper() {
+ let output = compile_tsrx(
+ r#"export function View({ value, Tag }) @{
+ <>
+
+
+
+
+ <{Tag} />
+ >
+}"#,
+ "/exact/style-scope.tsrx",
+ );
+ let hash = output.css_hash.as_deref().expect("scope hash");
+ let css = output.css.as_deref().expect("TSRX CSS result");
+ assert!(css.contains(&format!(".used.{hash}")));
+ assert!(css.contains(&format!("span.{hash}")));
+ assert!(!output.code.contains(";",
+ "expression.tsrx",
+ );
+ let hash = expression.css_hash.as_deref().expect("expression hash");
+ assert!(
+ expression
+ .code
+ .contains(&format!("\"foo\": \"{hash} foo\"")),
+ "{}",
+ expression.code
+ );
+ assert!(
+ expression
+ .css
+ .as_deref()
+ .unwrap()
+ .contains("/* (unused) div")
+ );
+
+ let runtime = compile_tsrx(
+ r#"export function View() @{
+ let styles;
+ <>
+
+
+ >
+}"#,
+ "ref.tsrx",
+ );
+ let hash = runtime.css_hash.as_deref().expect("runtime hash");
+ assert!(
+ runtime
+ .code
+ .contains(&format!("styles = {{ \"foo\": \"{hash} foo\" }}")),
+ "{}",
+ runtime.code
+ );
+ assert!(runtime.code.contains(&format!("foo {hash}")));
+
+ let refs = compile_tsrx(
+ r#"let styles;
+const holder = {};
+const callback = value => value;
+const getRef = () => holder;
+export const view = <>
+
+
+>;"#,
+ "ref-forms.tsrx",
+ );
+ assert!(refs.code.contains("styles = {"), "{}", refs.code);
+ assert!(refs.code.contains("holder.value = {"), "{}", refs.code);
+ assert!(refs.code.contains("callback(value)"), "{}", refs.code);
+ assert!(
+ refs.code.contains("let _tsrx_style_ref_1 = getRef()"),
+ "{}",
+ refs.code
+ );
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn rejects_multiple_runtime_styles_per_fragment() {
+ let result = compile(
+ "const view = <>>;",
+ &CompileOptions {
+ filename: Some("duplicate.tsrx".into()),
+ syntax: Syntax::Tsrx,
+ ..CompileOptions::default()
+ },
+ );
+ let error = result.expect_err("multiple runtime styles must fail");
+ assert!(
+ error
+ .to_string()
+ .contains("TSRX fragments can only have one style tag")
+ );
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn reports_empty_css_only_on_the_tsrx_route() {
+ let tsrx = compile_tsrx("export const view = ;", "empty.tsrx");
+ assert_eq!(tsrx.css.as_deref(), Some(""));
+ assert_eq!(tsrx.css_hash, None);
+
+ let jsx = compile("export const view = ;", &CompileOptions::default()).unwrap();
+ assert_eq!(jsx.css, None);
+ assert_eq!(jsx.css_hash, None);
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn scopes_control_flow_elements_and_annotates_the_whole_owner() {
+ let output = compile_tsrx(
+ r#"const Component = props => props.children;
+export const View = ({ visible, items, Tag }) => <>
+
+ @if (visible) { }
+ @for (const item of items) { }
+ <{Tag} />
+
+>;"#,
+ "control-style.tsrx",
+ );
+ let hash = output.css_hash.as_deref().expect("owner hash");
+ let css = output.css.as_deref().expect("owner CSS");
+ assert!(css.contains(&format!("span.{hash}")), "{css}");
+ assert!(!css.contains("/* (unused) span"), "{css}");
+ assert!(output.code.contains(&format!("")));
+ assert!(output.code.contains(&format!("")));
+ assert!(output.code.contains(&format!("class: \"{hash}\"")));
+ assert!(output.code.contains(&format!("")));
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn excludes_for_and_try_pending_styles_from_owner_collection() {
+ let output = compile_tsrx(
+ r#"export const View = ({ items }) => <>
+
+ @for (const item of items) { <>> }
+ @try { } @pending { <>> }
+>;"#,
+ "style-boundaries.tsrx",
+ );
+ let hash = output.css_hash.as_deref().expect("outer style scope");
+ assert!(!hash.contains(' '), "{:?}", output.css_hash);
+ let css = output.css.as_deref().unwrap();
+ assert!(css.contains(".outer"), "{css}");
+ assert!(!css.contains(".loop"), "{css}");
+ assert!(!css.contains(".pending"), "{css}");
+ assert_eq!(output.code.matches("> };",
+ "for-style-boundary.tsrx",
+ );
+ assert_eq!(for_only.css.as_deref(), Some(""));
+ assert_eq!(for_only.css_hash, None);
+
+ let pending_only = compile_tsrx(
+ "export const view = () => @try { } @pending { <>> };",
+ "pending-style-boundary.tsrx",
+ );
+ assert_eq!(pending_only.css.as_deref(), Some(""));
+ assert_eq!(pending_only.css_hash, None);
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn style_refs_export_classes_and_expression_refs_are_ignored() {
+ let runtime = compile_tsrx(
+ r#"let styles;
+const holder = {};
+const callback = value => value;
+const getRef = () => ({ current: null });
+export const view = <>
+
+
+>;"#,
+ "ref-export.tsrx",
+ );
+ let hash = runtime.css_hash.as_deref().expect("runtime hash");
+ let css = runtime.css.as_deref().unwrap();
+ assert!(css.contains(&format!(".foo.{hash}")), "{css}");
+ assert!(!css.contains("/* (unused) .foo"), "{css}");
+ assert!(runtime.code.contains(&format!("\"foo\": \"{hash} foo\"")));
+ assert!(runtime.code.contains(&format!("")));
+ assert!(runtime.code.contains("styles = {"), "{}", runtime.code);
+ assert!(
+ runtime.code.contains("holder.value = {"),
+ "{}",
+ runtime.code
+ );
+ assert!(runtime.code.contains("callback(value)"), "{}", runtime.code);
+ assert!(
+ runtime.code.matches("_tsrx_style_ref_").count() >= 2,
+ "{}",
+ runtime.code
+ );
+
+ let expression = compile_tsrx(
+ "const ignored = () => {}; export const styles = ;",
+ "expression-ref.tsrx",
+ );
+ assert!(
+ expression.code.contains(&format!(
+ "\"foo\": \"{} foo\"",
+ expression.css_hash.unwrap()
+ )),
+ "{}",
+ expression.code
+ );
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn leaves_unowned_styles_unextracted_and_keeps_owner_visitor_order() {
+ let unowned = compile_tsrx(
+ ";",
+ "unowned-style.tsrx",
+ );
+ assert_eq!(unowned.css.as_deref(), Some(""));
+ assert_eq!(unowned.css_hash, None);
+
+ let ordered = compile_tsrx(
+ r#"export function View() @{
+ const early = ;
+ <>
+
+
+ >
+}"#,
+ "style-owner-order.tsrx",
+ );
+ let css = ordered.css.as_deref().unwrap();
+ assert!(
+ css.find(".owner").unwrap() < css.find(".early").unwrap(),
+ "{css}"
+ );
+ }
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn import_source_skip_preserves_authored_tsrx_and_css_but_omits_tsrx_maps() {
+ let source = "export const view = <>
>;";
+ let skipped = compile(
+ source,
+ &CompileOptions {
+ filename: Some("skip.tsrx".into()),
+ syntax: Syntax::Tsrx,
+ require_import_source: Some("solid-js".into()),
+ source_map: true,
+ ..CompileOptions::default()
+ },
+ )
+ .unwrap();
+ assert_eq!(skipped.code, source);
+ assert!(skipped.css.as_deref().is_some_and(|css| !css.is_empty()));
+ assert!(skipped.css_hash.is_some());
+ assert_eq!(skipped.source_map, None);
+
+ let tsrx = compile(
+ source,
+ &CompileOptions {
+ filename: Some("mapped.tsrx".into()),
+ syntax: Syntax::Tsrx,
+ source_map: true,
+ ..CompileOptions::default()
+ },
+ )
+ .unwrap();
+ assert_eq!(tsrx.source_map, None);
+
+ let jsx = compile(
+ "export const view =
;",
+ &CompileOptions {
+ filename: Some("mapped.tsx".into()),
+ source_map: true,
+ ..CompileOptions::default()
+ },
+ )
+ .unwrap();
+ assert!(jsx.source_map.is_some());
+ }
}
diff --git a/packages/compiler/src/config.rs b/packages/compiler/src/config.rs
index ddfae6b66..ffd30b9a7 100644
--- a/packages/compiler/src/config.rs
+++ b/packages/compiler/src/config.rs
@@ -68,6 +68,10 @@ pub struct TransformOptions {
pub struct TransformResult {
pub code: String,
pub map: Option
,
+ /// Extracted TSRX stylesheet output. Absent for ordinary JSX transforms.
+ pub css: Option,
+ /// Space-separated TSRX scope hashes. Absent when no stylesheet was emitted.
+ pub css_hash: Option,
}
pub(crate) fn source_type_for_filename(filename: Option<&str>) -> Result {
diff --git a/packages/compiler/src/lazy.rs b/packages/compiler/src/lazy.rs
index 332dd5bd4..23694da4f 100644
--- a/packages/compiler/src/lazy.rs
+++ b/packages/compiler/src/lazy.rs
@@ -50,7 +50,12 @@ pub fn transform_lazy(
) -> Result {
let options = options.unwrap_or_default();
let Some(filename) = options.filename.as_deref() else {
- return Ok(TransformResult { code, map: None });
+ return Ok(TransformResult {
+ code,
+ map: None,
+ css: None,
+ css_hash: None,
+ });
};
let source_type = source_type_for_filename(Some(filename))?;
@@ -71,7 +76,12 @@ pub fn transform_lazy(
// Nothing matched: hand back the input untouched instead of a
// reprint (the Babel support pass reprints regardless, but callers
// only care about the placeholder injection).
- return Ok(TransformResult { code, map: None });
+ return Ok(TransformResult {
+ code,
+ map: None,
+ css: None,
+ css_hash: None,
+ });
}
let mut rewriter = Rewriter {
@@ -93,6 +103,8 @@ pub fn transform_lazy(
Ok(TransformResult {
code: build.code,
map: build.map.map(|map| map.to_json_string()),
+ css: None,
+ css_hash: None,
})
}
diff --git a/packages/compiler/src/node_adapter.rs b/packages/compiler/src/node_adapter.rs
index 15ea77048..c61ea0440 100644
--- a/packages/compiler/src/node_adapter.rs
+++ b/packages/compiler/src/node_adapter.rs
@@ -66,6 +66,8 @@ pub fn transform(code: String, options: Option) -> Result) -> Result,
/// Projected offset of each lazy pattern's opening bracket, with its
/// preallocated `__lazyN` name.
pub lazy_patterns: Vec<(u32, String, bool)>,
@@ -196,7 +206,11 @@ fn exported_lazy_declaration(root: Node<'_>) -> Option> {
invalid
}
-pub fn project(source: &str, tape: &tsrx_tape_schema::FlatTape) -> Result {
+pub fn project(
+ source: &str,
+ filename: &str,
+ tape: &tsrx_tape_schema::FlatTape,
+) -> Result {
let root = Node::root(tape).ok_or(ProjectError {
message: "TSRX parse produced no program".into(),
start: 0,
@@ -208,9 +222,13 @@ pub fn project(source: &str, tape: &tsrx_tape_schema::FlatTape) -> Result Result {
/// in document order. Does not descend into found specials: their renderers
/// re-collect within themselves. `position` classifies `node` itself when it
/// is special.
-fn collect_specials<'t>(node: Node<'t>, position: Position, out: &mut Vec>) {
+fn collect_specials<'t>(
+ node: Node<'t>,
+ position: Position,
+ styles: &StyleProjection<'t>,
+ out: &mut Vec>,
+) {
let ty = node.ty();
+ let start = node.span().map_or(u32::MAX, |span| span.0);
let special_span = if lazy_assignment_pattern(node).is_some() {
node.span()
@@ -359,6 +385,10 @@ fn collect_specials<'t>(node: Node<'t>, position: Position, out: &mut Vec(node: Node<'t>, position: Position, out: &mut Vec) -> Option<(u32, u32)> {
Some((start, end))
}
-fn collect_children<'t>(node: Node<'t>, out: &mut Vec>) {
+fn collect_children<'t>(node: Node<'t>, styles: &StyleProjection<'t>, out: &mut Vec>) {
let ty = node.ty();
for (key, value) in node.fields() {
if matches!(key, "type" | "start" | "end" | "metadata" | "loc" | "range") {
@@ -426,7 +456,7 @@ fn collect_children<'t>(node: Node<'t>, out: &mut Vec>) {
match value.kind() {
tsrx_tape_schema::ValueKind::Object => {
if let Some(child) = Node::from_value(node.tape(), value) {
- collect_specials(child, child_position, out);
+ collect_specials(child, child_position, styles, out);
}
}
tsrx_tape_schema::ValueKind::List => {
@@ -436,7 +466,7 @@ fn collect_children<'t>(node: Node<'t>, out: &mut Vec>) {
if let Some(item) = node.tape().list_value(entry)
&& let Some(child) = Node::from_value(node.tape(), item)
{
- collect_specials(child, child_position, out);
+ collect_specials(child, child_position, styles, out);
}
next = node.tape().list_value_next(entry);
}
@@ -537,9 +567,10 @@ struct BlockParts<'t> {
renders: Vec>,
}
-struct Renderer<'s> {
+struct Renderer<'s, 't> {
source: &'s str,
out: String,
+ styles: StyleProjection<'t>,
/// Document-ordered lazy pattern spans; index = lazy id.
lazy_ids: Vec<(u32, u32)>,
lazy_patterns: Vec<(u32, String, bool)>,
@@ -547,7 +578,7 @@ struct Renderer<'s> {
suppress_nested_lazy: usize,
}
-impl<'s> Renderer<'s> {
+impl<'s, 't> Renderer<'s, 't> {
fn push_verbatim(&mut self, start: u32, end: u32) {
if end <= start {
return;
@@ -570,7 +601,7 @@ impl<'s> Renderer<'s> {
position: Position,
) -> Result<()> {
let mut specials = Vec::new();
- collect_specials(scope, position, &mut specials);
+ collect_specials(scope, position, &self.styles, &mut specials);
self.emit_region(start, end, &mut specials)
}
@@ -598,10 +629,15 @@ impl<'s> Renderer<'s> {
/// directly, copies everything else verbatim with nested specials.
fn emit_node(&mut self, node: Node<'_>, position: Position) -> Result<()> {
let ty = node.ty();
+ let start = node.span().map_or(u32::MAX, |span| span.0);
if ty == "JSXCodeBlock"
|| is_construct(ty)
|| ty == "JSXStyleElement"
|| is_dynamic_element(node)
+ || (ty == "JSXElement"
+ && (self.styles.element_hashes.contains_key(&start)
+ || self.styles.owner_setups.contains_key(&start)))
+ || (ty == "JSXFragment" && self.styles.owner_setups.contains_key(&start))
|| (ty == "JSXAttribute" && node.bool_field("shorthand"))
|| lazy_assignment_pattern(node).is_some()
|| is_lazy_pattern(node)
@@ -610,7 +646,7 @@ impl<'s> Renderer<'s> {
}
let (start, end) = span_of(node)?;
let mut specials = Vec::new();
- collect_children(node, &mut specials);
+ collect_children(node, &self.styles, &mut specials);
self.emit_region(start, end, &mut specials)
}
@@ -621,12 +657,10 @@ impl<'s> Renderer<'s> {
"JSXForExpression" => self.render_for(node),
"JSXSwitchExpression" => self.render_switch(node),
"JSXTryExpression" => self.render_try(node, position),
- "JSXStyleElement" => Err(ProjectError::new(
- "TSRX scoped \n \n >\n}\n";
+ let filename = "/exact/components/card.tsrx";
+ let outputs: Vec<_> = [Generate::Dom, Generate::Ssr, Generate::Universal]
+ .into_iter()
+ .map(|generate| {
+ compile(
+ source,
+ &CompileOptions {
+ filename: Some(filename.into()),
+ ..fixture_options(generate)
+ },
+ )
+ .expect("scoped styles compile")
+ })
+ .collect();
+ let expected_css = outputs[0].css.as_deref().expect("TSRX CSS result");
+ let expected_hash = outputs[0].css_hash.as_deref().expect("scope hash");
+ assert!(expected_css.contains(&format!(".used.{expected_hash}")));
+ assert!(expected_css.contains("(unused)"), "{expected_css}");
+ for output in &outputs {
+ assert_eq!(output.css.as_deref(), Some(expected_css));
+ assert_eq!(output.css_hash.as_deref(), Some(expected_hash));
+ assert!(!output.code.contains("\n hi
\n \n}\n",
+ "export const C = () => <>\n \n \n \n>;",
);
assert!(
- message
- .to_lowercase()
- .contains("scoped
+
+ >
+}`,
+ "style-dynamic-child.tsrx"
+ );
+
+ expect(result.css).toContain(`.card.${result.cssHash}`);
+ });
+
test("emits class maps, style refs, and document-ordered metadata", () => {
const expression = compareStyleMetadata(
`export const first = ;
diff --git a/packages/compiler/src/tsrx/style/analysis.rs b/packages/compiler/src/tsrx/style/analysis.rs
index b35ebb221..412841ea9 100644
--- a/packages/compiler/src/tsrx/style/analysis.rs
+++ b/packages/compiler/src/tsrx/style/analysis.rs
@@ -247,15 +247,11 @@ struct FlatElement<'a> {
pub(super) struct Arena<'a> {
nodes: Vec>,
- has_dynamic: bool,
}
impl<'a> Arena<'a> {
pub(super) fn from_roots(roots: &'a [Element]) -> Self {
- let mut arena = Self {
- nodes: Vec::new(),
- has_dynamic: false,
- };
+ let mut arena = Self { nodes: Vec::new() };
let root_indexes: Vec<_> = roots
.iter()
.map(|element| arena.add(element, None))
@@ -285,10 +281,7 @@ impl<'a> Arena<'a> {
.iter()
.filter_map(|child| match child {
ElementChild::Element(child) => Some(self.add(child, Some(index))),
- ElementChild::Dynamic => {
- self.has_dynamic = true;
- None
- }
+ ElementChild::Dynamic => None,
})
.collect();
for (position, child) in child_indexes.iter().copied().enumerate() {
@@ -383,9 +376,6 @@ fn matches_complex(
element: usize,
parent: &Option>,
) -> bool {
- if arena.has_dynamic {
- return true;
- }
let Some(last_local) = complex
.parts
.iter()
diff --git a/packages/compiler/src/tsrx/style/tests.rs b/packages/compiler/src/tsrx/style/tests.rs
index 1de8fa3ac..9ba0e8b31 100644
--- a/packages/compiler/src/tsrx/style/tests.rs
+++ b/packages/compiler/src/tsrx/style/tests.rs
@@ -1,7 +1,5 @@
use super::*;
-use super::*;
-
fn input<'a>(css: &'a str, elements: &'a [Element], kind: StyleKind) -> StyleInput<'a> {
StyleInput {
css,
@@ -149,6 +147,8 @@ fn prunes_against_element_tree_and_tracks_scoped_ids() {
))
.unwrap();
assert!(!conservative.css.contains("(unused)"));
+ assert!(conservative.css.contains(".runtime-class.tsrx-"));
+ assert!(conservative.scoped_elements.contains(&3));
}
#[test]
From 66cce9841931a445dbade9a399514f3c749b8ee2 Mon Sep 17 00:00:00 2001
From: Ryan Carniato
Date: Fri, 28 Aug 2026 02:15:50 -0700
Subject: [PATCH 8/9] Add authored source maps for native TSRX.
Compose Oxc mappings through exact projection ranges while leaving generated scaffolding unmapped.
Co-authored-by: Cursor
---
.changeset/support-tsrx-scoped-styles.md | 2 +-
.changeset/support-tsrx-source-maps.md | 5 +
documentation/tsrx/frontend-notes.md | 9 +-
packages/compiler/Cargo.lock | 1 +
packages/compiler/Cargo.toml | 3 +-
packages/compiler/README.md | 2 +-
.../__tests__/tsrx-style-parity.test.js | 6 +-
packages/compiler/src/compiler.rs | 43 ++-
packages/compiler/src/tsrx/mod.rs | 28 +-
packages/compiler/src/tsrx/project.rs | 9 +
packages/compiler/src/tsrx/rewrite.rs | 107 ++++++--
packages/compiler/src/tsrx/source_map.rs | 252 ++++++++++++++++++
packages/compiler/tests/tsrx_frontend.rs | 202 +++++++++++++-
13 files changed, 632 insertions(+), 37 deletions(-)
create mode 100644 .changeset/support-tsrx-source-maps.md
create mode 100644 packages/compiler/src/tsrx/source_map.rs
diff --git a/.changeset/support-tsrx-scoped-styles.md b/.changeset/support-tsrx-scoped-styles.md
index 4479834fb..cfe0ec549 100644
--- a/.changeset/support-tsrx-scoped-styles.md
+++ b/.changeset/support-tsrx-scoped-styles.md
@@ -3,4 +3,4 @@
"@solidjs/compiler": patch
---
-Add compile-time scoped styles, CSS sidecar output, style class maps, and style refs to both TSRX frontends. Native TSRX transforms temporarily omit source maps rather than returning maps against generated projection text.
+Add compile-time scoped styles, CSS sidecar output, style class maps, and style refs to both TSRX frontends.
diff --git a/.changeset/support-tsrx-source-maps.md b/.changeset/support-tsrx-source-maps.md
new file mode 100644
index 000000000..bcdfc75eb
--- /dev/null
+++ b/.changeset/support-tsrx-source-maps.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/compiler": patch
+---
+
+Emit native TSRX source maps against authored `.tsrx` locations while leaving compiler-generated projection ranges unmapped.
diff --git a/documentation/tsrx/frontend-notes.md b/documentation/tsrx/frontend-notes.md
index 5d4adb069..6bebf4a13 100644
--- a/documentation/tsrx/frontend-notes.md
+++ b/documentation/tsrx/frontend-notes.md
@@ -60,7 +60,7 @@ All flow-control imports come from `solid-js`; `dynamic` from `@solidjs/web`.
| `let &[a, b, ...rest] = expr` | Indexed reads plus a fresh `Array.from(__lazyN).slice(2)` rest view per read — **no getter auto-calling** | yes; the rest binding is read-only |
| `&{ a }` in function or arrow params | `__lazyN` param (type annotation/default preserved), member reads | yes; sync, async, multi-parameter, and generic arrows |
| Scoped `;`,
expect(ordered.native.css.indexOf(".owner")).toBeLessThan(ordered.native.css.indexOf(".early"));
});
- test("preserves authored skip output and safely omits TSRX source maps", () => {
+ test("preserves authored skip output and emits compiled TSRX source maps", () => {
const source = 'export const view = <>>;';
const filename = path.join(__dirname, "style-import-source-skip.tsrx");
const skipped = transform(source, {
@@ -283,7 +283,9 @@ export const styles = ;`,
filename,
sourceMap: true
});
- expect(mapped.map).toBeNull();
+ const sourceMap = JSON.parse(mapped.map);
+ expect(sourceMap.sources).toEqual([filename]);
+ expect(sourceMap.sourcesContent).toEqual([source]);
expect(
transform("export const view = ;", {
...options,
diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs
index 85a584351..8c4151879 100644
--- a/packages/compiler/src/compiler.rs
+++ b/packages/compiler/src/compiler.rs
@@ -195,6 +195,7 @@ fn compile_inner(source: &str, options: &CompileOptions) -> Result Result Result Result
#[cfg(feature = "tsrx")]
#[test]
- fn import_source_skip_preserves_authored_tsrx_and_css_but_omits_tsrx_maps() {
+ fn import_source_skip_preserves_authored_tsrx_while_compiled_tsrx_emits_maps() {
let source = "export const view = <>>;";
let skipped = compile(
source,
@@ -796,7 +814,12 @@ export const view = <>
},
)
.unwrap();
- assert_eq!(tsrx.source_map, None);
+ let map = tsrx
+ .source_map
+ .as_deref()
+ .expect("compiled TSRX source map");
+ assert!(map.contains("\"sources\":[\"mapped.tsrx\"]"), "{map}");
+ assert!(map.contains("\"sourcesContent\""), "{map}");
let jsx = compile(
"export const view = ;",
diff --git a/packages/compiler/src/tsrx/mod.rs b/packages/compiler/src/tsrx/mod.rs
index 255d55732..a57c133b1 100644
--- a/packages/compiler/src/tsrx/mod.rs
+++ b/packages/compiler/src/tsrx/mod.rs
@@ -11,6 +11,7 @@
mod project;
mod rewrite;
+mod source_map;
mod style;
mod style_projection;
mod tape;
@@ -26,7 +27,11 @@ use tsrx_tape_schema::{CoordinateDomain, ParseCompleteness, RecordIndex, ValueRe
use crate::error::CompileError;
/// Parse TSRX source and project it to plain TSX for the shared pipeline.
-pub fn run_frontend(source: &str, filename: Option<&str>) -> Result {
+pub fn run_frontend(
+ source: &str,
+ filename: Option<&str>,
+ source_maps: bool,
+) -> Result {
let filename = filename.unwrap_or("input.tsrx");
let options = TsrxParseOptions {
filename,
@@ -46,7 +51,7 @@ pub fn run_frontend(source: &str, filename: Option<&str>) -> Result(
allocator: &'a oxc_allocator::Allocator,
program: &mut oxc_ast::ast::Program<'a>,
projection: &Projection,
+ source_maps: bool,
) -> Result<(), CompileError> {
- rewrite::apply(allocator, program, projection).map_err(CompileError::transform)
+ rewrite::apply(allocator, program, projection, source_maps).map_err(CompileError::transform)
+}
+
+/// Compose codegen's projected-TSX source map back to authored TSRX.
+pub fn compose_source_map(
+ intermediate: &oxc_sourcemap::SourceMap<'_>,
+ projection: &Projection,
+ authored_source: &str,
+ filename: &str,
+) -> String {
+ source_map::compose(
+ intermediate,
+ &projection.source_map,
+ &projection.text,
+ authored_source,
+ filename,
+ )
}
fn parse_source(
diff --git a/packages/compiler/src/tsrx/project.rs b/packages/compiler/src/tsrx/project.rs
index 2591037f0..b01f58712 100644
--- a/packages/compiler/src/tsrx/project.rs
+++ b/packages/compiler/src/tsrx/project.rs
@@ -22,6 +22,7 @@
//! re-analyzed cheaply during emission.
use super::{
+ source_map::ProjectionMap,
style_projection::{
self, RefSetup, StyleAction, StyleProjection, class_attribute, decode_json_string,
is_callback_ref, is_class_attribute, is_direct_ref_target, push_class_map, push_js_string,
@@ -36,6 +37,8 @@ pub struct Projection {
pub css: String,
/// Space-separated scope hashes, or `None` when no styles were present.
pub css_hash: Option,
+ /// Exact authored ranges copied into `text`, used to compose codegen maps.
+ pub(super) source_map: ProjectionMap,
/// Projected offset of each lazy pattern's opening bracket, with its
/// preallocated `__lazyN` name.
pub lazy_patterns: Vec<(u32, String, bool)>,
@@ -210,6 +213,7 @@ pub fn project(
source: &str,
filename: &str,
tape: &tsrx_tape_schema::FlatTape,
+ source_maps: bool,
) -> Result {
let root = Node::root(tape).ok_or(ProjectError {
message: "TSRX parse produced no program".into(),
@@ -233,6 +237,7 @@ pub fn project(
lazy_patterns: Vec::new(),
accessor_arrows: Vec::new(),
suppress_nested_lazy: 0,
+ source_map: ProjectionMap::new(source_maps),
};
renderer.emit_verbatim_with_specials(root, 0, source.len() as u32, Position::Expression)?;
@@ -241,6 +246,7 @@ pub fn project(
text: renderer.out,
css,
css_hash,
+ source_map: renderer.source_map,
lazy_patterns: renderer.lazy_patterns,
accessor_arrows: renderer.accessor_arrows,
})
@@ -576,6 +582,7 @@ struct Renderer<'s, 't> {
lazy_patterns: Vec<(u32, String, bool)>,
accessor_arrows: Vec<(u32, Vec)>,
suppress_nested_lazy: usize,
+ source_map: ProjectionMap,
}
impl<'s, 't> Renderer<'s, 't> {
@@ -583,6 +590,8 @@ impl<'s, 't> Renderer<'s, 't> {
if end <= start {
return;
}
+ self.source_map
+ .record_verbatim(self.out.len() as u32, start, end);
self.out
.push_str(&self.source[start as usize..end as usize]);
}
diff --git a/packages/compiler/src/tsrx/rewrite.rs b/packages/compiler/src/tsrx/rewrite.rs
index 9f56dd2bd..071016bf0 100644
--- a/packages/compiler/src/tsrx/rewrite.rs
+++ b/packages/compiler/src/tsrx/rewrite.rs
@@ -24,7 +24,7 @@ use oxc_ast::ast::{
};
use oxc_ast_visit::{VisitMut, walk_mut};
use oxc_semantic::{Semantic, SemanticBuilder};
-use oxc_span::{GetSpan, Span};
+use oxc_span::{GetSpan, GetSpanMut, Span};
use oxc_syntax::{
number::NumberBase,
operator::{AssignmentOperator, BinaryOperator, LogicalOperator},
@@ -165,6 +165,7 @@ pub fn apply<'a>(
allocator: &'a Allocator,
program: &mut Program<'a>,
projection: &Projection,
+ source_maps: bool,
) -> Result<(), String> {
if projection.lazy_patterns.is_empty() && projection.accessor_arrows.is_empty() {
return Ok(());
@@ -185,6 +186,7 @@ pub fn apply<'a>(
names,
temp_scopes: vec![Vec::new()],
active_expansions: Vec::new(),
+ source_maps,
error: None,
};
rewriter.visit_program(program);
@@ -559,14 +561,80 @@ struct Rewriter<'a, 'p> {
names: Names,
temp_scopes: Vec>,
active_expansions: Vec,
+ source_maps: bool,
error: Option,
}
+struct SourceMapAnchor {
+ span: Span,
+}
+
+impl<'a> VisitMut<'a> for SourceMapAnchor {
+ fn visit_assignment_expression(
+ &mut self,
+ expression: &mut oxc_ast::ast::AssignmentExpression<'a>,
+ ) {
+ if expression.span.is_empty() {
+ expression.span = self.span;
+ }
+ walk_mut::walk_assignment_expression(self, expression);
+ }
+
+ fn visit_update_expression(&mut self, expression: &mut oxc_ast::ast::UpdateExpression<'a>) {
+ if expression.span.is_empty() {
+ expression.span = self.span;
+ }
+ walk_mut::walk_update_expression(self, expression);
+ }
+
+ fn visit_identifier_reference(
+ &mut self,
+ identifier: &mut oxc_ast::ast::IdentifierReference<'a>,
+ ) {
+ if identifier.span.is_empty() {
+ identifier.span = self.span;
+ }
+ }
+}
+
impl<'a> Rewriter<'a, '_> {
fn generated_span(span: Span) -> Span {
Span::new(span.start, span.start)
}
+ fn authored_expression(&self, mut expression: Expression<'a>, span: Span) -> Expression<'a> {
+ if self.source_maps {
+ *expression.span_mut() = span;
+ }
+ expression
+ }
+
+ fn source_map_anchor(&self, span: Span) -> SourceMapAnchor {
+ SourceMapAnchor { span }
+ }
+
+ fn anchor_expression(&self, expression: &mut Expression<'a>, span: Span) {
+ if !self.source_maps {
+ return;
+ }
+ self.source_map_anchor(span).visit_expression(expression);
+ }
+
+ fn anchor_assignment_target(&self, target: &mut AssignmentTarget<'a>, span: Span) {
+ if !self.source_maps {
+ return;
+ }
+ self.source_map_anchor(span).visit_assignment_target(target);
+ }
+
+ fn anchor_simple_assignment_target(&self, target: &mut SimpleAssignmentTarget<'a>, span: Span) {
+ if !self.source_maps {
+ return;
+ }
+ let mut anchor = SourceMapAnchor { span };
+ anchor.visit_simple_assignment_target(target);
+ }
+
fn replacement(&self, span: Span) -> Option> {
self.plan
.references
@@ -672,12 +740,13 @@ impl<'a> Rewriter<'a, '_> {
}
fn raw_access(&self, binding: &LazyBinding<'a>, span: Span) -> Expression<'a> {
- binding
+ let expression = binding
.steps
.iter()
.fold(self.source_access(binding, span), |value, step| {
self.apply_raw_step(value, step, span)
- })
+ });
+ self.authored_expression(expression, span)
}
fn value_access(&mut self, binding: &LazyBinding<'a>, span: Span) -> Expression<'a> {
@@ -714,7 +783,7 @@ impl<'a> Rewriter<'a, '_> {
}
}
}
- match &binding.kind {
+ let expression = match &binding.kind {
LazyKind::Value => value,
LazyKind::ObjectRest(keys) => {
let Some(omit) = self.plan.omit_name.as_deref() else {
@@ -770,7 +839,8 @@ impl<'a> Rewriter<'a, '_> {
false,
)
}
- }
+ };
+ self.authored_expression(expression, span)
}
fn identifier_target(&self, name: &str, span: Span) -> AssignmentTarget<'a> {
@@ -918,8 +988,10 @@ impl<'a> Rewriter<'a, '_> {
next,
));
}
- self.ast
- .expression_sequence(Self::generated_span(span), expressions)
+ let sequence = self
+ .ast
+ .expression_sequence(Self::generated_span(span), expressions);
+ self.authored_expression(sequence, span)
}
fn lower_default_update(
@@ -1021,8 +1093,10 @@ impl<'a> Rewriter<'a, '_> {
));
expressions.push(self.ident(&old_name, span));
}
- self.ast
- .expression_sequence(Self::generated_span(span), expressions)
+ let sequence = self
+ .ast
+ .expression_sequence(Self::generated_span(span), expressions);
+ self.authored_expression(sequence, span)
}
fn temp_declaration(&self, names: Vec) -> Statement<'a> {
@@ -1260,18 +1334,16 @@ impl<'a> VisitMut<'a> for Rewriter<'a, '_> {
&& let Some(Replacement::Lazy(binding)) = self.replacement(ident.span)
&& binding.direct_default.is_some()
{
+ let span = assignment.span;
+ let reference_span = ident.span;
let mut right = assignment.right.clone_in(self.allocator);
self.visit_expression(&mut right);
- let lowered = self.lower_default_assignment(
- assignment.span,
- assignment.operator,
- right,
- &binding,
- );
+ let lowered = self.lower_default_assignment(span, assignment.operator, right, &binding);
*expression = lowered;
assert!(self.begin_expansion(&binding));
walk_mut::walk_expression(self, expression);
self.end_expansion(&binding);
+ self.anchor_expression(expression, reference_span);
return;
}
@@ -1301,11 +1373,13 @@ impl<'a> VisitMut<'a> for Rewriter<'a, '_> {
&& let Some(Replacement::Lazy(binding)) = self.replacement(ident.span)
&& binding.direct_default.is_some()
{
+ let reference_span = ident.span;
let lowered = self.lower_default_update(update, &binding);
*expression = lowered;
assert!(self.begin_expansion(&binding));
walk_mut::walk_expression(self, expression);
self.end_expansion(&binding);
+ self.anchor_expression(expression, reference_span);
return;
}
@@ -1323,6 +1397,7 @@ impl<'a> VisitMut<'a> for Rewriter<'a, '_> {
*expression = self.value_access(&binding, span);
walk_mut::walk_expression(self, expression);
self.end_expansion(&binding);
+ self.anchor_expression(expression, span);
}
Some(Replacement::Call) => {
let callee = std::mem::replace(expression, self.ast.expression_null_literal(span));
@@ -1353,6 +1428,7 @@ impl<'a> VisitMut<'a> for Rewriter<'a, '_> {
assert!(self.begin_expansion(&binding));
walk_mut::walk_assignment_target(self, target);
self.end_expansion(&binding);
+ self.anchor_assignment_target(target, span);
return;
}
walk_mut::walk_assignment_target(self, target);
@@ -1377,6 +1453,7 @@ impl<'a> VisitMut<'a> for Rewriter<'a, '_> {
assert!(self.begin_expansion(&binding));
walk_mut::walk_simple_assignment_target(self, &mut update.argument);
self.end_expansion(&binding);
+ self.anchor_simple_assignment_target(&mut update.argument, span);
return;
}
walk_mut::walk_update_expression(self, update);
diff --git a/packages/compiler/src/tsrx/source_map.rs b/packages/compiler/src/tsrx/source_map.rs
new file mode 100644
index 000000000..8d1e741cb
--- /dev/null
+++ b/packages/compiler/src/tsrx/source_map.rs
@@ -0,0 +1,252 @@
+//! Source-map support for the authored-text TSRX projection.
+//!
+//! Oxc codegen maps generated JavaScript back to the projected TSX. This
+//! module records the authored bytes copied into that projection and composes
+//! codegen's map through those exact ranges. Generated projection gaps remain
+//! explicitly unmapped instead of being attributed to nearby TSRX syntax.
+
+use oxc_sourcemap::{SourceMap, SourceMapBuilder};
+use oxc_syntax::identifier::is_identifier_name;
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+struct ProjectionSegment {
+ projected_start: u32,
+ projected_end: u32,
+ authored_start: u32,
+}
+
+/// Exact affine ranges copied from authored TSRX into projected TSX.
+#[derive(Debug)]
+pub(super) struct ProjectionMap {
+ enabled: bool,
+ segments: Vec,
+}
+
+impl ProjectionMap {
+ pub fn new(enabled: bool) -> Self {
+ Self {
+ enabled,
+ segments: Vec::new(),
+ }
+ }
+
+ pub fn record_verbatim(
+ &mut self,
+ projected_start: u32,
+ authored_start: u32,
+ authored_end: u32,
+ ) {
+ if !self.enabled || authored_end <= authored_start {
+ return;
+ }
+ let len = authored_end - authored_start;
+ let projected_end = projected_start + len;
+ if let Some(previous) = self.segments.last_mut()
+ && previous.projected_end == projected_start
+ && previous.authored_start + (previous.projected_end - previous.projected_start)
+ == authored_start
+ {
+ previous.projected_end = projected_end;
+ return;
+ }
+ self.segments.push(ProjectionSegment {
+ projected_start,
+ projected_end,
+ authored_start,
+ });
+ }
+
+ fn authored_offset(&self, projected_offset: u32) -> Option {
+ let index = self
+ .segments
+ .partition_point(|segment| segment.projected_start <= projected_offset);
+ let segment = self.segments.get(index.checked_sub(1)?)?;
+ (projected_offset < segment.projected_end)
+ .then(|| segment.authored_start + projected_offset - segment.projected_start)
+ }
+}
+
+/// Compose an Oxc JavaScript → projected-TSX map into a JavaScript → authored-
+/// TSRX map. Tokens landing in generated projection gaps are retained as
+/// source-less mappings so a preceding authored mapping cannot bleed across
+/// generated code.
+pub(super) fn compose(
+ intermediate: &SourceMap<'_>,
+ projection: &ProjectionMap,
+ projected_source: &str,
+ authored_source: &str,
+ filename: &str,
+) -> String {
+ let projected_lines = LineOffsets::new(projected_source);
+ let authored_lines = LineOffsets::new(authored_source);
+ let mut builder = SourceMapBuilder::default();
+ let source_id = builder.set_source_and_content(filename, authored_source);
+ if let Some(file) = intermediate.get_file() {
+ builder.set_file(file);
+ }
+
+ for token in intermediate.get_tokens() {
+ let mapped = token
+ .get_source_id()
+ .and_then(|_| projected_lines.byte_offset(token.get_src_line(), token.get_src_col()))
+ .and_then(|offset| projection.authored_offset(offset))
+ .and_then(|offset| authored_lines.line_column(offset));
+
+ if let Some((line, column)) = mapped {
+ let name_id = token
+ .get_name_id()
+ .and_then(|id| intermediate.get_name(id))
+ // Oxc derives names by slicing a node's source span. Projected
+ // wrapper and whole-pattern spans can therefore yield strings
+ // such as `{ name }`, which are not source-map symbol names.
+ .filter(|name| is_identifier_name(name))
+ .map(|name| builder.add_name(name));
+ builder.add_token(
+ token.get_dst_line(),
+ token.get_dst_col(),
+ line,
+ column,
+ Some(source_id),
+ name_id,
+ );
+ } else {
+ builder.add_token(token.get_dst_line(), token.get_dst_col(), 0, 0, None, None);
+ }
+ }
+
+ builder.into_sourcemap().to_json_string()
+}
+
+/// Converts between UTF-8 byte offsets and source-map line/UTF-16-column
+/// coordinates. JavaScript source maps count lines from zero.
+struct LineOffsets<'a> {
+ source: &'a str,
+ starts: Vec,
+}
+
+impl<'a> LineOffsets<'a> {
+ fn new(source: &'a str) -> Self {
+ let mut starts = vec![0];
+ let mut chars = source.char_indices().peekable();
+ while let Some((offset, ch)) = chars.next() {
+ let next = match ch {
+ '\r' => {
+ if chars.peek().is_some_and(|(_, next)| *next == '\n') {
+ let (next_offset, next) = chars.next().expect("peeked line feed");
+ next_offset + next.len_utf8()
+ } else {
+ offset + ch.len_utf8()
+ }
+ }
+ '\n' | '\u{2028}' | '\u{2029}' => offset + ch.len_utf8(),
+ _ => continue,
+ };
+ starts.push(next as u32);
+ }
+ Self { source, starts }
+ }
+
+ fn byte_offset(&self, line: u32, utf16_column: u32) -> Option {
+ let start = *self.starts.get(line as usize)? as usize;
+ let end = self
+ .starts
+ .get(line as usize + 1)
+ .copied()
+ .map_or(self.source.len(), |offset| offset as usize);
+ let mut column = 0u32;
+ for (relative, ch) in self.source[start..end].char_indices() {
+ if column == utf16_column {
+ return Some((start + relative) as u32);
+ }
+ column += ch.len_utf16() as u32;
+ if column > utf16_column {
+ return None;
+ }
+ }
+ (column == utf16_column).then_some(end as u32)
+ }
+
+ fn line_column(&self, byte_offset: u32) -> Option<(u32, u32)> {
+ let byte_offset = byte_offset as usize;
+ if byte_offset > self.source.len() || !self.source.is_char_boundary(byte_offset) {
+ return None;
+ }
+ let line = self
+ .starts
+ .partition_point(|start| *start as usize <= byte_offset)
+ .checked_sub(1)?;
+ let start = self.starts[line] as usize;
+ let column = self.source[start..byte_offset].encode_utf16().count() as u32;
+ Some((line as u32, column))
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn projection_map_resolves_only_exact_verbatim_ranges() {
+ let mut map = ProjectionMap::new(true);
+ map.record_verbatim(3, 10, 14);
+ map.record_verbatim(7, 14, 16);
+ map.record_verbatim(12, 30, 32);
+
+ assert_eq!(map.authored_offset(2), None);
+ assert_eq!(map.authored_offset(3), Some(10));
+ assert_eq!(map.authored_offset(8), Some(15));
+ assert_eq!(map.authored_offset(9), None);
+ assert_eq!(map.authored_offset(12), Some(30));
+ assert_eq!(map.authored_offset(14), None);
+ }
+
+ #[test]
+ fn line_offsets_use_utf16_columns_and_javascript_line_breaks() {
+ let lines = LineOffsets::new("🚀a\r\n中\u{2028}z");
+ assert_eq!(lines.byte_offset(0, 0), Some(0));
+ assert_eq!(lines.byte_offset(0, 2), Some(4));
+ assert_eq!(lines.byte_offset(0, 1), None);
+ assert_eq!(lines.byte_offset(1, 1), Some(10));
+ assert_eq!(lines.byte_offset(2, 0), Some(13));
+ assert_eq!(lines.line_column(4), Some((0, 2)));
+ assert_eq!(lines.line_column(7), Some((1, 0)));
+ assert_eq!(lines.line_column(13), Some((2, 0)));
+ }
+
+ #[test]
+ fn composition_preserves_generated_gaps_as_unmapped_tokens() {
+ let projected = "xxname yy";
+ let authored = "before name after";
+ let mut projection = ProjectionMap::new(true);
+ projection.record_verbatim(2, 7, 11);
+
+ let mut intermediate = SourceMapBuilder::default();
+ let projected_id = intermediate.set_source_and_content("input.tsrx", projected);
+ let invalid_name = intermediate.add_name("{ name }");
+ let valid_name = intermediate.add_name("name");
+ intermediate.add_token(0, 0, 0, 0, Some(projected_id), None);
+ intermediate.add_token(0, 2, 0, 2, Some(projected_id), Some(invalid_name));
+ intermediate.add_token(0, 3, 0, 3, Some(projected_id), Some(valid_name));
+ intermediate.add_token(0, 6, 0, 6, Some(projected_id), None);
+ let intermediate = intermediate.into_sourcemap();
+
+ let json = compose(
+ &intermediate,
+ &projection,
+ projected,
+ authored,
+ "input.tsrx",
+ );
+ let composed = SourceMap::from_json_string(&json).expect("valid composed map");
+ let tokens = composed.get_tokens().collect::>();
+ assert_eq!(tokens[0].get_source_id(), None);
+ assert_eq!(tokens[1].get_source_id(), Some(0));
+ assert_eq!((tokens[1].get_src_line(), tokens[1].get_src_col()), (0, 7));
+ assert_eq!(tokens[1].get_name_id(), None);
+ assert_eq!(tokens[2].get_source_id(), Some(0));
+ assert_eq!(tokens[2].get_name_id(), Some(0));
+ assert_eq!(tokens[3].get_source_id(), None);
+ assert_eq!(composed.get_names().collect::>(), vec!["name"]);
+ assert_eq!(composed.get_source_content(0), Some(authored));
+ }
+}
diff --git a/packages/compiler/tests/tsrx_frontend.rs b/packages/compiler/tests/tsrx_frontend.rs
index cd6e711a0..e1a40d261 100644
--- a/packages/compiler/tests/tsrx_frontend.rs
+++ b/packages/compiler/tests/tsrx_frontend.rs
@@ -13,6 +13,7 @@
use std::path::{Path, PathBuf};
+use oxc_sourcemap::SourceMap;
use solidjs_compiler::{CompileErrorKind, CompileOptions, Generate, Syntax, compile};
fn built_ins() -> Vec {
@@ -71,10 +72,17 @@ fn compile_corpus(dir: &str, generate: Generate) -> Vec<(String, String)> {
.unwrap_or_else(|error| panic!("{name}: fixture must have code.tsrx: {error}"));
let options = CompileOptions {
filename: Some(format!("{name}.tsrx")),
+ source_map: true,
..fixture_options(generate)
};
match compile(&source, &options) {
- Ok(output) => outputs.push((name, output.code)),
+ Ok(output) => {
+ assert!(
+ output.source_map.is_some(),
+ "{dir}/{name}: source-map request must return a map"
+ );
+ outputs.push((name, output.code));
+ }
Err(error) => failures.push(format!("{dir}/{name}: {error}")),
}
}
@@ -86,6 +94,71 @@ fn compile_corpus(dir: &str, generate: Generate) -> Vec<(String, String)> {
outputs
}
+fn line_column(source: &str, byte_offset: usize) -> (u32, u32) {
+ let mut line = 0u32;
+ let mut line_start = 0usize;
+ let mut chars = source[..byte_offset].char_indices().peekable();
+ while let Some((offset, ch)) = chars.next() {
+ let next = match ch {
+ '\r' => {
+ if chars.peek().is_some_and(|(_, next)| *next == '\n') {
+ let (next_offset, next) = chars.next().expect("peeked line feed");
+ next_offset + next.len_utf8()
+ } else {
+ offset + ch.len_utf8()
+ }
+ }
+ '\n' | '\u{2028}' | '\u{2029}' => offset + ch.len_utf8(),
+ _ => continue,
+ };
+ line += 1;
+ line_start = next;
+ }
+ let column = source[line_start..byte_offset].encode_utf16().count() as u32;
+ (line, column)
+}
+
+fn assert_maps_to(
+ output: &solidjs_compiler::CompileOutput,
+ generated_needle: &str,
+ generated_relative_offset: usize,
+ authored_source: &str,
+ authored_needle: &str,
+) {
+ let generated_offset = output
+ .code
+ .find(generated_needle)
+ .unwrap_or_else(|| panic!("generated output does not contain {generated_needle:?}"))
+ + generated_relative_offset;
+ let authored_offset = authored_source
+ .find(authored_needle)
+ .unwrap_or_else(|| panic!("authored source does not contain {authored_needle:?}"));
+ let generated_position = line_column(&output.code, generated_offset);
+ let authored_position = line_column(authored_source, authored_offset);
+ let json = output.source_map.as_deref().expect("source map output");
+ let map = SourceMap::from_json_string(json).expect("valid source map");
+ let lookup = map.generate_lookup_table();
+ let token = map
+ .lookup_token(&lookup, generated_position.0, generated_position.1)
+ .unwrap_or_else(|| {
+ panic!(
+ "no mapping for {generated_needle:?} at {generated_position:?}\ncode:\n{}\nmap:\n{json}",
+ output.code
+ )
+ });
+ assert_eq!(
+ token.get_source_id(),
+ Some(0),
+ "{generated_needle:?} must map to authored TSRX"
+ );
+ assert_eq!(
+ (token.get_src_line(), token.get_src_col()),
+ authored_position,
+ "{generated_needle:?} must map to {authored_needle:?}"
+ );
+ assert_eq!(map.get_source_content(0), Some(authored_source));
+}
+
#[test]
fn compiles_the_dom_fixture_corpus() {
let outputs = compile_corpus("__tsrx_dom_fixtures__", Generate::Dom);
@@ -171,6 +244,133 @@ fn compiles_standalone_lazy_assignment_statements() {
assert!(output.code.contains("__lazy0.value++"), "{}", output.code);
}
+#[test]
+fn source_maps_compose_verbatim_tsrx_ranges_through_codegen() {
+ let source = r#"const marker = "🚀";
+export function Card(props: { visible: boolean; name: string }) @{
+
+ @if (props.visible) {
+ {props.name}
+ }
+
+}"#;
+ let output = compile(
+ source,
+ &CompileOptions {
+ filename: Some("unicode-card.tsrx".into()),
+ source_map: true,
+ ..fixture_options(Generate::Dom)
+ },
+ )
+ .expect("TSRX source maps compile");
+
+ let map = SourceMap::from_json_string(output.source_map.as_deref().expect("source map"))
+ .expect("valid source map");
+ assert_eq!(map.get_source(0), Some("unicode-card.tsrx"));
+ assert_maps_to(&output, "marker", 0, source, "marker");
+ assert_maps_to(&output, "props.visible", 0, source, "props.visible");
+ assert_maps_to(&output, "props.name", 0, source, "props.name");
+}
+
+#[test]
+fn source_maps_follow_lazy_reads_back_to_their_authored_use() {
+ let source = r#"export function User(props: { name: string }) @{
+ const &{ name } = props;
+ {name}
+}"#;
+ let output = compile(
+ source,
+ &CompileOptions {
+ filename: Some("lazy-user.tsrx".into()),
+ source_map: true,
+ ..fixture_options(Generate::Dom)
+ },
+ )
+ .expect("lazy TSRX source maps compile");
+
+ assert_maps_to(
+ &output,
+ "__lazy0.name",
+ "__lazy0.".len(),
+ source,
+ "name}
",
+ );
+}
+
+#[test]
+fn source_maps_cover_reordered_switches_and_accessor_rewrites() {
+ let switch_source = r#"export function Status({ status }) @{
+ @switch (status) {
+ @case "ready": { {status}
}
+ @default: { waiting
}
+ }
+}"#;
+ let switch_output = compile(
+ switch_source,
+ &CompileOptions {
+ filename: Some("status.tsrx".into()),
+ source_map: true,
+ ..fixture_options(Generate::Dom)
+ },
+ )
+ .expect("switch source maps compile");
+ assert_maps_to(&switch_output, "status ===", 0, switch_source, "status) {");
+
+ let for_source = r#"export function List({ items }) @{
+
+ @for (const item of items; index index; key item.id) {
+ - {index + 1}. {item.name}
+ }
+
+}"#;
+ let for_output = compile(
+ for_source,
+ &CompileOptions {
+ filename: Some("list.tsrx".into()),
+ source_map: true,
+ ..fixture_options(Generate::Dom)
+ },
+ )
+ .expect("keyed for source maps compile");
+ assert_maps_to(&for_output, "item().name", 0, for_source, "item.name");
+ assert_maps_to(&for_output, "index()", 0, for_source, "index +");
+}
+
+#[test]
+fn source_maps_reset_to_each_defaulted_lazy_use_after_fallbacks() {
+ let source = r#"export function Counter(source) @{
+ const &{ value = 1 } = source;
+ const read = value;
+ ++value;
+ {read}
+}"#;
+ let output = compile(
+ source,
+ &CompileOptions {
+ filename: Some("counter.tsrx".into()),
+ source_map: true,
+ ..fixture_options(Generate::Dom)
+ },
+ )
+ .expect("defaulted lazy source maps compile");
+
+ assert_maps_to(
+ &output,
+ "? 1 : __lazyValue0",
+ "? 1 : ".len(),
+ source,
+ "value;\n ++",
+ );
+ assert_maps_to(
+ &output,
+ "__lazyValue1[__lazyValue2] = ++",
+ 0,
+ source,
+ "value;\n ",
+ );
+ assert_maps_to(&output, "++__lazyValue3", 0, source, "value;\n
");
+}
+
// -- syntax routing ----------------------------------------------------------
const TSRX_SOURCE: &str = "export function C() @{\n
hi
\n}\n";
From 5a1abb3ffceeaa00705c03522dd31030c4f49427 Mon Sep 17 00:00:00 2001
From: Ryan Carniato
Date: Fri, 28 Aug 2026 16:05:53 -0700
Subject: [PATCH 9/9] Add compiler-owned TSRX semantic tooling foundation.
Centralize Solid TSRX control-flow semantics for runtime and independently typecheckable projections while fixing index-only loop callbacks, authored coordinates, and embedded payload ordering.
Co-authored-by: Cursor
---
.changeset/add-tsrx-semantic-ir.md | 5 +
.changeset/fix-tsrx-index-loop-mode.md | 6 +
documentation/tsrx/frontend-notes.md | 105 ++-
packages/babel-plugin/src/tsrx/desugar.ts | 25 +-
.../__tsrx_dom_fixtures__/forKeyed/output.js | 3 +-
.../nestedControlFlow/output.js | 1 +
.../nestedControlFlow/output.js | 5 +-
packages/compiler/Cargo.lock | 8 +-
packages/compiler/Cargo.toml | 4 +-
packages/compiler/README.md | 13 +-
.../__tests__/tsrx-for-semantics.test.js | 54 ++
.../tsrx-typecheck-projection.test.js | 101 +++
packages/compiler/index.js | 36 +
packages/compiler/src/compiler.rs | 47 +-
packages/compiler/src/lib.rs | 5 +
packages/compiler/src/node_adapter.rs | 132 +++
packages/compiler/src/tsrx/mod.rs | 40 +-
packages/compiler/src/tsrx/names.rs | 42 +
packages/compiler/src/tsrx/project.rs | 479 +++++------
packages/compiler/src/tsrx/rewrite.rs | 45 +-
packages/compiler/src/tsrx/semantic.rs | 786 ++++++++++++++++++
packages/compiler/src/tsrx/source_map.rs | 63 +-
.../compiler/src/tsrx/style_projection.rs | 14 +-
packages/compiler/src/tsrx/tape.rs | 55 ++
packages/compiler/src/tsrx/tooling.rs | 237 ++++++
packages/compiler/tests/tsrx_frontend.rs | 11 +
.../tests/tsrx_typecheck_projection.rs | 243 ++++++
packages/compiler/types.d.ts | 32 +
28 files changed, 2194 insertions(+), 403 deletions(-)
create mode 100644 .changeset/add-tsrx-semantic-ir.md
create mode 100644 .changeset/fix-tsrx-index-loop-mode.md
create mode 100644 packages/compiler/__tests__/tsrx-for-semantics.test.js
create mode 100644 packages/compiler/__tests__/tsrx-typecheck-projection.test.js
create mode 100644 packages/compiler/src/tsrx/names.rs
create mode 100644 packages/compiler/src/tsrx/semantic.rs
create mode 100644 packages/compiler/src/tsrx/tooling.rs
create mode 100644 packages/compiler/tests/tsrx_typecheck_projection.rs
diff --git a/.changeset/add-tsrx-semantic-ir.md b/.changeset/add-tsrx-semantic-ir.md
new file mode 100644
index 000000000..ee69fbddb
--- /dev/null
+++ b/.changeset/add-tsrx-semantic-ir.md
@@ -0,0 +1,5 @@
+---
+"@solidjs/compiler": patch
+---
+
+Add a typed semantic IR stage and a compiler-owned TSRX typecheck projection with authored source maps, style metadata, and embedded CSS/script regions.
diff --git a/.changeset/fix-tsrx-index-loop-mode.md b/.changeset/fix-tsrx-index-loop-mode.md
new file mode 100644
index 000000000..063f4fab8
--- /dev/null
+++ b/.changeset/fix-tsrx-index-loop-mode.md
@@ -0,0 +1,6 @@
+---
+"@solidjs/babel-plugin": patch
+"@solidjs/compiler": patch
+---
+
+Compile TSRX loops with an index but no explicit key using Solid's non-keyed callback shape.
diff --git a/documentation/tsrx/frontend-notes.md b/documentation/tsrx/frontend-notes.md
index 6bebf4a13..3c5e9083d 100644
--- a/documentation/tsrx/frontend-notes.md
+++ b/documentation/tsrx/frontend-notes.md
@@ -37,6 +37,36 @@ Cost is compile time and binary size; revisit if `oxc-tsrx` upstreams into oxc.
Both frontends therefore walk the **same logical AST**; the desugaring below is
specified once and implemented twice.
+## Author tooling contract
+
+The runtime compiler does not parse source on behalf of editor or lint tools.
+Experimental TSRX support therefore has three coordinated, independently
+versioned paths:
+
+1. `@solidjs/vite-plugin` selects the Babel or native Solid runtime compiler.
+2. `@tsrx/typescript-plugin` currently uses `@tsrx/solid`'s
+ `compile_to_volar_mappings` entry for editor services and `tsrx-tsc`.
+ `@solidjs/compiler` now also exposes a host-independent
+ `projectTsrxForTypecheck` foundation: compiler-owned post-rewrite TSX, an
+ authored source map, style sidecars, and parser-authored embedded regions.
+ Generated control-flow and dynamic-element helpers receive collision-safe
+ imports, so the projection can be checked directly under the host project's
+ Solid JSX configuration.
+ It deliberately does not implement Volar's rich segment mappings; a future
+ adapter must preserve those mappings rather than approximate them.
+3. `@tsrx/oxc` projects authored TSRX for Oxlint/Oxfmt and maps diagnostics and
+ safe fixes back to authored ranges.
+
+`@tsrx/solid`'s virtual projection must model the source-level callback
+contract, not expose Solid's internal callback accessors: accessor-backed
+`@for` item/index and `@catch` error reads are implicit in authored TSRX.
+Compiler, Volar, and runtime fixtures cover the same callback-mode matrix.
+
+The recommended general lint/format path is `@tsrx/oxc`.
+`@tsrx/eslint-parser` and `@tsrx/eslint-plugin` remain useful for TSRX-specific
+rules, but generic ESLint token-, scope-, and type-aware rules are not yet
+complete enough to be the primary checker.
+
## Lowering contract (oracle-verified, adapted to 2.0 RC)
All flow-control imports come from `solid-js`; `dynamic` from `@solidjs/web`.
@@ -49,6 +79,7 @@ All flow-control imports come from `solid-js`; `dynamic` from `@solidjs/web`.
| `@if (c) { A } @else { B }` | `A` | yes |
| `@if / @else if / … / @else` (chain) | `` + `` per branch | yes |
| `@for (const x of expr; index i; key k(x))` | ` k(x)}>{(x, i) => …}` | yes — RC `For` has the `keyed: (item) => any` overload |
+| `@for (const x of expr; index i)` | `{(x, i) => …}` | yes — accessor item, raw numeric index |
| `@empty { F }` | `fallback={F}` on `For` | yes |
| `@switch (v) { @case 'a': {A} @default: {D} }` | `A…` | yes |
| `@try { C } @pending { P } @catch (e, reset) { E }` | ` E}>C` | yes, with one adaptation (below) |
@@ -101,13 +132,14 @@ treatment of `&` bindings. Report upstream to `@tsrx/solid`.
Bindings below an ancestor default and rest views are read-only; attempting
to write them produces a structured diagnostic instead of targeting an
invalid raw path.
-- **RC `For` accessor semantics (adaptation).** With a `keyed` function the
- children callback receives the item as an _accessor_, and the index
- parameter is an accessor in all modes: the desugarer rewrites reads of the
- item binding (keyed only) and the index binding to calls, scope-aware.
- Destructured keyed bindings become synthetic lazy parameters backed by that
- accessor, so nested/defaulted/computed/rest reads remain deferred when a row
- with the same key receives a replacement item.
+- **RC `For` accessor semantics (adaptation).** With a custom `keyed` function
+ the children callback receives accessor item and index parameters. An index
+ without a key selects `keyed={false}`, whose callback receives an accessor
+ item and raw numeric index; without either clause, the default callback item
+ is raw. The desugarer rewrites only accessor-backed bindings to calls,
+ scope-aware. Destructured accessor items become synthetic lazy parameters,
+ so nested/defaulted/computed/rest reads remain deferred when a row is
+ replaced.
- **RC `@catch` accessor semantics (adaptation).** Identifier error bindings
rewrite to accessor calls. Object and array patterns become synthetic lazy
parameters backed by the `ErrorAccessor`, preserving defaults, computed
@@ -126,27 +158,57 @@ direction (`oxc_ast` → tape, for NAPI transfer). Building `oxc_ast` from the
tape would mean a full-language ESTree deserializer against oxc 0.144 with a
per-upgrade sync burden.
-Revised architecture — **desugared text projection** (the same technique
-`oxc-tsrx` uses internally in `projection/` + `reconstruct/`):
+Revised architecture — **compiler-owned semantic IR followed by desugared text
+projection**:
1. `tsrx_parser_engine::parse_tsrx` (pinned rev) parses and validates the
TSRX source — the only TSRX grammar authority on the Rust side; its
diagnostics surface as-is; unsupported syntax fails closed.
-2. Walk the tape to locate TSRX constructs and their clause spans; emit a
- projected TSX source: original bytes verbatim outside constructs, the
- Babel frontend's exact desugared Solid-JSX form inside (contract frozen by
- the Stage 2 fixture snapshots). Escape-rule validation (return/break/
- continue) happens here with the same messages as the Babel frontend.
-3. Parse the projection with our crates.io oxc 0.144; run the existing
- dom/ssr/universal transforms unchanged.
-4. Lazy `&` bindings: rewrite during projection (pattern → `__lazyN`,
+2. `semantic.rs` lowers the parser-interchange `FlatTape` into
+ `SolidTsrxModule`: typed code blocks, if chains, for loops (including
+ computed callback mode), switches, and try/pending/catch clauses with
+ authored UTF-8 spans. It structurally validates required fields and records
+ typed dynamic/lazy/style/raw-script/shorthand/element sites for later
+ backends.
+ Ordinary JavaScript expressions and blocks remain read-only tape nodes in
+ this transitional slice; `FlatTape` itself is not the compiler IR.
+3. `project.rs` consumes those typed Solid nodes and emits projected TSX:
+ original bytes verbatim outside constructs, the Babel frontend's exact
+ desugared Solid-JSX form inside (contract frozen by the Stage 2 fixture
+ snapshots). Escape-rule validation remains at template-block projection
+ boundaries so its messages, authored locations, and validation order stay
+ unchanged.
+4. Parse the projection with our crates.io oxc 0.144. `oxc_ast::Program` is
+ the convergence seam with the existing dom/ssr/universal transforms. A
+ future direct backend can lower the semantic IR to that same seam without
+ requiring a full generic ESTree tape deserializer.
+5. Lazy `&` bindings: rewrite during projection (pattern → `__lazyN`,
deterministic ids matching `@tsrx/core`'s `generate_lazy_id`), with reads
rewritten scope-aware to match `applyLazyTransforms` output.
-5. Record an affine offset map for every verbatim-copied range. Native source
+6. Record an affine offset map for every verbatim-copied range. Native source
maps compose Oxc's generated-JavaScript → projected-TSX tokens through this
map to authored TSRX coordinates. Generated projection gaps emit source-less
tokens so preceding authored mappings cannot bleed across compiler-created
scaffolding, mirroring upstream `projection/mapping.rs`'s fail-closed policy.
+7. `tooling.rs` stops at that seam after semantic lazy/accessor rewrites and
+ uses Oxc codegen to print valid TypeScript/TSX. Runtime compilation and this
+ backend call the same projected-TSX parser and rewrite helpers; tooling never
+ reparses authored TSRX or rediscovers Solid callback semantics. Its standard
+ source map composes back to authored `.tsrx`, and its CSS/raw-script embeds
+ come from typed parser sites. Rust ranges remain authored UTF-8 bytes; the
+ N-API adapter converts them to JavaScript UTF-16 string offsets.
+
+`SolidTsrxModule` is internal compiler architecture, not a stable exported
+`Node` API. This stage deliberately avoids both a second JavaScript semantic
+implementation and a runtime dependency on `@tsrx/core` or `@tsrx/solid`.
+
+Migration slices must remain subtractive: when semantic lowering owns a tape
+shape or target decision, projection must consume that typed result and delete
+its duplicate field discovery in the same change. Parser-shape helpers shared
+by semantic, style, and projection passes live in `tape.rs`; transitional
+fields need a named future backend consumer rather than an open-ended
+compatibility shim. Every slice records its net code growth, preserves the
+full byte-parity corpus, and is cleaned up before the next backend is added.
### Known upstream gaps (pin in fixtures)
@@ -172,9 +234,10 @@ Revised architecture — **desugared text projection** (the same technique
`.tsrx` filenames. TSRX machinery is lazily loaded / feature-gated so JSX
paths are untouched.
2. Parse with the foreign parser (`@tsrx/core` / `oxc-tsrx`).
-3. One conversion walk producing the compiler's native AST (Babel AST /
- `oxc_ast` 0.144) with TSRX nodes desugared per the contract above,
- preserving source locations.
+3. Babel desugars on its ESTree path. The Rust frontend lowers FlatTape into
+ `SolidTsrxModule`, projects byte-identical TSX, then reparses to
+ `oxc_ast::Program`; direct IR lowering can replace only that projection
+ bridge in a later slice.
4. Existing shared lowering (`shared/` + `dom`/`ssr`/`universal`) runs
unchanged; builtIns handling picks up `Show`/`For`/`Switch`/`Match`/
`Errored`/`Loading` as usual.
diff --git a/packages/babel-plugin/src/tsrx/desugar.ts b/packages/babel-plugin/src/tsrx/desugar.ts
index a5290337f..72f72583d 100644
--- a/packages/babel-plugin/src/tsrx/desugar.ts
+++ b/packages/babel-plugin/src/tsrx/desugar.ts
@@ -15,9 +15,10 @@
* - `@if` — `Show` for a single branch, `Switch`/`Match` for chains; `@else`
* becomes `fallback`.
* - `@for (const x of expr; index i; key k)` — `For`; `key` present emits
- * `keyed={(x) => k}`; `@empty` becomes `fallback`. RC `For` hands the
- * callback an item *accessor* in keyed mode and an index accessor always,
- * so reads of `x` (keyed only) and `i` rewrite to calls.
+ * `keyed={(x) => k}`, while an index without a key emits `keyed={false}`;
+ * `@empty` becomes `fallback`. RC `For` hands the callback an item accessor
+ * in custom-key and non-keyed modes. The index is an accessor only with a
+ * custom key.
* - `@switch` — `Switch` with one `Match when={disc === test}` per `@case`;
* `@default` becomes `fallback`.
* - `@try/@pending/@catch (e, reset)` — `
@@ -592,6 +593,8 @@ function forToJsx(node: EsNode): EsNode {
const each = transform(node.right as EsNode);
const index = isNode(node.index) ? node.index : null;
const key = isNode(node.key) ? node.key : null;
+ const usesIndexOnlyMode = index !== null && key === null;
+ const itemIsAccessor = key !== null || usesIndexOnlyMode;
const parts = blockToParts(toBlockBody(node.body as EsNode), "@for");
const renderExpr = rendersToExpression(parts.renders, node.body as EsNode);
@@ -605,20 +608,26 @@ function forToJsx(node: EsNode): EsNode {
)
: renderExpr;
- // RC `For` semantics: keyed mode hands the callback an item accessor, and
- // the index parameter is always an accessor — rewrite reads to calls.
- if (key && pattern.type === "Identifier")
+ // RC `For` callback shape:
+ // - default keyed mode: raw item, accessor index (there is no TSRX index)
+ // - keyed={false}: accessor item, raw index
+ // - custom key: accessor item, accessor index
+ if (itemIsAccessor && pattern.type === "Identifier")
rewriteReadsToCalls(callbackBody, (pattern as unknown as { name: string }).name);
- if (index) rewriteReadsToCalls(callbackBody, (index as unknown as { name: string }).name);
+ if (key && index) rewriteReadsToCalls(callbackBody, (index as unknown as { name: string }).name);
const callbackPattern =
- key && pattern.type !== "Identifier" ? accessorLazyPattern(pattern) : pattern;
+ itemIsAccessor && pattern.type !== "Identifier" ? accessorLazyPattern(pattern) : pattern;
const params: EsNode[] = [callbackPattern];
if (index) params.push(index);
const attributes = [jsxAttr("each", each, node.right as EsNode)];
if (key) {
attributes.push(jsxAttr("keyed", arrow([eagerPattern(pattern)], transform(key), key), key));
+ } else if (usesIndexOnlyMode) {
+ attributes.push(
+ jsxAttr("keyed", withLoc({ type: "Literal", value: false, raw: "false" }, index), index)
+ );
}
if (isNode(node.empty)) {
const emptyExpr = blockToExpression(node.empty, "@empty");
diff --git a/packages/babel-plugin/test/__tsrx_dom_fixtures__/forKeyed/output.js b/packages/babel-plugin/test/__tsrx_dom_fixtures__/forKeyed/output.js
index f21f6ec26..fcc3b0b87 100644
--- a/packages/babel-plugin/test/__tsrx_dom_fixtures__/forKeyed/output.js
+++ b/packages/babel-plugin/test/__tsrx_dom_fixtures__/forKeyed/output.js
@@ -38,8 +38,9 @@ export function WithSetup({ posts }) {
_el$6,
_$createComponent(_$For, {
each: posts,
+ keyed: false,
children: (post, n) => {
- const heading = post.title.trim();
+ const heading = post().title.trim();
var _el$7 = _tmpl$5(),
_el$8 = _el$7.firstChild,
_el$9 = _el$8.nextSibling,
diff --git a/packages/babel-plugin/test/__tsrx_dom_fixtures__/nestedControlFlow/output.js b/packages/babel-plugin/test/__tsrx_dom_fixtures__/nestedControlFlow/output.js
index ec9eaf22f..568823b99 100644
--- a/packages/babel-plugin/test/__tsrx_dom_fixtures__/nestedControlFlow/output.js
+++ b/packages/babel-plugin/test/__tsrx_dom_fixtures__/nestedControlFlow/output.js
@@ -28,6 +28,7 @@ export function Grid({ rows, dense }) {
get each() {
return row().cells;
},
+ keyed: false,
get fallback() {
return _tmpl$4();
},
diff --git a/packages/babel-plugin/test/__tsrx_ssr_fixtures__/nestedControlFlow/output.js b/packages/babel-plugin/test/__tsrx_ssr_fixtures__/nestedControlFlow/output.js
index 4e214fccd..1f9932c69 100644
--- a/packages/babel-plugin/test/__tsrx_ssr_fixtures__/nestedControlFlow/output.js
+++ b/packages/babel-plugin/test/__tsrx_ssr_fixtures__/nestedControlFlow/output.js
@@ -23,14 +23,15 @@ export function Grid({ rows, dense }) {
get each() {
return row().cells;
},
+ keyed: false,
get fallback() {
return _$ssr(_tmpl$4);
},
children: (cell, c) => {
var _v$4, _v$5;
return (
- (_v$4 = () => _$escape(c())),
- (_v$5 = _$escape(cell)),
+ (_v$4 = _$escape(c)),
+ (_v$5 = () => _$escape(cell())),
_$ssr(_tmpl$5, _v$4, _v$5)
);
}
diff --git a/packages/compiler/Cargo.lock b/packages/compiler/Cargo.lock
index 4d897d1fa..45f3fb7a1 100644
--- a/packages/compiler/Cargo.lock
+++ b/packages/compiler/Cargo.lock
@@ -482,7 +482,7 @@ dependencies = [
[[package]]
name = "oxc_adapter"
version = "0.7.0"
-source = "git+https://github.com/tsrx-org/oxc?rev=0702360d4a4e2137ae8e3e01ca3c9d78f126b175#0702360d4a4e2137ae8e3e01ca3c9d78f126b175"
+source = "git+https://github.com/tsrx-org/oxc?rev=135653a23aab15231557112548812275baba8727#135653a23aab15231557112548812275baba8727"
dependencies = [
"oxc-miette 3.0.1",
"oxc_allocator 0.140.0",
@@ -1336,7 +1336,7 @@ dependencies = [
[[package]]
name = "tsrx_parser_engine"
version = "0.7.0"
-source = "git+https://github.com/tsrx-org/oxc?rev=0702360d4a4e2137ae8e3e01ca3c9d78f126b175#0702360d4a4e2137ae8e3e01ca3c9d78f126b175"
+source = "git+https://github.com/tsrx-org/oxc?rev=135653a23aab15231557112548812275baba8727#135653a23aab15231557112548812275baba8727"
dependencies = [
"oxc_adapter",
"tsrx_syntax",
@@ -1348,7 +1348,7 @@ dependencies = [
[[package]]
name = "tsrx_syntax"
version = "0.7.0"
-source = "git+https://github.com/tsrx-org/oxc?rev=0702360d4a4e2137ae8e3e01ca3c9d78f126b175#0702360d4a4e2137ae8e3e01ca3c9d78f126b175"
+source = "git+https://github.com/tsrx-org/oxc?rev=135653a23aab15231557112548812275baba8727#135653a23aab15231557112548812275baba8727"
dependencies = [
"unicode-id-start",
]
@@ -1356,7 +1356,7 @@ dependencies = [
[[package]]
name = "tsrx_tape_schema"
version = "0.7.0"
-source = "git+https://github.com/tsrx-org/oxc?rev=0702360d4a4e2137ae8e3e01ca3c9d78f126b175#0702360d4a4e2137ae8e3e01ca3c9d78f126b175"
+source = "git+https://github.com/tsrx-org/oxc?rev=135653a23aab15231557112548812275baba8727#135653a23aab15231557112548812275baba8727"
[[package]]
name = "unicode-id-start"
diff --git a/packages/compiler/Cargo.toml b/packages/compiler/Cargo.toml
index 88aef1b44..ea299a049 100644
--- a/packages/compiler/Cargo.toml
+++ b/packages/compiler/Cargo.toml
@@ -33,8 +33,8 @@ oxc_sourcemap = { version = "8.1", optional = true }
oxc_span = "0.144"
oxc_str = "0.144"
oxc_syntax = "0.144"
-tsrx_parser_engine = { git = "https://github.com/tsrx-org/oxc", rev = "0702360d4a4e2137ae8e3e01ca3c9d78f126b175", optional = true }
-tsrx_tape_schema = { git = "https://github.com/tsrx-org/oxc", rev = "0702360d4a4e2137ae8e3e01ca3c9d78f126b175", optional = true }
+tsrx_parser_engine = { git = "https://github.com/tsrx-org/oxc", rev = "135653a23aab15231557112548812275baba8727", optional = true }
+tsrx_tape_schema = { git = "https://github.com/tsrx-org/oxc", rev = "135653a23aab15231557112548812275baba8727", optional = true }
[dev-dependencies]
# `noop` stubs the Node-API symbols so `cargo test` can link the unit-test
diff --git a/packages/compiler/README.md b/packages/compiler/README.md
index b3b1d8bb5..f0b6935c8 100644
--- a/packages/compiler/README.md
+++ b/packages/compiler/README.md
@@ -99,6 +99,8 @@ Destructured bindings in keyed `@for` loops and `@catch` clauses stay deferred a
The frontend uses the community [oxc-tsrx](https://github.com/tsrx-org/oxc) parser at a pinned revision. Statement containers can be used as function bodies, statements, expressions (`const x = @{ … }`), and JSX children or expression containers. See `documentation/tsrx/frontend-notes.md` in the repository for the full frontend notes.
+`projectTsrxForTypecheck(source, { filename })` is an experimental compiler-owned projection for editor and typecheck integrations. It returns independently typecheckable post-rewrite TSX without running the DOM/SSR/universal transforms, injecting collision-safe imports for generated Solid control-flow and dynamic-element helpers. The result also includes an authored `.tsrx` source map, processed `css`/`cssHash`, and parser-authored embedded CSS/raw-script regions. Embedded offsets use JavaScript UTF-16 string coordinates. This is a generic compiler API, not a Volar mapping adapter.
+
### Source maps
Pass `sourceMap: true` to receive a JSON source map string in `result.map`. For TSRX, the compiler composes Oxc's generated-JavaScript map through the internal TSX text projection, returning the original `.tsrx` filename and source in `sources` and `sourcesContent`. Authored expressions and lazy/accessor rewrites map back to their TSRX locations; projection-only scaffolding remains explicitly unmapped rather than being attributed to nearby syntax.
@@ -155,16 +157,25 @@ The runtime module defaults to `@solidjs/web/server-functions`. Function IDs use
The crate also exposes a host-independent Rust API. The crate name is `solidjs-compiler`; the Node `transform()` delegates to the same core.
```rust
-use solidjs_compiler::{compile, CompileOptions};
+use solidjs_compiler::{
+ compile, project_tsrx_for_typecheck, CompileOptions,
+ TsrxTypecheckProjectionOptions,
+};
let output = compile(
"const view = {name()}
;",
&CompileOptions::default(),
)?;
+
+let tsrx_source = "export function View() @{ }";
+let virtual_tsx =
+ project_tsrx_for_typecheck(tsrx_source, &TsrxTypecheckProjectionOptions::default())?;
```
`CompileOptions::default()` uses `module_name: "@solidjs/web"` and the same control-flow `built_ins` as the Babel plugin. Build with `--no-default-features` when embedding without the Node-API adapter.
+The unstable Rust typecheck projection reports embedded ranges in authored UTF-8 bytes. The N-API adapter converts those ranges to UTF-16 code units for JavaScript tooling.
+
> **Stability:** the Rust API is unstable while the compiler is pre-1.0. Options, output, and error types may change in any release — pin an exact revision when embedding it.
## Performance
diff --git a/packages/compiler/__tests__/tsrx-for-semantics.test.js b/packages/compiler/__tests__/tsrx-for-semantics.test.js
new file mode 100644
index 000000000..7fe7b4f3b
--- /dev/null
+++ b/packages/compiler/__tests__/tsrx-for-semantics.test.js
@@ -0,0 +1,54 @@
+const { compileBabel, compileOxc, modes } = require("./parity/harness");
+
+const source = `
+export function Rows({ rows }) @{
+
+ @for (const row of rows; index index) {
+ - {index}: {row.name}
+ }
+
+}
+`;
+
+const destructuredSource = `
+export function Rows({ rows }) @{
+
+ @for (const { name } of rows; index index) {
+ - {index}: {name}
+ }
+
+}
+`;
+
+describe("TSRX @for semantics", () => {
+ const compilers = [
+ ["Babel", () => compileBabel(source, modes["tsrx-dom"].options, "for-index.tsrx")],
+ ["native", () => compileOxc(source, "for-index", modes["tsrx-dom"].options, ".tsrx")]
+ ];
+
+ test.each(compilers)(
+ "%s uses non-keyed callback types when an index has no key",
+ (_, compile) => {
+ const output = compile();
+
+ expect(output).toContain("keyed: false");
+ expect(output).toContain("index");
+ expect(output).not.toContain("index()");
+ expect(output).toContain("row().name");
+ }
+ );
+
+ test.each([
+ ["Babel", () => compileBabel(destructuredSource, modes["tsrx-dom"].options, "for-index.tsrx")],
+ [
+ "native",
+ () => compileOxc(destructuredSource, "for-index", modes["tsrx-dom"].options, ".tsrx")
+ ]
+ ])("%s keeps index-only destructuring lazy", (_, compile) => {
+ const output = compile();
+
+ expect(output).toContain("keyed: false");
+ expect(output).not.toContain("index()");
+ expect(output).toMatch(/__lazy\d+\(\)\.name/);
+ });
+});
diff --git a/packages/compiler/__tests__/tsrx-typecheck-projection.test.js b/packages/compiler/__tests__/tsrx-typecheck-projection.test.js
new file mode 100644
index 000000000..5a3086489
--- /dev/null
+++ b/packages/compiler/__tests__/tsrx-typecheck-projection.test.js
@@ -0,0 +1,101 @@
+const path = require("path");
+const ts = require("typescript");
+const { projectTsrxForTypecheck } = require("..");
+
+function typecheck(code) {
+ const filename = path.join(__dirname, "__virtual-tsrx-projection.tsx");
+ const repository = path.resolve(__dirname, "../../..");
+ const options = {
+ baseUrl: repository,
+ jsx: ts.JsxEmit.Preserve,
+ jsxImportSource: "@solidjs/web",
+ module: ts.ModuleKind.ESNext,
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
+ paths: {
+ "solid-js": ["packages/solid/src/index.ts"],
+ "@solidjs/web": ["packages/web/src/index.ts"],
+ "@solidjs/web/jsx-runtime": ["packages/web/jsx/jsx.d.ts"]
+ },
+ target: ts.ScriptTarget.ESNext,
+ strict: true,
+ noEmit: true,
+ skipLibCheck: true
+ };
+ const host = ts.createCompilerHost(options);
+ const getSourceFile = host.getSourceFile.bind(host);
+ host.fileExists = name => name === filename || ts.sys.fileExists(name);
+ host.readFile = name => (name === filename ? code : ts.sys.readFile(name));
+ host.getSourceFile = (name, languageVersion, onError, shouldCreateNewSourceFile) =>
+ name === filename
+ ? ts.createSourceFile(name, code, languageVersion, true, ts.ScriptKind.TSX)
+ : getSourceFile(name, languageVersion, onError, shouldCreateNewSourceFile);
+ const program = ts.createProgram([filename], options, host);
+ return ts
+ .getPreEmitDiagnostics(program)
+ .filter(diagnostic => diagnostic.file?.fileName === filename)
+ .map(diagnostic => ts.flattenDiagnosticMessageText(diagnostic.messageText, "\n"));
+}
+
+describe("TSRX typecheck projection", () => {
+ test("returns post-rewrite TSX, authored maps, styles, and UTF-16 embeds", () => {
+ const css = ".card { color: red }";
+ const script = '{"emoji":"🚀"}';
+ const source = `const marker = "🚀";
+export function Card({ rows }) @{
+ <>
+
+ @for (const { name = "missing" } of rows; index index) {
+ {name}:{index}
+ }
+ @try { } @catch (error) { {error.message}
}
+
+ >
+}`;
+
+ const output = projectTsrxForTypecheck(source, { filename: "card.tsrx" });
+
+ expect(output.code).toContain("keyed={false}");
+ expect(output.code).toMatch(/__lazy\d+\(\)\.name/);
+ expect(output.code).toContain("error().message");
+ expect(output.cssHash).toMatch(/^tsrx-/);
+ expect(output.css).toContain(output.cssHash);
+ expect(JSON.parse(output.map)).toMatchObject({
+ sources: ["card.tsrx"],
+ sourcesContent: [source]
+ });
+ expect(output.embeddedRegions).toEqual([
+ {
+ kind: "css",
+ start: source.indexOf(css),
+ end: source.indexOf(css) + css.length,
+ content: css
+ },
+ {
+ kind: "script",
+ start: source.indexOf(script),
+ end: source.indexOf(script) + script.length,
+ content: script
+ }
+ ]);
+ expect(Buffer.byteLength(source.slice(0, source.indexOf(css)))).toBeGreaterThan(
+ source.indexOf(css)
+ );
+ });
+
+ test(
+ "emits collision-safe helper imports that TypeScript can check directly",
+ { timeout: 15000 },
+ () => {
+ const source = `const __tsrx_For0 = "taken";
+export function Rows({ rows }: { rows: { name: string }[] }) @{
+ @for (const row of rows; index index) {
+ {row.name}:{index}
+ }
+}`;
+ const output = projectTsrxForTypecheck(source, { filename: "rows.tsrx" });
+
+ expect(output.code).toContain("For as __tsrx_For1");
+ expect(typecheck(output.code)).toEqual([]);
+ }
+ );
+});
diff --git a/packages/compiler/index.js b/packages/compiler/index.js
index 96b182465..4f0a52b68 100644
--- a/packages/compiler/index.js
+++ b/packages/compiler/index.js
@@ -30,6 +30,41 @@ function transformAsync(code, options) {
return Promise.resolve().then(() => transform(code, options));
}
+function projectTsrxForTypecheck(code, options) {
+ if (typeof code !== "string") {
+ throw new TypeError(
+ "@solidjs/compiler projectTsrxForTypecheck() expects source code as a string"
+ );
+ }
+ const nativeOptions = validateTypecheckProjectionOptions(options);
+ const result = native.projectTsrxForTypecheck(code, nativeOptions);
+ return {
+ code: result.code,
+ map: result.map,
+ css: result.css,
+ cssHash: result.cssHash ?? null,
+ embeddedRegions: result.embeddedRegions
+ };
+}
+
+function validateTypecheckProjectionOptions(options) {
+ if (options == null) return options;
+ if (typeof options !== "object" || Array.isArray(options)) {
+ throw new TypeError(
+ "@solidjs/compiler projectTsrxForTypecheck() expects options to be an object"
+ );
+ }
+ for (const key of Object.keys(options)) {
+ if (key !== "filename") {
+ throw new Error(`@solidjs/compiler received unknown option \`${key}\``);
+ }
+ }
+ if (options.filename !== undefined && typeof options.filename !== "string") {
+ throw new TypeError("@solidjs/compiler `filename` option must be a string");
+ }
+ return options;
+}
+
function transformDirectives(code, options) {
if (typeof code !== "string") {
throw new TypeError("@solidjs/compiler transformDirectives() expects source code as a string");
@@ -413,6 +448,7 @@ function isMissingPackage(error, packageName) {
module.exports = {
transform,
transformAsync,
+ projectTsrxForTypecheck,
transformDirectives,
transformDirectivesAsync,
transformLazy,
diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs
index 8c4151879..3b55f327c 100644
--- a/packages/compiler/src/compiler.rs
+++ b/packages/compiler/src/compiler.rs
@@ -212,23 +212,17 @@ fn compile_inner(source: &str, options: &CompileOptions) -> Result Result Result(
+ allocator: &'a Allocator,
+ source: &'a str,
+ source_type: SourceType,
+) -> Result, CompileError> {
+ // Babel has no ParenthesizedExpression node (parens are trivia), so the
+ // transform's expression matchers must never see one either. Preserving
+ // parens here can hide logical expressions from conditional wrapping and
+ // desynchronize generated output from Babel.
+ let parsed = Parser::new(allocator, source, source_type)
+ .with_options(ParseOptions {
+ preserve_parens: false,
+ ..ParseOptions::default()
+ })
+ .parse();
+ if let Some(error) = crate::shared::parser::first_parser_error(parsed.diagnostics) {
+ return Err(CompileError::parse(error));
+ }
+ Ok(parsed.program)
+}
+
pub(crate) fn has_jsx_import_source(
program: &oxc_ast::ast::Program<'_>,
source: &str,
diff --git a/packages/compiler/src/lib.rs b/packages/compiler/src/lib.rs
index 0b341d821..bf5b50e19 100644
--- a/packages/compiler/src/lib.rs
+++ b/packages/compiler/src/lib.rs
@@ -35,6 +35,11 @@ mod universal;
pub use compiler::{CompileOptions, CompileOutput, Generate, Renderer, Syntax, Wrapper, compile};
pub use error::{CompileError, CompileErrorKind};
+#[cfg(feature = "tsrx")]
+pub use tsrx::{
+ TsrxEmbeddedRegion, TsrxEmbeddedRegionKind, TsrxTypecheckProjection,
+ TsrxTypecheckProjectionOptions, project_tsrx_for_typecheck,
+};
#[cfg(feature = "node")]
pub use node_adapter::*;
diff --git a/packages/compiler/src/node_adapter.rs b/packages/compiler/src/node_adapter.rs
index c61ea0440..b054edc49 100644
--- a/packages/compiler/src/node_adapter.rs
+++ b/packages/compiler/src/node_adapter.rs
@@ -16,6 +16,115 @@ use crate::{CompileOptions, Generate, Renderer, Syntax, Wrapper};
const UNSUPPORTED_GENERATE: &str =
"The @solidjs/compiler backend implements DOM, SSR, universal, and dynamic modes only";
+#[cfg(feature = "tsrx")]
+#[napi(object)]
+#[derive(Default)]
+pub struct ProjectTsrxForTypecheckOptions {
+ pub filename: Option,
+}
+
+#[cfg(feature = "tsrx")]
+#[napi(object)]
+pub struct TsrxTypecheckEmbeddedRegion {
+ pub kind: String,
+ /// Authored JavaScript string offset in UTF-16 code units.
+ pub start: u32,
+ /// Authored JavaScript string offset in UTF-16 code units.
+ pub end: u32,
+ pub content: String,
+}
+
+#[cfg(feature = "tsrx")]
+#[napi(object)]
+pub struct TsrxTypecheckProjectionResult {
+ pub code: String,
+ pub map: String,
+ pub css: String,
+ pub css_hash: Option,
+ pub embedded_regions: Vec,
+}
+
+/// Experimental host-independent TSRX projection for typechecking tools.
+#[cfg(feature = "tsrx")]
+#[napi]
+pub fn project_tsrx_for_typecheck(
+ code: String,
+ options: Option,
+) -> Result {
+ let options = options.unwrap_or_default();
+ let output = crate::tsrx::project_tsrx_for_typecheck(
+ &code,
+ &crate::tsrx::TsrxTypecheckProjectionOptions {
+ filename: options.filename,
+ },
+ )
+ .map_err(|error| Error::from_reason(error.to_string()))?;
+ let endpoints = output
+ .embedded_regions
+ .iter()
+ .flat_map(|region| [region.start, region.end])
+ .collect::>();
+ let utf16_endpoints = utf16_offsets(&code, &endpoints)?;
+ let embedded_regions = output
+ .embedded_regions
+ .into_iter()
+ .zip(utf16_endpoints.chunks_exact(2))
+ .map(|(region, offsets)| {
+ let kind = match region.kind {
+ crate::tsrx::TsrxEmbeddedRegionKind::Css => "css",
+ crate::tsrx::TsrxEmbeddedRegionKind::Script => "script",
+ };
+ TsrxTypecheckEmbeddedRegion {
+ kind: kind.into(),
+ start: offsets[0],
+ end: offsets[1],
+ content: region.content,
+ }
+ })
+ .collect();
+ Ok(TsrxTypecheckProjectionResult {
+ code: output.code,
+ map: output.source_map,
+ css: output.css,
+ css_hash: output.css_hash,
+ embedded_regions,
+ })
+}
+
+#[cfg(feature = "tsrx")]
+fn utf16_offsets(source: &str, byte_offsets: &[u32]) -> Result> {
+ let mut indexed = byte_offsets.iter().copied().enumerate().collect::>();
+ indexed.sort_unstable_by_key(|(_, offset)| *offset);
+ let mut converted = vec![0; byte_offsets.len()];
+ let mut byte = 0usize;
+ let mut utf16 = 0usize;
+ for (index, target) in indexed {
+ let target = target as usize;
+ if target > source.len() {
+ return Err(Error::from_reason(
+ "TSRX embedded region exceeds the source length",
+ ));
+ }
+ while byte < target {
+ let character = source[byte..]
+ .chars()
+ .next()
+ .ok_or_else(|| Error::from_reason("TSRX embedded region exceeds the source"))?;
+ byte += character.len_utf8();
+ utf16 += character.len_utf16();
+ }
+ if byte != target {
+ return Err(Error::from_reason(
+ "TSRX embedded region is not on a UTF-8 boundary",
+ ));
+ }
+ converted[index] = u32::try_from(utf16).map_err(|_| {
+ Error::from_reason("TSRX embedded region exceeds the N-API offset range")
+ })?;
+ }
+ Ok(converted)
+}
+
/// The `"use server"` directive pass — a second, independent transform over
/// the same parse infrastructure as the JSX pass. Applies to plain
/// `.js`/`.ts` modules as well as JSX/TSX.
@@ -280,4 +389,27 @@ mod tests {
)
.expect("next accepts an explicitly empty moduleName");
}
+
+ #[cfg(feature = "tsrx")]
+ #[test]
+ fn typecheck_projection_converts_all_embedded_offsets_in_one_utf16_pass() {
+ let source = "const marker = \"🚀\"; export const C = () => <>>;";
+ let output = project_tsrx_for_typecheck(
+ source.into(),
+ Some(ProjectTsrxForTypecheckOptions {
+ filename: Some("offsets.tsrx".into()),
+ }),
+ )
+ .expect("TSRX typecheck projection");
+ assert_eq!(output.embedded_regions.len(), 2);
+ for region in output.embedded_regions {
+ let byte_start = source.find(®ion.content).expect("embedded content");
+ let byte_end = byte_start + region.content.len();
+ assert_eq!(
+ region.start,
+ source[..byte_start].encode_utf16().count() as u32
+ );
+ assert_eq!(region.end, source[..byte_end].encode_utf16().count() as u32);
+ }
+ }
}
diff --git a/packages/compiler/src/tsrx/mod.rs b/packages/compiler/src/tsrx/mod.rs
index a57c133b1..3e905e2d9 100644
--- a/packages/compiler/src/tsrx/mod.rs
+++ b/packages/compiler/src/tsrx/mod.rs
@@ -2,21 +2,28 @@
//!
//! Routes `.tsrx` sources through `tsrx_parser_engine` (the community
//! `oxc-tsrx` project, pinned by revision — the only TSRX grammar authority
-//! on the Rust side), desugars every TSRX construct to Solid builtIn JSX as
-//! a *text projection* over the authored source, reparses the projection
-//! with the crate's own oxc, and finishes with symbol-exact lazy/accessor
-//! rewrites. The desugaring contract is frozen by
-//! `@solidjs/babel-plugin/src/tsrx/desugar.ts` and its fixture corpus; both
-//! frontends must lower identically.
-
+//! on the Rust side), lowers parser interchange into compiler-owned typed
+//! Solid TSRX semantic IR, projects that IR to Solid builtIn JSX over the
+//! authored source, reparses the projection with the crate's own oxc, and
+//! finishes with symbol-exact lazy/accessor rewrites. The desugaring contract
+//! is frozen by `@solidjs/babel-plugin/src/tsrx/desugar.ts` and its fixture
+//! corpus; both frontends must lower identically.
+
+mod names;
mod project;
mod rewrite;
+mod semantic;
mod source_map;
mod style;
mod style_projection;
mod tape;
+mod tooling;
pub use project::Projection;
+pub use tooling::{
+ TsrxEmbeddedRegion, TsrxEmbeddedRegionKind, TsrxTypecheckProjection,
+ TsrxTypecheckProjectionOptions, project_tsrx_for_typecheck,
+};
use tsrx_parser_engine::{
TsrxParseOptions, TsrxParseRequest, TsrxParseResult, TsrxUtf16ParseRequest,
@@ -67,6 +74,23 @@ pub fn apply_rewrites<'a>(
rewrite::apply(allocator, program, projection, source_maps).map_err(CompileError::transform)
}
+/// Parse compiler-projected TSX with the exact runtime parser configuration.
+pub(crate) fn parse_projected_tsx<'a>(
+ allocator: &'a oxc_allocator::Allocator,
+ projection: &'a Projection,
+) -> Result, CompileError> {
+ let parsed = oxc_parser::Parser::new(allocator, &projection.text, oxc_span::SourceType::tsx())
+ .with_options(oxc_parser::ParseOptions {
+ preserve_parens: false,
+ ..oxc_parser::ParseOptions::default()
+ })
+ .parse();
+ if let Some(error) = crate::shared::parser::first_parser_error(parsed.diagnostics) {
+ return Err(CompileError::parse(error));
+ }
+ Ok(parsed.program)
+}
+
/// Compose codegen's projected-TSX source map back to authored TSRX.
pub fn compose_source_map(
intermediate: &oxc_sourcemap::SourceMap<'_>,
@@ -183,7 +207,7 @@ fn line_column(source: &str, offset: u32) -> (u32, u32) {
.rposition(|byte| *byte == b'\n')
.map(|position| position + 1)
.unwrap_or(0);
- let column = source[line_start..offset].chars().count() as u32;
+ let column = source[line_start..offset].encode_utf16().count() as u32;
(line, column)
}
diff --git a/packages/compiler/src/tsrx/names.rs b/packages/compiler/src/tsrx/names.rs
new file mode 100644
index 000000000..c6fe47835
--- /dev/null
+++ b/packages/compiler/src/tsrx/names.rs
@@ -0,0 +1,42 @@
+use std::collections::{HashMap, HashSet};
+
+use oxc_semantic::Semantic;
+
+#[derive(Default)]
+pub(super) struct Names {
+ used: HashSet,
+ next: HashMap<&'static str, u32>,
+}
+
+impl Names {
+ pub fn from_semantic(semantic: &Semantic<'_>) -> Self {
+ let mut names = Self::default();
+ for node in semantic.nodes() {
+ match node.kind() {
+ oxc_ast::AstKind::BindingIdentifier(ident) => {
+ names.used.insert(ident.name.to_string());
+ }
+ oxc_ast::AstKind::IdentifierReference(ident) => {
+ names.used.insert(ident.name.to_string());
+ }
+ oxc_ast::AstKind::JSXIdentifier(ident) => {
+ names.used.insert(ident.name.to_string());
+ }
+ _ => {}
+ }
+ }
+ names
+ }
+
+ pub fn allocate(&mut self, prefix: &'static str) -> String {
+ let mut index = *self.next.get(prefix).unwrap_or(&0);
+ loop {
+ let name = format!("{prefix}{index}");
+ index += 1;
+ if self.used.insert(name.clone()) {
+ self.next.insert(prefix, index);
+ return name;
+ }
+ }
+ }
+}
diff --git a/packages/compiler/src/tsrx/project.rs b/packages/compiler/src/tsrx/project.rs
index b01f58712..f65cd6825 100644
--- a/packages/compiler/src/tsrx/project.rs
+++ b/packages/compiler/src/tsrx/project.rs
@@ -1,8 +1,8 @@
//! TSRX → Solid JSX desugaring, as authored-text projection.
//!
//! Mirrors `@solidjs/babel-plugin`'s `src/tsrx/desugar.ts` (the frozen
-//! contract) construct-for-construct, but in the text domain: the tape from
-//! `tsrx_parser_engine` locates TSRX constructs, and each construct span is
+//! contract) construct-for-construct, but in the text domain: typed nodes from
+//! [`super::semantic`] identify Solid semantics and each construct extent is
//! replaced with the desugared Solid-JSX source form. Authored bytes outside
//! constructs are copied verbatim. The projected text reparses with the
//! crate's own oxc and produces the same AST the Babel frontend hands its
@@ -22,6 +22,10 @@
//! re-analyzed cheaply during emission.
use super::{
+ semantic::{
+ self, CatchBinding, CodeBlock, ControlFlow, ForLoop, IfChain, Switch as SemanticSwitch,
+ SwitchArm, Try as SemanticTry,
+ },
source_map::ProjectionMap,
style_projection::{
self, RefSetup, StyleAction, StyleProjection, class_attribute, decode_json_string,
@@ -37,6 +41,8 @@ pub struct Projection {
pub css: String,
/// Space-separated scope hashes, or `None` when no styles were present.
pub css_hash: Option,
+ /// Parser-authored embedded CSS and raw-text script bodies.
+ pub(super) embedded_regions: Vec,
/// Exact authored ranges copied into `text`, used to compose codegen maps.
pub(super) source_map: ProjectionMap,
/// Projected offset of each lazy pattern's opening bracket, with its
@@ -44,7 +50,8 @@ pub struct Projection {
pub lazy_patterns: Vec<(u32, String, bool)>,
/// Projected offset of a generated arrow (its parameter `(`), with the
/// binding names whose reads must become zero-argument calls (RC accessor
- /// semantics for keyed `For` items, `For` indexes, and `@catch` errors).
+ /// semantics for non-default `For` items, custom-key `For` indexes, and
+ /// `@catch` errors).
pub accessor_arrows: Vec<(u32, Vec)>,
}
@@ -125,24 +132,10 @@ fn is_function(ty: &str) -> bool {
FUNCTION_TYPES.contains(&ty)
}
-fn is_construct(ty: &str) -> bool {
- matches!(
- ty,
- "JSXIfExpression" | "JSXForExpression" | "JSXSwitchExpression" | "JSXTryExpression"
- )
-}
-
fn is_render_entry(ty: &str) -> bool {
RENDER_ENTRY_TYPES.contains(&ty)
}
-fn is_dynamic_element(node: Node<'_>) -> bool {
- node.ty() == "JSXElement"
- && node
- .node_field("openingElement")
- .is_some_and(|opening| opening.bool_field("isDynamic"))
-}
-
fn is_lazy_pattern(node: Node<'_>) -> bool {
matches!(node.ty(), "ArrayPattern" | "ObjectPattern") && node.bool_field("lazy")
}
@@ -226,14 +219,20 @@ pub fn project(
));
}
- let styles = style_projection::plan(source, filename, root)?;
+ let semantic = semantic::lower(root).map_err(|error| ProjectError {
+ message: error.message,
+ start: error.start,
+ })?;
+ let styles = style_projection::plan(source, filename, &semantic)?;
let css = styles.css.clone();
let css_hash = styles.css_hash.clone();
+ let embedded_regions = semantic.embedded_regions.clone();
let mut renderer = Renderer {
source,
out: String::with_capacity(source.len() + source.len() / 4),
+ semantic: &semantic,
styles,
- lazy_ids: collect_lazy_ids(root),
+ lazy_ids: collect_lazy_ids(root, &semantic),
lazy_patterns: Vec::new(),
accessor_arrows: Vec::new(),
suppress_nested_lazy: 0,
@@ -246,6 +245,7 @@ pub fn project(
text: renderer.out,
css,
css_hash,
+ embedded_regions,
source_map: renderer.source_map,
lazy_patterns: renderer.lazy_patterns,
accessor_arrows: renderer.accessor_arrows,
@@ -254,7 +254,7 @@ pub fn project(
/// Preallocate `__lazyN` names for every lazy pattern in document order,
/// mirroring `@tsrx/core`'s `preallocateLazyIds`. Keyed by pattern span.
-fn collect_lazy_ids(root: Node<'_>) -> Vec<(u32, u32)> {
+fn collect_lazy_ids(root: Node<'_>, semantic: &semantic::SolidTsrxModule<'_>) -> Vec<(u32, u32)> {
let mut spans: Vec<(u32, u32)> = Vec::new();
tape::walk(root, &mut |node| {
match node.ty() {
@@ -280,25 +280,26 @@ fn collect_lazy_ids(root: Node<'_>) -> Vec<(u32, u32)> {
}
_ => {}
}
- if node.ty() == "JSXForExpression"
- && node.has_node_field("key")
- && let Some(pattern) = for_binding_pattern(node)
- && pattern.ty() != "Identifier"
- && let Some(span) = pattern.span()
- {
- spans.push(span);
- }
- if node.ty() == "JSXTryExpression"
- && let Some(pattern) = node
- .node_field("handler")
- .and_then(|handler| handler.node_field("param"))
+ true
+ });
+ for control in &semantic.control_flow {
+ let pattern = match control {
+ ControlFlow::For(loop_) if loop_.callback_mode.item_is_accessor() => {
+ Some(loop_.pattern)
+ }
+ ControlFlow::Try(try_) => try_.catch.as_ref().and_then(|catch| match catch.binding {
+ Some(CatchBinding::Pattern(pattern)) => Some(pattern),
+ _ => None,
+ }),
+ _ => None,
+ };
+ if let Some(pattern) = pattern
&& pattern.ty() != "Identifier"
&& let Some(span) = pattern.span()
{
spans.push(span);
}
- true
- });
+ }
spans.sort_unstable();
spans.dedup();
spans
@@ -345,17 +346,6 @@ fn collect_topmost_lazy_patterns(node: Node<'_>, spans: &mut Vec<(u32, u32)>) {
}
}
-fn for_binding_pattern(node: Node<'_>) -> Option> {
- let left = node.node_field("left")?;
- if left.ty() != "VariableDeclaration" {
- return Some(left);
- }
- left.list_field("declarations")
- .flatten()
- .next()
- .and_then(|declarator| declarator.node_field("id"))
-}
-
// ---------------------------------------------------------------------------
// Special-node collection (for verbatim regions)
// ---------------------------------------------------------------------------
@@ -376,6 +366,7 @@ fn collect_specials<'t>(
node: Node<'t>,
position: Position,
styles: &StyleProjection<'t>,
+ semantic: &semantic::SolidTsrxModule<'t>,
out: &mut Vec>,
) {
let ty = node.ty();
@@ -383,14 +374,13 @@ fn collect_specials<'t>(
let special_span = if lazy_assignment_pattern(node).is_some() {
node.span()
- } else if is_construct(ty) {
- // The engine's construct spans can exclude trailing clause blocks
- // (`@for … {…} @empty {…}` ends at the body); the replacement must
- // swallow every clause, so extend the end over clause-field spans.
- construct_replacement_span(node)
+ } else if let Some(control) = semantic.control_for(node) {
+ let extent = control.origin().extent;
+ Some((extent.start, extent.end))
} else if ty == "JSXCodeBlock"
|| ty == "JSXStyleElement"
- || is_dynamic_element(node)
+ || semantic.raw_text_script_for(node).is_some()
+ || tape::is_dynamic_element(node)
|| (ty == "JSXElement"
&& (styles.element_hashes.contains_key(&start)
|| styles.owner_setups.contains_key(&start)))
@@ -415,40 +405,15 @@ fn collect_specials<'t>(
return;
}
- collect_children(node, styles, out);
+ collect_children(node, styles, semantic, out);
}
-/// A construct's authored span extended to the furthest end of any direct
-/// clause node (the tape is a source-ordered tree, so field spans can only
-/// point at text belonging to the construct).
-fn construct_replacement_span(node: Node<'_>) -> Option<(u32, u32)> {
- let (start, mut end) = node.span()?;
- for (key, value) in node.fields() {
- if matches!(key, "type" | "start" | "end" | "metadata" | "loc" | "range") {
- continue;
- }
- match value.kind() {
- tsrx_tape_schema::ValueKind::Object => {
- if let Some(child) = Node::from_value(node.tape(), value)
- && let Some((_, child_end)) = child.span()
- {
- end = end.max(child_end);
- }
- }
- tsrx_tape_schema::ValueKind::List => {
- for child in node.list_value(value).flatten() {
- if let Some((_, child_end)) = child.span() {
- end = end.max(child_end);
- }
- }
- }
- _ => {}
- }
- }
- Some((start, end))
-}
-
-fn collect_children<'t>(node: Node<'t>, styles: &StyleProjection<'t>, out: &mut Vec>) {
+fn collect_children<'t>(
+ node: Node<'t>,
+ styles: &StyleProjection<'t>,
+ semantic: &semantic::SolidTsrxModule<'t>,
+ out: &mut Vec>,
+) {
let ty = node.ty();
for (key, value) in node.fields() {
if matches!(key, "type" | "start" | "end" | "metadata" | "loc" | "range") {
@@ -462,7 +427,7 @@ fn collect_children<'t>(node: Node<'t>, styles: &StyleProjection<'t>, out: &mut
match value.kind() {
tsrx_tape_schema::ValueKind::Object => {
if let Some(child) = Node::from_value(node.tape(), value) {
- collect_specials(child, child_position, styles, out);
+ collect_specials(child, child_position, styles, semantic, out);
}
}
tsrx_tape_schema::ValueKind::List => {
@@ -472,7 +437,7 @@ fn collect_children<'t>(node: Node<'t>, styles: &StyleProjection<'t>, out: &mut
if let Some(item) = node.tape().list_value(entry)
&& let Some(child) = Node::from_value(node.tape(), item)
{
- collect_specials(child, child_position, styles, out);
+ collect_specials(child, child_position, styles, semantic, out);
}
next = node.tape().list_value_next(entry);
}
@@ -573,9 +538,10 @@ struct BlockParts<'t> {
renders: Vec>,
}
-struct Renderer<'s, 't> {
+struct Renderer<'s, 'm, 't> {
source: &'s str,
out: String,
+ semantic: &'m semantic::SolidTsrxModule<'t>,
styles: StyleProjection<'t>,
/// Document-ordered lazy pattern spans; index = lazy id.
lazy_ids: Vec<(u32, u32)>,
@@ -585,7 +551,7 @@ struct Renderer<'s, 't> {
source_map: ProjectionMap,
}
-impl<'s, 't> Renderer<'s, 't> {
+impl<'s, 'm, 't> Renderer<'s, 'm, 't> {
fn push_verbatim(&mut self, start: u32, end: u32) {
if end <= start {
return;
@@ -610,7 +576,7 @@ impl<'s, 't> Renderer<'s, 't> {
position: Position,
) -> Result<()> {
let mut specials = Vec::new();
- collect_specials(scope, position, &self.styles, &mut specials);
+ collect_specials(scope, position, &self.styles, self.semantic, &mut specials);
self.emit_region(start, end, &mut specials)
}
@@ -639,10 +605,10 @@ impl<'s, 't> Renderer<'s, 't> {
fn emit_node(&mut self, node: Node<'_>, position: Position) -> Result<()> {
let ty = node.ty();
let start = node.span().map_or(u32::MAX, |span| span.0);
- if ty == "JSXCodeBlock"
- || is_construct(ty)
+ if self.semantic.control_for(node).is_some()
|| ty == "JSXStyleElement"
- || is_dynamic_element(node)
+ || self.semantic.raw_text_script_for(node).is_some()
+ || tape::is_dynamic_element(node)
|| (ty == "JSXElement"
&& (self.styles.element_hashes.contains_key(&start)
|| self.styles.owner_setups.contains_key(&start)))
@@ -655,20 +621,27 @@ impl<'s, 't> Renderer<'s, 't> {
}
let (start, end) = span_of(node)?;
let mut specials = Vec::new();
- collect_children(node, &self.styles, &mut specials);
+ collect_children(node, &self.styles, self.semantic, &mut specials);
self.emit_region(start, end, &mut specials)
}
fn render_special(&mut self, node: Node<'_>, position: Position) -> Result<()> {
+ if let Some(control) = self.semantic.control_for(node) {
+ return match control {
+ ControlFlow::CodeBlock(code) => self.render_code_block(code, position),
+ ControlFlow::If(chain) => self.render_if(chain),
+ ControlFlow::For(loop_) => self.render_for(loop_),
+ ControlFlow::Switch(switch) => self.render_switch(switch),
+ ControlFlow::Try(try_) => self.render_try(try_, position),
+ };
+ }
match node.ty() {
- "JSXCodeBlock" => self.render_code_block(node, position),
- "JSXIfExpression" => self.render_if(node),
- "JSXForExpression" => self.render_for(node),
- "JSXSwitchExpression" => self.render_switch(node),
- "JSXTryExpression" => self.render_try(node, position),
"JSXStyleElement" => self.render_style(node),
"JSXFragment" => self.render_scoped_fragment(node, position),
"JSXAttribute" => self.render_shorthand_attr(node),
+ "JSXElement" if self.semantic.raw_text_script_for(node).is_some() => {
+ self.render_raw_text_script(node, position)
+ }
"JSXElement" => self.render_scoped_element(node, position),
"ExpressionStatement" if lazy_assignment_pattern(node).is_some() => {
self.render_lazy_assignment(node)
@@ -686,14 +659,10 @@ impl<'s, 't> Renderer<'s, 't> {
// -- @{} statement containers ---------------------------------------------
- fn render_code_block(&mut self, node: Node<'_>, position: Position) -> Result<()> {
- let render = node.node_field("render").ok_or_else(|| {
- ProjectError::new(
- "A TSRX statement container is missing its rendered output node",
- node,
- )
- })?;
- let setup: Vec> = node.list_field("body").flatten().collect();
+ fn render_code_block(&mut self, code: &CodeBlock<'t>, position: Position) -> Result<()> {
+ let node = code.origin.tape;
+ let render = code.render;
+ let setup = &code.setup;
let style_setups = self
.styles
.owner_setups
@@ -704,7 +673,7 @@ impl<'s, 't> Renderer<'s, 't> {
match position {
Position::FunctionBody => {
self.push("{\n");
- self.emit_statements(&setup)?;
+ self.emit_statements(setup)?;
for setup in &style_setups {
self.emit_ref_setup(setup)?;
}
@@ -726,7 +695,7 @@ impl<'s, 't> Renderer<'s, 't> {
self.render_entry_expression(render)?;
} else {
self.push("(() => {\n");
- self.emit_statements(&setup)?;
+ self.emit_statements(setup)?;
for setup in &style_setups {
self.emit_ref_setup(setup)?;
}
@@ -744,68 +713,44 @@ impl<'s, 't> Renderer<'s, 't> {
// -- @if — Show / Switch+Match ---------------------------------------------
- fn render_if(&mut self, node: Node<'_>) -> Result<()> {
- struct Branch<'t> {
- test: Node<'t>,
- block: Node<'t>,
- }
- let mut branches = Vec::new();
- let mut current = node;
- let else_block: Option>;
- loop {
- branches.push(Branch {
- test: current
- .node_field("test")
- .ok_or_else(|| ProjectError::new("TSRX @if is missing its condition", node))?,
- block: current.node_field("consequent").ok_or_else(|| {
- ProjectError::new("TSRX @if is missing its consequent block", node)
- })?,
- });
- match current.node_field("alternate") {
- Some(alternate) if matches!(alternate.ty(), "IfStatement" | "JSXIfExpression") => {
- current = alternate;
- }
- other => {
- else_block = other;
- break;
- }
- }
- }
-
+ fn render_if(&mut self, chain: &IfChain<'t>) -> Result<()> {
// Validate in the Babel frontend's order: @else first, then branches.
- if let Some(block) = else_block {
- self.block_parts(block, Some("@else"))?;
+ if let Some(fallback) = &chain.fallback {
+ self.block_parts(fallback.node, Some("@else"))?;
}
- for branch in &branches {
- self.block_parts(branch.block, Some("@if"))?;
+ for branch in &chain.branches {
+ self.block_parts(branch.body.node, Some("@if"))?;
}
- let has_fallback = else_block.is_some_and(|block| !block_is_empty(block));
+ let has_fallback = chain
+ .fallback
+ .as_ref()
+ .is_some_and(|fallback| !block_is_empty(fallback.node));
- if let [branch] = branches.as_slice() {
+ if let [branch] = chain.branches.as_slice() {
self.push("");
+ return self.emit_construct_children(branch.body.node, "@if", "");
}
self.push("");
- for branch in &branches {
+ for branch in &chain.branches {
self.push("")?;
+ self.emit_construct_children(branch.body.node, "@if", "")?;
}
self.push("");
Ok(())
@@ -838,31 +783,14 @@ impl<'s, 't> Renderer<'s, 't> {
// -- @for — For --------------------------------------------------------------
- fn render_for(&mut self, node: Node<'_>) -> Result<()> {
- if node.str_field("statementType") != Some("ForOfStatement") {
- return Err(ProjectError::new(
- "@for must iterate with for...of; for...in and classic for loops are not TSRX template constructs",
- node,
- ));
- }
- if node.bool_field("await") {
- return Err(ProjectError::new(
- "`for await` is not supported inside Solid TSRX templates",
- node,
- ));
- }
-
- let pattern = for_binding_pattern(node)
- .ok_or_else(|| ProjectError::new("TSRX @for is missing its binding", node))?;
- let each = node
- .node_field("right")
- .ok_or_else(|| ProjectError::new("TSRX @for is missing its iterable", node))?;
- let index = node.node_field("index");
- let key = node.node_field("key");
-
- let body = node
- .node_field("body")
- .ok_or_else(|| ProjectError::new("TSRX @for is missing its body", node))?;
+ fn render_for(&mut self, loop_: &ForLoop<'t>) -> Result<()> {
+ let node = loop_.origin.tape;
+ let pattern = loop_.pattern;
+ let each = loop_.iterable;
+ let index = loop_.index;
+ let key = loop_.key;
+ let mode = loop_.callback_mode;
+ let body = loop_.body.node;
// Validate the body before attribute emission (Babel order); reject a
// renderless body up front.
let parts = self.block_parts(body, Some("@for"))?;
@@ -886,8 +814,11 @@ impl<'s, 't> Renderer<'s, 't> {
self.push(") => (");
self.emit_node(key, Position::Expression)?;
self.push(")}");
+ } else if mode.emits_non_keyed_intent() {
+ self.push(" keyed={false}");
}
- if let Some(empty) = node.node_field("empty") {
+ if let Some(empty) = &loop_.empty {
+ let empty = empty.node;
if !block_is_empty(empty) {
self.push(" fallback={");
self.emit_block_expression(empty, "@empty")?;
@@ -899,16 +830,19 @@ impl<'s, 't> Renderer<'s, 't> {
}
self.push(">{");
- // RC `For` semantics: keyed mode hands the callback an item accessor,
- // and the index parameter is always an accessor — the post-reparse
- // pass rewrites those reads to calls, anchored at this arrow.
+ // RC `For` callback shape:
+ // - default keyed mode: raw item, accessor index (there is no TSRX index)
+ // - keyed={false}: accessor item, raw index
+ // - custom key: accessor item, accessor index
+ // The post-reparse pass rewrites accessor reads at this arrow.
let mut accessor_names = Vec::new();
- if key.is_some()
+ if mode.item_is_accessor()
&& let Some(name) = ident_name(pattern)
{
accessor_names.push(name.to_string());
}
- if let Some(index) = index
+ if mode.index_is_accessor()
+ && let Some(index) = index
&& let Some(name) = ident_name(index)
{
accessor_names.push(name.to_string());
@@ -919,7 +853,7 @@ impl<'s, 't> Renderer<'s, 't> {
}
self.push("(");
- if key.is_some() && pattern.ty() != "Identifier" {
+ if mode.item_is_accessor() && pattern.ty() != "Identifier" {
self.render_lazy_pattern_with_source(pattern, true)?;
} else {
self.emit_node(pattern, Position::Expression)?;
@@ -946,59 +880,47 @@ impl<'s, 't> Renderer<'s, 't> {
// -- @switch — Switch / Match --------------------------------------------------
- fn render_switch(&mut self, node: Node<'_>) -> Result<()> {
- let discriminant = node
- .node_field("discriminant")
- .ok_or_else(|| ProjectError::new("TSRX @switch is missing its discriminant", node))?;
- let cases: Vec> = node.list_field("cases").flatten().collect();
-
+ fn render_switch(&mut self, switch: &SemanticSwitch<'t>) -> Result<()> {
// Validate every case in authored order first (the @default case is
// emitted out of order, as the leading `fallback` attribute).
- for case in &cases {
- let entries = case_entries(*case);
- let label = if case.has_node_field("test") {
- "@case"
- } else {
- "@default"
+ for arm in &switch.arms {
+ let label = match arm {
+ SwitchArm::Case { .. } => "@case",
+ SwitchArm::Default { .. } => "@default",
};
- let parts = self.block_parts_of_entries(&entries, Some(label))?;
+ let parts = self.block_parts_of_entries(arm.entries(), Some(label))?;
if !parts.setup.is_empty() && parts.renders.is_empty() {
return Err(ProjectError::new(
"A TSRX @case block with setup statements must end with rendered output",
- *case,
+ arm.origin().tape,
));
}
}
- let default_case = cases
- .iter()
- .copied()
- .find(|case| !case.has_node_field("test"));
- let has_fallback = default_case.is_some_and(|case| {
- let (setup, renders) = partition_entries(&case_entries(case));
+ let default_arm = switch.default_arm();
+ let has_fallback = default_arm.is_some_and(|arm| {
+ let (setup, renders) = partition_entries(arm.entries());
!(setup.is_empty() && renders.is_empty())
});
self.push("");
- for case in &cases {
- let Some(test) = case.node_field("test") else {
+ for arm in &switch.arms {
+ let SwitchArm::Case { test, entries, .. } = arm else {
continue;
};
self.push("");
continue;
@@ -1013,10 +935,10 @@ impl<'s, 't> Renderer<'s, 't> {
Shape::Expr
};
if shape == Shape::Jsx {
- self.emit_case_expression(*case, "@case")?;
+ self.emit_case_expression(entries, "@case")?;
} else {
self.push("{");
- self.emit_case_expression(*case, "@case")?;
+ self.emit_case_expression(entries, "@case")?;
self.push("}");
}
self.push("");
@@ -1025,25 +947,16 @@ impl<'s, 't> Renderer<'s, 't> {
Ok(())
}
- fn emit_case_expression(&mut self, case: Node<'_>, label: &str) -> Result<()> {
- let entries = case_entries(case);
- let parts = self.block_parts_of_entries(&entries, Some(label))?;
+ fn emit_case_expression(&mut self, entries: &[Node<'_>], label: &str) -> Result<()> {
+ let parts = self.block_parts_of_entries(entries, Some(label))?;
self.emit_parts_expression(&parts)
}
// -- @try / @pending / @catch — Errored / Loading -------------------------------
- fn render_try(&mut self, node: Node<'_>, position: Position) -> Result<()> {
- if let Some(finalizer) = node.node_field("finalizer") {
- return Err(ProjectError::new(
- "@finally is not part of the TSRX template grammar",
- finalizer,
- ));
- }
- let block = node
- .node_field("block")
- .ok_or_else(|| ProjectError::new("TSRX @try is missing its block", node))?;
-
+ fn render_try(&mut self, try_: &SemanticTry<'t>, position: Position) -> Result<()> {
+ let node = try_.origin.tape;
+ let block = try_.body.node;
// Validate in the Babel frontend's order: @try, then @pending, then
// @catch — output order is the reverse nesting.
let content_parts = self.block_parts(block, Some("@try"))?;
@@ -1059,42 +972,36 @@ impl<'s, 't> Renderer<'s, 't> {
)
});
}
- let pending = node.node_field("pending");
+ let pending = try_.pending.as_ref().map(|pending| pending.node);
if let Some(pending) = pending {
self.block_parts(pending, Some("@pending"))?;
}
- let handler = node.node_field("handler");
+ let handler = try_.catch.as_ref();
let mut error_name = String::from("_e");
let mut reset_name: Option = None;
let mut has_error_param = false;
let mut error_pattern = None;
if let Some(handler) = handler {
- if let Some(param) = handler.node_field("param") {
- if !matches!(param.ty(), "Identifier" | "ObjectPattern" | "ArrayPattern") {
- return Err(ProjectError::new(
- "The @catch error binding must be an identifier, object pattern, or array pattern",
- param,
- ));
- }
- if param.ty() == "Identifier"
- && let Some(name) = ident_name(param)
- {
- error_name = name.to_string();
- has_error_param = true;
- } else {
- error_pattern = Some(param);
+ if let Some(binding) = &handler.binding {
+ match binding {
+ CatchBinding::Identifier { name, .. } => {
+ error_name = (*name).to_string();
+ has_error_param = true;
+ }
+ CatchBinding::Pattern(pattern) => error_pattern = Some(*pattern),
}
}
- if let Some(reset) = handler.node_field("resetParam").and_then(ident_name) {
+ if let Some(reset) = handler.reset.and_then(ident_name) {
reset_name = Some(reset.to_string());
}
- let handler_body = handler
- .node_field("body")
- .ok_or_else(|| ProjectError::new("TSRX @catch is missing its block", handler))?;
+ let handler_body = handler.body.node;
let handler_parts = self.block_parts(handler_body, Some("@catch"))?;
if handler_parts.renders.is_empty() {
return Err(if handler_parts.setup.is_empty() {
- ProjectError::new("A TSRX @catch block must end with rendered output", handler)
+ ProjectError::new(
+ "A TSRX @catch block must end with rendered output",
+ handler.origin.tape,
+ )
} else {
ProjectError::new(
"A TSRX @catch block with setup statements must end with rendered output",
@@ -1139,8 +1046,7 @@ impl<'s, 't> Renderer<'s, 't> {
self.push(reset);
}
self.push(") => (");
- let handler_body = handler.node_field("body").unwrap();
- self.emit_block_expression(handler_body, "@catch")?;
+ self.emit_block_expression(handler.body.node, "@catch")?;
self.push(")}>");
}
@@ -1234,7 +1140,7 @@ impl<'s, 't> Renderer<'s, 't> {
self.begin_style_setup(&setups, position)?;
let (start, end) = span_of(node)?;
let mut specials = Vec::new();
- collect_children(node, &self.styles, &mut specials);
+ collect_children(node, &self.styles, self.semantic, &mut specials);
self.emit_region(start, end, &mut specials)?;
self.end_style_setup(&setups, position);
Ok(())
@@ -1255,7 +1161,7 @@ impl<'s, 't> Renderer<'s, 't> {
.map(|hashes| hashes.join(" "))
.unwrap_or_default();
self.begin_style_setup(&setups, position)?;
- if is_dynamic_element(node) {
+ if tape::is_dynamic_element(node) {
self.render_dynamic_element(node, &hashes)?;
} else {
self.render_native_scoped_element(node, &hashes)?;
@@ -1264,6 +1170,39 @@ impl<'s, 't> Renderer<'s, 't> {
Ok(())
}
+ fn render_raw_text_script(&mut self, node: Node<'_>, position: Position) -> Result<()> {
+ let start = span_of(node)?.0;
+ let setups = self
+ .styles
+ .owner_setups
+ .get(&start)
+ .cloned()
+ .unwrap_or_default();
+ let hashes = self
+ .styles
+ .element_hashes
+ .get(&start)
+ .map(|hashes| hashes.join(" "))
+ .unwrap_or_default();
+ let payload = self
+ .semantic
+ .raw_text_script_for(node)
+ .map(|script| script.payload)
+ .ok_or_else(|| ProjectError::new("A TSRX raw-text script is malformed", node))?;
+ let source = self.source;
+ let content = source
+ .get(payload.start as usize..payload.end as usize)
+ .ok_or_else(|| ProjectError::new("A TSRX raw-text script span is invalid", node))?;
+ self.begin_style_setup(&setups, position)?;
+ self.emit_native_opening(node, &hashes)?;
+ self.push("{");
+ push_js_string(&mut self.out, content);
+ self.push("}");
+ self.push_verbatim(payload.end, span_of(node)?.1);
+ self.end_style_setup(&setups, position);
+ Ok(())
+ }
+
fn begin_style_setup(&mut self, setups: &[RefSetup<'t>], position: Position) -> Result<()> {
if setups.is_empty() {
return Ok(());
@@ -1342,12 +1281,28 @@ impl<'s, 't> Renderer<'s, 't> {
}
fn render_native_scoped_element(&mut self, node: Node<'_>, hash: &str) -> Result<()> {
+ let opening_end = self.emit_native_opening(node, hash)?;
+ let (_, node_end) = span_of(node)?;
+ let mut children = Vec::new();
+ for child in node.list_field("children").flatten() {
+ collect_specials(
+ child,
+ Position::JsxChild,
+ &self.styles,
+ self.semantic,
+ &mut children,
+ );
+ }
+ self.emit_region(opening_end, node_end, &mut children)
+ }
+
+ fn emit_native_opening(&mut self, node: Node<'_>, hash: &str) -> Result {
let opening = node
.node_field("openingElement")
.ok_or_else(|| ProjectError::new("JSX element is missing its opening tag", node))?;
let (opening_start, opening_end) = span_of(opening)?;
let mut specials = Vec::new();
- collect_children(opening, &self.styles, &mut specials);
+ collect_children(opening, &self.styles, self.semantic, &mut specials);
if hash.is_empty() {
self.emit_region(opening_start, opening_end, &mut specials)?;
@@ -1374,12 +1329,7 @@ impl<'s, 't> Renderer<'s, 't> {
self.emit_region(insertion, opening_end, &mut specials)?;
}
- let (_, node_end) = span_of(node)?;
- let mut children = Vec::new();
- for child in node.list_field("children").flatten() {
- collect_specials(child, Position::JsxChild, &self.styles, &mut children);
- }
- self.emit_region(opening_end, node_end, &mut children)
+ Ok(opening_end)
}
fn emit_scoped_attribute_value(&mut self, value: Node<'_>, hash: &str) -> Result<()> {
@@ -1459,7 +1409,13 @@ impl<'s, 't> Renderer<'s, 't> {
// specials (preserves JSXText exactly, like the Babel frontend).
let mut specials = Vec::new();
for child in node.list_field("children").flatten() {
- collect_specials(child, Position::JsxChild, &self.styles, &mut specials);
+ collect_specials(
+ child,
+ Position::JsxChild,
+ &self.styles,
+ self.semantic,
+ &mut specials,
+ );
}
self.emit_region(opening_end, children_end, &mut specials)?;
self.push("");
@@ -1504,7 +1460,7 @@ impl<'s, 't> Renderer<'s, 't> {
// the reparsed program resolves scope exactly, and the post-reparse
// pass renames them.
let mut specials = Vec::new();
- collect_children(node, &self.styles, &mut specials);
+ collect_children(node, &self.styles, self.semantic, &mut specials);
self.suppress_nested_lazy += 1;
let result = self.emit_region(span.0, span.1, &mut specials);
self.suppress_nested_lazy -= 1;
@@ -1514,7 +1470,7 @@ impl<'s, 't> Renderer<'s, 't> {
fn emit_eager_pattern(&mut self, node: Node<'_>) -> Result<()> {
let span = span_of(node)?;
let mut specials = Vec::new();
- collect_children(node, &self.styles, &mut specials);
+ collect_children(node, &self.styles, self.semantic, &mut specials);
self.emit_region(span.0, span.1, &mut specials)
}
@@ -1666,15 +1622,6 @@ impl<'s, 't> Renderer<'s, 't> {
}
}
-fn case_entries<'t>(case: Node<'t>) -> Vec> {
- let consequent: Vec> = case.list_field("consequent").flatten().collect();
- if consequent.len() == 1 && consequent[0].ty() == "BlockStatement" {
- consequent[0].list_field("body").flatten().collect()
- } else {
- consequent
- }
-}
-
fn span_of(node: Node<'_>) -> Result<(u32, u32)> {
node.span().ok_or_else(|| {
ProjectError::new(
diff --git a/packages/compiler/src/tsrx/rewrite.rs b/packages/compiler/src/tsrx/rewrite.rs
index 071016bf0..7df203acd 100644
--- a/packages/compiler/src/tsrx/rewrite.rs
+++ b/packages/compiler/src/tsrx/rewrite.rs
@@ -14,7 +14,7 @@
//! reads only: writes and read-write updates stay untouched, mirroring the
//! Babel frontend's `rewriteReadsToCalls`.
-use std::collections::{HashMap, HashSet};
+use std::collections::HashMap;
use oxc_allocator::{Allocator, CloneIn};
use oxc_ast::ast::{
@@ -34,30 +34,10 @@ use oxc_syntax::{
use crate::shared::ast_builder::AstBuilder;
-use super::project::Projection;
+use super::{names::Names, project::Projection};
type SpanKey = (u32, u32);
-#[derive(Default)]
-struct Names {
- used: HashSet,
- next: HashMap<&'static str, u32>,
-}
-
-impl Names {
- fn allocate(&mut self, prefix: &'static str) -> String {
- let mut index = *self.next.get(prefix).unwrap_or(&0);
- loop {
- let name = format!("{prefix}{index}");
- index += 1;
- if self.used.insert(name.clone()) {
- self.next.insert(prefix, index);
- return name;
- }
- }
- }
-}
-
enum AccessStep<'a> {
Static(String),
Computed(Expression<'a>),
@@ -175,7 +155,7 @@ pub fn apply<'a>(
.with_build_nodes(true)
.build(program)
.semantic;
- let mut names = collect_names(&semantic);
+ let mut names = Names::from_semantic(&semantic);
let plan = build_plan(allocator, &semantic, projection, &mut names)?;
drop(semantic);
@@ -197,25 +177,6 @@ pub fn apply<'a>(
}
}
-fn collect_names(semantic: &Semantic<'_>) -> Names {
- let mut names = Names::default();
- for node in semantic.nodes() {
- match node.kind() {
- oxc_ast::AstKind::BindingIdentifier(ident) => {
- names.used.insert(ident.name.to_string());
- }
- oxc_ast::AstKind::IdentifierReference(ident) => {
- names.used.insert(ident.name.to_string());
- }
- oxc_ast::AstKind::JSXIdentifier(ident) => {
- names.used.insert(ident.name.to_string());
- }
- _ => {}
- }
- }
- names
-}
-
fn build_plan<'a>(
allocator: &'a Allocator,
semantic: &Semantic<'_>,
diff --git a/packages/compiler/src/tsrx/semantic.rs b/packages/compiler/src/tsrx/semantic.rs
new file mode 100644
index 000000000..5df832b58
--- /dev/null
+++ b/packages/compiler/src/tsrx/semantic.rs
@@ -0,0 +1,786 @@
+//! Compiler-owned semantic IR for authored Solid TSRX.
+//!
+//! `FlatTape` is the parser interchange format. This module is the boundary
+//! that turns its string-keyed ESTree/TSRX records into typed Solid constructs
+//! before any text projection is emitted. Ordinary JavaScript expressions and
+//! blocks remain tape [`Node`] references in this transitional slice; future
+//! backends can lower those leaves directly to their native AST.
+
+use std::collections::HashMap;
+
+use super::tape::{self, Node};
+use tsrx_tape_schema::RecordIndex;
+
+/// A half-open range in authored UTF-8 bytes.
+#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
+pub struct AuthoredSpan {
+ pub start: u32,
+ pub end: u32,
+}
+
+impl AuthoredSpan {
+ fn of(node: Node<'_>) -> Result {
+ node.span()
+ .map(|(start, end)| Self { start, end })
+ .ok_or_else(|| {
+ SemanticError::new(
+ format!("TSRX node `{}` is missing its span", node.ty()),
+ node,
+ )
+ })
+ }
+}
+
+/// The authored origin of one semantic construct.
+///
+/// `span` is the parser node's exact origin. `extent` also includes trailing
+/// clauses that some parser revisions omit from the parent node span and is
+/// therefore the range replaced by text projection.
+#[derive(Clone, Copy)]
+pub struct Origin<'t> {
+ pub span: AuthoredSpan,
+ pub extent: AuthoredSpan,
+ pub tape: Node<'t>,
+}
+
+impl<'t> Origin<'t> {
+ fn new(node: Node<'t>, include_clauses: bool) -> Result {
+ let span = AuthoredSpan::of(node)?;
+ let extent = if include_clauses {
+ construct_extent(node, span)
+ } else {
+ span
+ };
+ Ok(Self {
+ span,
+ extent,
+ tape: node,
+ })
+ }
+}
+
+/// A typed template block whose entries still reference transitional tape nodes.
+#[derive(Clone)]
+pub struct TemplateBlock<'t> {
+ pub node: Node<'t>,
+}
+
+impl<'t> TemplateBlock<'t> {
+ fn new(node: Node<'t>) -> Self {
+ Self { node }
+ }
+}
+
+#[derive(Clone)]
+pub struct CodeBlock<'t> {
+ pub origin: Origin<'t>,
+ pub setup: Vec>,
+ pub render: Node<'t>,
+}
+
+#[derive(Clone)]
+pub struct IfBranch<'t> {
+ pub test: Node<'t>,
+ pub body: TemplateBlock<'t>,
+}
+
+#[derive(Clone)]
+pub struct IfChain<'t> {
+ pub origin: Origin<'t>,
+ pub branches: Vec>,
+ pub fallback: Option>,
+}
+
+/// Runtime callback contract selected by the authored `index`/`key` clauses.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum ForCallbackMode {
+ /// No index and no custom key: raw item.
+ Default,
+ /// `index` without `key`: accessor item and raw numeric index.
+ Indexed,
+ /// Custom key without an authored index: accessor item.
+ Keyed,
+ /// Custom key and index: accessor item and accessor index.
+ KeyedIndexed,
+}
+
+impl ForCallbackMode {
+ pub fn from_clauses(has_index: bool, has_key: bool) -> Self {
+ match (has_index, has_key) {
+ (false, false) => Self::Default,
+ (true, false) => Self::Indexed,
+ (false, true) => Self::Keyed,
+ (true, true) => Self::KeyedIndexed,
+ }
+ }
+
+ pub fn item_is_accessor(self) -> bool {
+ self != Self::Default
+ }
+
+ pub fn index_is_accessor(self) -> bool {
+ self == Self::KeyedIndexed
+ }
+
+ pub fn emits_non_keyed_intent(self) -> bool {
+ self == Self::Indexed
+ }
+}
+
+#[derive(Clone)]
+pub struct ForLoop<'t> {
+ pub origin: Origin<'t>,
+ pub pattern: Node<'t>,
+ pub iterable: Node<'t>,
+ pub index: Option>,
+ pub key: Option>,
+ pub body: TemplateBlock<'t>,
+ pub empty: Option>,
+ pub callback_mode: ForCallbackMode,
+}
+
+#[derive(Clone)]
+pub enum SwitchArm<'t> {
+ Case {
+ origin: Origin<'t>,
+ test: Node<'t>,
+ entries: Vec>,
+ },
+ Default {
+ origin: Origin<'t>,
+ entries: Vec>,
+ },
+}
+
+impl<'t> SwitchArm<'t> {
+ pub fn origin(&self) -> Origin<'t> {
+ match self {
+ Self::Case { origin, .. } | Self::Default { origin, .. } => *origin,
+ }
+ }
+
+ pub fn entries(&self) -> &[Node<'t>] {
+ match self {
+ Self::Case { entries, .. } | Self::Default { entries, .. } => entries,
+ }
+ }
+}
+
+#[derive(Clone)]
+pub struct Switch<'t> {
+ pub origin: Origin<'t>,
+ pub discriminant: Node<'t>,
+ pub arms: Vec>,
+}
+
+impl<'t> Switch<'t> {
+ pub fn default_arm(&self) -> Option<&SwitchArm<'t>> {
+ self.arms
+ .iter()
+ .find(|arm| matches!(arm, SwitchArm::Default { .. }))
+ }
+}
+
+#[derive(Clone)]
+pub enum CatchBinding<'t> {
+ Identifier { name: &'t str },
+ Pattern(Node<'t>),
+}
+
+#[derive(Clone)]
+pub struct TryCatch<'t> {
+ pub origin: Origin<'t>,
+ pub binding: Option>,
+ pub reset: Option>,
+ pub body: TemplateBlock<'t>,
+}
+
+#[derive(Clone)]
+pub struct Try<'t> {
+ pub origin: Origin<'t>,
+ pub body: TemplateBlock<'t>,
+ pub pending: Option>,
+ pub catch: Option>,
+}
+
+#[derive(Clone)]
+pub enum ControlFlow<'t> {
+ CodeBlock(CodeBlock<'t>),
+ If(IfChain<'t>),
+ For(ForLoop<'t>),
+ Switch(Switch<'t>),
+ Try(Try<'t>),
+}
+
+impl<'t> ControlFlow<'t> {
+ pub fn origin(&self) -> Origin<'t> {
+ match self {
+ Self::CodeBlock(node) => node.origin,
+ Self::If(node) => node.origin,
+ Self::For(node) => node.origin,
+ Self::Switch(node) => node.origin,
+ Self::Try(node) => node.origin,
+ }
+ }
+}
+
+#[derive(Clone, Copy)]
+pub struct RawTextScript<'t> {
+ pub origin: Origin<'t>,
+ pub payload: AuthoredSpan,
+}
+
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum EmbeddedKind {
+ Css,
+ Script,
+}
+
+/// Authored embedded-language region supplied structurally by the parser.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub struct EmbeddedRegion {
+ pub kind: EmbeddedKind,
+ pub span: AuthoredSpan,
+}
+
+/// Typed semantic view of one Solid TSRX module.
+pub struct SolidTsrxModule<'t> {
+ pub root: Node<'t>,
+ pub control_flow: Vec>,
+ pub raw_text_scripts: Vec>,
+ pub embedded_regions: Vec,
+ control_index: HashMap,
+ raw_text_script_index: HashMap,
+}
+
+impl<'t> SolidTsrxModule<'t> {
+ pub fn control_for(&self, node: Node<'_>) -> Option<&ControlFlow<'t>> {
+ self.control_index
+ .get(&node.object())
+ .map(|index| &self.control_flow[*index])
+ }
+
+ pub fn raw_text_script_for(&self, node: Node<'_>) -> Option<&RawTextScript<'t>> {
+ self.raw_text_script_index
+ .get(&node.object())
+ .map(|index| &self.raw_text_scripts[*index])
+ }
+}
+
+/// A semantic-lowering diagnostic in authored coordinates.
+#[derive(Debug)]
+pub struct SemanticError {
+ pub message: String,
+ pub start: u32,
+}
+
+impl SemanticError {
+ fn new(message: impl Into, node: Node<'_>) -> Self {
+ Self {
+ message: message.into(),
+ start: node.span().map_or(0, |span| span.0),
+ }
+ }
+}
+
+/// Lower the parser-interchange root into compiler-owned Solid TSRX IR.
+pub fn lower<'t>(root: Node<'t>) -> Result, SemanticError> {
+ AuthoredSpan::of(root)?;
+ let mut controls = Vec::new();
+ let mut raw_text_scripts = Vec::new();
+ let mut embedded_regions = Vec::new();
+ let mut lowering_error = None;
+ tape::walk(root, &mut |node| {
+ if lowering_error.is_some() {
+ return false;
+ }
+ if matches!(
+ node.ty(),
+ "JSXCodeBlock"
+ | "JSXIfExpression"
+ | "JSXForExpression"
+ | "JSXSwitchExpression"
+ | "JSXTryExpression"
+ ) {
+ controls.push(node);
+ } else if let Some(raw) = tape::raw_text_payload(node, "script") {
+ match (Origin::new(node, false), AuthoredSpan::of(raw)) {
+ (Ok(origin), Ok(payload)) => {
+ raw_text_scripts.push(RawTextScript { origin, payload });
+ embedded_regions.push(EmbeddedRegion {
+ kind: EmbeddedKind::Script,
+ span: payload,
+ });
+ }
+ (Err(error), _) | (_, Err(error)) => {
+ lowering_error = Some(error);
+ return false;
+ }
+ }
+ }
+ if node.ty() == "JSXStyleElement"
+ && let Some((start, end)) = tape::paired_element_payload_span(node)
+ {
+ embedded_regions.push(EmbeddedRegion {
+ kind: EmbeddedKind::Css,
+ span: AuthoredSpan { start, end },
+ });
+ }
+ true
+ });
+ if let Some(error) = lowering_error {
+ return Err(error);
+ }
+
+ // Tape field order is not source order. Lower in authored order so the
+ // first structural diagnostic remains deterministic; for equal starts,
+ // validate the containing construct before its descendants.
+ controls.sort_by_key(|node| {
+ let (start, end) = node.span().unwrap_or((u32::MAX, 0));
+ (start, std::cmp::Reverse(end))
+ });
+ let mut control_flow = Vec::with_capacity(controls.len());
+ for node in controls {
+ control_flow.push(match node.ty() {
+ "JSXCodeBlock" => lower_code_block(node)?,
+ "JSXIfExpression" => lower_if(node)?,
+ "JSXForExpression" => lower_for(node)?,
+ "JSXSwitchExpression" => lower_switch(node)?,
+ "JSXTryExpression" => lower_try(node)?,
+ _ => {
+ unreachable!("control candidates are filtered before semantic lowering")
+ }
+ });
+ }
+ let control_index = control_flow
+ .iter()
+ .enumerate()
+ .map(|(index, control)| (control.origin().tape.object(), index))
+ .collect();
+ raw_text_scripts.sort_by_key(|script| script.origin.span);
+ let raw_text_script_index = raw_text_scripts
+ .iter()
+ .enumerate()
+ .map(|(index, script)| (script.origin.tape.object(), index))
+ .collect();
+ embedded_regions.sort_by_key(|region| region.span);
+ Ok(SolidTsrxModule {
+ root,
+ control_flow,
+ raw_text_scripts,
+ embedded_regions,
+ control_index,
+ raw_text_script_index,
+ })
+}
+
+fn lower_code_block<'t>(node: Node<'t>) -> Result, SemanticError> {
+ let render = required_node(
+ node,
+ "render",
+ "A TSRX statement container is missing its rendered output node",
+ )?;
+ Ok(ControlFlow::CodeBlock(CodeBlock {
+ origin: Origin::new(node, false)?,
+ setup: node.list_field("body").flatten().collect(),
+ render,
+ }))
+}
+
+fn lower_if<'t>(node: Node<'t>) -> Result, SemanticError> {
+ let mut branches = Vec::new();
+ let mut current = node;
+ let fallback;
+ loop {
+ let test = current
+ .node_field("test")
+ .ok_or_else(|| SemanticError::new("TSRX @if is missing its condition", node))?;
+ let body_node = current
+ .node_field("consequent")
+ .ok_or_else(|| SemanticError::new("TSRX @if is missing its consequent block", node))?;
+ branches.push(IfBranch {
+ test,
+ body: TemplateBlock::new(body_node),
+ });
+ match current.node_field("alternate") {
+ Some(alternate) if matches!(alternate.ty(), "IfStatement" | "JSXIfExpression") => {
+ current = alternate;
+ }
+ alternate => {
+ fallback = alternate.map(TemplateBlock::new);
+ break;
+ }
+ }
+ }
+ Ok(ControlFlow::If(IfChain {
+ origin: Origin::new(node, true)?,
+ branches,
+ fallback,
+ }))
+}
+
+fn lower_for<'t>(node: Node<'t>) -> Result, SemanticError> {
+ if node.str_field("statementType") != Some("ForOfStatement") {
+ return Err(SemanticError::new(
+ "@for must iterate with for...of; for...in and classic for loops are not TSRX template constructs",
+ node,
+ ));
+ }
+ if node.bool_field("await") {
+ return Err(SemanticError::new(
+ "`for await` is not supported inside Solid TSRX templates",
+ node,
+ ));
+ }
+ let pattern = tape::for_binding_pattern(node)
+ .ok_or_else(|| SemanticError::new("TSRX @for is missing its binding", node))?;
+ let iterable = required_node(node, "right", "TSRX @for is missing its iterable")?;
+ let index = node.node_field("index");
+ let key = node.node_field("key");
+ let body = TemplateBlock::new(required_node(
+ node,
+ "body",
+ "TSRX @for is missing its body",
+ )?);
+ let empty = node.node_field("empty").map(TemplateBlock::new);
+ Ok(ControlFlow::For(ForLoop {
+ origin: Origin::new(node, true)?,
+ pattern,
+ iterable,
+ index,
+ key,
+ body,
+ empty,
+ callback_mode: ForCallbackMode::from_clauses(index.is_some(), key.is_some()),
+ }))
+}
+
+fn lower_switch<'t>(node: Node<'t>) -> Result, SemanticError> {
+ let discriminant = required_node(
+ node,
+ "discriminant",
+ "TSRX @switch is missing its discriminant",
+ )?;
+ let mut arms = Vec::new();
+ for case in node.list_field("cases").flatten() {
+ let origin = Origin::new(case, false)?;
+ let entries = case_entries(case);
+ arms.push(match case.node_field("test") {
+ Some(test) => SwitchArm::Case {
+ origin,
+ test,
+ entries,
+ },
+ None => SwitchArm::Default { origin, entries },
+ });
+ }
+ Ok(ControlFlow::Switch(Switch {
+ origin: Origin::new(node, true)?,
+ discriminant,
+ arms,
+ }))
+}
+
+fn lower_try<'t>(node: Node<'t>) -> Result, SemanticError> {
+ if let Some(finalizer) = node.node_field("finalizer") {
+ return Err(SemanticError::new(
+ "@finally is not part of the TSRX template grammar",
+ finalizer,
+ ));
+ }
+ let body = TemplateBlock::new(required_node(
+ node,
+ "block",
+ "TSRX @try is missing its block",
+ )?);
+ let pending = node.node_field("pending").map(TemplateBlock::new);
+ let catch = node
+ .node_field("handler")
+ .map(|handler| {
+ let binding = handler
+ .node_field("param")
+ .map(|param| match param.ty() {
+ "Identifier" => Ok(CatchBinding::Identifier {
+ name: param.str_field("name").unwrap_or(""),
+ }),
+ "ObjectPattern" | "ArrayPattern" => Ok(CatchBinding::Pattern(param)),
+ _ => Err(SemanticError::new(
+ "The @catch error binding must be an identifier, object pattern, or array pattern",
+ param,
+ )),
+ })
+ .transpose()?;
+ let catch_body = required_node(
+ handler,
+ "body",
+ "TSRX @catch is missing its block",
+ )?;
+ Ok(TryCatch {
+ origin: Origin::new(handler, false)?,
+ binding,
+ reset: handler.node_field("resetParam"),
+ body: TemplateBlock::new(catch_body),
+ })
+ })
+ .transpose()?;
+ Ok(ControlFlow::Try(Try {
+ origin: Origin::new(node, true)?,
+ body,
+ pending,
+ catch,
+ }))
+}
+
+fn required_node<'t>(
+ node: Node<'t>,
+ field: &str,
+ message: &str,
+) -> Result, SemanticError> {
+ node.node_field(field)
+ .ok_or_else(|| SemanticError::new(message, node))
+}
+
+fn case_entries<'t>(case: Node<'t>) -> Vec> {
+ let consequent: Vec> = case.list_field("consequent").flatten().collect();
+ if consequent.len() == 1 && consequent[0].ty() == "BlockStatement" {
+ consequent[0].list_field("body").flatten().collect()
+ } else {
+ consequent
+ }
+}
+
+fn construct_extent(node: Node<'_>, span: AuthoredSpan) -> AuthoredSpan {
+ let mut end = span.end;
+ for (key, value) in node.fields() {
+ if matches!(key, "type" | "start" | "end" | "metadata" | "loc" | "range") {
+ continue;
+ }
+ match value.kind() {
+ tsrx_tape_schema::ValueKind::Object => {
+ if let Some(child) = Node::from_value(node.tape(), value)
+ && let Some((_, child_end)) = child.span()
+ {
+ end = end.max(child_end);
+ }
+ }
+ tsrx_tape_schema::ValueKind::List => {
+ for child in node.list_value(value).flatten() {
+ if let Some((_, child_end)) = child.span() {
+ end = end.max(child_end);
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+ AuthoredSpan {
+ start: span.start,
+ end,
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use tsrx_parser_engine::TsrxParseOptions;
+ use tsrx_tape_schema::{CoordinateDomain, ValueRef};
+
+ fn parse(source: &str) -> tsrx_tape_schema::FlatTape {
+ let result = crate::tsrx::parse_source(
+ source,
+ TsrxParseOptions {
+ filename: "semantic.tsrx",
+ include_ts_fields: true,
+ ..TsrxParseOptions::default()
+ },
+ )
+ .expect("parse TSRX");
+ let domain = result.coordinate_domain;
+ let mut tape = result.program.expect("complete tape");
+ if domain == CoordinateDomain::OriginalUtf16Units {
+ crate::tsrx::rebase_utf16_spans(source, &mut tape).expect("rebase UTF-16 spans");
+ }
+ tape
+ }
+
+ fn at<'t>(module: &'t SolidTsrxModule<'t>, source: &str, needle: &str) -> &'t ControlFlow<'t> {
+ let start = source.find(needle).expect("needle") as u32;
+ module
+ .control_flow
+ .iter()
+ .find(|control| control.origin().span.start == start)
+ .expect("control at authored offset")
+ }
+
+ #[test]
+ fn lowers_statement_container_shape() {
+ let source = "export function C() @{\n const value = 1;\n {value}
\n}";
+ let tape = parse(source);
+ let module = lower(Node::root(&tape).unwrap()).expect("semantic IR");
+ let ControlFlow::CodeBlock(block) = at(&module, source, "@{") else {
+ panic!("expected statement container");
+ };
+ assert_eq!(block.setup.len(), 1);
+ assert_eq!(block.setup[0].ty(), "VariableDeclaration");
+ assert_eq!(block.render.ty(), "JSXElement");
+ assert_eq!(block.origin.span.start, source.find("@{").unwrap() as u32);
+ }
+
+ #[test]
+ fn records_only_consumed_raw_text_and_embedded_regions() {
+ let source = "export function C() @{\n\
+ <>>\n\
+ }";
+ let tape = parse(source);
+ let module = lower(Node::root(&tape).unwrap()).expect("semantic IR");
+ let script = module.raw_text_scripts.first().expect("raw script");
+ assert_eq!(
+ &source[script.payload.start as usize..script.payload.end as usize],
+ "{\"ok\":true}"
+ );
+ assert_eq!(
+ module
+ .raw_text_script_for(script.origin.tape)
+ .map(|script| script.payload),
+ Some(script.payload)
+ );
+ assert_eq!(
+ module
+ .embedded_regions
+ .iter()
+ .map(|region| region.kind)
+ .collect::>(),
+ [EmbeddedKind::Css, EmbeddedKind::Script]
+ );
+ }
+
+ #[test]
+ fn lowers_if_chain_and_fallback() {
+ let source = "export const C = ({ a, b }) => @if (a) { } @else if (b) { } @else { };";
+ let tape = parse(source);
+ let module = lower(Node::root(&tape).unwrap()).expect("semantic IR");
+ let ControlFlow::If(chain) = at(&module, source, "@if") else {
+ panic!("expected if chain");
+ };
+ assert_eq!(
+ module
+ .control_flow
+ .iter()
+ .filter(|control| matches!(control, ControlFlow::If(_)))
+ .count(),
+ 1
+ );
+ assert_eq!(chain.branches.len(), 2);
+ assert!(chain.fallback.is_some());
+ assert_eq!(chain.origin.span.start, source.find("@if").unwrap() as u32);
+ assert_eq!(
+ chain.origin.extent.end,
+ source.rfind('}').unwrap() as u32 + 1
+ );
+ }
+
+ #[test]
+ fn computes_for_callback_mode_matrix() {
+ let source = "export function C({ xs }) @{\n\
+ \n\
+ @for (const a of xs) {
}\n\
+ @for (const b of xs; index i) {
}\n\
+ @for (const c of xs; key c.id) {
}\n\
+ @for (const d of xs; index j; key d.id) {
}\n\
+
\n\
+ }";
+ let tape = parse(source);
+ let module = lower(Node::root(&tape).unwrap()).expect("semantic IR");
+ let modes: Vec<_> = module
+ .control_flow
+ .iter()
+ .filter_map(|control| match control {
+ ControlFlow::For(loop_) => Some(loop_.callback_mode),
+ _ => None,
+ })
+ .collect();
+ assert_eq!(
+ modes,
+ [
+ ForCallbackMode::Default,
+ ForCallbackMode::Indexed,
+ ForCallbackMode::Keyed,
+ ForCallbackMode::KeyedIndexed,
+ ]
+ );
+ assert!(!modes[0].item_is_accessor());
+ assert!(modes[1].emits_non_keyed_intent());
+ assert!(!modes[1].index_is_accessor());
+ assert!(modes[3].index_is_accessor());
+ }
+
+ #[test]
+ fn lowers_switch_default_and_try_clauses() {
+ let source = "export function C({ x }) @{\n\
+ <>\n\
+ @switch (x) { @case 1: { } @default: { } }\n\
+ @try { } @pending { } @catch (error, reset) { }\n\
+ >\n\
+ }";
+ let tape = parse(source);
+ let module = lower(Node::root(&tape).unwrap()).expect("semantic IR");
+ let ControlFlow::Switch(switch) = at(&module, source, "@switch") else {
+ panic!("expected switch");
+ };
+ assert_eq!(switch.arms.len(), 2);
+ assert!(switch.default_arm().is_some());
+ let ControlFlow::Try(try_) = at(&module, source, "@try") else {
+ panic!("expected try");
+ };
+ assert!(try_.pending.is_some());
+ let catch = try_.catch.as_ref().expect("catch");
+ assert!(matches!(
+ catch.binding,
+ Some(CatchBinding::Identifier { name: "error", .. })
+ ));
+ assert_eq!(
+ catch.reset.and_then(|node| node.str_field("name")),
+ Some("reset")
+ );
+ }
+
+ #[test]
+ fn preserves_utf8_authored_spans_after_utf16_rebase() {
+ let source = "const marker = \"🚀\";\nexport const C = ({ ok }) => @if (ok) { };";
+ let tape = parse(source);
+ let module = lower(Node::root(&tape).unwrap()).expect("semantic IR");
+ let control = at(&module, source, "@if");
+ assert_eq!(
+ control.origin().span.start,
+ source.find("@if").unwrap() as u32
+ );
+ assert_eq!(
+ &source[control.origin().span.start as usize..control.origin().span.end as usize],
+ "@if (ok) { }"
+ );
+ }
+
+ #[test]
+ fn rejects_missing_required_fields_at_the_construct_origin() {
+ let source = "export const C = ({ ok }) => @if (ok) { };";
+ let mut tape = parse(source);
+ let mut target = None;
+ tape::walk(Node::root(&tape).unwrap(), &mut |node| {
+ if node.ty() == "JSXIfExpression" {
+ target = Some(node.object());
+ return false;
+ }
+ true
+ });
+ let object = target.expect("if record");
+ let field = tape.field_index(object, "test").expect("test field");
+ tape.set_field_value(field, ValueRef::MISSING)
+ .expect("remove test");
+ let error = match lower(Node::root(&tape).unwrap()) {
+ Ok(_) => panic!("malformed IR must fail"),
+ Err(error) => error,
+ };
+ assert_eq!(error.message, "TSRX @if is missing its condition");
+ assert_eq!(error.start, source.find("@if").unwrap() as u32);
+ }
+}
diff --git a/packages/compiler/src/tsrx/source_map.rs b/packages/compiler/src/tsrx/source_map.rs
index 8d1e741cb..ab36b5442 100644
--- a/packages/compiler/src/tsrx/source_map.rs
+++ b/packages/compiler/src/tsrx/source_map.rs
@@ -56,7 +56,7 @@ impl ProjectionMap {
});
}
- fn authored_offset(&self, projected_offset: u32) -> Option {
+ pub(super) fn authored_offset(&self, projected_offset: u32) -> Option {
let index = self
.segments
.partition_point(|segment| segment.projected_start <= projected_offset);
@@ -122,6 +122,8 @@ pub(super) fn compose(
struct LineOffsets<'a> {
source: &'a str,
starts: Vec,
+ /// Per-line `(relative UTF-8 byte, UTF-16 column)` boundaries.
+ columns: Vec>,
}
impl<'a> LineOffsets<'a> {
@@ -143,32 +145,46 @@ impl<'a> LineOffsets<'a> {
};
starts.push(next as u32);
}
- Self { source, starts }
+ let columns = starts
+ .iter()
+ .enumerate()
+ .map(|(line, start)| {
+ let start = *start as usize;
+ let end = starts
+ .get(line + 1)
+ .copied()
+ .map_or(source.len(), |offset| offset as usize);
+ let line = &source[start..end];
+ let mut utf16 = 0u32;
+ let mut boundaries = Vec::with_capacity(line.chars().count() + 1);
+ for (relative, character) in line.char_indices() {
+ boundaries.push((relative as u32, utf16));
+ utf16 += character.len_utf16() as u32;
+ }
+ boundaries.push((line.len() as u32, utf16));
+ boundaries
+ })
+ .collect();
+ Self {
+ source,
+ starts,
+ columns,
+ }
}
fn byte_offset(&self, line: u32, utf16_column: u32) -> Option {
- let start = *self.starts.get(line as usize)? as usize;
- let end = self
- .starts
- .get(line as usize + 1)
- .copied()
- .map_or(self.source.len(), |offset| offset as usize);
- let mut column = 0u32;
- for (relative, ch) in self.source[start..end].char_indices() {
- if column == utf16_column {
- return Some((start + relative) as u32);
- }
- column += ch.len_utf16() as u32;
- if column > utf16_column {
- return None;
- }
- }
- (column == utf16_column).then_some(end as u32)
+ let line = line as usize;
+ let start = *self.starts.get(line)?;
+ let boundaries = self.columns.get(line)?;
+ let index = boundaries
+ .binary_search_by_key(&utf16_column, |(_, column)| *column)
+ .ok()?;
+ Some(start + boundaries[index].0)
}
fn line_column(&self, byte_offset: u32) -> Option<(u32, u32)> {
let byte_offset = byte_offset as usize;
- if byte_offset > self.source.len() || !self.source.is_char_boundary(byte_offset) {
+ if byte_offset > self.source.len() {
return None;
}
let line = self
@@ -176,7 +192,12 @@ impl<'a> LineOffsets<'a> {
.partition_point(|start| *start as usize <= byte_offset)
.checked_sub(1)?;
let start = self.starts[line] as usize;
- let column = self.source[start..byte_offset].encode_utf16().count() as u32;
+ let relative = (byte_offset - start) as u32;
+ let boundaries = &self.columns[line];
+ let index = boundaries
+ .binary_search_by_key(&relative, |(byte, _)| *byte)
+ .ok()?;
+ let column = boundaries[index].1;
Some((line as u32, column))
}
}
diff --git a/packages/compiler/src/tsrx/style_projection.rs b/packages/compiler/src/tsrx/style_projection.rs
index 5296fa89f..8553db47e 100644
--- a/packages/compiler/src/tsrx/style_projection.rs
+++ b/packages/compiler/src/tsrx/style_projection.rs
@@ -8,6 +8,7 @@ use std::collections::{BTreeMap, BTreeSet};
use super::project::ProjectError;
use super::{
+ semantic::SolidTsrxModule,
style::{
self, Attribute, AttributeValue, ClassMapEntry, Element, ElementChild, ElementKind,
StyleInput, StyleKind, StyleLocation,
@@ -40,9 +41,9 @@ pub(super) struct StyleProjection<'t> {
pub(super) fn plan<'s, 't>(
source: &'s str,
filename: &'s str,
- root: Node<'t>,
+ module: &SolidTsrxModule<'t>,
) -> Result, ProjectError> {
- StyleProcessor::process(source, filename, root)
+ StyleProcessor::process(source, filename, module.root)
}
struct StyleProcessor<'s, 't> {
@@ -532,7 +533,7 @@ fn build_style_element(node: Node<'_>) -> Element {
let id = node.span().map_or(0, |span| span.0);
let opening = node.node_field("openingElement");
let name = opening.and_then(|opening| opening.node_field("name"));
- let kind = if is_dynamic_element(node) {
+ let kind = if tape::is_dynamic_element(node) {
ElementKind::Dynamic
} else if let Some(name) = name
&& name.ty() == "JSXIdentifier"
@@ -734,10 +735,3 @@ pub(super) fn decode_json_string(value: &str) -> Option {
}
Some(out)
}
-
-fn is_dynamic_element(node: Node<'_>) -> bool {
- node.ty() == "JSXElement"
- && node
- .node_field("openingElement")
- .is_some_and(|opening| opening.bool_field("isDynamic"))
-}
diff --git a/packages/compiler/src/tsrx/tape.rs b/packages/compiler/src/tsrx/tape.rs
index a6b4ac687..cf27e0008 100644
--- a/packages/compiler/src/tsrx/tape.rs
+++ b/packages/compiler/src/tsrx/tape.rs
@@ -89,6 +89,10 @@ impl<'t> Node<'t> {
pub fn tape(self) -> &'t FlatTape {
self.tape
}
+
+ pub(crate) fn object(self) -> RecordIndex {
+ self.object
+ }
}
struct NodeIter<'t> {
@@ -149,3 +153,54 @@ pub fn walk_children<'t>(node: Node<'t>, visit: &mut impl FnMut(Node<'t>) -> boo
}
}
}
+
+/// Binding pattern introduced by a TSRX/JavaScript `for...of` record.
+pub fn for_binding_pattern(node: Node<'_>) -> Option> {
+ let left = node.node_field("left")?;
+ if left.ty() != "VariableDeclaration" {
+ return Some(left);
+ }
+ left.list_field("declarations")
+ .flatten()
+ .next()
+ .and_then(|declarator| declarator.node_field("id"))
+}
+
+/// Whether an element uses TSRX's expression-valued dynamic tag syntax.
+pub fn is_dynamic_element(node: Node<'_>) -> bool {
+ node.ty() == "JSXElement"
+ && node
+ .node_field("openingElement")
+ .is_some_and(|opening| opening.bool_field("isDynamic"))
+}
+
+/// Lowercase intrinsic name of an ordinary JSX element.
+pub fn intrinsic_element_name(node: Node<'_>) -> Option<&str> {
+ if node.ty() != "JSXElement" || is_dynamic_element(node) {
+ return None;
+ }
+ node.node_field("openingElement")?
+ .node_field("name")?
+ .str_field("name")
+}
+
+/// Exact authored payload span of a paired JSX element.
+pub fn paired_element_payload_span(node: Node<'_>) -> Option<(u32, u32)> {
+ let start = node.node_field("openingElement")?.span()?.1;
+ let end = node.node_field("closingElement")?.span()?.0;
+ (start <= end).then_some((start, end))
+}
+
+/// Raw-text child supplied by the TSRX parser for an intrinsic element.
+///
+/// This deliberately requires the parser's `content` and child `raw` fields;
+/// callers must not infer raw-text regions by scanning authored source.
+pub fn raw_text_payload<'t>(node: Node<'t>, expected_name: &str) -> Option> {
+ if intrinsic_element_name(node) != Some(expected_name) || node.str_field("content").is_none() {
+ return None;
+ }
+ let mut children = node.list_field("children").flatten();
+ let child = children.next()?;
+ (children.next().is_none() && child.ty() == "JSXText" && child.str_field("raw").is_some())
+ .then_some(child)
+}
diff --git a/packages/compiler/src/tsrx/tooling.rs b/packages/compiler/src/tsrx/tooling.rs
new file mode 100644
index 000000000..3b523c2e9
--- /dev/null
+++ b/packages/compiler/src/tsrx/tooling.rs
@@ -0,0 +1,237 @@
+//! Host-independent TSRX projection for typecheck and editor tooling.
+//!
+//! This backend ends at post-semantic-rewrite TSX. It intentionally does not
+//! contain host mappings or run Solid's DOM, SSR, or universal transforms.
+
+use std::{collections::HashMap, path::PathBuf};
+
+use oxc_allocator::Allocator;
+use oxc_ast::ast::{IdentifierReference, ImportOrExportKind, JSXIdentifier, Program, Statement};
+use oxc_ast_visit::VisitMut;
+use oxc_codegen::{Codegen, CodegenOptions};
+use oxc_semantic::SemanticBuilder;
+use oxc_span::Span;
+
+use super::{
+ apply_rewrites, compose_source_map,
+ names::Names,
+ parse_projected_tsx,
+ project::Projection,
+ run_frontend,
+ semantic::{EmbeddedKind as SemanticEmbeddedKind, EmbeddedRegion as SemanticEmbeddedRegion},
+};
+use crate::{CompileError, shared::ast_builder::AstBuilder};
+
+const TYPECHECK_HELPERS: [(&str, &str, &str); 7] = [
+ ("For", "solid-js", "__tsrx_For"),
+ ("Show", "solid-js", "__tsrx_Show"),
+ ("Switch", "solid-js", "__tsrx_Switch"),
+ ("Match", "solid-js", "__tsrx_Match"),
+ ("Errored", "solid-js", "__tsrx_Errored"),
+ ("Loading", "solid-js", "__tsrx_Loading"),
+ ("Dynamic", "@solidjs/web", "__tsrx_Dynamic"),
+];
+
+/// Options for the unstable host-independent typecheck projection.
+#[derive(Clone, Debug, Default, Eq, PartialEq)]
+pub struct TsrxTypecheckProjectionOptions {
+ pub filename: Option,
+}
+
+/// Language of an authored embedded region.
+#[derive(Clone, Copy, Debug, Eq, PartialEq)]
+pub enum TsrxEmbeddedRegionKind {
+ Css,
+ Script,
+}
+
+/// Authored embedded-language region.
+///
+/// Rust offsets are UTF-8 byte offsets. Host adapters must convert them to
+/// their string-coordinate domain (the Node adapter exposes UTF-16 units).
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TsrxEmbeddedRegion {
+ pub kind: TsrxEmbeddedRegionKind,
+ pub start: u32,
+ pub end: u32,
+ pub content: String,
+}
+
+/// Owned post-rewrite virtual TSX and its authored sidecars.
+#[derive(Clone, Debug, Eq, PartialEq)]
+pub struct TsrxTypecheckProjection {
+ pub code: String,
+ pub source_map: String,
+ pub css: String,
+ pub css_hash: Option,
+ pub embedded_regions: Vec,
+}
+
+/// Project authored TSRX into valid post-rewrite TSX for typechecking tools.
+///
+/// This unstable API uses the same semantic IR, text projection, projected
+/// TSX parser, and lazy/accessor rewrite helpers as [`crate::compile`].
+pub fn project_tsrx_for_typecheck(
+ source: &str,
+ options: &TsrxTypecheckProjectionOptions,
+) -> Result {
+ let filename = options.filename.as_deref().unwrap_or("input.tsrx");
+ let projection = run_frontend(source, Some(filename), true)?;
+ let allocator = Allocator::default();
+ let mut program = parse_projected_tsx(&allocator, &projection)?;
+ inject_typecheck_helpers(&allocator, &mut program, &projection);
+ apply_rewrites(&allocator, &mut program, &projection, true)?;
+ let build = Codegen::new()
+ .with_options(CodegenOptions {
+ source_map_path: Some(PathBuf::from(filename)),
+ ..CodegenOptions::default()
+ })
+ .build(&program);
+ let intermediate = build.map.as_ref().ok_or_else(|| {
+ CompileError::transform("TSRX typecheck projection did not produce a source map")
+ })?;
+ let source_map = compose_source_map(intermediate, &projection, source, filename);
+
+ Ok(TsrxTypecheckProjection {
+ code: build.code,
+ source_map,
+ css: projection.css,
+ css_hash: projection.css_hash,
+ embedded_regions: collect_embedded_regions(source, &projection.embedded_regions)?,
+ })
+}
+
+fn inject_typecheck_helpers<'a>(
+ allocator: &'a Allocator,
+ program: &mut Program<'a>,
+ projection: &Projection,
+) {
+ let semantic = SemanticBuilder::new()
+ .with_build_nodes(true)
+ .build(program)
+ .semantic;
+ let mut names = Names::from_semantic(&semantic);
+ drop(semantic);
+ let aliases = TYPECHECK_HELPERS
+ .iter()
+ .map(|(name, _, prefix)| (*name, names.allocate(prefix)))
+ .collect::>();
+ let mut renamer = TypecheckHelperRenamer {
+ ast: AstBuilder::new(allocator),
+ projection,
+ aliases: &aliases,
+ used: HashMap::new(),
+ };
+ renamer.visit_program(program);
+
+ for source in ["@solidjs/web", "solid-js"] {
+ let helpers = TYPECHECK_HELPERS
+ .iter()
+ .filter_map(|(name, helper_source, _)| {
+ (*helper_source == source)
+ .then(|| {
+ renamer
+ .used
+ .get(name)
+ .map(|span| (*name, aliases[*name].as_str(), *span))
+ })
+ .flatten()
+ })
+ .collect::>();
+ if !helpers.is_empty() {
+ program
+ .body
+ .insert(0, helper_import(allocator, source, &helpers));
+ }
+ }
+}
+
+struct TypecheckHelperRenamer<'a, 'p> {
+ ast: AstBuilder<'a>,
+ projection: &'p Projection,
+ aliases: &'p HashMap<&'static str, String>,
+ used: HashMap<&'static str, Span>,
+}
+
+impl<'a> TypecheckHelperRenamer<'a, '_> {
+ fn renamed_helper(&mut self, identifier: &str, span: Span) -> Option {
+ let (name, alias) = self.aliases.iter().find(|(name, _)| identifier == **name)?;
+ if self
+ .projection
+ .source_map
+ .authored_offset(span.start)
+ .is_some()
+ {
+ return None;
+ }
+ self.used.entry(*name).or_insert(span);
+ Some(alias.clone())
+ }
+}
+
+impl<'a> VisitMut<'a> for TypecheckHelperRenamer<'a, '_> {
+ fn visit_jsx_identifier(&mut self, identifier: &mut JSXIdentifier<'a>) {
+ if let Some(name) = self.renamed_helper(identifier.name.as_str(), identifier.span) {
+ identifier.name = self.ast.str(&name);
+ }
+ }
+
+ fn visit_identifier_reference(&mut self, identifier: &mut IdentifierReference<'a>) {
+ if let Some(name) = self.renamed_helper(identifier.name.as_str(), identifier.span) {
+ identifier.name = self.ast.str(&name).into();
+ }
+ }
+}
+
+fn helper_import<'a>(
+ allocator: &'a Allocator,
+ source: &str,
+ helpers: &[(&str, &str, Span)],
+) -> Statement<'a> {
+ let ast = AstBuilder::new(allocator);
+ let span = helpers[0].2;
+ let mut specifiers = ast.vec_with_capacity(helpers.len());
+ for (imported, local, _) in helpers {
+ specifiers.push(ast.import_declaration_specifier_import_specifier(
+ span,
+ ast.module_export_name_identifier_name(span, ast.ident(imported)),
+ ast.binding_identifier(span, ast.ident(local)),
+ ImportOrExportKind::Value,
+ ));
+ }
+ Statement::ImportDeclaration(ast.alloc_import_declaration(
+ span,
+ Some(specifiers),
+ ast.string_literal(span, ast.str(source), None),
+ None,
+ None,
+ ImportOrExportKind::Value,
+ ))
+}
+
+fn collect_embedded_regions(
+ source: &str,
+ regions: &[SemanticEmbeddedRegion],
+) -> Result, CompileError> {
+ regions
+ .iter()
+ .map(|region| {
+ let content = source
+ .get(region.span.start as usize..region.span.end as usize)
+ .ok_or_else(|| {
+ CompileError::parse("TSRX parser returned an invalid embedded region span")
+ })?
+ .to_owned();
+ let kind = match region.kind {
+ SemanticEmbeddedKind::Css => TsrxEmbeddedRegionKind::Css,
+ SemanticEmbeddedKind::Script => TsrxEmbeddedRegionKind::Script,
+ };
+ Ok(TsrxEmbeddedRegion {
+ kind,
+ start: region.span.start,
+ end: region.span.end,
+ content,
+ })
+ })
+ .collect()
+}
diff --git a/packages/compiler/tests/tsrx_frontend.rs b/packages/compiler/tests/tsrx_frontend.rs
index e1a40d261..3f2360847 100644
--- a/packages/compiler/tests/tsrx_frontend.rs
+++ b/packages/compiler/tests/tsrx_frontend.rs
@@ -561,6 +561,17 @@ fn unicode_offsets_preserve_authored_diagnostic_coordinates() {
message.ends_with("(4:17)"),
"UTF-16 spans must rebase to authored line/column coordinates: {message}"
);
+
+ let source =
+ "const emoji = \"🚀\"; export function C({ obj }) @{ @for (const key in obj) { } }";
+ let expected_column = source[..source.find("@for").expect("@for")]
+ .encode_utf16()
+ .count();
+ let message = compile_error(source);
+ assert!(
+ message.ends_with(&format!("(1:{expected_column})")),
+ "same-line astral characters count as two UTF-16 units: {message}"
+ );
}
#[test]
diff --git a/packages/compiler/tests/tsrx_typecheck_projection.rs b/packages/compiler/tests/tsrx_typecheck_projection.rs
new file mode 100644
index 000000000..a156a4725
--- /dev/null
+++ b/packages/compiler/tests/tsrx_typecheck_projection.rs
@@ -0,0 +1,243 @@
+//! Compiler-owned TSRX projection coverage through the unstable Rust API.
+#![cfg(all(feature = "tsrx", not(feature = "node")))]
+
+use oxc_sourcemap::SourceMap;
+use solidjs_compiler::{
+ CompileOptions, Syntax, TsrxEmbeddedRegionKind, TsrxTypecheckProjection,
+ TsrxTypecheckProjectionOptions, compile, project_tsrx_for_typecheck,
+};
+
+fn project(source: &str) -> TsrxTypecheckProjection {
+ project_tsrx_for_typecheck(
+ source,
+ &TsrxTypecheckProjectionOptions {
+ filename: Some("typecheck.tsrx".into()),
+ },
+ )
+ .expect("typecheck projection")
+}
+
+fn line_column(source: &str, byte_offset: usize) -> (u32, u32) {
+ let line = source[..byte_offset]
+ .bytes()
+ .filter(|byte| *byte == b'\n')
+ .count() as u32;
+ let line_start = source[..byte_offset]
+ .rfind('\n')
+ .map_or(0, |offset| offset + 1);
+ (
+ line,
+ source[line_start..byte_offset].encode_utf16().count() as u32,
+ )
+}
+
+#[test]
+fn projects_identifier_and_destructured_callback_modes() {
+ let source = r#"export function Rows({ rows }) @{
+ <>
+ @for (const plain of rows) { {plain.name}
}
+ @for (const indexed of rows; index index) { {indexed.name}:{index}
}
+ @for (const keyed of rows; key keyed.id) { {keyed.name}
}
+ @for (const both of rows; index position; key both.id) { {both.name}:{position}
}
+ @for (const { name = "missing", ...rest } of rows; index offset) {
+ {name}:{rest.extra}:{offset}
+ }
+ @try { } @catch (error) { {error.message}
}
+ >
+}"#;
+ let output = project(source);
+
+ assert!(output.code.contains("from \"solid-js\""));
+ assert!(output.code.contains("<__tsrx_For0"));
+ assert!(output.code.contains("<__tsrx_Errored0"));
+ assert!(output.code.contains("plain.name"));
+ assert!(!output.code.contains("plain().name"));
+ assert!(output.code.contains("indexed().name"));
+ assert!(output.code.contains("keyed().name"));
+ assert!(output.code.contains("both().name"));
+ assert!(output.code.contains("position()"));
+ assert!(output.code.contains("keyed={false}"));
+ assert!(output.code.contains("__lazy"));
+ assert!(output.code.contains(".name"));
+ assert!(output.code.contains(".extra"));
+ assert!(output.code.contains("error().message"));
+
+ let runtime = compile(
+ source,
+ &CompileOptions {
+ filename: Some("typecheck.tsrx".into()),
+ syntax: Syntax::Tsrx,
+ ..CompileOptions::default()
+ },
+ )
+ .expect("runtime projection");
+ for shared_semantic_read in ["indexed().name", "error().message"] {
+ assert!(
+ runtime.code.contains(shared_semantic_read),
+ "runtime and tooling must share {shared_semantic_read}: {}",
+ runtime.code
+ );
+ }
+}
+
+#[test]
+fn typecheck_helper_aliases_do_not_capture_authored_bindings_or_elements() {
+ let source = r#"const __tsrx_For0 = "taken";
+const For = (props: { children?: unknown }) => props.children;
+export function Rows({ rows }: { rows: { name: string }[] }) @{
+ <>
+ authored
+ @for (const row of rows; index index) { {row.name}:{index}
}
+ >
+}"#;
+ let output = project(source);
+
+ assert!(
+ output.code.contains("For as __tsrx_For1"),
+ "{}",
+ output.code
+ );
+ assert!(
+ output.code.contains("authored"),
+ "{}",
+ output.code
+ );
+ assert!(output.code.contains("<__tsrx_For1"), "{}", output.code);
+}
+
+#[test]
+fn projects_lazy_defaults_dynamic_tags_and_scoped_styles() {
+ let source = r#"export function Card({ model, Tag }: Props) @{
+ const &{ title = "untitled", nested: { count = 0 } } = model;
+ <>
+
+ {title}:{count}
+ <{Tag} class="card" />
+ >
+}"#;
+ let output = project(source);
+
+ assert!(output.code.contains("const __lazy0 = model"));
+ assert!(output.code.contains("=== void 0"));
+ assert!(
+ output.code.contains("<__tsrx_Dynamic0 component={Tag}"),
+ "{}",
+ output.code
+ );
+ assert!(output.code.contains("from \"@solidjs/web\""));
+ assert!(!output.code.contains("
+ "}
+ >
+}"#;
+ let output = project(source);
+ assert!(output.code.contains("> }"#,
+ r#"export function Assets() @{ <>> }"#,
+ r#"export function Assets() @{ <>> }"#,
+ ] {
+ let runtime = compile(
+ source,
+ &CompileOptions {
+ filename: Some("assets.tsrx".into()),
+ syntax: Syntax::Tsrx,
+ ..CompileOptions::default()
+ },
+ )
+ .expect("runtime projection accepts authored embed order");
+ assert!(runtime.code.contains("script"));
+
+ let tooling = project(source);
+ assert_eq!(
+ tooling
+ .embedded_regions
+ .iter()
+ .filter(|region| region.kind == TsrxEmbeddedRegionKind::Css)
+ .count(),
+ 1
+ );
+ assert_eq!(
+ tooling
+ .embedded_regions
+ .iter()
+ .filter(|region| region.kind == TsrxEmbeddedRegionKind::Script)
+ .count(),
+ source.matches("