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-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/fix-tsrx-dynamic-child-style-scoping.md b/.changeset/fix-tsrx-dynamic-child-style-scoping.md new file mode 100644 index 000000000..2557508bd --- /dev/null +++ b/.changeset/fix-tsrx-dynamic-child-style-scoping.md @@ -0,0 +1,5 @@ +--- +"@solidjs/compiler": patch +--- + +Preserve scoped selectors when a TSRX element contains dynamic children. 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/.changeset/fix-tsrx-lazy-update-rewrites.md b/.changeset/fix-tsrx-lazy-update-rewrites.md new file mode 100644 index 000000000..4811f8b1e --- /dev/null +++ b/.changeset/fix-tsrx-lazy-update-rewrites.md @@ -0,0 +1,5 @@ +--- +"@solidjs/compiler": patch +--- + +Rewrite prefix and postfix updates of TSRX lazy bindings to their deferred member targets, matching the Babel frontend. diff --git a/.changeset/improve-tsrx-lazy-destructuring.md b/.changeset/improve-tsrx-lazy-destructuring.md new file mode 100644 index 000000000..b434b6b4f --- /dev/null +++ b/.changeset/improve-tsrx-lazy-destructuring.md @@ -0,0 +1,6 @@ +--- +"@solidjs/babel-plugin": minor +"@solidjs/compiler": minor +--- + +Support recursive lazy destructuring, lazy arrow parameters, per-read defaults, computed keys, rest views, standalone assignments, accessor-backed keyed-loop and catch patterns, and JavaScript-correct writes and updates across the Babel and native TSRX frontends. diff --git a/.changeset/support-tsrx-expression-containers.md b/.changeset/support-tsrx-expression-containers.md new file mode 100644 index 000000000..1fd609cf6 --- /dev/null +++ b/.changeset/support-tsrx-expression-containers.md @@ -0,0 +1,5 @@ +--- +"@solidjs/compiler": patch +--- + +Support TSRX statement containers in expression positions in the native compiler. diff --git a/.changeset/support-tsrx-scoped-styles.md b/.changeset/support-tsrx-scoped-styles.md new file mode 100644 index 000000000..cfe0ec549 --- /dev/null +++ b/.changeset/support-tsrx-scoped-styles.md @@ -0,0 +1,6 @@ +--- +"@solidjs/babel-plugin": patch +"@solidjs/compiler": patch +--- + +Add compile-time scoped styles, CSS sidecar output, style class maps, and style refs to both TSRX frontends. diff --git a/.changeset/support-tsrx-server-function-composition.md b/.changeset/support-tsrx-server-function-composition.md new file mode 100644 index 000000000..e7f05dbd1 --- /dev/null +++ b/.changeset/support-tsrx-server-function-composition.md @@ -0,0 +1,5 @@ +--- +"@solidjs/compiler": patch +--- + +Support composing lowered `.tsrx` modules with the server-function directive transform while preserving path-stable function IDs. 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/.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..3c5e9083d --- /dev/null +++ b/documentation/tsrx/frontend-notes.md @@ -0,0 +1,243 @@ +# 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. + +## 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`. + +| 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 | +| `@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) | +| `<{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 | +| Nested/renamed/computed lazy bindings and `= default` | Deferred access chain; defaults apply only for `undefined`, evaluate lazily, and read the member once | yes | +| `&{ selected, ...rest }` | Per-read reactive `omit(__lazyN, "selected")` view | yes; the rest binding is read-only | +| `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 ` +``` + +## 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..723e03182 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,35 @@ 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`. +- Scoped ` + +

    hi

    + +}`) + ).rejects.toThrow(/only have one style tag/i); + }); + + test("return inside an @if branch is rejected", async () => { + await expect( + compile(`export function C({ ok }) @{ +
    + @if (ok) { + return

    no

    ; + } +
    +}`) + ).rejects.toThrow(/return/i); + }); + + test("@for over for-in is rejected", async () => { + await expect( + compile(`export function C({ obj }) @{ +
      + @for (const key in obj) { +
    • {key}
    • + } +
    +}`) + ).rejects.toThrow(); + }); + + test.each([ + { + name: "return escaping an @for body", + source: + "export function C({ xs }) @{
      @for (const x of xs) { return
    • {x}
    • ; }
    }", + message: /Return statements are not allowed/ + }, + { + name: "continue escaping an @for body", + source: + "export function C({ xs }) @{
      @for (const x of xs) { continue;
    • {x}
    • }
    }", + message: /Continue statements are not allowed/ + }, + { + name: "break escaping an @switch case", + source: "export const C = ({ x }) => @switch (x) { @case 1: { break;

    } };", + message: /break.*invalid/i + }, + { + name: "for-await inside a template", + source: "export function C({ xs }) @{

      @for await (const x of xs) {
    • {x}
    • }
    }", + message: /Unexpected token/ + }, + { + name: "a statement after rendered output", + source: "export function C() @{

    ; const x = 1; }", + message: /statements cannot follow the rendered output/ + }, + { + name: "multiple rendered output nodes", + source: "export function C() @{

    ; }", + message: /renders a single node/ + }, + { + name: "an @finally clause", + source: "export const C = () => @try {

    } @finally {

    };", + message: /Missing `@catch` or `@pending`/ + }, + { + name: "whitespace between @ and a statement container", + source: "export function C() @ {

    }", + message: /Unexpected token/ + }, + { + name: "whitespace between & and a lazy pattern", + source: "export function C({ x }) @{ const & { value } = x;

    {value}

    }", + message: /Unexpected token/ + } + ])("rejects $name", async ({ source, message }) => { + await expect(compile(source)).rejects.toThrow(message); + }); + + test("statement container without an output node is rejected", async () => { + await expect( + compile(`export function C() @{ + const x = 1; +}`) + ).rejects.toThrow(); + }); + + test("syntax: 'jsx' disables TSRX routing even for .tsrx filenames", async () => { + await expect( + compile( + `export function C() @{ +

    hi

    +}`, + { syntax: "jsx" } + ) + ).rejects.toThrow(); + }); + + test("syntax: 'tsrx' forces TSRX parsing for non-.tsrx filenames", async () => { + const code = await compile( + `export const C = ({ on }) => @if (on) { +

    yes

    +};`, + { filename: "case.tsx", syntax: "tsrx" } + ); + expect(code).toContain("Show"); + }); + + test("plain JSX files are untouched by the TSRX frontend", async () => { + const code = await compile(`export const C = () =>

    hi

    ;`, { + filename: "case.jsx" + }); + expect(code).toContain("_$template"); + }); +}); diff --git a/packages/babel-plugin/test/tsrx-lazy.spec.js b/packages/babel-plugin/test/tsrx-lazy.spec.js new file mode 100644 index 000000000..44bfb7e6c --- /dev/null +++ b/packages/babel-plugin/test/tsrx-lazy.spec.js @@ -0,0 +1,414 @@ +const babel = require("@babel/core"); +const plugin = require("../index"); + +function compile(code) { + return babel.transformSync(code, { + babelrc: false, + configFile: false, + filename: "lazy.tsrx", + plugins: [[plugin, { generate: "dom" }]] + }).code; +} + +function execute(code) { + return Function(`${compile(code)}\nreturn result;`)(); +} + +describe("TSRX lazy destructuring", () => { + test("defaults use undefined semantics and updates write the defaulted value", () => { + const result = execute(` + let backing; + let reads = 0; + let writes = 0; + let fallbacks = 0; + const source = { + get value() { + reads++; + return backing; + }, + set value(next) { + writes++; + backing = next; + } + }; + let &{ value = ++fallbacks } = source; + + const first = value; + const post = value++; + const pre = ++value; + value = undefined; + const compound = value += 5; + source.value = null; + const nullValue = value; + + const result = { + first, + post, + pre, + compound, + nullValue, + backing, + reads, + writes, + fallbacks + }; + `); + + expect(result).toEqual({ + first: 1, + post: 2, + pre: 4, + compound: 8, + nullValue: null, + backing: null, + reads: 5, + writes: 5, + fallbacks: 3 + }); + }); + + test("nested and computed defaults stay lazy and evaluate each member once", () => { + const result = execute(` + let keyReads = 0; + let fallbackReads = 0; + let backing; + const source = { + nested: { + get value() { + keyReads++; + return backing; + }, + set value(next) { + backing = next; + } + } + }; + let &{ + nested: &{ + ["value"]: renamed = ++fallbackReads + } + } = source; + + const first = renamed; + const post = renamed++; + const result = { + first, + post, + current: source.nested.value, + keyReads, + fallbackReads + }; + `); + + expect(result).toEqual({ + first: 1, + post: 2, + current: 3, + keyReads: 3, + fallbackReads: 2 + }); + }); + + test("array rest creates a fresh Array.from slice for every read", () => { + const result = execute(` + const arrayLike = { 0: "a", 1: "b", 2: "c", length: 3 }; + let &[head, ...tail] = arrayLike; + + let iterations = 0; + const iterable = { + *[Symbol.iterator]() { + iterations++; + yield 1; + yield 2; + yield 3; + } + }; + let &[first, ...remaining] = iterable; + + const result = { + head, + tailA: tail, + tailB: tail, + first, + remainingA: remaining, + remainingB: remaining, + iterations + }; + `); + + expect(result).toEqual({ + head: "a", + tailA: ["b", "c"], + tailB: ["b", "c"], + // Non-rest lazy array elements preserve TSRX's indexed-read semantics; + // only the rest view consumes generic iterables. + first: undefined, + remainingA: [2, 3], + remainingB: [2, 3], + iterations: 2 + }); + expect(result.tailA).not.toBe(result.tailB); + expect(result.remainingA).not.toBe(result.remainingB); + }); + + test("object rest lowers to a collision-safe reactive omit call", () => { + const output = compile(` + const __lazy0 = 0; + const __lazyOmit0 = 0; + const source = { selected: 1, other: 2 }; + let &{ selected, ...rest } = source; + const result = [selected, rest]; + `); + + expect(output).toContain('import { omit as __lazyOmit1 } from "solid-js"'); + expect(output).toContain("let __lazy1 = source"); + expect(output).toContain('__lazyOmit1(__lazy1, "selected")'); + }); + + test("function names and parameters shadow outer lazy bindings", () => { + const result = execute(` + const source = { value: 42 }; + let &{ value } = source; + const named = function value(param = value) { + return param; + }; + const parameter = (value = value) => value; + let parameterThrew = false; + try { + parameter(); + } catch (error) { + parameterThrew = error instanceof ReferenceError; + } + const result = { + namedUsesItself: named() === named, + parameterThrew, + outer: value + }; + `); + + expect(result).toEqual({ + namedUsesItself: true, + parameterThrew: true, + outer: 42 + }); + }); + + test("var lazy bindings remain visible across their function and program scope", () => { + const result = execute(` + const programSource = { programValue: "program" }; + if (true) { + var &{ programValue } = programSource; + } + + function fromBlock(source) { + if (true) { + var &{ value } = source; + } + return value; + } + + function fromLoop(source) { + for (var &{ value } = source; value < 3; value++) {} + return value; + } + + const result = { + programValue, + block: fromBlock({ value: 2 }), + loop: fromLoop({ value: 0 }) + }; + `); + + expect(result).toEqual({ + programValue: "program", + block: 2, + loop: 3 + }); + }); + + test("var collection stops at nested function, class, and static-block boundaries", () => { + const result = execute(` + const outerSource = { value: "outer" }; + let &{ value } = outerSource; + + function nested() { + if (true) { + var &{ value } = { value: "function" }; + } + return value; + } + + class Holder { + static before = value; + static { + if (true) { + var &{ value } = { value: "static" }; + } + this.inside = value; + } + static after = value; + } + + const result = { + outer: value, + nested: nested(), + before: Holder.before, + inside: Holder.inside, + after: Holder.after + }; + `); + + expect(result).toEqual({ + outer: "outer", + nested: "function", + before: "outer", + inside: "static", + after: "outer" + }); + }); + + test("loop scopes preserve iteration, shadowing, and post-loop var visibility", () => { + const result = execute(` + const outerSource = { value: 10 }; + let &{ value } = outerSource; + + const classic = { value: 0 }; + const lexicalSeen = []; + for (let &{ value } = classic; value < 2; value++) { + lexicalSeen.push(value); + } + + const iterationSeen = []; + for (const &{ value } of [{ value: 3 }, { value: 4 }]) { + iterationSeen.push(value); + } + + const varSeen = []; + for (var &{ item } of [{ item: "a" }, { item: "b" }]) { + varSeen.push(item); + } + + const result = { + lexicalSeen, + classicValue: classic.value, + iterationSeen, + outerAfterLoops: value, + varSeen, + itemAfterLoop: item + }; + `); + + expect(result).toEqual({ + lexicalSeen: [0, 1], + classicValue: 2, + iterationSeen: [3, 4], + outerAfterLoops: 10, + varSeen: ["a", "b"], + itemAfterLoop: "b" + }); + }); + + test("for-of lexical lazy bindings are in the RHS temporal dead zone", () => { + expect(() => + execute(` + const outerSource = { value: 10 }; + let &{ value } = outerSource; + for (const &{ value } of (value, [])) {} + const result = value; + `) + ).toThrow(ReferenceError); + }); + + test("defaulted component names lower through Dynamic", () => { + const output = compile(` + function View(&{ Component = "div" }) @{ + + } + `); + + expect(output).toContain("Dynamic"); + expect(output).toContain("get component()"); + expect(output).toContain("=== void 0"); + }); + + test("embedded defaults and computed keys recursively read lazy bindings", () => { + const result = execute(` + const source = {}; + let &{ a = 1, b = a } = source; + + let keyReads = 0; + const outer = { + get key() { + keyReads++; + return "target"; + } + }; + let &{ key } = outer; + const computed = { target: undefined }; + let &{ [key]: value = key } = computed; + + const result = { + a, + b, + first: value, + second: value, + keyReads + }; + `); + + expect(result).toEqual({ + a: 1, + b: 1, + first: "target", + second: "target", + keyReads: 4 + }); + }); + + test("export-wrapped globalThis bindings reject lazy array rest", () => { + expect(() => + compile(` + export const globalThis = {}; + const source = { 0: "a", 1: "b", length: 2 }; + let &[head, ...tail] = source; + `) + ).toThrow(/cannot safely access the intrinsic Array/i); + }); + + test.each([ + ` + const source = { value: 1 }; + const &{ value } = source; + export { value }; + `, + ` + const source = { value: 1 }; + export const &{ value } = source; + ` + ])("rejects exporting lazy bindings", source => { + expect(() => compile(source)).toThrow(/lazy bindings cannot be exported/i); + }); + + test.each(["value = 1", "value += 1", "value++", "++value"])( + "writes below an ancestor default fail: %s", + write => { + expect(() => + compile(` + const source = {}; + let &{ nested: { value } = {} } = source; + ${write}; + `) + ).toThrow(/nested beneath an ancestor default is read-only/i); + } + ); + + test("writes to rest bindings fail with a focused diagnostic", () => { + expect(() => + compile(` + const source = { selected: 1, other: 2 }; + let &{ selected, ...rest } = source; + rest = {}; + `) + ).toThrow(/lazy rest binding is read-only/i); + }); +}); diff --git a/packages/babel-plugin/test/tsrx-ssr.spec.js b/packages/babel-plugin/test/tsrx-ssr.spec.js new file mode 100644 index 000000000..53a6f967e --- /dev/null +++ b/packages/babel-plugin/test/tsrx-ssr.spec.js @@ -0,0 +1,17 @@ +const path = require("path"); +const { runFixtures } = require("./fixtures"); +const plugin = require("../index"); + +runFixtures({ + plugin, + pluginOptions: { + moduleName: "r-server", + builtIns: ["For", "Show", "Switch", "Match", "Errored", "Loading", "Dynamic"], + generate: "ssr", + wrapConditionals: true, + contextToCustomElements: true, + requireImportSource: false + }, + title: "Convert TSRX", + fixtures: path.join(__dirname, "__tsrx_ssr_fixtures__") +}); diff --git a/packages/babel-plugin/test/tsrx-style.spec.js b/packages/babel-plugin/test/tsrx-style.spec.js new file mode 100644 index 000000000..52b7a5e6b --- /dev/null +++ b/packages/babel-plugin/test/tsrx-style.spec.js @@ -0,0 +1,279 @@ +const path = require("path"); +const babel = require("@babel/core"); +const tsrx = require("@tsrx/core"); +const plugin = require("../index"); + +const pluginOptions = { + moduleName: "r-dom", + builtIns: ["For", "Show", "Switch", "Match", "Errored", "Loading", "Dynamic"], + generate: "dom" +}; + +async function compile(source, filename = "style-case.tsrx", options = {}) { + return babel.transformAsync(source, { + babelrc: false, + configFile: false, + plugins: [[plugin, { ...pluginOptions, ...options }]], + filename + }); +} + +describe("TSRX scoped styles", () => { + test("exposes core-rendered CSS metadata and scopes host and dynamic tags", async () => { + const result = await compile(`export function C({ value, Tag }) @{ + <> + +
    + + + <{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 source = `export const C = () => <> + +
    +;`; + 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(" { + const [styled, plain] = await Promise.all([ + compile(`export const C = () => <>
    ;`), + 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(".top-level-marker { color: red; }` + }, + { + 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/babel-plugin/test/tsrx-universal.spec.js b/packages/babel-plugin/test/tsrx-universal.spec.js new file mode 100644 index 000000000..f96227c51 --- /dev/null +++ b/packages/babel-plugin/test/tsrx-universal.spec.js @@ -0,0 +1,14 @@ +const path = require("path"); +const { runFixtures } = require("./fixtures"); +const plugin = require("../index"); + +runFixtures({ + plugin, + pluginOptions: { + moduleName: "r-custom", + builtIns: ["For", "Show", "Switch", "Match", "Errored", "Loading", "Dynamic"], + generate: "universal" + }, + title: "Convert TSRX", + fixtures: path.join(__dirname, "__tsrx_universal_fixtures__") +}); diff --git a/packages/compiler/Cargo.lock b/packages/compiler/Cargo.lock index 93049d2c9..45f3fb7a1 100644 --- a/packages/compiler/Cargo.lock +++ b/packages/compiler/Cargo.lock @@ -29,6 +29,9 @@ name = "bitflags" version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +dependencies = [ + "serde_core", +] [[package]] name = "bytecount" @@ -51,6 +54,21 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "compact_str" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "serde", + "static_assertions", +] + [[package]] name = "compact_str" version = "0.10.0" @@ -420,6 +438,22 @@ version = "4.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d211803b9b6b570f68772237e415a029d5a50c65d382910b879fb19d3271f94d" +[[package]] +name = "oxc-miette" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e0df30faa68797917ca4263e7a2f889ec829e4da2dcb3d6dc752f7a494180f3" +dependencies = [ + "cfg-if", + "memchr", + "owo-colors", + "oxc-miette-derive", + "textwrap", + "thiserror", + "unicode-segmentation", + "unicode-width", +] + [[package]] name = "oxc-miette" version = "4.0.0" @@ -434,6 +468,50 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "oxc-miette-derive" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acc072d11d45ebe7801459b4e829184ba0934d68027fdc51d327335b53a95a49" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "oxc_adapter" +version = "0.7.0" +source = "git+https://github.com/tsrx-org/oxc?rev=135653a23aab15231557112548812275baba8727#135653a23aab15231557112548812275baba8727" +dependencies = [ + "oxc-miette 3.0.1", + "oxc_allocator 0.140.0", + "oxc_ast 0.140.0", + "oxc_ast_visit 0.140.0", + "oxc_data_structures 0.140.0", + "oxc_diagnostics 0.140.0", + "oxc_estree 0.140.0", + "oxc_parser 0.140.0", + "oxc_semantic 0.140.0", + "oxc_span 0.140.0", + "oxc_syntax 0.140.0", + "rustc-hash", + "tsrx_tape_schema", +] + +[[package]] +name = "oxc_allocator" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "allocator-api2", + "hashbrown", + "oxc_data_structures 0.140.0", + "oxc_estree 0.140.0", + "rustc-hash", + "serde", +] + [[package]] name = "oxc_allocator" version = "0.144.0" @@ -442,10 +520,27 @@ checksum = "9262c7bbd58c8c32c2c60d1a7b6c274cd2aeaff1b726a2fb86081b75084698cb" dependencies = [ "allocator-api2", "hashbrown", - "oxc_data_structures", + "oxc_data_structures 0.144.0", "rustc-hash", ] +[[package]] +name = "oxc_ast" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "bitflags", + "oxc_allocator 0.140.0", + "oxc_ast_macros 0.140.0", + "oxc_data_structures 0.140.0", + "oxc_diagnostics 0.140.0", + "oxc_estree 0.140.0", + "oxc_regular_expression 0.140.0", + "oxc_span 0.140.0", + "oxc_str 0.140.0", + "oxc_syntax 0.140.0", +] + [[package]] name = "oxc_ast" version = "0.144.0" @@ -453,15 +548,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8134809b665d524d9a0ddeee87a5d3602776aa2410a10a0439fd25eeb594a5d" dependencies = [ "bitflags", - "oxc_allocator", - "oxc_ast_macros", - "oxc_data_structures", - "oxc_diagnostics", - "oxc_estree", - "oxc_regular_expression", - "oxc_span", - "oxc_str", - "oxc_syntax", + "oxc_allocator 0.144.0", + "oxc_ast_macros 0.144.0", + "oxc_data_structures 0.144.0", + "oxc_diagnostics 0.144.0", + "oxc_estree 0.144.0", + "oxc_regular_expression 0.144.0", + "oxc_span 0.144.0", + "oxc_str 0.144.0", + "oxc_syntax 0.144.0", +] + +[[package]] +name = "oxc_ast_macros" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "phf 0.14.0", + "proc-macro2", + "quote", + "syn 2.0.119", ] [[package]] @@ -476,16 +582,27 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "oxc_ast_visit" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "oxc_allocator 0.140.0", + "oxc_ast 0.140.0", + "oxc_span 0.140.0", + "oxc_syntax 0.140.0", +] + [[package]] name = "oxc_ast_visit" version = "0.144.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4a57626cd6ef87f5173110a301868e3c438fd8e0576f489d9a61540ef133f686" dependencies = [ - "oxc_allocator", - "oxc_ast", - "oxc_span", - "oxc_syntax", + "oxc_allocator 0.144.0", + "oxc_ast 0.144.0", + "oxc_span 0.144.0", + "oxc_syntax 0.144.0", ] [[package]] @@ -496,25 +613,40 @@ checksum = "5f8a30cbe510caef092aa75fe29e8d37576ca8eb2ab3e9ab34a453a92863e7f0" dependencies = [ "bitflags", "cow-utils", - "oxc_allocator", - "oxc_ast", - "oxc_data_structures", - "oxc_ecmascript", + "oxc_allocator 0.144.0", + "oxc_ast 0.144.0", + "oxc_data_structures 0.144.0", + "oxc_ecmascript 0.144.0", "oxc_index", - "oxc_semantic", + "oxc_semantic 0.144.0", "oxc_sourcemap", - "oxc_span", - "oxc_str", - "oxc_syntax", + "oxc_span 0.144.0", + "oxc_str 0.144.0", + "oxc_syntax 0.144.0", "rustc-hash", ] +[[package]] +name = "oxc_data_structures" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" + [[package]] name = "oxc_data_structures" version = "0.144.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a71efea56db93d3ce9e88e1d9f74f6d50fc2b06092444cdd08579f5827fd8db" +[[package]] +name = "oxc_diagnostics" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "cow-utils", + "oxc-miette 3.0.1", + "percent-encoding", +] + [[package]] name = "oxc_diagnostics" version = "0.144.0" @@ -523,11 +655,27 @@ checksum = "e5ff8638533094ecdcd38a63930c003c261d81650e4531c3bc805c797aec2a52" dependencies = [ "cow-utils", "memchr", - "oxc-miette", + "oxc-miette 4.0.0", "percent-encoding", "smallvec", ] +[[package]] +name = "oxc_ecmascript" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "cow-utils", + "num-bigint", + "num-traits", + "oxc_allocator 0.140.0", + "oxc_ast 0.140.0", + "oxc_regular_expression 0.140.0", + "oxc_span 0.140.0", + "oxc_syntax 0.140.0", + "smallvec", +] + [[package]] name = "oxc_ecmascript" version = "0.144.0" @@ -538,10 +686,20 @@ dependencies = [ "itoa", "num-bigint", "num-traits", - "oxc_ast", - "oxc_data_structures", - "oxc_span", - "oxc_syntax", + "oxc_ast 0.144.0", + "oxc_data_structures 0.144.0", + "oxc_span 0.144.0", + "oxc_syntax 0.144.0", +] + +[[package]] +name = "oxc_estree" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "dragonbox_ecma", + "itoa", + "oxc_data_structures 0.140.0", ] [[package]] @@ -560,6 +718,29 @@ dependencies = [ "serde", ] +[[package]] +name = "oxc_parser" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "bitflags", + "cow-utils", + "memchr", + "num-bigint", + "num-traits", + "oxc_allocator 0.140.0", + "oxc_ast 0.140.0", + "oxc_data_structures 0.140.0", + "oxc_diagnostics 0.140.0", + "oxc_ecmascript 0.140.0", + "oxc_regular_expression 0.140.0", + "oxc_span 0.140.0", + "oxc_str 0.140.0", + "oxc_syntax 0.140.0", + "rustc-hash", + "seq-macro", +] + [[package]] name = "oxc_parser" version = "0.144.0" @@ -571,19 +752,35 @@ dependencies = [ "memchr", "num-bigint", "num-traits", - "oxc_allocator", - "oxc_ast", - "oxc_data_structures", - "oxc_diagnostics", - "oxc_ecmascript", - "oxc_regular_expression", - "oxc_span", - "oxc_str", - "oxc_syntax", + "oxc_allocator 0.144.0", + "oxc_ast 0.144.0", + "oxc_data_structures 0.144.0", + "oxc_diagnostics 0.144.0", + "oxc_ecmascript 0.144.0", + "oxc_regular_expression 0.144.0", + "oxc_span 0.144.0", + "oxc_str 0.144.0", + "oxc_syntax 0.144.0", "rustc-hash", "seq-macro", ] +[[package]] +name = "oxc_regular_expression" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "bitflags", + "oxc_allocator 0.140.0", + "oxc_ast_macros 0.140.0", + "oxc_diagnostics 0.140.0", + "oxc_span 0.140.0", + "oxc_str 0.140.0", + "phf 0.14.0", + "rustc-hash", + "unicode-id-start", +] + [[package]] name = "oxc_regular_expression" version = "0.144.0" @@ -591,16 +788,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c375b89d88ec03301b0e62528715699551871861197aa39148e95a1422e1ae9b" dependencies = [ "bitflags", - "oxc_allocator", - "oxc_ast_macros", - "oxc_diagnostics", - "oxc_span", - "oxc_str", + "oxc_allocator 0.144.0", + "oxc_ast_macros 0.144.0", + "oxc_diagnostics 0.144.0", + "oxc_span 0.144.0", + "oxc_str 0.144.0", "phf 0.14.0", "rustc-hash", "unicode-id-start", ] +[[package]] +name = "oxc_semantic" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "itertools", + "memchr", + "oxc_allocator 0.140.0", + "oxc_ast 0.140.0", + "oxc_ast_visit 0.140.0", + "oxc_data_structures 0.140.0", + "oxc_diagnostics 0.140.0", + "oxc_ecmascript 0.140.0", + "oxc_index", + "oxc_span 0.140.0", + "oxc_str 0.140.0", + "oxc_syntax 0.140.0", + "rustc-hash", + "self_cell", + "smallvec", +] + [[package]] name = "oxc_semantic" version = "0.144.0" @@ -609,16 +828,16 @@ checksum = "0d8d80604c4220c8f5b1c19682216921397fb45a616db47d3a044474662f0c2e" dependencies = [ "itertools", "memchr", - "oxc_allocator", - "oxc_ast", - "oxc_ast_visit", - "oxc_data_structures", - "oxc_diagnostics", - "oxc_ecmascript", + "oxc_allocator 0.144.0", + "oxc_ast 0.144.0", + "oxc_ast_visit 0.144.0", + "oxc_data_structures 0.144.0", + "oxc_diagnostics 0.144.0", + "oxc_ecmascript 0.144.0", "oxc_index", - "oxc_span", - "oxc_str", - "oxc_syntax", + "oxc_span 0.144.0", + "oxc_str 0.144.0", + "oxc_syntax 0.144.0", "rustc-hash", "self_cell", "smallvec", @@ -637,18 +856,44 @@ dependencies = [ "serde_json", ] +[[package]] +name = "oxc_span" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "compact_str 0.9.1", + "oxc-miette 3.0.1", + "oxc_allocator 0.140.0", + "oxc_ast_macros 0.140.0", + "oxc_estree 0.140.0", + "oxc_str 0.140.0", + "serde", +] + [[package]] name = "oxc_span" version = "0.144.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abd732008dbfb09984106925f7ff7268687c8020b89bd7ee0f0b695c5bb07950" dependencies = [ - "compact_str", - "oxc-miette", - "oxc_allocator", - "oxc_ast_macros", - "oxc_estree", - "oxc_str", + "compact_str 0.10.0", + "oxc-miette 4.0.0", + "oxc_allocator 0.144.0", + "oxc_ast_macros 0.144.0", + "oxc_estree 0.144.0", + "oxc_str 0.144.0", +] + +[[package]] +name = "oxc_str" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "compact_str 0.9.1", + "hashbrown", + "oxc_allocator 0.140.0", + "oxc_estree 0.140.0", + "serde", ] [[package]] @@ -657,10 +902,30 @@ version = "0.144.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e1d2ccb0f5aa4da9fc98ef70f59553c2779e28e064a8b271fcb1e88c11a63607" dependencies = [ - "compact_str", + "compact_str 0.10.0", "hashbrown", - "oxc_allocator", - "oxc_estree", + "oxc_allocator 0.144.0", + "oxc_estree 0.144.0", +] + +[[package]] +name = "oxc_syntax" +version = "0.140.0" +source = "git+https://github.com/oxc-project/oxc?rev=8e0ed2ebb96137fb1611cdbd5742d5cb46037d40#8e0ed2ebb96137fb1611cdbd5742d5cb46037d40" +dependencies = [ + "bitflags", + "cow-utils", + "dragonbox_ecma", + "nonmax", + "oxc_allocator 0.140.0", + "oxc_ast_macros 0.140.0", + "oxc_estree 0.140.0", + "oxc_index", + "oxc_span 0.140.0", + "oxc_str 0.140.0", + "phf 0.14.0", + "serde", + "unicode-id-start", ] [[package]] @@ -673,12 +938,12 @@ dependencies = [ "cow-utils", "dragonbox_ecma", "nonmax", - "oxc_allocator", - "oxc_ast_macros", - "oxc_estree", + "oxc_allocator 0.144.0", + "oxc_ast_macros 0.144.0", + "oxc_estree 0.144.0", "oxc_index", - "oxc_span", - "oxc_str", + "oxc_span 0.144.0", + "oxc_str 0.144.0", "phf 0.14.0", "unicode-id-start", ] @@ -851,6 +1116,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -955,15 +1226,18 @@ dependencies = [ "napi", "napi-build", "napi-derive", - "oxc_allocator", - "oxc_ast", - "oxc_ast_visit", + "oxc_allocator 0.144.0", + "oxc_ast 0.144.0", + "oxc_ast_visit 0.144.0", "oxc_codegen", - "oxc_parser", - "oxc_semantic", - "oxc_span", - "oxc_str", - "oxc_syntax", + "oxc_parser 0.144.0", + "oxc_semantic 0.144.0", + "oxc_sourcemap", + "oxc_span 0.144.0", + "oxc_str 0.144.0", + "oxc_syntax 0.144.0", + "tsrx_parser_engine", + "tsrx_tape_schema", ] [[package]] @@ -1039,6 +1313,51 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "thiserror" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tsrx_parser_engine" +version = "0.7.0" +source = "git+https://github.com/tsrx-org/oxc?rev=135653a23aab15231557112548812275baba8727#135653a23aab15231557112548812275baba8727" +dependencies = [ + "oxc_adapter", + "tsrx_syntax", + "tsrx_tape_schema", + "unicode-segmentation", + "unicode-width", +] + +[[package]] +name = "tsrx_syntax" +version = "0.7.0" +source = "git+https://github.com/tsrx-org/oxc?rev=135653a23aab15231557112548812275baba8727#135653a23aab15231557112548812275baba8727" +dependencies = [ + "unicode-id-start", +] + +[[package]] +name = "tsrx_tape_schema" +version = "0.7.0" +source = "git+https://github.com/tsrx-org/oxc?rev=135653a23aab15231557112548812275baba8727#135653a23aab15231557112548812275baba8727" + [[package]] name = "unicode-id-start" version = "1.4.0" diff --git a/packages/compiler/Cargo.toml b/packages/compiler/Cargo.toml index b1482e153..ea299a049 100644 --- a/packages/compiler/Cargo.toml +++ b/packages/compiler/Cargo.toml @@ -9,8 +9,13 @@ publish = false crate-type = ["cdylib", "rlib"] [features] -default = ["node"] +default = ["node", "tsrx"] node = ["dep:napi", "dep:napi-derive"] +# TSRX syntax frontend. `tsrx_parser_engine` (community oxc-tsrx project, pinned +# rev) is the only TSRX grammar authority on the Rust side; it carries its own +# git-pinned oxc (0.140) which coexists with our crates.io oxc — no oxc types +# cross the boundary (the parse result is a revision-neutral FlatTape). +tsrx = ["dep:oxc_sourcemap", "dep:tsrx_parser_engine", "dep:tsrx_tape_schema"] [dependencies] html5ever = "0.39.0" @@ -24,9 +29,12 @@ oxc_ast_visit = "0.144" oxc_codegen = "0.144" oxc_parser = "0.144" oxc_semantic = "0.144" +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 = "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 1d82cd776..f0b6935c8 100644 --- a/packages/compiler/README.md +++ b/packages/compiler/README.md @@ -78,14 +78,38 @@ const result = transform(source, { }); ``` +### TSRX (experimental) + +TSRX (TypeScript Render Extensions) is a syntax for declarative UI whose constructs (`@if`/`@else`, `@for … @empty`, `@switch`/`@case`, `@try`/`@catch`/`@pending`, `@{}` statement containers, lazy destructuring `&{ }` / `&[ ]`) desugar to the Solid control-flow components. `.tsrx` filenames route through the TSRX frontend automatically and compile to the same output as `@solidjs/babel-plugin`'s TSRX support, byte for byte. + +```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(" { + const result = compareStyleMetadata( + `export function Card(props: { label: string }) @{ + <> + +

    {props.label}
    + +}`, + "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 = ; +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 emits compiled 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 + }); + const sourceMap = JSON.parse(mapped.map); + expect(sourceMap.sources).toEqual([filename]); + expect(sourceMap.sourcesContent).toEqual([source]); + expect( + transform("export const view =
    ;", { + ...options, + filename: "mapped.tsx", + sourceMap: true + }).map + ).not.toBeNull(); + }); +}); 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 2a9d41760..4f0a52b68 100644 --- a/packages/compiler/index.js +++ b/packages/compiler/index.js @@ -12,16 +12,59 @@ 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) { 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"); @@ -405,6 +448,7 @@ function isMissingPackage(error, packageName) { module.exports = { transform, transformAsync, + projectTsrxForTypecheck, transformDirectives, transformDirectivesAsync, transformLazy, diff --git a/packages/compiler/package.json b/packages/compiler/package.json index aaa435532..a60677785 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -30,7 +30,7 @@ "bench": "pnpm run build && node scripts/bench.mjs", "lint": "cargo clippy --manifest-path ./Cargo.toml -- -D warnings", "test": "pnpm run test:rust && pnpm run build:debug && vitest run", - "test:rust": "cargo test --manifest-path ./Cargo.toml && cargo test --manifest-path ./Cargo.toml --no-default-features", + "test:rust": "cargo test --manifest-path ./Cargo.toml && cargo test --manifest-path ./Cargo.toml --no-default-features && cargo test --manifest-path ./Cargo.toml --no-default-features --features tsrx", "artifacts": "napi artifacts", "napi:version": "napi version && node ./sync-optional-deps.mjs", "create-npm-dirs": "napi create-npm-dirs" diff --git a/packages/compiler/src/compiler.rs b/packages/compiler/src/compiler.rs index 56f006a26..3b55f327c 100644 --- a/packages/compiler/src/compiler.rs +++ b/packages/compiler/src/compiler.rs @@ -21,6 +21,19 @@ pub enum Generate { Dynamic, } +/// Source syntax selection, mirroring the Babel plugin's `syntax` option. +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum Syntax { + /// Route `.tsrx` filenames through the TSRX frontend, everything else + /// through standard JSX. + #[default] + Auto, + /// Never use the TSRX frontend. + Jsx, + /// Force the TSRX frontend for every file. + Tsrx, +} + /// A wrapper import setting without any Node-API representation in its interface. #[derive(Clone, Debug, Default, Eq, PartialEq)] pub enum Wrapper { @@ -62,6 +75,9 @@ pub(crate) fn default_built_ins() -> Vec { #[derive(Clone, Debug, Eq, PartialEq)] pub struct CompileOptions { pub filename: Option, + /// Source syntax routing (Babel's `syntax`): `Auto` sends `.tsrx` + /// filenames through the TSRX frontend (requires the `tsrx` feature). + pub syntax: Syntax, pub module_name: String, pub generate: Generate, pub hydratable: bool, @@ -92,6 +108,7 @@ impl Default for CompileOptions { fn default() -> Self { Self { filename: None, + syntax: Syntax::default(), module_name: DEFAULT_MODULE_NAME.into(), generate: Generate::Dom, hydratable: false, @@ -124,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. @@ -150,33 +171,82 @@ pub(crate) fn compile_for_node_adapter( } fn compile_inner(source: &str, options: &CompileOptions) -> Result { - let source_type = source_type_for_filename(options.filename.as_deref())?; - let allocator = Allocator::default(); - // 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(); + let authored_source = source; + let tsrx_route = match options.syntax { + Syntax::Jsx => false, + Syntax::Tsrx => true, + Syntax::Auto => options + .filename + .as_deref() + .is_some_and(|filename| filename.ends_with(".tsrx")), + }; - if let Some(error) = crate::shared::parser::first_parser_error(parsed.diagnostics) { - return Err(CompileError::parse(error)); + #[cfg(not(feature = "tsrx"))] + if tsrx_route { + return Err(CompileError::configuration( + "TSRX sources require a @solidjs/compiler build with the `tsrx` feature", + )); } + // The projection owns the text the parser borrows; it must outlive the + // allocator-tied AST, so it is declared before the allocator. + #[cfg(feature = "tsrx")] + let projection = if tsrx_route { + Some(crate::tsrx::run_frontend( + source, + options.filename.as_deref(), + options.source_map, + )?) + } else { + None + }; + #[cfg(feature = "tsrx")] + let source: &str = projection + .as_ref() + .map_or(source, |projection| projection.text.as_str()); + + let source_type = if tsrx_route { + // The projected text is plain TSX regardless of the authored filename. + SourceType::tsx() + } else { + source_type_for_filename(options.filename.as_deref())? + }; + let allocator = Allocator::default(); + #[cfg(feature = "tsrx")] + let mut program = if let Some(projection) = projection.as_ref() { + crate::tsrx::parse_projected_tsx(&allocator, projection)? + } else { + parse_program(&allocator, source, source_type)? + }; + #[cfg(not(feature = "tsrx"))] + let mut program = parse_program(&allocator, source, source_type)?; + if let Some(lib) = options.require_import_source.as_deref() - && !has_jsx_import_source(&parsed.program, source, lib) + && !has_jsx_import_source(&program, source, lib) { + #[cfg(feature = "tsrx")] + let (css, css_hash) = projection.as_ref().map_or((None, None), |projection| { + (Some(projection.css.clone()), projection.css_hash.clone()) + }); + #[cfg(not(feature = "tsrx"))] + let (css, css_hash) = (None, None); return Ok(CompileOutput { - code: source.to_string(), + // Babel's requireImportSource gate skips the transform, so callers + // receive exactly what they authored rather than the internal TSRX + // projection. Style metadata was already extracted and remains + // available to pipeline integrations. + code: authored_source.to_string(), source_map: None, + css, + css_hash, }); } - let mut program = parsed.program; + #[cfg(feature = "tsrx")] + if let Some(projection) = projection.as_ref() { + crate::tsrx::apply_rewrites(&allocator, &mut program, projection, options.source_map)?; + } + match options.generate { Generate::Dom => { let mut transform = AstDomTransform::new( @@ -264,18 +334,69 @@ fn compile_inner(source: &str, options: &CompileOptions) -> 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, @@ -407,4 +528,321 @@ mod tests { let configuration = compile("const view =
    ;", &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(".foo { color:red } div { color:blue };", + "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(""))); + assert!(output.code.contains(&format!(""))); + + let for_only = compile_tsrx( + "export const view = ({ items }) => @for (const item of items) { <> };", + "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_while_compiled_tsrx_emits_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(); + 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 =
    ;", + &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 402474ad9..872d56671 100644 --- a/packages/compiler/src/config.rs +++ b/packages/compiler/src/config.rs @@ -14,6 +14,10 @@ pub struct RendererOption { #[derive(Default)] pub struct TransformOptions { pub filename: Option, + /// Source syntax routing, matching `@solidjs/babel-plugin`'s `syntax`: + /// `"auto"` (default) compiles `.tsrx` filenames with the TSRX frontend, + /// `"jsx"` never does, `"tsrx"` forces TSRX for every file. + pub syntax: Option, /// Runtime module compiled output imports helpers from. /// Default `"@solidjs/web"`. pub module_name: Option, @@ -64,9 +68,18 @@ 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 { + if filename.is_some_and(|filename| filename.ends_with(".tsrx")) { + // Secondary passes receive already-projected ordinary code but retain + // the authored filename for stable path-derived metadata. + return Ok(SourceType::tsx()); + } filename .map(SourceType::from_path) .transpose() diff --git a/packages/compiler/src/dom/children.rs b/packages/compiler/src/dom/children.rs index 10fe2ef6e..5ff9decf2 100644 --- a/packages/compiler/src/dom/children.rs +++ b/packages/compiler/src/dom/children.rs @@ -111,10 +111,15 @@ impl<'a> AstDomTransform<'a, '_> { // (`detectExpressions`), even if nothing ends up // referencing it. Nodes without ids don't consume // a walk position (Babel's `i` counts ids only). - if filtered_index(child).is_some_and(|position| { - self.detect_expressions(&filtered, position) - }) { - let name = self.next_element_id(); + // A preceding dynamic slot may have already + // claimed this node's id as its insert marker. + let claimed = self.pending_child_walk.take(); + if claimed.is_some() + || filtered_index(child).is_some_and(|position| { + self.detect_expressions(&filtered, position) + }) + { + let name = claimed.unwrap_or_else(|| self.next_element_id()); let lookup = self.child_walk_expression( element.span, element_id, @@ -178,11 +183,14 @@ impl<'a> AstDomTransform<'a, '_> { // position; the walk is emitted even though unused. // In dev hydratable mode the walk validates the tag // (`getFirstChild`/`getNextSibling`). - if filtered_index(&element.children[index]) - .is_some_and(|position| self.detect_expressions(&filtered, position)) + let claimed = self.pending_child_walk.take(); + if claimed.is_some() + || filtered_index(&element.children[index]).is_some_and(|position| { + self.detect_expressions(&filtered, position) + }) { let child_tag = element_name(&child.opening_element.name)?; - let name = self.next_element_id(); + let name = claimed.unwrap_or_else(|| self.next_element_id()); let lookup = self.child_element_expression( child.span, element_id, @@ -230,10 +238,13 @@ impl<'a> AstDomTransform<'a, '_> { if let Some(value) = self.static_jsx_expression_value(&container.expression) { template.push_both(&escape_html_text_expression(&value)); if !in_text_run { - if filtered_index(child).is_some_and(|position| { - self.detect_expressions(&filtered, position) - }) { - let name = self.next_element_id(); + let claimed = self.pending_child_walk.take(); + if claimed.is_some() + || filtered_index(child).is_some_and(|position| { + self.detect_expressions(&filtered, position) + }) + { + let name = claimed.unwrap_or_else(|| self.next_element_id()); let lookup = self.child_walk_expression( element.span, element_id, @@ -614,9 +625,11 @@ impl<'a> AstDomTransform<'a, '_> { initial: None, }); } - if has_following_static_content(&children[following_start..]) { + // Babel's `nextChild(childNodes, index)`: the marker is the first + // following sibling's positional id when one will exist. + if let Some(marker) = self.claim_following_walk(children, following_start, span) { return Some(InsertMarker { - marker: self.child_walk_expression(span, element_id, *child_node_index), + marker, initial: None, }); } @@ -690,10 +703,12 @@ impl<'a> AstDomTransform<'a, '_> { template: &mut crate::dom::template::TemplateHtml, declarations: &mut std::vec::Vec>, ) -> Expression<'a> { + // Babel: `exprId = childNodes[index + 1].id` — ride the immediately + // following sibling's positional id when it will exist. if !self.slot_boxed_by_text(children, index) - && self.next_child_is_template_node(children, index) + && let Some(marker) = self.claim_following_walk(children, index + 1, span) { - return self.child_walk_expression(span, element_id, *child_node_index); + return marker; } self.dedicated_slot_placeholder(span, element_id, child_node_index, template, declarations) } @@ -758,12 +773,23 @@ impl<'a> AstDomTransform<'a, '_> { false } - /// Whether the immediately following retained child contributes a template - /// node (non-empty text, static expression, or native element) that can - /// serve as this slot's marker. - fn next_child_is_template_node(&self, children: &[JSXChild<'a>], index: usize) -> bool { - for child in &children[index + 1..] { - return match child { + /// Babel rides the immediately following sibling's positional id as the + /// slot's marker (`childNodes[index + 1].id` per-slot, `nextChild` in the + /// multi branch) — the id its own transform declares. When the first + /// retained child at/after `start` is a template node (non-empty text, + /// static expression, native element) that will receive a positional id + /// (its lowering guard mirrors `detectExpressions`), allocate that name + /// now, park it in `pending_child_walk` for the child's lowering to + /// declare, and return a reference to it. + fn claim_following_walk( + &mut self, + children: &[JSXChild<'a>], + start: usize, + span: oxc_span::Span, + ) -> Option> { + let mut following: Option<&JSXChild<'a>> = None; + for child in &children[start..] { + let is_template_node = match child { JSXChild::Text(text) => { if trim_jsx_text(&text.value).is_empty() { continue; @@ -780,8 +806,35 @@ impl<'a> AstDomTransform<'a, '_> { JSXChild::Element(child) => !is_component_name(&child.opening_element.name), _ => false, }; + if is_template_node { + following = Some(child); + } + break; } - false + let following = following?; + // The sibling's walk declaration is guarded by `detectExpressions` + // over the filtered child list; only claim an id the sibling will + // actually declare. (Dynamic native elements always declare, but a + // dynamic slot immediately before them makes the detect true anyway.) + let filtered: std::vec::Vec<&JSXChild<'a>> = children + .iter() + .filter(|child| match child { + JSXChild::Text(text) => !trim_jsx_text(&text.value).is_empty(), + JSXChild::ExpressionContainer(container) => { + !matches!(container.expression, JSXExpression::EmptyExpression(_)) + } + _ => true, + }) + .collect(); + let position = filtered + .iter() + .position(|candidate| std::ptr::eq(*candidate, following))?; + if !self.detect_expressions(&filtered, position) { + return None; + } + let name = self.next_element_id(); + self.pending_child_walk = Some(name.clone()); + Some(self.identifier_expression(span, &name)) } /// `getNextMatch(.nextSibling | .firstChild, "")` @@ -843,7 +896,12 @@ impl<'a> AstDomTransform<'a, '_> { child }; let mut child_template = crate::dom::template::TemplateHtml::open_tag(&tag_name); - let child_id = self.next_element_id(); + // A preceding dynamic slot may have claimed this element's id as its + // insert marker (Babel's `childNodes[index + 1].id`). + let child_id = self + .pending_child_walk + .take() + .unwrap_or_else(|| self.next_element_id()); let mut child_declarations = std::vec::Vec::new(); let mut child_operations = std::vec::Vec::new(); @@ -987,18 +1045,6 @@ fn spread_child_expression<'a>( } } -fn has_following_static_content(children: &[JSXChild<'_>]) -> bool { - children.iter().any(|child| match child { - JSXChild::Text(text) => !trim_jsx_text(&text.value).is_empty(), - JSXChild::ExpressionContainer(container) => { - !matches!(container.expression, JSXExpression::EmptyExpression(_)) - && static_jsx_expression(&container.expression, None).is_some() - } - JSXChild::Element(child) => !is_component_name(&child.opening_element.name), - _ => false, - }) -} - fn has_previous_static_text(children: &[JSXChild<'_>]) -> bool { children.iter().rev().any(|child| match child { JSXChild::Text(text) => !trim_jsx_text(&text.value).is_empty(), diff --git a/packages/compiler/src/dom/element.rs b/packages/compiler/src/dom/element.rs index 5b293360b..710efa4bd 100644 --- a/packages/compiler/src/dom/element.rs +++ b/packages/compiler/src/dom/element.rs @@ -86,6 +86,12 @@ pub(crate) struct AstDomTransform<'a, 'source> { /// (`getFirstChild`/`getNextSibling`) chain from it by name — the plain /// member walks re-derive from the root instead (equalized by traversal). pub(crate) last_child_walk: Option<(String, usize)>, + /// A positional walk name pre-allocated by a dynamic slot's marker for + /// the immediately following template child (Babel rides + /// `childNodes[index + 1].id` / `nextChild`, which the sibling's own + /// transform declares). The next retained child's lowering must consume + /// this instead of allocating a fresh id. + pub(crate) pending_child_walk: Option, /// Whether the current template root saw a delegated event handler or a /// spread (which may carry one); consumed at the root to emit a single /// `runHydrationEvents()` after setup. @@ -185,6 +191,7 @@ impl<'a, 'source> AstDomTransform<'a, 'source> { skip_xmlns_attribute: false, hydration_walk_anchor: None, last_child_walk: None, + pending_child_walk: None, has_hydratable_event: false, element_index: 0, this_index: 0, @@ -380,10 +387,10 @@ impl<'a, 'source> AstDomTransform<'a, 'source> { subject: patched_subject.clone(), }); } - if patched_subject.is_none() { - if let Some(statement) = self.wrap_dynamics_statement(dynamics) { - operations.push(statement); - } + if patched_subject.is_none() + && let Some(statement) = self.wrap_dynamics_statement(dynamics) + { + operations.push(statement); } if self.should_close_tag(&tag_name, CloseTagContext::root()) { template.html.push_str(&format!("")); 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/lib.rs b/packages/compiler/src/lib.rs index a20fb5694..bf5b50e19 100644 --- a/packages/compiler/src/lib.rs +++ b/packages/compiler/src/lib.rs @@ -29,10 +29,17 @@ mod node_adapter; mod refresh; mod shared; mod ssr; +#[cfg(feature = "tsrx")] +mod tsrx; mod universal; -pub use compiler::{CompileOptions, CompileOutput, Generate, Renderer, Wrapper, compile}; +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 9407a4e67..b054edc49 100644 --- a/packages/compiler/src/node_adapter.rs +++ b/packages/compiler/src/node_adapter.rs @@ -11,11 +11,120 @@ pub use crate::directives::{ }; pub use crate::lazy::TransformLazyOptions; pub use crate::refresh::TransformRefreshOptions; -use crate::{CompileOptions, Generate, Renderer, Wrapper}; +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. @@ -66,6 +175,8 @@ pub fn transform(code: String, options: Option) -> Result Result { "dynamic" => Generate::Dynamic, _ => return Err(Error::from_reason(UNSUPPORTED_GENERATE)), }; + // Same fallthrough as the Babel plugin's `isTsrxSource`: any value other + // than "tsrx"/"jsx" behaves as "auto". + let syntax = match options.syntax.as_deref() { + Some("tsrx") => Syntax::Tsrx, + Some("jsx") => Syntax::Jsx, + _ => Syntax::Auto, + }; Ok(CompileOptions { filename: options.filename, + syntax, module_name, generate, hydratable: options.hydratable.unwrap_or(false), @@ -149,6 +268,8 @@ fn legacy_preflight( return Ok(TransformResult { code: code.to_owned(), map: None, + css: None, + css_hash: None, }); } Err(Error::from_reason(validation_error)) @@ -268,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/refresh/mod.rs b/packages/compiler/src/refresh/mod.rs index 61022fb3b..8a12665a6 100644 --- a/packages/compiler/src/refresh/mod.rs +++ b/packages/compiler/src/refresh/mod.rs @@ -112,7 +112,12 @@ pub fn transform_refresh( if !changed { // Skipped modules (`@refresh skip`) and modules with nothing to // register come back untouched. - return Ok(TransformResult { code, map: None }); + return Ok(TransformResult { + code, + map: None, + css: None, + css_hash: None, + }); } let build = Codegen::new() @@ -127,5 +132,7 @@ pub fn transform_refresh( 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/shared/ast_builder.rs b/packages/compiler/src/shared/ast_builder.rs index f616494ab..1dee1cc22 100644 --- a/packages/compiler/src/shared/ast_builder.rs +++ b/packages/compiler/src/shared/ast_builder.rs @@ -12,7 +12,9 @@ use oxc_span::Span; use oxc_str::{Ident, Str}; use oxc_syntax::{ number::NumberBase, - operator::{AssignmentOperator, BinaryOperator, LogicalOperator, UnaryOperator}, + operator::{ + AssignmentOperator, BinaryOperator, LogicalOperator, UnaryOperator, UpdateOperator, + }, }; #[derive(Clone, Copy)] @@ -215,6 +217,16 @@ impl<'a> AstBuilder<'a> { Expression::new_unary_expression(span, operator, argument, &self.inner()) } + pub(crate) fn expression_update( + &self, + span: Span, + operator: UpdateOperator, + prefix: bool, + argument: SimpleAssignmentTarget<'a>, + ) -> Expression<'a> { + Expression::new_update_expression(span, operator, prefix, argument, &self.inner()) + } + #[allow(clippy::too_many_arguments)] pub(crate) fn expression_function( &self, @@ -686,6 +698,35 @@ impl<'a> AstBuilder<'a> { JSXExpressionContainer::new(span, expression, &self.inner()) } + pub(crate) fn jsx_attribute_item_expression( + &self, + span: Span, + name: &str, + expression: Expression<'a>, + ) -> JSXAttributeItem<'a> { + JSXAttributeItem::Attribute(JSXAttribute::boxed( + span, + JSXAttributeName::Identifier(JSXIdentifier::boxed(span, self.str(name), &self.inner())), + Some(JSXAttributeValue::ExpressionContainer( + JSXExpressionContainer::boxed(span, expression.into(), &self.inner()), + )), + &self.inner(), + )) + } + + pub(crate) fn jsx_identifier(&self, span: Span, name: impl Into>) -> JSXIdentifier<'a> { + JSXIdentifier::new(span, name, &self.inner()) + } + + pub(crate) fn alloc_jsx_member_expression( + &self, + span: Span, + object: JSXMemberExpressionObject<'a>, + property: JSXIdentifier<'a>, + ) -> ArenaBox<'a, JSXMemberExpression<'a>> { + JSXMemberExpression::boxed(span, object, property, &self.inner()) + } + pub(crate) fn template_element_with_lone_surrogates( &self, span: Span, diff --git a/packages/compiler/src/tsrx/mod.rs b/packages/compiler/src/tsrx/mod.rs new file mode 100644 index 000000000..3e905e2d9 --- /dev/null +++ b/packages/compiler/src/tsrx/mod.rs @@ -0,0 +1,223 @@ +//! TSRX syntax frontend (feature `tsrx`). +//! +//! Routes `.tsrx` sources through `tsrx_parser_engine` (the community +//! `oxc-tsrx` project, pinned by revision — the only TSRX grammar authority +//! 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, + parse_tsrx_utf16_with_options, parse_tsrx_with_options, +}; +use tsrx_tape_schema::{CoordinateDomain, ParseCompleteness, RecordIndex, ValueRef}; + +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>, + source_maps: bool, +) -> Result { + let filename = filename.unwrap_or("input.tsrx"); + let options = TsrxParseOptions { + filename, + include_ts_fields: true, + ..TsrxParseOptions::default() + }; + + let result = parse_source(source, options)?; + if result.status != ParseCompleteness::Complete { + return Err(first_diagnostic_error(source, &result)); + } + let mut tape = result + .program + .ok_or_else(|| CompileError::parse("TSRX parse returned no program"))?; + + if result.coordinate_domain == CoordinateDomain::OriginalUtf16Units { + rebase_utf16_spans(source, &mut tape).map_err(CompileError::parse)?; + } + + project::project(source, filename, &tape, source_maps).map_err(|error| { + let (line, column) = line_column(source, error.start); + CompileError::parse(format!("{} ({line}:{column})", error.message)) + }) +} + +/// Apply the post-reparse lazy/accessor rewrites to the projected program. +pub fn apply_rewrites<'a>( + allocator: &'a oxc_allocator::Allocator, + program: &mut oxc_ast::ast::Program<'a>, + projection: &Projection, + source_maps: bool, +) -> Result<(), CompileError> { + 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<'_>, + projection: &Projection, + authored_source: &str, + filename: &str, +) -> String { + source_map::compose( + intermediate, + &projection.source_map, + &projection.text, + authored_source, + filename, + ) +} + +fn parse_source( + source: &str, + options: TsrxParseOptions<'_>, +) -> Result { + if source.is_ascii() { + return parse_tsrx_with_options(&TsrxParseRequest { source }, options) + .map_err(|error| CompileError::parse(format!("TSRX parse failed: {error:?}"))); + } + // The canonical route is ASCII-only; non-ASCII sources go through the + // exact-UTF-16 route and their spans are rebased to UTF-8 bytes below. + let units: Vec = source.encode_utf16().collect(); + parse_tsrx_utf16_with_options(&TsrxUtf16ParseRequest { source: &units }, options) + .map_err(|error| CompileError::parse(format!("TSRX parse failed: {error:?}"))) +} + +/// Rewrite every `start`/`end` field in the tape from UTF-16 code units to +/// UTF-8 byte offsets, so the projection can splice authored bytes directly. +fn rebase_utf16_spans(source: &str, tape: &mut tsrx_tape_schema::FlatTape) -> Result<(), String> { + // Prefix table: UTF-16 unit index → UTF-8 byte offset. + let mut unit_to_byte: Vec = Vec::new(); + for (byte_offset, ch) in source.char_indices() { + for _ in 0..ch.len_utf16() { + unit_to_byte.push(byte_offset as u32); + } + } + unit_to_byte.push(source.len() as u32); + + let convert = |units: u32| -> Result { + unit_to_byte + .get(units as usize) + .copied() + .ok_or_else(|| format!("TSRX span offset {units} exceeds the source length")) + }; + + let object_count = tape.object_count() as u32; + for object in 0..object_count { + let object = RecordIndex::new(object); + for name in ["start", "end"] { + let Some(field) = tape.field_index(object, name) else { + continue; + }; + let Some(value) = tape.field_value(field) else { + continue; + }; + let Some(units) = tape.scalar_u32(value) else { + continue; + }; + tape.set_field_value(field, ValueRef::inline_u32(convert(units)?)) + .map_err(|error| error.to_string())?; + } + } + Ok(()) +} + +fn first_diagnostic_error(source: &str, result: &TsrxParseResult) -> CompileError { + let table = &result.errors; + let Some(record) = table.records().first() else { + return CompileError::parse("TSRX parse failed without diagnostics"); + }; + let message = table + .string(record.message) + .unwrap_or("TSRX parse failed") + .to_string(); + let start = table + .labels(record.labels) + .and_then(|labels| { + labels + .iter() + .find(|label| label.primary) + .or_else(|| labels.first()) + }) + .map(|label| label.span.start); + match start { + Some(start) => { + // Failed parses report in the authored domain of the used route; + // for the UTF-16 route the offset is in code units, close enough + // for a line/column computed over chars. + let (line, column) = if result.coordinate_domain == CoordinateDomain::OriginalUtf16Units + { + line_column_utf16(source, start) + } else { + line_column(source, start) + }; + CompileError::parse(format!("{message} ({line}:{column})")) + } + None => CompileError::parse(message), + } +} + +/// 1-based line and 0-based column for a UTF-8 byte offset (ESTree `loc` +/// convention, matching the Babel frontend's error suffix). +fn line_column(source: &str, offset: u32) -> (u32, u32) { + let offset = (offset as usize).min(source.len()); + let before = &source.as_bytes()[..offset]; + let line = 1 + before.iter().filter(|byte| **byte == b'\n').count() as u32; + let line_start = before + .iter() + .rposition(|byte| *byte == b'\n') + .map(|position| position + 1) + .unwrap_or(0); + let column = source[line_start..offset].encode_utf16().count() as u32; + (line, column) +} + +fn line_column_utf16(source: &str, offset_units: u32) -> (u32, u32) { + let mut units = 0u32; + for (byte_offset, ch) in source.char_indices() { + if units >= offset_units { + return line_column(source, byte_offset as u32); + } + units += ch.len_utf16() as u32; + } + line_column(source, source.len() as u32) +} 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 new file mode 100644 index 000000000..f65cd6825 --- /dev/null +++ b/packages/compiler/src/tsrx/project.rs @@ -0,0 +1,1780 @@ +//! 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: 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 +//! pipeline — generated parentheses are trivia (`preserve_parens: false`) +//! and generated JSX carries no stray whitespace children. +//! +//! Lazy `&` patterns are only *stripped* here (each pattern keeps its +//! authored binding names, so the reparsed program has real, +//! scope-resolvable bindings); the `__lazyN` renames and accessor-call +//! rewrites happen after the reparse in [`crate::tsrx::rewrite`], driven by +//! the anchors recorded in [`Projection`]. +//! +//! Emission is strictly append-only (no detached buffers), so anchor offsets +//! are final as they are recorded. Where output order differs from authored +//! order (a `@switch` `@default` case becomes the leading `fallback` +//! attribute), blocks are validated in the Babel frontend's order first and +//! 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, + is_callback_ref, is_class_attribute, is_direct_ref_target, push_class_map, push_js_string, + }, + tape::{self, Node}, +}; + +/// Result of projecting one TSRX module to plain TSX. +pub struct Projection { + pub text: String, + /// Concatenated extracted stylesheets in owner-visitor order. + 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 + /// preallocated `__lazyN` name. + 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 non-default `For` items, custom-key `For` indexes, and + /// `@catch` errors). + pub accessor_arrows: Vec<(u32, Vec)>, +} + +/// A structured frontend diagnostic in authored coordinates. +pub struct ProjectError { + pub message: String, + /// Authored byte offset the diagnostic points at. + pub start: u32, +} + +impl ProjectError { + fn new(message: impl Into, node: Node<'_>) -> Self { + Self { + message: message.into(), + start: node.span().map(|(start, _)| start).unwrap_or(0), + } + } +} + +type Result = std::result::Result; + +/// Whether a rendered expression is structurally JSX (usable as a bare JSX +/// child) or needs an expression container. +#[derive(Clone, Copy, PartialEq)] +enum Shape { + Jsx, + Expr, +} + +/// Where a node sits, which decides its rendered form. +#[derive(Clone, Copy, PartialEq)] +enum Position { + /// The `@{}` body of a function: statements plus `return render;`. + FunctionBody, + /// Expression slot (arrow body, attribute container, argument, …). + Expression, + /// JSX child slot: non-JSX results need `{…}` wrapping. + JsxChild, +} + +const FUNCTION_TYPES: [&str; 3] = [ + "FunctionDeclaration", + "FunctionExpression", + "ArrowFunctionExpression", +]; + +const RENDER_ENTRY_TYPES: [&str; 9] = [ + "JSXElement", + "JSXFragment", + "JSXText", + "JSXCodeBlock", + "JSXIfExpression", + "JSXForExpression", + "JSXSwitchExpression", + "JSXTryExpression", + "JSXStyleElement", +]; + +const LOOP_TYPES: [&str; 5] = [ + "ForStatement", + "ForInStatement", + "ForOfStatement", + "WhileStatement", + "DoWhileStatement", +]; + +/// TSRX constructs are validation boundaries: their blocks are checked when +/// they desugar, with their own construct label. +const ESCAPE_BOUNDARY_TYPES: [&str; 5] = [ + "JSXCodeBlock", + "JSXIfExpression", + "JSXForExpression", + "JSXSwitchExpression", + "JSXTryExpression", +]; + +fn is_function(ty: &str) -> bool { + FUNCTION_TYPES.contains(&ty) +} + +fn is_render_entry(ty: &str) -> bool { + RENDER_ENTRY_TYPES.contains(&ty) +} + +fn is_lazy_pattern(node: Node<'_>) -> bool { + matches!(node.ty(), "ArrayPattern" | "ObjectPattern") && node.bool_field("lazy") +} + +fn lazy_assignment_pattern(node: Node<'_>) -> Option> { + if node.ty() != "ExpressionStatement" { + return None; + } + let expression = node.node_field("expression")?; + if expression.ty() != "AssignmentExpression" || expression.str_field("operator") != Some("=") { + return None; + } + expression + .node_field("left") + .filter(|pattern| is_lazy_pattern(*pattern)) +} + +fn contains_lazy_pattern(node: Node<'_>) -> bool { + let mut found = false; + tape::walk(node, &mut |child| { + if is_lazy_pattern(child) { + found = true; + return false; + } + true + }); + found +} + +fn contains_pattern_default(node: Node<'_>) -> bool { + let mut found = false; + tape::walk(node, &mut |child| { + if child.ty() == "AssignmentPattern" { + found = true; + return false; + } + true + }); + found +} + +fn exported_lazy_declaration(root: Node<'_>) -> Option> { + let mut invalid = None; + tape::walk(root, &mut |node| { + if matches!( + node.ty(), + "ExportNamedDeclaration" | "ExportDefaultDeclaration" + ) && let Some(declaration) = node.node_field("declaration") + && declaration.ty() == "VariableDeclaration" + && declaration + .list_field("declarations") + .flatten() + .any(|declarator| { + declarator + .node_field("id") + .is_some_and(contains_lazy_pattern) + }) + { + invalid = Some(declaration); + return false; + } + invalid.is_none() + }); + invalid +} + +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(), + start: 0, + })?; + if let Some(declaration) = exported_lazy_declaration(root) { + return Err(ProjectError::new( + "TSRX lazy bindings cannot be exported", + declaration, + )); + } + + 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, &semantic), + 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)?; + + Ok(Projection { + text: renderer.out, + css, + css_hash, + embedded_regions, + source_map: renderer.source_map, + lazy_patterns: renderer.lazy_patterns, + accessor_arrows: renderer.accessor_arrows, + }) +} + +/// 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<'_>, semantic: &semantic::SolidTsrxModule<'_>) -> Vec<(u32, u32)> { + let mut spans: Vec<(u32, u32)> = Vec::new(); + tape::walk(root, &mut |node| { + match node.ty() { + "VariableDeclarator" => { + if let Some(pattern) = node.node_field("id") { + collect_topmost_lazy_patterns(pattern, &mut spans); + } + } + "FunctionDeclaration" | "FunctionExpression" | "ArrowFunctionExpression" => { + for pattern in node.list_field("params").flatten() { + collect_topmost_lazy_patterns(pattern, &mut spans); + } + } + "CatchClause" => { + if let Some(pattern) = node.node_field("param") { + collect_topmost_lazy_patterns(pattern, &mut spans); + } + } + "ExpressionStatement" => { + if let Some(pattern) = lazy_assignment_pattern(node) { + collect_topmost_lazy_patterns(pattern, &mut spans); + } + } + _ => {} + } + 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); + } + } + spans.sort_unstable(); + spans.dedup(); + spans +} + +fn collect_topmost_lazy_patterns(node: Node<'_>, spans: &mut Vec<(u32, u32)>) { + match node.ty() { + "AssignmentPattern" => { + if let Some(left) = node.node_field("left") { + collect_topmost_lazy_patterns(left, spans); + } + } + "RestElement" => { + if let Some(argument) = node.node_field("argument") { + collect_topmost_lazy_patterns(argument, spans); + } + } + "ObjectPattern" | "ArrayPattern" if is_lazy_pattern(node) => { + if let Some(span) = node.span() { + spans.push(span); + } + } + "ObjectPattern" => { + for property in node.list_field("properties").flatten() { + let child = if property.ty() == "RestElement" { + property.node_field("argument") + } else { + property.node_field("value") + }; + if let Some(child) = child { + collect_topmost_lazy_patterns(child, spans); + } + } + } + "ArrayPattern" => { + for element in node.list_field("elements").flatten() { + collect_topmost_lazy_patterns(element, spans); + } + if let Some(rest) = node.node_field("rest") { + collect_topmost_lazy_patterns(rest, spans); + } + } + _ => {} + } +} + +// --------------------------------------------------------------------------- +// Special-node collection (for verbatim regions) +// --------------------------------------------------------------------------- + +struct Special<'t> { + node: Node<'t>, + /// Replacement span in authored bytes (differs from the node span only + /// for lazy patterns, which also consume the preceding `&` sigil). + span: (u32, u32), + position: Position, +} + +/// Find the outermost nodes within `node`'s subtree that need re-rendering, +/// 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, + styles: &StyleProjection<'t>, + semantic: &semantic::SolidTsrxModule<'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() + } 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" + || 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))) + || (ty == "JSXFragment" && styles.owner_setups.contains_key(&start)) + || (ty == "JSXAttribute" && node.bool_field("shorthand")) + { + node.span() + } else if is_lazy_pattern(node) { + // The `&` sigil sits immediately before the pattern's bracket. + node.span() + .map(|(start, end)| (start.saturating_sub(1), end)) + } else { + None + }; + + if let Some(span) = special_span { + out.push(Special { + node, + span, + position, + }); + return; + } + + collect_children(node, styles, semantic, out); +} + +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") { + continue; + } + let child_position = match (ty, key) { + (parent, "body") if is_function(parent) => Position::FunctionBody, + ("JSXElement" | "JSXFragment", "children") => Position::JsxChild, + _ => Position::Expression, + }; + match value.kind() { + tsrx_tape_schema::ValueKind::Object => { + if let Some(child) = Node::from_value(node.tape(), value) { + collect_specials(child, child_position, styles, semantic, out); + } + } + tsrx_tape_schema::ValueKind::List => { + if let Some(list) = value.as_list() { + let mut next = node.tape().list_first_value(list); + while let Some(entry) = next.filter(|entry| !entry.is_none()) { + if let Some(item) = node.tape().list_value(entry) + && let Some(child) = Node::from_value(node.tape(), item) + { + collect_specials(child, child_position, styles, semantic, out); + } + next = node.tape().list_value_next(entry); + } + } + } + _ => {} + } + } +} + +// --------------------------------------------------------------------------- +// Shape prediction (decides `{…}` wrapping before any text is emitted) +// --------------------------------------------------------------------------- + +/// Shape of one render entry once lowered to an expression. +fn predict_entry_shape(node: Node<'_>) -> Shape { + match node.ty() { + "JSXElement" | "JSXFragment" | "JSXStyleElement" => Shape::Jsx, + "JSXText" => Shape::Expr, + "JSXIfExpression" | "JSXForExpression" | "JSXSwitchExpression" => Shape::Jsx, + "JSXTryExpression" => predict_try_shape(node), + "JSXCodeBlock" => { + if node.list_field("body").next().is_some() { + Shape::Expr + } else { + node.node_field("render") + .map(predict_entry_shape) + .unwrap_or(Shape::Expr) + } + } + _ => Shape::Expr, + } +} + +fn predict_try_shape(node: Node<'_>) -> Shape { + if node.has_node_field("handler") || node.has_node_field("pending") { + return Shape::Jsx; + } + node.node_field("block") + .map(|block| predict_block_shape(block)) + .unwrap_or(Shape::Expr) +} + +/// Shape of a whole template block lowered to an expression (ignoring the +/// empty case, which callers reject or omit before wrapping decisions). +fn predict_block_shape(block: Node<'_>) -> Shape { + let (setup, renders) = partition_entries(&block_entries(block)); + if !setup.is_empty() { + return Shape::Expr; + } + match renders.as_slice() { + [only] => predict_entry_shape(*only), + [] => Shape::Expr, + _ => Shape::Jsx, + } +} + +fn block_entries<'t>(block: Node<'t>) -> Vec> { + if block.ty() == "BlockStatement" { + block.list_field("body").flatten().collect() + } else { + vec![block] + } +} + +/// Split entries into setup statements and render outputs, skipping +/// whitespace-only JSXText. Pure analysis; ordering errors surface in +/// [`Renderer::block_parts_of_entries`]. +fn partition_entries<'t>(entries: &[Node<'t>]) -> (Vec>, Vec>) { + let mut setup = Vec::new(); + let mut renders = Vec::new(); + for entry in entries { + if is_render_entry(entry.ty()) { + if entry.ty() == "JSXText" && entry.str_field("value").is_some_and(decode_json_ws_only) + { + continue; + } + renders.push(*entry); + } else if renders.is_empty() { + setup.push(*entry); + } + } + (setup, renders) +} + +/// Whether a template block lowers to nothing (fully empty block). +fn block_is_empty(block: Node<'_>) -> bool { + let (setup, renders) = partition_entries(&block_entries(block)); + setup.is_empty() && renders.is_empty() +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +struct BlockParts<'t> { + setup: Vec>, + renders: Vec>, +} + +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)>, + lazy_patterns: Vec<(u32, String, bool)>, + accessor_arrows: Vec<(u32, Vec)>, + suppress_nested_lazy: usize, + source_map: ProjectionMap, +} + +impl<'s, 'm, 't> Renderer<'s, 'm, 't> { + fn push_verbatim(&mut self, start: u32, end: u32) { + 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]); + } + + fn push(&mut self, text: &str) { + self.out.push_str(text); + } + + /// Emit `[start, end)` of authored source, re-rendering the specials + /// found within `scope`'s subtree. + fn emit_verbatim_with_specials( + &mut self, + scope: Node<'_>, + start: u32, + end: u32, + position: Position, + ) -> Result<()> { + let mut specials = Vec::new(); + collect_specials(scope, position, &self.styles, self.semantic, &mut specials); + self.emit_region(start, end, &mut specials) + } + + /// Emit an authored region, splicing in the specials whose replacement + /// span lies within `[start, end)`. + fn emit_region(&mut self, start: u32, end: u32, specials: &mut Vec>) -> Result<()> { + // Tape field order is not source order (e.g. `children` can precede + // `openingElement`): splice in document order. + specials.sort_by_key(|special| special.span.0); + let mut cursor = start; + for special in specials.iter() { + let (s_start, s_end) = special.span; + if s_start < cursor || s_end > end { + continue; + } + self.push_verbatim(cursor, s_start); + self.render_special(special.node, special.position)?; + cursor = s_end; + } + self.push_verbatim(cursor, end); + Ok(()) + } + + /// Emit one authored node in the given position: dispatches specials + /// 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 self.semantic.control_for(node).is_some() + || ty == "JSXStyleElement" + || 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))) + || (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) + { + return self.render_special(node, position); + } + let (start, end) = span_of(node)?; + let mut specials = Vec::new(); + 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() { + "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) + } + "ArrayPattern" | "ObjectPattern" if self.suppress_nested_lazy > 0 => { + self.emit_eager_pattern(node) + } + "ArrayPattern" | "ObjectPattern" => self.render_lazy_pattern(node), + other => Err(ProjectError::new( + format!("Unsupported TSRX construct `{other}`"), + node, + )), + } + } + + // -- @{} statement containers --------------------------------------------- + + 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 + .get(&span_of(node)?.0) + .cloned() + .unwrap_or_default(); + + match position { + Position::FunctionBody => { + self.push("{\n"); + self.emit_statements(setup)?; + for setup in &style_setups { + self.emit_ref_setup(setup)?; + } + self.push("return "); + self.render_entry_expression(render)?; + self.push(";\n}"); + } + Position::Expression | Position::JsxChild => { + let wrap = position == Position::JsxChild + && (if setup.is_empty() && style_setups.is_empty() { + predict_entry_shape(render) == Shape::Expr + } else { + true + }); + if wrap { + self.push("{"); + } + if setup.is_empty() && style_setups.is_empty() { + self.render_entry_expression(render)?; + } else { + self.push("(() => {\n"); + self.emit_statements(setup)?; + for setup in &style_setups { + self.emit_ref_setup(setup)?; + } + self.push("return "); + self.render_entry_expression(render)?; + self.push(";\n})()"); + } + if wrap { + self.push("}"); + } + } + } + Ok(()) + } + + // -- @if — Show / Switch+Match --------------------------------------------- + + fn render_if(&mut self, chain: &IfChain<'t>) -> Result<()> { + // Validate in the Babel frontend's order: @else first, then branches. + if let Some(fallback) = &chain.fallback { + self.block_parts(fallback.node, Some("@else"))?; + } + for branch in &chain.branches { + self.block_parts(branch.body.node, Some("@if"))?; + } + + let has_fallback = chain + .fallback + .as_ref() + .is_some_and(|fallback| !block_is_empty(fallback.node)); + + if let [branch] = chain.branches.as_slice() { + self.push(""); + } + + self.push(""); + for branch in &chain.branches { + self.push("")?; + } + self.push(""); + Ok(()) + } + + /// Emit a construct's block as its JSX children (or self-close when the + /// block renders nothing), then the closing tag. + fn emit_construct_children( + &mut self, + block: Node<'_>, + construct: &str, + closing: &str, + ) -> Result<()> { + if block_is_empty(block) { + self.push("/>"); + return Ok(()); + } + self.push(">"); + let shape = predict_block_shape(block); + if shape == Shape::Jsx { + self.emit_block_expression(block, construct)?; + } else { + self.push("{"); + self.emit_block_expression(block, construct)?; + self.push("}"); + } + self.push(closing); + Ok(()) + } + + // -- @for — For -------------------------------------------------------------- + + 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"))?; + if parts.renders.is_empty() { + return Err(ProjectError::new( + "A TSRX @for body must end with rendered output", + node, + )); + } + + 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) = &loop_.empty { + let empty = empty.node; + if !block_is_empty(empty) { + self.push(" fallback={"); + self.emit_block_expression(empty, "@empty")?; + self.push("}"); + } else { + // Still validate the empty block (escape rules apply). + self.block_parts(empty, Some("@empty"))?; + } + } + self.push(">{"); + + // 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 mode.item_is_accessor() + && let Some(name) = ident_name(pattern) + { + accessor_names.push(name.to_string()); + } + if mode.index_is_accessor() + && let Some(index) = index + && let Some(name) = ident_name(index) + { + accessor_names.push(name.to_string()); + } + if !accessor_names.is_empty() { + self.accessor_arrows + .push((self.out.len() as u32, accessor_names)); + } + + self.push("("); + if mode.item_is_accessor() && pattern.ty() != "Identifier" { + self.render_lazy_pattern_with_source(pattern, true)?; + } else { + self.emit_node(pattern, Position::Expression)?; + } + if let Some(index) = index { + self.push(", "); + self.emit_node(index, Position::Expression)?; + } + self.push(") => "); + if parts.setup.is_empty() { + self.push("("); + self.emit_renders_expression(&parts.renders)?; + self.push(")"); + } else { + self.push("{\n"); + self.emit_statements(&parts.setup)?; + self.push("return "); + self.emit_renders_expression(&parts.renders)?; + self.push(";\n}"); + } + self.push("}"); + Ok(()) + } + + // -- @switch — Switch / Match -------------------------------------------------- + + 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 arm in &switch.arms { + let label = match arm { + SwitchArm::Case { .. } => "@case", + SwitchArm::Default { .. } => "@default", + }; + 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", + arm.origin().tape, + )); + } + } + + 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 arm in &switch.arms { + let SwitchArm::Case { test, entries, .. } = arm else { + continue; + }; + self.push(""); + continue; + } + self.push(">"); + let shape = if setup.is_empty() { + match renders.as_slice() { + [only] => predict_entry_shape(*only), + _ => Shape::Jsx, + } + } else { + Shape::Expr + }; + if shape == Shape::Jsx { + self.emit_case_expression(entries, "@case")?; + } else { + self.push("{"); + self.emit_case_expression(entries, "@case")?; + self.push("}"); + } + self.push(""); + } + self.push(""); + Ok(()) + } + + 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, 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"))?; + if content_parts.renders.is_empty() { + // Setup-only blocks get blockToExpression's message; fully empty + // ones get the @try-specific message, matching the Babel frontend. + return Err(if content_parts.setup.is_empty() { + ProjectError::new("A TSRX @try block must end with rendered output", node) + } else { + ProjectError::new( + "A TSRX @try block with setup statements must end with rendered output", + block, + ) + }); + } + let pending = try_.pending.as_ref().map(|pending| pending.node); + if let Some(pending) = pending { + self.block_parts(pending, Some("@pending"))?; + } + 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(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.reset.and_then(ident_name) { + reset_name = Some(reset.to_string()); + } + 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.origin.tape, + ) + } else { + ProjectError::new( + "A TSRX @catch block with setup statements must end with rendered output", + handler_body, + ) + }); + } + } + + let inner_shape = if pending.is_some() { + Shape::Jsx + } else { + predict_block_shape(block) + }; + let result_shape = if handler.is_some() { + Shape::Jsx + } else { + inner_shape + }; + + let wrap = position == Position::JsxChild && result_shape != Shape::Jsx; + if wrap { + self.push("{"); + } + + if let Some(handler) = handler { + self.push(" ("); + self.emit_block_expression(handler.body.node, "@catch")?; + self.push(")}>"); + } + + if let Some(pending) = pending { + self.push(""); + let content_shape = predict_block_shape(block); + if content_shape == Shape::Jsx { + self.emit_block_expression(block, "@try")?; + } else { + self.push("{"); + self.emit_block_expression(block, "@try")?; + self.push("}"); + } + self.push(""); + } else if handler.is_some() { + let content_shape = predict_block_shape(block); + if content_shape == Shape::Jsx { + self.emit_block_expression(block, "@try")?; + } else { + self.push("{"); + self.emit_block_expression(block, "@try")?; + self.push("}"); + } + } else { + self.emit_block_expression(block, "@try")?; + } + + if handler.is_some() { + self.push(""); + } + if wrap { + self.push("}"); + } + Ok(()) + } + + // -- Dynamic tags and shorthand props ----------------------------------------- + + fn render_style(&mut self, node: Node<'_>) -> Result<()> { + let start = span_of(node)?.0; + match self.styles.actions.get(&start).cloned() { + Some(StyleAction::Remove) => Ok(()), + Some(StyleAction::ClassMap(entries)) => { + push_class_map(&mut self.out, &entries); + Ok(()) + } + Some(StyleAction::EmptyElement) => { + let opening = node.node_field("openingElement").ok_or_else(|| { + ProjectError::new("A TSRX \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\ + }"; + 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 new file mode 100644 index 000000000..ab36b5442 --- /dev/null +++ b/packages/compiler/src/tsrx/source_map.rs @@ -0,0 +1,273 @@ +//! 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, + }); + } + + pub(super) 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, + /// Per-line `(relative UTF-8 byte, UTF-16 column)` boundaries. + columns: 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); + } + 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 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() { + 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 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)) + } +} + +#[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/src/tsrx/style/analysis.rs b/packages/compiler/src/tsrx/style/analysis.rs new file mode 100644 index 000000000..412841ea9 --- /dev/null +++ b/packages/compiler/src/tsrx/style/analysis.rs @@ -0,0 +1,680 @@ +use super::render::{strip_prefix, unescape_ident}; +use super::*; + +pub(super) fn analyze_items(items: &mut [Item], parent: Option) -> Result<(), StyleError> { + analyze_items_inner(items, parent) +} + +fn analyze_items_inner(items: &mut [Item], parent: Option) -> Result<(), StyleError> { + for item in items { + match item { + Item::Rule(rule) => { + rule.parent = parent; + for complex in &mut rule.selectors.selectors { + let mut after_global = false; + for (index, relative) in complex.parts.iter_mut().enumerate() { + if after_global { + relative.global_like = true; + } + let global_index = relative.simple.iter().position(is_bare_global); + if index == 0 && global_index == Some(0) { + rule.global_block = true; + } + if global_index == Some(0) { + after_global = true; + } + relative.global = relative_is_global(relative); + relative.global_like |= relative.simple.iter().all(|simple| { + matches!(simple, Simple::Pseudo(_) | Simple::PseudoElement(..)) + && matches!( + simple, + Simple::Pseudo(Pseudo { name, .. }) if name == "root" || name == "host" + ) + }); + analyze_pseudos(&mut relative.simple)?; + } + complex.used = complex.parts.iter().all(|p| p.global || p.global_like); + if let Some(index) = complex.parts.iter().position(relative_is_global) + && index != 0 + && index + 1 != complex.parts.len() + && complex.parts[index + 1..] + .iter() + .any(|part| !relative_is_global(part)) + { + return Err(StyleError { + message: ":global(...) can be at the start or end of a selector sequence, but not in the middle.".into(), + offset: complex.parts[index].start, + }); + } + } + let ptr = rule as *const Rule as usize; + analyze_items_inner(&mut rule.block.items, Some(ptr))?; + } + Item::At(at) => { + if let Some(block) = &mut at.block { + analyze_items_inner(&mut block.items, parent)?; + } + } + Item::Decl(_) => {} + } + } + Ok(()) +} + +fn analyze_pseudos(simple: &mut [Simple]) -> Result<(), StyleError> { + for selector in simple { + let Simple::Pseudo(pseudo) = selector else { + continue; + }; + if let Some(args) = &mut pseudo.args { + for complex in &mut args.selectors { + for relative in &mut complex.parts { + relative.global = relative_is_global(relative); + relative.global_like = relative.simple.iter().all(|simple| { + matches!( + simple, + Simple::Pseudo(Pseudo { name, .. }) if name == "root" || name == "host" + ) + }); + analyze_pseudos(&mut relative.simple)?; + } + complex.used = complex.parts.iter().all(|x| x.global || x.global_like); + } + if pseudo.name == "global" + && args.selectors.iter().any(|complex| { + complex + .parts + .iter() + .any(|part| part.simple.iter().any(is_bare_global)) + }) + { + return Err(StyleError { + message: "A :global selector cannot be inside a pseudoclass.".into(), + offset: pseudo.start, + }); + } + } + } + Ok(()) +} + +fn is_bare_global(simple: &Simple) -> bool { + matches!(simple, Simple::Pseudo(Pseudo { name, args: None, .. }) if name == "global") +} + +fn relative_is_global(relative: &Relative) -> bool { + let Some(Simple::Pseudo(first)) = relative.simple.first() else { + return false; + }; + first.name == "global" + && (first.args.is_none() + || relative + .simple + .iter() + .all(|x| matches!(x, Simple::Pseudo(_) | Simple::PseudoElement(..)))) +} + +pub(super) fn collect_class_map( + items: &mut [Item], + entries: &mut BTreeMap, +) { + for item in items { + match item { + Item::Rule(rule) => { + for complex in &mut rule.selectors.selectors { + collect_complex_classes(complex, entries); + } + collect_class_map(&mut rule.block.items, entries); + } + Item::At(at) => { + if let Some(block) = &mut at.block { + collect_class_map(&mut block.items, entries); + } + } + Item::Decl(_) => {} + } + } +} + +fn collect_complex_classes( + complex: &mut Complex, + entries: &mut BTreeMap, +) -> bool { + let mut contains_class_map_selector = false; + if complex.parts.len() == 1 { + let relative = &complex.parts[0]; + if !relative.global + && !relative.global_like + && relative.simple.len() == 1 + && let Simple::Class(name, start, end) = &relative.simple[0] + { + contains_class_map_selector = true; + entries + .entry(unescape_ident(name)) + .or_insert((*start, *end)); + } + } + for part in &mut complex.parts { + for simple in &mut part.simple { + if let Simple::Pseudo(Pseudo { + args: Some(args), .. + }) = simple + { + for child in &mut args.selectors { + contains_class_map_selector |= collect_complex_classes(child, entries); + } + } + } + } + complex.class_map |= contains_class_map_selector; + contains_class_map_selector +} + +pub(super) fn prepare_expression(items: &mut [Item]) { + fn walk(items: &mut [Item], nested_rule: bool) { + for item in items { + match item { + Item::Rule(rule) => { + for complex in &mut rule.selectors.selectors { + complex.used = nested_rule || complex.class_map || rule.global_block; + mark_scoped(complex); + } + walk(&mut rule.block.items, true); + } + Item::At(at) => { + if let Some(block) = &mut at.block { + walk(&mut block.items, nested_rule); + } + } + Item::Decl(_) => {} + } + } + } + walk(items, false); +} + +pub(super) fn preserve_class_map(items: &mut [Item]) { + for item in items { + match item { + Item::Rule(rule) => { + for complex in &mut rule.selectors.selectors { + if complex.class_map { + complex.used = true; + mark_scoped(complex); + } + } + preserve_class_map(&mut rule.block.items); + } + Item::At(at) => { + if let Some(block) = &mut at.block { + preserve_class_map(&mut block.items); + } + } + Item::Decl(_) => {} + } + } +} + +fn mark_scoped(complex: &mut Complex) { + for part in &mut complex.parts { + if !part.global { + part.scoped = true; + } + for simple in &mut part.simple { + if let Simple::Pseudo(Pseudo { + args: Some(args), + name, + .. + }) = simple + && matches!(name.as_str(), "is" | "where" | "has" | "not" | "global") + { + for child in &mut args.selectors { + mark_scoped(child); + } + } + } + } +} + +#[derive(Clone)] +struct FlatElement<'a> { + element: &'a Element, + parent: Option, + children: Vec, + siblings: Vec, + position: usize, +} + +pub(super) struct Arena<'a> { + nodes: Vec>, +} + +impl<'a> Arena<'a> { + pub(super) fn from_roots(roots: &'a [Element]) -> Self { + let mut arena = Self { nodes: Vec::new() }; + let root_indexes: Vec<_> = roots + .iter() + .map(|element| arena.add(element, None)) + .collect(); + for (position, index) in root_indexes.iter().copied().enumerate() { + arena.nodes[index].siblings = root_indexes.clone(); + arena.nodes[index].position = position; + } + arena + } + + pub(super) fn len(&self) -> usize { + self.nodes.len() + } + + fn add(&mut self, element: &'a Element, parent: Option) -> usize { + let index = self.nodes.len(); + self.nodes.push(FlatElement { + element, + parent, + children: Vec::new(), + siblings: Vec::new(), + position: 0, + }); + let child_indexes: Vec<_> = element + .children + .iter() + .filter_map(|child| match child { + ElementChild::Element(child) => Some(self.add(child, Some(index))), + ElementChild::Dynamic => None, + }) + .collect(); + for (position, child) in child_indexes.iter().copied().enumerate() { + self.nodes[child].siblings = child_indexes.clone(); + self.nodes[child].position = position; + } + self.nodes[index].children = child_indexes; + index + } +} + +pub(super) fn prune_items( + items: &mut [Item], + arena: &Arena<'_>, + element: usize, + scoped: &mut BTreeSet, + parent_selectors: Option>, +) { + for item in items { + match item { + Item::Rule(rule) => { + let mut effective = Vec::new(); + let has_animation = rule_has_animation(rule); + for complex in &mut rule.selectors.selectors { + let matched = matches_complex(complex, arena, element, &parent_selectors); + if matched || has_animation { + complex.used = true; + scope_matching_parts(complex, scoped, arena.nodes[element].element.id); + } + effective.push(complex.clone()); + } + prune_items( + &mut rule.block.items, + arena, + element, + scoped, + Some(effective), + ); + } + Item::At(at) => { + if let Some(block) = &mut at.block { + prune_items( + block.items.as_mut_slice(), + arena, + element, + scoped, + parent_selectors.clone(), + ); + } + } + Item::Decl(_) => {} + } + } +} + +fn rule_has_animation(rule: &Rule) -> bool { + rule.block.items.iter().any(|item| { + matches!( + item, + Item::Decl(Declaration { property, .. }) + if matches!(strip_prefix(property).as_str(), "animation" | "animation-name") + ) + }) +} + +fn scope_matching_parts(complex: &mut Complex, scoped: &mut BTreeSet, id: u32) { + for part in &mut complex.parts { + if part.scoped { + scoped.insert(id); + } + for simple in &mut part.simple { + if let Simple::Pseudo(Pseudo { + args: Some(args), + name, + .. + }) = simple + && matches!(name.as_str(), "is" | "where" | "has") + { + for child in &mut args.selectors { + if child.used { + scope_matching_parts(child, scoped, id); + } + } + } + } + } +} + +fn matches_complex( + complex: &mut Complex, + arena: &Arena<'_>, + element: usize, + parent: &Option>, +) -> bool { + let Some(last_local) = complex + .parts + .iter() + .rposition(|part| !part.global && !part.global_like) + else { + return true; + }; + match_at(complex, last_local, arena, element, parent) +} + +fn match_at( + complex: &mut Complex, + part: usize, + arena: &Arena<'_>, + element: usize, + parent: &Option>, +) -> bool { + if !compound_matches(&mut complex.parts[part], arena, element, parent) { + return false; + } + let matched = if part == 0 { + true + } else { + let combinator = complex.parts[part] + .combinator + .as_ref() + .map(|x| x.0.as_str()) + .unwrap_or(" ") + .to_string(); + match combinator.as_str() { + ">" => arena.nodes[element] + .parent + .is_some_and(|parent_el| match_at(complex, part - 1, arena, parent_el, parent)), + " " => { + let mut ancestor = arena.nodes[element].parent; + let mut found = false; + while let Some(index) = ancestor { + if match_at(complex, part - 1, arena, index, parent) { + found = true; + break; + } + ancestor = arena.nodes[index].parent; + } + found + || complex.parts[..part] + .iter() + .all(|part| part.global || part.global_like) + } + "+" => { + let node = &arena.nodes[element]; + node.position > 0 + && match_at( + complex, + part - 1, + arena, + node.siblings[node.position - 1], + parent, + ) + } + "~" => { + let node = &arena.nodes[element]; + node.siblings[..node.position] + .iter() + .copied() + .any(|sibling| match_at(complex, part - 1, arena, sibling, parent)) + } + // `@tsrx/core` deliberately treats unknown combinators, including + // the column combinator, as a possible match without traversing + // the selector on its other side. + _ => true, + } + }; + if matched && !complex.parts[part].global && !complex.parts[part].global_like { + complex.parts[part].scoped = true; + } + matched +} + +fn compound_matches( + relative: &mut Relative, + arena: &Arena<'_>, + index: usize, + parent: &Option>, +) -> bool { + if relative.global || relative.global_like { + return true; + } + let element = arena.nodes[index].element; + for simple in &mut relative.simple { + let matches = match simple { + Simple::Type(name, ..) => match &element.kind { + ElementKind::Native(tag) => name == "*" || tag.eq_ignore_ascii_case(name), + ElementKind::Dynamic => true, + ElementKind::Component => name == "*", + }, + Simple::Class(name, ..) => attr_matches( + element, + "class", + Some(&unescape_ident(name)), + Some("~="), + false, + ), + Simple::Id(name, ..) => { + attr_matches(element, "id", Some(&unescape_ident(name)), Some("="), false) + } + Simple::Attr(attr) => { + whitelisted_attr(element, &attr.name) + || attr_matches( + element, + &attr.name, + attr.value.as_deref(), + attr.op.as_deref(), + attr.insensitive, + ) + } + Simple::Pseudo(pseudo) if pseudo.name == "root" || pseudo.name == "host" => false, + Simple::Pseudo(pseudo) if pseudo.name == "global" => { + if let Some(args) = &mut pseudo.args { + args.selectors + .iter_mut() + .any(|complex| matches_complex(complex, arena, index, parent)) + } else { + true + } + } + Simple::Pseudo(pseudo) + if matches!(pseudo.name.as_str(), "is" | "where" | "has" | "not") => + { + let Some(args) = &mut pseudo.args else { + continue; + }; + if pseudo.name == "has" { + let descendants = descendants(arena, index); + args.selectors.iter_mut().any(|complex| { + descendants.iter().copied().any(|descendant| { + let matched = matches_complex(complex, arena, descendant, parent); + complex.used |= matched; + matched + }) + }) + } else if pseudo.name == "not" { + for complex in &mut args.selectors { + complex.used = true; + } + true + } else { + args.selectors.iter_mut().any(|complex| { + let matched = matches_complex(complex, arena, index, parent); + complex.used |= matched; + matched + }) + } + } + Simple::Nest(..) => parent.as_ref().is_none_or(|parents| { + parents + .iter() + .cloned() + .any(|mut complex| matches_complex(&mut complex, arena, index, &None)) + }), + _ => true, + }; + if !matches { + return false; + } + } + true +} + +fn descendants(arena: &Arena<'_>, root: usize) -> Vec { + let mut out = Vec::new(); + let mut stack = arena.nodes[root].children.clone(); + while let Some(index) = stack.pop() { + out.push(index); + stack.extend(arena.nodes[index].children.iter().copied()); + } + out +} + +fn attr_matches( + element: &Element, + name: &str, + expected: Option<&str>, + op: Option<&str>, + insensitive: bool, +) -> bool { + if element.has_spread { + return true; + } + for attr in &element.attributes { + let accepted = attr.name.eq_ignore_ascii_case(name) + || attr.name.eq_ignore_ascii_case(&format!("${name}")) + || (name.eq_ignore_ascii_case("class") && attr.name.eq_ignore_ascii_case("className")); + if !accepted { + continue; + } + let Some(expected) = expected else { + return true; + }; + let Some(AttributeValue::Static(actual)) = &attr.value else { + return true; + }; + let (mut expected, mut actual) = (expected.to_string(), actual.to_string()); + if insensitive { + expected.make_ascii_lowercase(); + actual.make_ascii_lowercase(); + } + return match op { + Some("=") => actual == expected, + Some("~=") => actual.split_whitespace().any(|x| x == expected), + Some("|=") => actual == expected || actual.starts_with(&format!("{expected}-")), + Some("^=") => actual.starts_with(&expected), + Some("$=") => actual.ends_with(&expected), + Some("*=") => actual.contains(&expected), + _ => true, + }; + } + false +} + +fn whitelisted_attr(element: &Element, attr: &str) -> bool { + let ElementKind::Native(tag) = &element.kind else { + return false; + }; + matches!( + ( + tag.to_ascii_lowercase().as_str(), + attr.to_ascii_lowercase().as_str() + ), + ("details" | "dialog", "open") + | ("form", "novalidate") + | ( + "iframe", + "allow" | "allowfullscreen" | "allowpaymentrequest" | "loading" | "referrerpolicy" + ) + | ("img", "loading") + | ( + "input", + "accept" + | "autocomplete" + | "capture" + | "checked" + | "disabled" + | "max" + | "maxlength" + | "min" + | "minlength" + | "multiple" + | "pattern" + | "placeholder" + | "readonly" + | "required" + | "size" + | "step" + ) + | ("object", "typemustmatch") + | ("ol", "reversed" | "start" | "type") + | ("optgroup", "disabled") + | ("option", "selected" | "disabled") + | ("script", "async" | "defer" | "nomodule" | "type") + | ("select", "disabled" | "required" | "multiple" | "size") + | ( + "textarea", + "autocomplete" + | "disabled" + | "maxlength" + | "minlength" + | "placeholder" + | "readonly" + | "required" + | "rows" + | "wrap" + ) + | ( + "video", + "autoplay" | "controls" | "loop" | "muted" | "playsinline" + ) + ) +} + +pub(super) fn collect_keyframes(items: &[Item]) -> BTreeSet { + fn walk(items: &[Item], global: bool, out: &mut BTreeSet) { + for item in items { + match item { + Item::At(at) if strip_prefix(&at.name) == "keyframes" => { + if !global && !at.prelude.starts_with("-global-") { + out.insert(at.prelude.clone()); + } + } + Item::At(at) => { + if let Some(block) = &at.block { + walk(&block.items, global, out); + } + } + Item::Rule(rule) => walk(&rule.block.items, global || rule.global_block, out), + Item::Decl(_) => {} + } + } + } + let mut out = BTreeSet::new(); + walk(items, false, &mut out); + out +} diff --git a/packages/compiler/src/tsrx/style/hash.rs b/packages/compiler/src/tsrx/style/hash.rs new file mode 100644 index 000000000..22e8e1949 --- /dev/null +++ b/packages/compiler/src/tsrx/style/hash.rs @@ -0,0 +1,70 @@ +pub(super) fn sha256(input: &[u8]) -> String { + const K: [u32; 64] = [ + 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, + 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, + 0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, + 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, + 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, + 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, + 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116, + 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, + 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, + 0xc67178f2, + ]; + let mut data = input.to_vec(); + let bits = (data.len() as u64) * 8; + data.push(0x80); + while data.len() % 64 != 56 { + data.push(0); + } + data.extend_from_slice(&bits.to_be_bytes()); + let mut h = [ + 0x6a09e667u32, + 0xbb67ae85, + 0x3c6ef372, + 0xa54ff53a, + 0x510e527f, + 0x9b05688c, + 0x1f83d9ab, + 0x5be0cd19, + ]; + for chunk in data.chunks_exact(64) { + let mut w = [0u32; 64]; + for (i, word) in w[..16].iter_mut().enumerate() { + *word = u32::from_be_bytes(chunk[i * 4..i * 4 + 4].try_into().unwrap()); + } + for i in 16..64 { + let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3); + let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10); + w[i] = w[i - 16] + .wrapping_add(s0) + .wrapping_add(w[i - 7]) + .wrapping_add(s1); + } + let [mut a, mut b, mut c, mut d, mut e, mut f, mut g, mut hh] = h; + for i in 0..64 { + let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25); + let ch = (e & f) ^ (!e & g); + let t1 = hh + .wrapping_add(s1) + .wrapping_add(ch) + .wrapping_add(K[i]) + .wrapping_add(w[i]); + let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22); + let maj = (a & b) ^ (a & c) ^ (b & c); + let t2 = s0.wrapping_add(maj); + hh = g; + g = f; + f = e; + e = d.wrapping_add(t1); + d = c; + c = b; + b = a; + a = t1.wrapping_add(t2); + } + for (x, y) in h.iter_mut().zip([a, b, c, d, e, f, g, hh]) { + *x = x.wrapping_add(y); + } + } + h.iter().map(|x| format!("{x:08x}")).collect() +} diff --git a/packages/compiler/src/tsrx/style/mod.rs b/packages/compiler/src/tsrx/style/mod.rs new file mode 100644 index 000000000..ac2ca5fab --- /dev/null +++ b/packages/compiler/src/tsrx/style/mod.rs @@ -0,0 +1,334 @@ +//! Self-contained TSRX scoped-CSS semantics. +//! +//! This module deliberately has no dependency on the TSRX/Oxc frontend. The +//! frontend only needs to translate its template into [`Element`] values. + +use std::collections::{BTreeMap, BTreeSet}; +use std::fmt; + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct StyleLocation<'a> { + pub filename: &'a str, + pub line: u32, + pub column: u32, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum StyleKind { + #[default] + Block, + Expression, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ElementKind { + Native(String), + Dynamic, + Component, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum AttributeValue { + Static(String), + Dynamic, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Attribute { + pub name: String, + pub value: Option, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ElementChild { + Element(Element), + /// An expression or control-flow boundary which can produce arbitrary + /// elements. Matching across it must be conservative. + Dynamic, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Element { + /// Stable caller-owned identity, returned in `scoped_elements`. + pub id: u32, + pub kind: ElementKind, + pub attributes: Vec, + pub has_spread: bool, + pub children: Vec, +} + +#[cfg(test)] +impl Element { + pub fn native(id: u32, tag: impl Into) -> Self { + Self { + id, + kind: ElementKind::Native(tag.into()), + attributes: Vec::new(), + has_spread: false, + children: Vec::new(), + } + } + + pub fn with_static_attr(mut self, name: impl Into, value: impl Into) -> Self { + self.attributes.push(Attribute { + name: name.into(), + value: Some(AttributeValue::Static(value.into())), + }); + self + } +} + +#[derive(Clone, Copy, Debug)] +pub struct StyleInput<'a> { + pub css: &'a str, + pub location: StyleLocation<'a>, + pub elements: &'a [Element], + pub kind: StyleKind, + pub minify: bool, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ClassMapEntry { + pub class_name: String, + pub value: String, + pub start: usize, + pub end: usize, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StyleOutput { + pub css: String, + pub hash: String, + /// Lexicographically sorted, matching `build_style_class_map`. + pub class_map: Vec, + /// Native/dynamic element ids that need the hash class. + pub scoped_elements: BTreeSet, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StyleError { + pub message: String, + pub offset: usize, +} + +impl fmt::Display for StyleError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{} at CSS byte {}", self.message, self.offset) + } +} + +impl std::error::Error for StyleError {} + +pub fn compile_style(input: StyleInput<'_>) -> Result { + compile_style_with_class_map_selectors(input, false) +} + +/// Compile while preserving every standalone class selector exposed by the +/// returned class map. A frontend should set this for a free-standing style +/// block with a `ref`, because the referenced map can apply those classes +/// outside the statically visible element tree. +pub fn compile_style_with_class_map_selectors( + input: StyleInput<'_>, + preserve_class_map_selectors: bool, +) -> Result { + let hash_source = format!( + "{}:{}:{}:{}", + input.location.filename, input.location.line, input.location.column, input.css + ) + .replace('\r', ""); + let hash = format!("tsrx-{}", &sha256(hash_source.as_bytes())[..8]); + let mut sheet = Parser::new(input.css).parse()?; + analyze_items(&mut sheet.items, None)?; + + let mut class_entries = BTreeMap::new(); + collect_class_map(&mut sheet.items, &mut class_entries); + let arena = Arena::from_roots(input.elements); + let mut scoped_elements = BTreeSet::new(); + + match input.kind { + StyleKind::Expression => prepare_expression(&mut sheet.items), + StyleKind::Block => { + for index in 0..arena.len() { + prune_items(&mut sheet.items, &arena, index, &mut scoped_elements, None); + } + if preserve_class_map_selectors { + preserve_class_map(&mut sheet.items); + } + } + } + + let local_keyframes = collect_keyframes(&sheet.items); + let mut render = Render { + source: input.css, + hash: &hash, + minify: input.minify, + local_keyframes: &local_keyframes, + }; + let css = if let (Some(first), Some(last)) = (sheet.items.first(), sheet.items.last()) { + format!( + "{}{}{}", + &input.css[..first.span().0], + render.items(&sheet.items, false), + &input.css[last.span().1..] + ) + } else { + input.css.to_string() + }; + let class_map = class_entries + .into_iter() + .map(|(class_name, (start, end))| ClassMapEntry { + value: format!("{hash} {class_name}"), + class_name, + start, + end, + }) + .collect(); + Ok(StyleOutput { + css, + hash, + class_map, + scoped_elements, + }) +} + +#[derive(Clone, Debug)] +struct Sheet { + items: Vec, +} + +#[derive(Clone, Debug)] +enum Item { + Rule(Rule), + At(AtRule), + Decl(Declaration), +} + +impl Item { + fn span(&self) -> (usize, usize) { + match self { + Self::Rule(x) => (x.start, x.end), + Self::At(x) => (x.start, x.end), + Self::Decl(x) => (x.start, x.end_with_semicolon), + } + } +} + +#[derive(Clone, Debug)] +struct Rule { + start: usize, + end: usize, + selectors: SelectorList, + block: Block, + parent: Option, + global_block: bool, +} + +#[derive(Clone, Debug)] +struct AtRule { + start: usize, + end: usize, + name: String, + prelude: String, + prelude_start: usize, + block: Option, +} + +#[derive(Clone, Debug)] +struct Declaration { + start: usize, + end_with_semicolon: usize, + property: String, + value: String, + value_start: usize, +} + +#[derive(Clone, Debug)] +struct Block { + start: usize, + end: usize, + items: Vec, +} + +#[derive(Clone, Debug)] +struct SelectorList { + start: usize, + end: usize, + selectors: Vec, +} + +#[derive(Clone, Debug)] +struct Complex { + start: usize, + end: usize, + parts: Vec, + used: bool, + class_map: bool, +} + +#[derive(Clone, Debug)] +struct Relative { + start: usize, + combinator: Option<(String, usize, usize)>, + simple: Vec, + global: bool, + global_like: bool, + scoped: bool, +} + +#[derive(Clone, Debug)] +enum Simple { + Type(String, usize, usize), + Class(String, usize, usize), + Id(String, usize, usize), + Attr(AttrSelector), + Pseudo(Pseudo), + PseudoElement(usize, usize), + Nest(usize, usize), + Other(usize, usize), +} + +impl Simple { + fn span(&self) -> (usize, usize) { + match self { + Self::Type(_, a, b) + | Self::Class(_, a, b) + | Self::Id(_, a, b) + | Self::PseudoElement(a, b) + | Self::Nest(a, b) + | Self::Other(a, b) => (*a, *b), + Self::Attr(x) => (x.start, x.end), + Self::Pseudo(x) => (x.start, x.end), + } + } +} + +#[derive(Clone, Debug)] +struct AttrSelector { + start: usize, + end: usize, + name: String, + op: Option, + value: Option, + insensitive: bool, +} + +#[derive(Clone, Debug)] +struct Pseudo { + start: usize, + end: usize, + name: String, + args: Option, +} + +mod analysis; +mod hash; +mod parser; +mod render; + +#[cfg(test)] +mod tests; + +use analysis::*; +use hash::sha256; +use parser::Parser; +use render::Render; diff --git a/packages/compiler/src/tsrx/style/parser.rs b/packages/compiler/src/tsrx/style/parser.rs new file mode 100644 index 000000000..22184b537 --- /dev/null +++ b/packages/compiler/src/tsrx/style/parser.rs @@ -0,0 +1,541 @@ +use super::*; + +pub(super) struct Parser<'a> { + src: &'a str, + i: usize, +} + +impl<'a> Parser<'a> { + pub(super) fn new(src: &'a str) -> Self { + Self { src, i: 0 } + } + + pub(super) fn parse(mut self) -> Result { + let items = self.body(None)?; + Ok(Sheet { items }) + } + + fn err(&self, message: impl Into) -> Result { + Err(StyleError { + message: message.into(), + offset: self.i, + }) + } + + fn body(&mut self, close: Option) -> Result, StyleError> { + let mut out = Vec::new(); + loop { + self.trivia()?; + if self.i >= self.src.len() || close.is_some_and(|c| self.byte() == c) { + return Ok(out); + } + out.push(if self.byte() == b'@' { + Item::At(self.at_rule()?) + } else if close.is_some() { + self.block_item()? + } else { + Item::Rule(self.rule()?) + }); + } + } + + fn trivia(&mut self) -> Result<(), StyleError> { + loop { + while self.i < self.src.len() && self.byte().is_ascii_whitespace() { + self.i += 1; + } + if self.rest().starts_with("/*") { + let Some(n) = self.rest()[2..].find("*/") else { + return self.err("Unclosed CSS comment"); + }; + self.i += n + 4; + } else if self.rest().starts_with("") else { + return self.err("Unclosed HTML comment"); + }; + self.i += n + 7; + } else { + return Ok(()); + } + } + } + + fn block_item(&mut self) -> Result { + if self.byte() == b'@' { + return Ok(Item::At(self.at_rule()?)); + } + let save = self.i; + let (_, term) = self.value_until()?; + self.i = save; + if term == b'{' { + Ok(Item::Rule(self.rule()?)) + } else { + Ok(Item::Decl(self.declaration()?)) + } + } + + fn rule(&mut self) -> Result { + let start = self.i; + let selectors = self.selector_list(false)?; + let block = self.block()?; + Ok(Rule { + start, + end: block.end, + selectors, + block, + parent: None, + global_block: false, + }) + } + + fn at_rule(&mut self) -> Result { + let start = self.i; + self.i += 1; + let name = self.ident()?; + let prelude_start = self.i; + let (prelude, term) = self.value_until()?; + let block = if term == b'{' { + Some(self.block()?) + } else { + self.i += 1; + None + }; + let end = block.as_ref().map_or(self.i, |x| x.end); + Ok(AtRule { + start, + end, + name, + prelude, + prelude_start, + block, + }) + } + + fn block(&mut self) -> Result { + if self.i >= self.src.len() || self.byte() != b'{' { + return self.err("Expected `{`"); + } + let start = self.i; + self.i += 1; + let items = self.body(Some(b'}'))?; + if self.i >= self.src.len() { + return self.err("Expected `}`"); + } + self.i += 1; + Ok(Block { + start, + end: self.i, + items, + }) + } + + fn declaration(&mut self) -> Result { + let start = self.i; + while self.i < self.src.len() && !self.byte().is_ascii_whitespace() && self.byte() != b':' { + self.i += 1; + } + let property = self.src[start..self.i].to_string(); + self.ws(); + if self.i < self.src.len() && self.byte() == b':' { + self.i += 1; + } + self.ws(); + let value_start = self.i; + let (value, term) = self.value_until()?; + if value.is_empty() && !property.starts_with("--") { + return self.err("CSS Declaration cannot be empty"); + } + if term == b';' { + self.i += 1; + } + Ok(Declaration { + start, + end_with_semicolon: self.i, + property, + value, + value_start, + }) + } + + fn value_until(&mut self) -> Result<(String, u8), StyleError> { + let start = self.i; + let mut quote = 0; + let mut escaped = false; + let mut parens = 0usize; + while self.i < self.src.len() { + let b = self.byte(); + if escaped { + escaped = false; + } else if b == b'\\' { + escaped = true; + } else if quote != 0 { + if b == quote { + quote = 0; + } + } else if b == b'\'' || b == b'"' { + quote = b; + } else if b == b'(' { + parens += 1; + } else if b == b')' && parens > 0 { + parens -= 1; + } else if parens == 0 && matches!(b, b';' | b'{' | b'}') { + return Ok((self.src[start..self.i].trim().to_string(), b)); + } + self.i += 1; + } + self.err("Unexpected end of CSS") + } + + fn selector_list(&mut self, in_pseudo: bool) -> Result { + self.trivia()?; + let start = self.i; + let mut selectors = Vec::new(); + loop { + selectors.push(self.complex(in_pseudo)?); + let end = self.i; + self.trivia()?; + let terminal = if in_pseudo { b')' } else { b'{' }; + if self.i < self.src.len() && self.byte() == terminal { + return Ok(SelectorList { + start, + end, + selectors, + }); + } + if self.i >= self.src.len() || self.byte() != b',' { + return self.err("Expected `,` in selector list"); + } + self.i += 1; + self.trivia()?; + } + } + + fn complex(&mut self, in_pseudo: bool) -> Result { + let start = self.i; + let mut parts = Vec::new(); + let mut combinator = None; + loop { + let part_start = combinator + .as_ref() + .map_or(self.i, |x: &(String, usize, usize)| x.1); + let mut simple = Vec::new(); + loop { + if self.i >= self.src.len() { + return self.err("Unexpected end of selector"); + } + let b = self.byte(); + if b == b',' + || b == b'{' + || (in_pseudo && b == b')') + || self.rest().starts_with("||") + || b.is_ascii_whitespace() + || matches!(b, b'>' | b'+' | b'~') + { + break; + } + if in_pseudo && let Some(end) = self.nth_prefix_end() { + simple.push(Simple::Other(self.i, end)); + self.i = end; + } else { + simple.push(self.simple()?); + } + } + let part_end = self.i; + if !simple.is_empty() { + parts.push(Relative { + start: part_start, + combinator: combinator.take(), + simple, + global: false, + global_like: false, + scoped: false, + }); + } + let before_ws = self.i; + self.ws(); + if self.i >= self.src.len() + || self.byte() == b',' + || self.byte() == b'{' + || (in_pseudo && self.byte() == b')') + { + return Ok(Complex { + start, + end: part_end, + parts, + used: false, + class_map: false, + }); + } + let (name, a, b) = if self.rest().starts_with("||") { + let a = self.i; + self.i += 2; + let b = self.i; + self.ws(); + ("||".into(), a, b) + } else if matches!(self.byte(), b'>' | b'+' | b'~') { + let a = self.i; + let name = (self.byte() as char).to_string(); + self.i += 1; + let b = self.i; + self.ws(); + (name, a, b) + } else if self.i > before_ws { + (" ".into(), before_ws, self.i) + } else { + return self.err("Invalid selector"); + }; + combinator = Some((name, a, b)); + } + } + + fn simple(&mut self) -> Result { + let start = self.i; + match self.byte() { + b'&' => { + self.i += 1; + Ok(Simple::Nest(start, self.i)) + } + b'.' | b'#' => { + let kind = self.byte(); + self.i += 1; + let name = self.ident()?; + Ok(if kind == b'.' { + Simple::Class(name, start, self.i) + } else { + Simple::Id(name, start, self.i) + }) + } + b'[' => self.attribute(), + b':' => self.pseudo(), + b'*' => { + self.i += 1; + Ok(Simple::Type("*".into(), start, self.i)) + } + b if b.is_ascii_digit() => { + while self.i < self.src.len() + && (self.byte().is_ascii_digit() || self.byte() == b'.' || self.byte() == b'%') + { + self.i += 1; + } + Ok(Simple::Other(start, self.i)) + } + _ => { + let mut name = self.ident()?; + if self.i < self.src.len() && self.byte() == b'|' { + self.i += 1; + name = self.ident()?; + } + Ok(Simple::Type(name, start, self.i)) + } + } + } + + /// End of the grammar's `Nth` token. It includes the whitespace after + /// `of`, exactly like `REGEX_NTH_OF` in `@tsrx/core`. + fn nth_prefix_end(&self) -> Option { + let rest = self.rest(); + let lower = rest.to_ascii_lowercase(); + for keyword in ["even", "odd"] { + if lower.starts_with(keyword) { + let end = self.i + keyword.len(); + return nth_suffix_end(self.src, end); + } + } + let bytes = self.src.as_bytes(); + let mut at = self.i; + if at < bytes.len() && matches!(bytes[at], b'+' | b'-') { + at += 1; + } + let formula_start = at; + let mut saw_digit_or_n = false; + while at < bytes.len() { + let byte = bytes[at]; + if byte.is_ascii_digit() || byte.eq_ignore_ascii_case(&b'n') { + saw_digit_or_n = true; + at += 1; + } else if matches!(byte, b'+' | b'-') || byte.is_ascii_whitespace() { + at += 1; + } else { + break; + } + } + if at == formula_start || !saw_digit_or_n { + return None; + } + nth_suffix_end(self.src, at) + } + + fn pseudo(&mut self) -> Result { + let start = self.i; + self.i += 1; + if self.i < self.src.len() && self.byte() == b':' { + self.i += 1; + self.ident()?; + if self.i < self.src.len() && self.byte() == b'(' { + self.skip_balanced()?; + } + return Ok(Simple::PseudoElement(start, self.i)); + } + let name = self.ident()?; + let args = if self.i < self.src.len() && self.byte() == b'(' { + self.i += 1; + let args = self.selector_list(true)?; + if self.i >= self.src.len() || self.byte() != b')' { + return self.err("Expected `)`"); + } + self.i += 1; + Some(args) + } else { + None + }; + Ok(Simple::Pseudo(Pseudo { + start, + end: self.i, + name, + args, + })) + } + + fn attribute(&mut self) -> Result { + let start = self.i; + self.i += 1; + self.ws(); + let name = self.ident()?; + self.ws(); + let op = ["~=", "^=", "$=", "*=", "|=", "="] + .into_iter() + .find(|op| self.rest().starts_with(op)) + .map(str::to_string); + if let Some(op) = &op { + self.i += op.len(); + } + self.ws(); + let value = if op.is_some() { + let quote = if self.i < self.src.len() && matches!(self.byte(), b'\'' | b'"') { + let q = self.byte(); + self.i += 1; + Some(q) + } else { + None + }; + let a = self.i; + while self.i < self.src.len() + && quote.map_or( + !self.byte().is_ascii_whitespace() && self.byte() != b']', + |q| self.byte() != q, + ) + { + if self.byte() == b'\\' && self.i + 1 < self.src.len() { + self.i += 1; + } + self.i += 1; + } + let v = self.src[a..self.i].to_string(); + if quote.is_some() { + self.i += 1; + } + Some(v) + } else { + None + }; + self.ws(); + let flag_start = self.i; + while self.i < self.src.len() && self.byte().is_ascii_alphabetic() { + self.i += 1; + } + let insensitive = self.src[flag_start..self.i].contains('i'); + self.ws(); + if self.i >= self.src.len() || self.byte() != b']' { + return self.err("Expected `]`"); + } + self.i += 1; + Ok(Simple::Attr(AttrSelector { + start, + end: self.i, + name, + op, + value, + insensitive, + })) + } + + fn skip_balanced(&mut self) -> Result<(), StyleError> { + let mut depth = 0; + while self.i < self.src.len() { + match self.byte() { + b'(' => depth += 1, + b')' => { + depth -= 1; + if depth == 0 { + self.i += 1; + return Ok(()); + } + } + b'\\' => self.i += 1, + _ => {} + } + self.i += 1; + } + self.err("Expected `)`") + } + + fn ident(&mut self) -> Result { + let start = self.i; + if self.i < self.src.len() + && (self.byte().is_ascii_digit() + || (self.byte() == b'-' + && self.i + 1 < self.src.len() + && self.src.as_bytes()[self.i + 1].is_ascii_digit())) + { + return self.err("Unexpected CSS identifier"); + } + while self.i < self.src.len() { + let b = self.byte(); + if b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-') || b >= 0x80 { + self.i += 1; + } else if b == b'\\' && self.i + 1 < self.src.len() { + self.i += 2; + } else { + break; + } + } + if self.i == start { + return self.err("Expected identifier"); + } + Ok(self.src[start..self.i].to_string()) + } + + fn ws(&mut self) { + while self.i < self.src.len() && self.byte().is_ascii_whitespace() { + self.i += 1; + } + } + fn byte(&self) -> u8 { + self.src.as_bytes()[self.i] + } + fn rest(&self) -> &str { + &self.src[self.i..] + } +} + +fn nth_suffix_end(source: &str, mut end: usize) -> Option { + let bytes = source.as_bytes(); + let formula_end = end; + while end < bytes.len() && bytes[end].is_ascii_whitespace() { + end += 1; + } + if source[end..].starts_with("of") + && end + 2 < bytes.len() + && bytes[end + 2].is_ascii_whitespace() + { + end += 2; + while end < bytes.len() && bytes[end].is_ascii_whitespace() { + end += 1; + } + return Some(end); + } + if end < bytes.len() && matches!(bytes[end], b',' | b')') { + return Some(formula_end); + } + None +} diff --git a/packages/compiler/src/tsrx/style/render.rs b/packages/compiler/src/tsrx/style/render.rs new file mode 100644 index 000000000..291392c32 --- /dev/null +++ b/packages/compiler/src/tsrx/style/render.rs @@ -0,0 +1,398 @@ +use super::*; + +pub(super) struct Render<'a> { + pub(super) source: &'a str, + pub(super) hash: &'a str, + pub(super) minify: bool, + pub(super) local_keyframes: &'a BTreeSet, +} + +impl Render<'_> { + pub(super) fn items(&mut self, items: &[Item], global: bool) -> String { + if items.is_empty() { + return String::new(); + } + let start = items.first().unwrap().span().0; + let end = items.last().unwrap().span().1; + let mut out = String::new(); + let mut cursor = start; + for item in items { + let (a, b) = item.span(); + out.push_str(&self.source[cursor..a]); + out.push_str(&self.item(item, global)); + cursor = b; + } + out.push_str(&self.source[cursor..end]); + out + } + + fn item(&mut self, item: &Item, global: bool) -> String { + match item { + Item::Rule(rule) => self.rule(rule, global), + Item::At(at) => self.at_rule(at, global), + Item::Decl(decl) => self.declaration(decl), + } + } + + fn rule(&mut self, rule: &Rule, in_global: bool) -> String { + let used = rule.selectors.selectors.iter().any(|x| x.used); + let empty = self.rule_empty(rule); + if empty || (!used && !in_global) { + let label = if empty { "empty" } else { "unused" }; + return format!( + "/* ({label}) {}*/", + escape_comment_close(&self.source[rule.start..rule.end]) + ); + } + if rule.global_block + && rule.selectors.selectors.len() == 1 + && rule.selectors.selectors[0].parts.len() == 1 + && rule.selectors.selectors[0].parts[0].simple.len() == 1 + { + let inside = self.block_contents(&rule.block, true); + return if self.minify { + inside + } else { + format!( + "/* {}*/{}/*{}*/", + &self.source[rule.start..=rule.block.start], + inside, + &self.source[rule.block.end - 1..rule.end] + ) + }; + } + // `@tsrx/core` creates a fresh, unbumped specificity state for every + // rule selector list. Its ancestor check is keyed by + // `has_local_selectors`, which remains false in the analyzer; nested + // implicit selectors therefore receive `.hash`, not `:where(.hash)`. + let mut specificity = false; + let selectors = self.selector_list( + &rule.selectors, + in_global, + &mut specificity, + rule.parent.is_some(), + ); + let body = self.block_contents(&rule.block, in_global || rule.global_block); + format!( + "{}{}{}", + selectors, + &self.source[rule.selectors.end..=rule.block.start], + body + &self.source[rule.block.end - 1..rule.end] + ) + } + + fn rule_empty(&self, rule: &Rule) -> bool { + if rule.global_block { + return rule.block.items.is_empty(); + } + rule.block.items.iter().all(|item| match item { + Item::Decl(_) => false, + Item::Rule(child) => { + !child.selectors.selectors.iter().any(|x| x.used) || self.rule_empty(child) + } + Item::At(at) => at.block.as_ref().is_some_and(|x| x.items.is_empty()), + }) + } + + fn block_contents(&mut self, block: &Block, global: bool) -> String { + if block.items.is_empty() { + return self.source[block.start + 1..block.end - 1].to_string(); + } + let mut out = String::new(); + let mut cursor = block.start + 1; + for item in &block.items { + let (a, b) = item.span(); + out.push_str(&self.source[cursor..a]); + out.push_str(&self.item(item, global)); + cursor = b; + } + out.push_str(&self.source[cursor..block.end - 1]); + out + } + + fn at_rule(&mut self, at: &AtRule, global: bool) -> String { + if strip_prefix(&at.name) == "keyframes" { + let raw = &self.source[at.start..at.end]; + let name_start = at.prelude_start + + self.source[at.prelude_start..] + .find(|c: char| !c.is_whitespace()) + .unwrap_or(0); + let authored = at.prelude.trim(); + let rendered = if let Some(rendered) = authored.strip_prefix("-global-") { + rendered + } else if !global { + return replace_range( + raw, + name_start - at.start, + name_start - at.start, + &format!("{}-", self.hash), + ); + } else { + authored + }; + return replace_range( + raw, + name_start - at.start, + name_start - at.start + authored.len(), + rendered, + ); + } + let Some(block) = &at.block else { + return self.source[at.start..at.end].to_string(); + }; + format!( + "{}{}{}", + &self.source[at.start..=block.start], + self.block_contents(block, global), + &self.source[block.end - 1..at.end] + ) + } + + fn declaration(&self, decl: &Declaration) -> String { + let mut raw = self.source[decl.start..decl.end_with_semicolon].to_string(); + if matches!( + strip_prefix(&decl.property).as_str(), + "animation" | "animation-name" + ) { + let value_offset = decl.value_start - decl.start; + let rewritten = rewrite_animation(&decl.value, self.hash, self.local_keyframes); + raw = replace_range( + &raw, + value_offset, + value_offset + decl.value.len(), + &rewritten, + ); + } + raw + } + + fn selector_list( + &mut self, + list: &SelectorList, + global: bool, + specificity: &mut bool, + nested_rule: bool, + ) -> String { + let mut out = String::new(); + let mut cursor = list.start; + let mut pruning = false; + for (index, complex) in list.selectors.iter().enumerate() { + let used = complex.used || global; + if !used && !pruning { + if self.minify { + // The run is omitted below, including one separator. + } else if index == 0 { + out.push_str(&self.source[cursor..complex.start]); + out.push_str("/* (unused) "); + } else { + out.push_str(" /* (unused) "); + } + cursor = complex.start; + pruning = true; + } else if used && pruning { + let separator = &self.source[cursor..complex.start]; + let comma = separator.rfind(',').unwrap_or(0); + if !self.minify { + out.push_str(&self.source[cursor..cursor + comma]); + out.push_str("*/"); + out.push_str(&separator[comma..]); + } else if index > 0 && !out.trim_end().ends_with(',') { + out.push(','); + } + cursor = complex.start; + pruning = false; + } + if used { + out.push_str(&self.source[cursor..complex.start]); + let mut complex_specificity = *specificity; + out.push_str(&self.complex(complex, global, &mut complex_specificity, nested_rule)); + cursor = complex.end; + } else if !self.minify { + out.push_str(&escape_comment_close(&self.source[cursor..complex.end])); + cursor = complex.end; + } else { + cursor = complex.end; + } + } + if pruning { + if !self.minify { + out.push_str("*/"); + } + } else { + out.push_str(&self.source[cursor..list.end]); + } + out + } + + fn complex( + &mut self, + complex: &Complex, + global: bool, + specificity: &mut bool, + nested_rule: bool, + ) -> String { + let mut edits: Vec<(usize, usize, String)> = Vec::new(); + for relative in &complex.parts { + for simple in &relative.simple { + self.simple_edits( + simple, + relative, + global, + specificity, + nested_rule, + &mut edits, + ); + } + if relative.scoped && !global && !relative.global && !relative.global_like { + let modifier = if *specificity { + format!(":where(.{})", self.hash) + } else { + *specificity = true; + format!(".{}", self.hash) + }; + if !(relative + .simple + .iter() + .any(|x| matches!(x, Simple::Nest(..))) + || relative.simple.len() == 1 + && matches!( + &relative.simple[0], + Simple::Pseudo(Pseudo { name, .. }) if name == "is" || name == "where" + )) + { + if let Some(target) = relative + .simple + .iter() + .rev() + .find(|x| !matches!(x, Simple::Pseudo(_) | Simple::PseudoElement(..))) + { + let (a, b) = target.span(); + if matches!(target, Simple::Type(name, ..) if name == "*") { + edits.push((a, b, modifier)); + } else { + edits.push((b, b, modifier)); + } + } else if let Some(first) = relative.simple.first() { + edits.push((first.span().0, first.span().0, modifier)); + } + } + } + } + apply_edits(self.source, complex.start, complex.end, edits) + } + + fn simple_edits( + &mut self, + simple: &Simple, + relative: &Relative, + global: bool, + specificity: &mut bool, + nested_rule: bool, + edits: &mut Vec<(usize, usize, String)>, + ) { + let Simple::Pseudo(pseudo) = simple else { + return; + }; + if pseudo.name == "global" { + if let Some(args) = &pseudo.args { + edits.push((pseudo.start, args.start, String::new())); + edits.push((args.end, pseudo.end, String::new())); + } else { + let start = relative + .combinator + .as_ref() + .filter(|(name, _, _)| name == " ") + .map_or(pseudo.start, |(_, start, _)| *start); + let replacement = if nested_rule && relative.combinator.is_none() && relative.global + { + "&" + } else { + "" + }; + edits.push((start, pseudo.end, replacement.into())); + } + return; + } + if matches!(pseudo.name.as_str(), "is" | "where" | "has" | "not") + && let Some(args) = &pseudo.args + { + let rendered = self.selector_list(args, global || relative.global, specificity, false); + edits.push((args.start, args.end, rendered)); + } + } +} + +fn rewrite_animation(value: &str, hash: &str, names: &BTreeSet) -> String { + let mut out = String::with_capacity(value.len()); + let mut token = String::new(); + for ch in value.chars().chain(std::iter::once(';')) { + if ch.is_whitespace() || matches!(ch, ',' | ';' | '}') { + if names.contains(&token) { + out.push_str(hash); + out.push('-'); + } + out.push_str(&token); + token.clear(); + if ch != ';' { + out.push(ch); + } + } else { + token.push(ch); + } + } + out +} + +pub(super) fn strip_prefix(name: &str) -> String { + for prefix in ["-webkit-", "-moz-", "-o-", "-ms-"] { + if let Some(rest) = name.to_ascii_lowercase().strip_prefix(prefix) { + return rest.to_string(); + } + } + name.to_ascii_lowercase() +} + +pub(super) fn unescape_ident(name: &str) -> String { + let mut out = String::new(); + let mut escaped = false; + for ch in name.chars() { + if escaped { + out.push(ch); + escaped = false; + } else if ch == '\\' { + escaped = true; + } else { + out.push(ch); + } + } + out +} + +fn escape_comment_close(source: &str) -> String { + source.replace("*/", "*\\/") +} + +fn replace_range(source: &str, start: usize, end: usize, replacement: &str) -> String { + format!("{}{}{}", &source[..start], replacement, &source[end..]) +} + +fn apply_edits( + source: &str, + start: usize, + end: usize, + mut edits: Vec<(usize, usize, String)>, +) -> String { + edits.sort_by_key(|x| (x.0, x.1)); + let mut out = String::new(); + let mut cursor = start; + for (a, b, replacement) in edits { + if a < cursor { + continue; + } + out.push_str(&source[cursor..a]); + out.push_str(&replacement); + cursor = b; + } + out.push_str(&source[cursor..end]); + out +} diff --git a/packages/compiler/src/tsrx/style/tests.rs b/packages/compiler/src/tsrx/style/tests.rs new file mode 100644 index 000000000..9ba0e8b31 --- /dev/null +++ b/packages/compiler/src/tsrx/style/tests.rs @@ -0,0 +1,343 @@ +use super::*; + +fn input<'a>(css: &'a str, elements: &'a [Element], kind: StyleKind) -> StyleInput<'a> { + StyleInput { + css, + location: StyleLocation { + filename: "App.tsrx", + line: 3, + column: 4, + }, + elements, + kind, + minify: false, + } +} + +#[test] +fn matches_sha256_hash_and_strips_carriage_returns() { + let output = compile_style(input( + ".a .b:hover { color:red }", + &[], + StyleKind::Expression, + )) + .unwrap(); + assert_eq!(output.hash, "tsrx-f5ea950f"); + let cr = compile_style(StyleInput { + css: ".a\r {x:y}", + location: StyleLocation { + filename: "A\r.tsrx", + line: 1, + column: 0, + }, + elements: &[], + kind: StyleKind::Expression, + minify: false, + }) + .unwrap(); + let plain = compile_style(StyleInput { + css: ".a {x:y}", + location: StyleLocation { + filename: "A.tsrx", + line: 1, + column: 0, + }, + elements: &[], + kind: StyleKind::Expression, + minify: false, + }) + .unwrap(); + assert_eq!(cr.hash, plain.hash); +} + +#[test] +fn scopes_first_selector_then_uses_where() { + let mut parent = Element::native(1, "div").with_static_attr("class", "a"); + parent.children.push(ElementChild::Element( + Element::native(2, "span").with_static_attr("class", "b"), + )); + let roots = [parent]; + let output = + compile_style(input(".a .b:hover { color:red }", &roots, StyleKind::Block)).unwrap(); + assert_eq!( + output.css, + ".a.tsrx-f5ea950f .b:where(.tsrx-f5ea950f):hover { color:red }" + ); +} + +#[test] +fn expression_map_is_sorted_and_prunes_unreachable_rules() { + let css = + "div { color: red; }.z {a:b}.a {c:d}:global(.badge) {e:f}:global(body) { margin: 0; }"; + let output = compile_style(input(css, &[], StyleKind::Expression)).unwrap(); + assert_eq!( + output + .class_map + .iter() + .map(|x| x.class_name.as_str()) + .collect::>(), + ["a", "badge", "z"] + ); + assert!(output.css.starts_with("/* (unused) div { color: red; }*/")); + assert!(output.css.contains(".badge {e:f}")); + assert!( + output + .css + .contains("/* (unused) :global(body) { margin: 0; }*/") + ); +} + +#[test] +fn rewrites_keyframes_and_animation_names() { + let css = "@keyframes spin {from{x:y}to{x:z}} .a {animation: 1s spin, none}"; + let output = compile_style(input(css, &[], StyleKind::Expression)).unwrap(); + assert_eq!(output.hash, "tsrx-6209913b"); + assert_eq!( + output.css, + "@keyframes tsrx-6209913b-spin {from{x:y}to{x:z}} .a.tsrx-6209913b {animation: 1s tsrx-6209913b-spin, none}" + ); +} + +#[test] +fn preserves_global_blocks_with_parity_comments() { + let css = ":global { body { margin: 0 } }"; + let output = compile_style(input(css, &[], StyleKind::Block)).unwrap(); + assert_eq!(output.hash, "tsrx-24bf0053"); + assert_eq!(output.css, "/* :global {*/ body { margin: 0 } /*}*/"); +} + +#[test] +fn prunes_against_element_tree_and_tracks_scoped_ids() { + let mut section = Element::native(1, "section").with_static_attr("class", "card"); + section + .children + .push(ElementChild::Element(Element::native(2, "h2"))); + let roots = [section]; + let css = ".card {x:y}.card h2 {a:b}.card ol {c:d}"; + let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap(); + assert!(output.css.contains(".card.")); + assert!(output.css.contains("h2:where(")); + assert!(output.css.contains("/* (unused) .card ol {c:d}*/")); + assert_eq!(output.scoped_elements, BTreeSet::from([1, 2])); + + // Dynamic tags, attributes, and child regions are explicit + // conservative boundaries in the frontend model. + let dynamic = Element { + id: 3, + kind: ElementKind::Dynamic, + attributes: vec![Attribute { + name: "class".into(), + value: Some(AttributeValue::Dynamic), + }], + has_spread: false, + children: vec![ElementChild::Dynamic], + }; + let component = Element { + id: 4, + kind: ElementKind::Component, + attributes: Vec::new(), + has_spread: false, + children: Vec::new(), + }; + let dynamic_roots = [dynamic, component]; + let conservative = compile_style(input( + ".runtime-class {x:y}", + &dynamic_roots, + StyleKind::Block, + )) + .unwrap(); + assert!(!conservative.css.contains("(unused)")); + assert!(conservative.css.contains(".runtime-class.tsrx-")); + assert!(conservative.scoped_elements.contains(&3)); +} + +#[test] +fn supports_nested_rules_and_global_keyframes() { + let css = ".card { color:green; span {color:red} &:hover {color:blue} } @keyframes -global-pulse {from{x:y}}"; + let output = compile_style(input(css, &[], StyleKind::Expression)).unwrap(); + assert_eq!(output.hash, "tsrx-25e005fa"); + assert_eq!( + output.css, + ".card.tsrx-25e005fa { color:green; span.tsrx-25e005fa {color:red} &:hover {color:blue} } @keyframes pulse {from{x:y}}" + ); +} + +#[test] +fn preserves_outer_trivia_and_comments_unused_selector_runs() { + let roots = [ + Element::native(1, "div").with_static_attr("class", "a"), + Element::native(2, "div").with_static_attr("class", "c"), + ]; + let css = " .a, .b, .c {x:y}\n "; + let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap(); + assert_eq!( + output.css, + " .a.tsrx-99f3dd6b /* (unused) .b*/, .c.tsrx-99f3dd6b {x:y}\n " + ); +} + +#[test] +fn parses_and_conservatively_keeps_column_combinators() { + let roots = [Element::native(1, "div").with_static_attr("class", "b")]; + let css = ".a || .b {x:y}"; + let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap(); + assert_eq!(output.hash, "tsrx-f8aa311b"); + assert_eq!(output.css, ".a || .b.tsrx-f8aa311b {x:y}"); +} + +#[test] +fn preserves_nth_of_selector_lists_while_pruning_conservatively() { + let roots = [Element::native(1, "li")]; + let css = "li:nth-child(2n + 1 of .featured, :global(.external)) {x:y}"; + let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap(); + assert_eq!(output.hash, "tsrx-10770a85"); + assert_eq!( + output.css, + "li.tsrx-10770a85:nth-child(2n + 1 of .featured, :global(.external)) {x:y}" + ); +} + +#[test] +fn preserves_nth_last_child_formulas() { + let roots = [Element::native(1, "li")]; + let css = "li:nth-last-child(odd) {x:y}"; + let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap(); + assert_eq!(output.hash, "tsrx-0d1d03e4"); + assert_eq!(output.css, "li.tsrx-0d1d03e4:nth-last-child(odd) {x:y}"); +} + +#[test] +fn removes_global_modifier_whitespace_and_adds_nested_ampersand() { + let roots = [Element::native(1, "div")]; + let outer = compile_style(input("div :global.x {a:b}", &roots, StyleKind::Block)).unwrap(); + assert_eq!(outer.css, "div.tsrx-a070b2d0.x {a:b}"); + + let nested = compile_style(input("div { :global.x {a:b} }", &roots, StyleKind::Block)).unwrap(); + assert_eq!(nested.css, "div.tsrx-eec6818f { &.x {a:b} }"); +} + +#[test] +fn components_do_not_match_native_type_selectors() { + let roots = [Element { + id: 1, + kind: ElementKind::Component, + attributes: Vec::new(), + has_spread: false, + children: Vec::new(), + }]; + let output = compile_style(input("div {x:y}", &roots, StyleKind::Block)).unwrap(); + assert_eq!(output.hash, "tsrx-3853b06d"); + assert_eq!(output.css, "/* (unused) div {x:y}*/"); +} + +#[test] +fn matches_the_complete_dynamic_attribute_whitelist() { + let whitelist: &[(&str, &[&str])] = &[ + ("details", &["open"]), + ("dialog", &["open"]), + ("form", &["novalidate"]), + ( + "iframe", + &[ + "allow", + "allowfullscreen", + "allowpaymentrequest", + "loading", + "referrerpolicy", + ], + ), + ("img", &["loading"]), + ( + "input", + &[ + "accept", + "autocomplete", + "capture", + "checked", + "disabled", + "max", + "maxlength", + "min", + "minlength", + "multiple", + "pattern", + "placeholder", + "readonly", + "required", + "size", + "step", + ], + ), + ("object", &["typemustmatch"]), + ("ol", &["reversed", "start", "type"]), + ("optgroup", &["disabled"]), + ("option", &["disabled", "selected"]), + ("script", &["async", "defer", "nomodule", "type"]), + ("select", &["disabled", "multiple", "required", "size"]), + ( + "textarea", + &[ + "autocomplete", + "disabled", + "maxlength", + "minlength", + "placeholder", + "readonly", + "required", + "rows", + "wrap", + ], + ), + ( + "video", + &["autoplay", "controls", "loop", "muted", "playsinline"], + ), + ]; + for &(tag, attributes) in whitelist { + let roots = [Element::native(1, tag)]; + for &attribute in attributes { + let css = format!("{tag}[{attribute}]{{x:y}}"); + let output = compile_style(input(&css, &roots, StyleKind::Block)).unwrap(); + assert!( + !output.css.contains("(unused)"), + "{tag}[{attribute}] was pruned" + ); + } + } + + let roots = [ + Element::native(1, "input"), + Element::native(2, "iframe"), + Element::native(3, "form"), + ]; + let css = + "input[placeholder] {x:y} iframe[loading] {a:b} form[novalidate]{c:d} input[notreal]{d:e}"; + let output = compile_style(input(css, &roots, StyleKind::Block)).unwrap(); + assert_eq!(output.hash, "tsrx-f5567425"); + assert_eq!( + output.css, + "input[placeholder].tsrx-f5567425 {x:y} iframe[loading].tsrx-f5567425 {a:b} form[novalidate].tsrx-f5567425{c:d} /* (unused) input[notreal]{d:e}*/" + ); +} + +#[test] +fn optionally_preserves_class_map_selectors_for_style_refs() { + let output = compile_style_with_class_map_selectors( + input(".kept {x:y} div {a:b}", &[], StyleKind::Block), + true, + ) + .unwrap(); + assert_eq!(output.hash, "tsrx-6c055f88"); + assert_eq!( + output.css, + ".kept.tsrx-6c055f88 {x:y} /* (unused) div {a:b}*/" + ); + assert_eq!(output.class_map[0].class_name, "kept"); +} + +#[test] +fn rejects_global_in_the_middle() { + let error = + compile_style(input(".a :global(.x) .b {x:y}", &[], StyleKind::Expression)).unwrap_err(); + assert!(error.message.contains("not in the middle")); +} diff --git a/packages/compiler/src/tsrx/style_projection.rs b/packages/compiler/src/tsrx/style_projection.rs new file mode 100644 index 000000000..8553db47e --- /dev/null +++ b/packages/compiler/src/tsrx/style_projection.rs @@ -0,0 +1,737 @@ +//! Frontend planning for TSRX scoped styles. +//! +//! This module translates parser tape into the CSS engine's element model, +//! assigns style actions to authored nodes, and prepares renderer-facing hash +//! and ref metadata. It intentionally owns no general TSRX construct emission. + +use std::collections::{BTreeMap, BTreeSet}; + +use super::project::ProjectError; +use super::{ + semantic::SolidTsrxModule, + style::{ + self, Attribute, AttributeValue, ClassMapEntry, Element, ElementChild, ElementKind, + StyleInput, StyleKind, StyleLocation, + }, + tape::{self, Node}, +}; + +#[derive(Clone)] +pub(super) enum StyleAction { + Remove, + ClassMap(Vec), + EmptyElement, +} + +#[derive(Clone)] +pub(super) struct RefSetup<'t> { + pub(super) target: Node<'t>, + pub(super) class_map: Vec, + pub(super) temp_name: Option, +} + +pub(super) struct StyleProjection<'t> { + pub(super) actions: BTreeMap, + pub(super) element_hashes: BTreeMap>, + pub(super) owner_setups: BTreeMap>>, + pub(super) css: String, + pub(super) css_hash: Option, +} + +pub(super) fn plan<'s, 't>( + source: &'s str, + filename: &'s str, + module: &SolidTsrxModule<'t>, +) -> Result, ProjectError> { + StyleProcessor::process(source, filename, module.root) +} + +struct StyleProcessor<'s, 't> { + source: &'s str, + filename: &'s str, + consumed: BTreeSet, + actions: BTreeMap, + element_hashes: BTreeMap>, + owner_setups: BTreeMap>>, + stylesheets: Vec<(String, String)>, + identifiers: BTreeSet, + next_temp: usize, +} + +impl<'s, 't> StyleProcessor<'s, 't> { + fn process( + source: &'s str, + filename: &'s str, + root: Node<'t>, + ) -> Result, ProjectError> { + let mut identifiers = BTreeSet::new(); + tape::walk(root, &mut |node| { + if node.ty() == "Identifier" + && let Some(name) = node.str_field("name") + { + identifiers.insert(name.to_string()); + } + true + }); + let mut processor = Self { + source, + filename, + consumed: BTreeSet::new(), + actions: BTreeMap::new(), + element_hashes: BTreeMap::new(), + owner_setups: BTreeMap::new(), + stylesheets: Vec::new(), + identifiers, + next_temp: 0, + }; + processor.visit(root, None)?; + let css = processor + .stylesheets + .iter() + .map(|(css, _)| css.as_str()) + .collect(); + let hashes: Vec<_> = processor + .stylesheets + .iter() + .map(|(_, hash)| hash.as_str()) + .collect(); + Ok(StyleProjection { + actions: processor.actions, + element_hashes: processor.element_hashes, + owner_setups: processor.owner_setups, + css, + css_hash: (!hashes.is_empty()).then(|| hashes.join(" ")), + }) + } + + fn visit(&mut self, node: Node<'t>, parent: Option>) -> Result<(), ProjectError> { + if node.ty() == "JSXStyleElement" { + let start = span_of(node)?.0; + if !self.consumed.contains(&start) { + if is_style_expression_position(parent) { + self.compile_expression_style(node)?; + } else { + self.actions.insert(start, StyleAction::EmptyElement); + } + } + return Ok(()); + } + + if node.ty() == "JSXCodeBlock" + && let Some(render) = node.node_field("render") + && is_native_render_root(render) + { + self.prepare_runtime_scope(node, render)?; + } else if matches!(node.ty(), "JSXElement" | "JSXFragment") { + self.prepare_runtime_scope(node, node)?; + } + + if node.ty() == "JSXForExpression" { + self.mark_unowned_styles(node); + return Ok(()); + } + let skipped_pending = (node.ty() == "JSXTryExpression") + .then(|| node.node_field("pending")) + .flatten(); + for child in semantic_children(node) { + if skipped_pending.is_some_and(|pending| pending.span() == child.span()) { + self.mark_unowned_styles(child); + } else { + self.visit(child, Some(node))?; + } + } + Ok(()) + } + + fn mark_unowned_styles(&mut self, node: Node<'t>) { + if is_function_or_class_boundary(node) { + return; + } + if node.ty() == "JSXStyleElement" { + if let Some((start, _)) = node.span() + && !self.consumed.contains(&start) + { + self.actions.insert(start, StyleAction::EmptyElement); + } + return; + } + for child in structural_children(node, StructuralMode::Runtime) { + self.mark_unowned_styles(child); + } + } + + fn consume_runtime_styles(&mut self, node: Node<'t>) { + if is_function_or_class_boundary(node) { + return; + } + if node.ty() == "JSXStyleElement" { + if let Some((start, _)) = node.span() { + self.consumed.insert(start); + self.actions.insert(start, StyleAction::Remove); + } + return; + } + for child in structural_children(node, StructuralMode::Runtime) { + self.consume_runtime_styles(child); + } + } + + fn prepare_runtime_scope( + &mut self, + setup_owner: Node<'t>, + render_owner: Node<'t>, + ) -> Result<(), ProjectError> { + let render_children = structural_children(render_owner, StructuralMode::Runtime); + let mut styles = Vec::new(); + for child in &render_children { + collect_runtime_styles(*child, &self.consumed, &mut styles); + } + if styles.len() > 1 { + return Err(error( + "TSRX fragments can only have one style tag", + styles[1], + )); + } + let Some(style_node) = styles.first().copied() else { + return Ok(()); + }; + let style_start = span_of(style_node)?.0; + if self.consumed.contains(&style_start) { + return Ok(()); + } + + let (css, location) = self.style_source(style_node)?; + let roots = build_style_elements(&render_children); + let mut annotatable = BTreeSet::new(); + collect_annotatable_ids(&roots, &mut annotatable); + let refs = style_ref_targets(style_node); + let output = style::compile_style_with_class_map_selectors( + StyleInput { + css, + location, + elements: &roots, + kind: StyleKind::Block, + minify: false, + }, + !refs.is_empty(), + ) + .map_err(|style_error| self.style_error(style_node, style_error))?; + if !refs.is_empty() { + let owner_start = span_of(setup_owner)?.0; + for target in refs { + self.add_ref_setup(owner_start, target, output.class_map.clone()); + } + } + for id in annotatable { + self.element_hashes + .entry(id) + .or_default() + .push(output.hash.clone()); + } + self.consume_runtime_styles(render_owner); + self.stylesheets.push((output.css, output.hash)); + Ok(()) + } + + fn compile_expression_style(&mut self, style_node: Node<'t>) -> Result<(), ProjectError> { + let start = span_of(style_node)?.0; + let (css, location) = self.style_source(style_node)?; + let output = style::compile_style(StyleInput { + css, + location, + elements: &[], + kind: StyleKind::Expression, + minify: false, + }) + .map_err(|style_error| self.style_error(style_node, style_error))?; + self.actions + .insert(start, StyleAction::ClassMap(output.class_map)); + self.stylesheets.push((output.css, output.hash)); + Ok(()) + } + + fn style_source(&self, node: Node<'t>) -> Result<(&'s str, StyleLocation<'s>), ProjectError> { + let css = node.str_field("css").ok_or_else(|| { + error( + "A TSRX \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 \n \n
    \n;", + ); + assert!( + message.contains("TSRX fragments can only have one style tag (3:2)"), + "style diagnostic: {message}" + ); +} + +#[test] +fn rejects_return_inside_an_if_branch() { + let message = compile_error( + "export function C({ ok }) @{\n
    \n @if (ok) {\n return

    no

    ;\n }\n
    \n}\n", + ); + assert!( + message.contains("Return statements are not allowed"), + "@if return diagnostic: {message}" + ); +} + +#[test] +fn rejects_control_flow_and_structural_early_errors() { + let cases = [ + ( + "return escaping @for", + "export function C({ xs }) @{
      @for (const x of xs) { return
    • {x}
    • ; }
    }", + "Return statements are not allowed", + ), + ( + "continue escaping @for", + "export function C({ xs }) @{
      @for (const x of xs) { continue;
    • {x}
    • }
    }", + "Continue statements are not allowed", + ), + ( + "break escaping @switch", + "export const C = ({ x }) => @switch (x) { @case 1: { break;

    } };", + "Break statements are not allowed", + ), + ( + "for-await", + "export function C({ xs }) @{

      @for await (const x of xs) {
    • {x}
    • }
    }", + "`for await` is not supported", + ), + ( + "for-in", + "export function C({ obj }) @{
      @for (const key in obj) {
    • {key}
    • }
    }", + "@for must iterate with for...of", + ), + ( + "statement after output", + "export function C() @{

    ; const x = 1; }", + "render expression precedes another statement", + ), + ( + "multiple output nodes", + "export function C() @{

    ; }", + "render expression precedes another statement", + ), + ( + "@finally", + "export const C = () => @try {

    } @finally {

    };", + "expected an `@pending` or `@catch` clause", + ), + ( + "spaced statement-container sigil", + "export function C() @ {

    }", + "Expected a semicolon", + ), + ( + "spaced lazy-pattern sigil", + "export function C({ x }) @{ const & { value } = x;

    {value}

    }", + "Unexpected token", + ), + ]; + + for (name, source, expected) in cases { + let message = compile_error(source); + assert!( + message.contains(expected), + "{name} diagnostic must contain {expected:?}: {message}" + ); + } +} + +#[test] +fn unicode_offsets_preserve_authored_diagnostic_coordinates() { + let message = compile_error( + "const emoji = \"🚀\";\nexport function C() @{\n
    \n @if (true) { return

    ; }\n

    \n}\n", + ); + assert!( + 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] +fn rejects_statement_containers_without_rendered_output() { + let message = compile_error("export function C() @{\n const x = 1;\n}\n"); + assert!( + message.contains("A TSRX statement container is missing its rendered output node"), + "renderless container diagnostic: {message}" + ); +} + +#[test] +fn parse_errors_carry_authored_line_and_column() { + let error = compile( + "const broken = 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("