# TSRX - AI/LLM Documentation ## Overview TSRX (TypeScript Render Extensions) is a **TypeScript language extension** for building declarative UIs. It compiles one `.tsrx` source file to **Octane**, **React**, **Preact**, **Solid**, **Vue**, **Hono**, or **Ripple**. TSRX is the source language; the target picks the runtime semantics. Think of TSRX as a spiritual successor to JSX: the same mental model of embedding UI directly in TypeScript, but with its own flavor. Control flow, scoped styles, and locals sit in the template as first-class syntax instead of being squeezed through expression slots, and the language stays aware of them all the way through to the compiled output. **Key characteristics:** - **One source, many targets**: `octane`, `@tsrx/react`, `@tsrx/preact`, `@tsrx/solid`, `@tsrx/vue`, `@tsrx/hono`, `@tsrx/ripple` - **JSX-shaped templates**: JSX elements, fragments, text, and expression containers use standard JSX node shapes - **JSX statement containers**: `@{ ... }` keeps TypeScript setup local to the JSX that uses it - **Declarative control flow**: `@if`, `@for`, `@switch`, and `@try` are JSX expressions whose bodies use `{...}` template blocks - **Structural output rules**: statement containers finish with one JSX element, fragment, or JSX control-flow expression - **Scoped styles**: sibling-scoped ` } ``` Single-element TSRX expressions are valid component bodies too: ```tsrx const icon = ; ``` **LLM DO:** - Use `function Name(props: Props) { return
; }` for single-root UI building blocks. - Use `export function Name(...) @{ ... }` when setup statements and rendered output share a template scope. - Use `export function Name(...) @{ <>... }` when public components render multiple siblings. - Put setup first inside `@{...}` and finish the container with one JSX element, JSX fragment, or JSX control-flow expression. - If you see setup statements followed by bare JSX in a normal function `{ ... }`, add the missing `@` before the opening brace. - Use module-scope ` }; ``` `:global(...)` marks part of a selector as unscoped; see "Escape the scope with `:global()`" below before using it. React keeps authored attributes as written, so use `className` for React host elements and React component props. Other TSRX targets commonly use `class`. Scoped style blocks accept only the `ref` and `apply` attributes. ### A block styles its siblings and everything below them A standalone `
`. - Sibling blocks share one hash class. A nested children list that holds blocks is a nested scope with a hash class of its own: two blocks among an element's children share a hash class, while a block in the enclosing fragment and a block among that element's children do not. - Every element gets the hash class of each scope around it, outer first. Outer rules reach nested scopes; inner rules never reach out. - Elements returned from a callback such as `items.map((item) =>
  • )` are outside the scope and get no hash class. - A standalone `;`). - A selector that matches nothing the block can reach, including a selector that only matches the containing element, is removed as unused and left as a `/* (unused) ... */` comment. ```tsrx export function Panel() @{ <>
    Black
    Black and bold

    No margin

    } ``` With `A` and `B` as the two hash classes, the compiled classes are `outer A`, `A` on the `
    `, `inner A B`, and `A` on the `

    `. ### Put a block and its output in one fragment A `@{ ... }` body and every `@if`, `@for`, `@switch`, or `@try` branch (each `@else`, `@empty`, `@case`, `@default`, `@pending`, and `@catch` body too) render exactly one output node, and a `

    Red

    }
  • } function Note({ open }: { open: boolean }) @{
    @if (open) { // Works: one output node, a fragment holding the block and the

    . <>

    Red

    }
    } ``` ### Raw CSS needs a template around it Raw CSS in ``: an ordinary element with an expression child, which the compiler never scopes and never gives a hash class. ### Later rules win CSS is output in source order, outer scopes first, and at equal specificity the rule output last wins: - Outer scopes come before the scopes nested in them, even when the nested scope is written first. - A scope's blocks stay together as one group, in source order. - Sibling scopes follow source order. - An assigned block (`const theme = ;`) is output at its declaration. - An applied theme comes before the block that applies it. ### A block in a branch styles only that branch A `
    @if (ready) { <>

    Ready

    } @else { <>

    Waiting

    }
    } ``` `.status` sits outside the branches and reaches both of them. `.ok` matches only the `

    ` in the ready branch and `.wait` only the `

    ` in the other one, and both rules are in the stylesheet at all times. ### Escape the scope with `:global()` `:global(...)` marks the wrapped part of a selector as unscoped: it gets no hash class. Everything outside the parentheses is still scoped, so where the `:global` sits decides how far the rule reaches. The block form `:global { ... }` does the same for every rule inside it, and both forms work with CSS nesting: - Bare `:global(.toast)` -> `.toast`: a page-wide rule that matches anywhere on the page (ancestors, siblings, other components), exactly like a rule in a global stylesheet. - Prefixed `.card :global(.note)` -> `.card. .note`: only elements below your scoped `.card`, a child component's internals included. It can never climb up. - Leading `:global(.theme-dark) .card` -> `.theme-dark .card.`: your own element, only when an ancestor carries the class. - Compound `.card:global(.is-open)` -> `.card..is-open`: your own element, with a class another library toggles on it. - `:global` in the middle of a selector (`.card :global(.x) .title`) is the `TSRX3011` error: it may only start or end a selector. - Block `:global { .toast { ... } body { ... } }` -> `.toast { ... } body { ... }`: the wrapper is dropped (left behind as a comment) and everything inside is unscoped. Several page-wide rules at once. - Nested block `.card { :global { .note { ... } .tag { ... } } }` -> `.card. { .note { ... } .tag { ... } }`: the same reach as the prefixed form, with the scoped prefix written once. `.card { :global(.note) { ... } }` is the same thing in selector form. - Plain nesting `.card { .note { ... } }` -> `.card. { .note. { ... } }`: both parts scoped. Specificity: a scoped rule adds one hash class to its first compound only; later compounds get `:where(.)`, which adds none (`.card .title` -> `.card. .title:where(.)`). So a scoped `.note` (0,2,0) beats a bare `:global(.note)` (0,1,0) from anywhere on the page; a `theme.$class` or class-map rule carries its hash and also beats a bare global; a prefixed `.card :global(.note)` (0,3,0) beats a child component's own `.note` rule (0,2,0). At equal specificity the later stylesheet in module order wins. Prefer passing `theme.$class` (or a class-map entry such as `theme.note`) as a prop over `:global` for a child you own: the dependency is a visible prop, the child decides which of its elements receive it, renaming a class inside the child cannot silently break the parent, and the hash keeps the rule on the elements that carry it. With `:global` the child has no say and cannot see who styles it. Keep `:global` for a child you cannot change, always with a scoped selector in front so the rule cannot reach ancestors or unrelated components, and keep it narrow: it overrides the child's own rules by specificity. To style several of the child's classes, nest one `:global { ... }` block under your scoped selector. Never write a bare `:global` selector or a top-level `:global { ... }` block for anything but page-level elements. `@import` is not allowed in a `; } function Article({ html }: { html: string }) @{ <>

    } ``` ### Global Keyframes Keyframes are scoped by default: the compiler renames each local `@keyframes` declaration using the style block's hash and rewrites matching `animation` and `animation-name` references in that block. To share keyframes across components, prefix the declared name with `-global-`. The compiler removes the prefix and adds no hash; references use the name without the prefix. ```tsrx function App() @{ <>
    } function Child() @{ <>
    Child content
    } ``` Other style blocks can use `animation: fadeIn 1s` or `animation-name: fadeIn` once the defining stylesheet is loaded. Only the keyframe name is global; selectors such as `.child` remain sibling-scoped. Keyframes inside a `:global { ... }` block also retain unscoped names. These rules also apply to vendor-prefixed keyframe at-rules and animation properties. For React, use `className` instead of `class` on host elements. ### Assigning a block gives you a theme object Assigning a `; // theme.$class -> 'tsrx-1a2b3c4d' // theme.dark -> 'tsrx-1a2b3c4d dark' const Badge = ({ class: className }: { class?: string }) => ( New ); // Ripple, Preact, Solid, and Vue component props; React uses className. const App = () => ; ``` ### `apply` adds a theme to a whole scope `` does both: it applies the theme and declares a scoped block for that scope. - `apply={[a, b]}` applies several themes in order. - A theme can itself apply another theme (`const accent = ; export const theme = ; // theme.$class -> base's hash, then theme's own hash ``` ```tsrx // panel.tsrx import { theme } from './theme.tsrx'; export function Panel() @{ <> Purple
    Black: the local rule beats the theme's green
    @{ <>
    Black and bold: A, B, and the theme all reach here
    } } export function Card() @{ <> // Self-closed: applies the theme, declares no rules of its own.

    Title

    } export function App() @{ const theme = ; <>
    Blue: opted in
    Red

    Untouched

    } ``` `div { color: blue }` is kept and scoped as `div.`. The opted-in `
    ` and `Card`'s `
    ` and `

    ` match it, and so does the `.card` `
    `, because `theme.card` carries the hash too (`.card` still wins on specificity); the `

    ` carries nothing from `theme`. Inside `Card`, the compiled class is `` `${parentClass} ` ``. ### Cascade layers pass through `@layer` rules inside a block are kept as written, and the selectors inside them are still scoped. TSRX does not build scoping on `@layer`: layers order rules globally and cannot be attached to one subtree of elements, while sibling scoping gives an order that can be read off the file and `apply` adds classes to exactly the elements of one scope. ## Target-specific behavior ### React and Preact Hooks run where they are written. TSRX does not move a hook inside a template branch, loop, switch, or try block into a generated component, so the Rules of Hooks apply as they do in TSX: give hook state that belongs to a branch or a list item its own component. React and Preact can also emit async component functions when template body `await` is supported by the target pipeline. An `await` in an `@for`, `@empty`, `@switch`, or `@if` body is awaited by that component, and a loop body finishes one item before it starts the next. ```tsrx import { useEffect, useState } from 'react'; const PostItem = ({ post }: { post: Post }) => @{ useEffect(() => { console.log('viewed ' + post.title); }, [post.title]);

  • {post.title}
  • }; const UserProfile = ({ user, posts }: { user: User | null; posts: Post[] }) => @{ const [tab, setTab] = useState('overview'); if (!user) { return null; } <>

    {user.name}

      @for (const post of posts; key post.id) { }
    }; ``` ### Solid `@if` lowers to ``, `@switch` lowers to `/`, `@for` lowers to ``, `@try/catch` lowers to Solid error primitives, and `@try/pending` lowers to loading primitives. Read props as `props.name` (or split them with `splitProps`) rather than destructuring them, so Solid's getter-based prop reactivity is preserved. ### Vue Vue output targets `vue-jsx-vapor` and `defineVaporComponent(...)`. It supports directive-prefixed template control flow, scoped styles, host `innerHTML`, and `@try/pending` lowering through Vue suspense APIs. Component-level `await` is not supported for Vue TSRX. ### Octane Octane compiles TSRX directly to DOM operations without a virtual DOM while keeping React-style function components and hooks. It supports TSRX control flow, scoped styles, client rendering, SSR, hydration, and Suspense. Octane dependency arrays are compiler-inferred when omitted, hooks use stable call-site slots, and text inputs use native `onInput` for per-edit updates. File-local `module server { ... }` blocks define server functions; importing their named exports from `'server'` produces RPC calls in client output. ### Ripple Ripple is both a compiler target and a runtime. See the Ripple llms.txt (https://www.ripple-ts.com/llms.txt) for `track()`, `effect()`, `mount()`, `hydrate()`, collection primitives, raw HTML props, context, portals, and transporting reactivity across function boundaries. Ripple also defines a proposal-aligned `module server { ... }` profile with imports from `server`. `module server` is an explicit nested module scope rather than a directive annotation. That structural boundary supports isolation and static analysis and helps hosts reject implicit cross-boundary captures and keep server-only dependencies out of client output. It is not interchangeable with a target-specific `"use server"` directive; each target documents whether such a directive has any separate meaning. ## Common patterns ### Bail out of template rules with an inline function If you want unconstrained JavaScript-only control flow, put it in a plain nested function and call it from an event handler or expression. ```tsrx export const Counter = () => @{ let count = 0; const increment = () => { if (count >= 10) { count = 0; } else { count += 1; } }; }; ``` ### Scoped locals next to the JSX that uses them ```tsrx const Cart = ({ items }: { items: Item[] }) => @{ const subtotal = items.reduce((sum, item) => sum + item.price, 0); const discount = subtotal > 100 ? 0.1 : 0;

    Your cart

    Subtotal: ${subtotal}

    Save: ${(subtotal * discount).toFixed(2)}

    }; ``` ## TypeScript `.tsrx` is a full superset of TypeScript. Props, generics, utility types, and imports are standard TS. The compiler preserves types end-to-end and emits framework-native TSX or target code. ```tsrx type Props = { items: T[]; render: (item: T) => any; }; const List = ({ items, render }: Props) =>
      @for (const item of items) {
    • {render(item)}
    • }
    ; ``` ### Type checking Two paths type-check `.tsrx` files; both use the same target-specific transform. - **TypeScript 5.9 or 6 (default):** install `@tsrx/typescript-plugin` and run `tsrx-tsc --noEmit` (a `tsc` wrapper that understands `.tsrx`; it hosts the plugin itself). Add `{ "name": "@tsrx/typescript-plugin" }` to `compilerOptions.plugins` only for editors other than VS Code: their TypeScript server loads it from there so `.ts` importers resolve `.tsrx` modules. VS Code ships the plugin and needs no entry. Requires `typescript@^5.9.3 || ^6.0.0`. - **TypeScript 7 (native, not complete yet):** install `@tsrx/content-mapper` and a TypeScript 7.1 nightly (`typescript@next`, `7.1.0-dev.20260923.1` or newer; the stable 7.0 releases have no content-mapper protocol), declare the mapper in `tsconfig.json`, and run `tsc --runExternalCode --noEmit`, or keep running `tsrx-tsc --noEmit`: with such a nightly installed it runs the native compiler with `--runExternalCode` itself, and refuses a tsconfig without the mapper entry rather than let TypeScript skip `.tsrx` files. TypeScript 5 and 6 ignore the `contentMappers` key, so one tsconfig and one script serve both paths. ```json { "tsrx": { "compiler": "@tsrx/react" }, "contentMappers": [ { "package": "@tsrx/content-mapper", "extensions": [".tsrx"] } ], "compilerOptions": { "jsx": "react-jsx", "jsxImportSource": "react" } } ``` Editors: in VS Code, VS Code's own TypeScript serves `.tsrx` files: its tsserver with the TSRX plugin the extension ships (no tsconfig `plugins` entry needed), or TypeScript 7.1 with the mapper from `tsconfig.json`. For TypeScript 7.1, install `typescript@next` in the project, set `"js/ts.experimental.useTsgo": true` and `"js/ts.tsdk.path": "node_modules/typescript"` in the VS Code settings, and install the TypeScript 7 extension (`TypeScriptTeam.native-preview`). The extension activates Microsoft's TypeScript extensions, leaving server selection to them, and registers `.tsrx` for project discovery on TypeScript 7. Other editors run `tsrx-language-server --stdio --typescript-backend=native` beside TypeScript 7's `tsc --lsp`. Gaps in TypeScript 7 support and the upstream issues behind them: https://github.com/tsrx-org/tsrx/issues/136. ## Quick reference | Feature | Syntax | | ---------------------- | ------------------------------------------------------ | | Component function | `function Name(props: T) { return
    ; }` | | Multi-child component | `function Name(...) @{ <>... }` | | Statement container | `function Name(...) @{ ... }` | | Native TSRX value | `const v =
    Hi
    ;` | | Native TSRX fragment | `const v = <>Hi;` | | Text content | `

    literal

    `, `

    {expr}

    ` | | Expression children | `` | | Prop shorthand | `` | | Conditional rendering | `@if (cond) { }` | | List rendering | `@for (const x of xs; index i; key x.id) {
  • }` | | Multi-branch | `@switch (x) { @case 'a': { } }` | | Error boundary | `@try { } @catch (e) { }` | | Async boundary | `@try { } @pending { }` | | Guard exit | `if (!user) { return null; }` | | Scoped style | `` (beside the elements) | | Nested scope style | `

    ` (own hash, styles `p`) | | Plain TSX style | `` (ordinary element, not scoped) | | Dynamic CSS value | `style={{ '--tone': tone }}` + `var(--tone)` | | Unscoped child styles | `.wrap :global(.their)` or `.wrap { :global { ... } }` | | Page-level state | `:global(.theme-dark) .card` | | Page-wide rule | a `.css` file; bare `:global(body)` for page-level only | | Cross-component class | ``; React uses `className` | | Theme hash ($class) | `const t = ;` then `t.$class` | | Theme (apply) | `