# 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
`` (self-closed) adds the theme's `$class` to every
element of the scope it sits in, so the theme's element and descendant rules
reach those elements as if the theme had been written there. A self-closed
apply adds no hash class of its own. `` 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.
Green system-ui
>
}
```
Every element of `Panel` carries `A theme.$class`; the nested `div` carries
`A B theme.$class`. The CSS order is `base`, `theme`, scope A, scope B.
#### Opt single elements in with `$class`
`apply` reaches every element of a scope. To pick elements instead, put
`theme.$class` on them:
- `class={theme.$class}` opts one element in: it matches the theme's element and
descendant rules exactly as an element of an applying scope does, and no other
element is affected. `theme.card` stays `' card'`.
- `theme.$class` is an ordinary string: read it directly, destructure it
(`const { $class } = theme`), or pass the theme object along, and it works
the same.
- Pass `theme.$class` to a child component through a prop to opt its elements
in; on the child's elements it comes before the child's own hash class.
- `$class` and `apply` combine. Listing several themes' `$class` values on one
element composes them there the way `apply={[a, b]}` does on a scope.
```tsrx
function Card({ parentClass }: { parentClass: string }) @{
<>
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]);
>
};
```
### 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 | `