Introduction

A .tsrx file is TypeScript with HTML-like markup in it, plus blocks like @if and @for for showing something only sometimes, or once per item in a list. yuku-tsrx reads those files.

It is a parser, an analyzer, and a code generator for .tsrx, written in Zig, with a JavaScript API on top of a native addon. You call it from Node, you get back a real TSRX syntax tree, and you can hand a tree back to it and get source out again.

import { parseModule, walk } from "yuku-tsrx";

const program = parseModule(source, "Cart.tsrx");

walk(program, {
  JSXCodeBlock(node) {
    // every TSRX code block in the file
  },
});

What a .tsrx file looks like#

export function Cart({ items }): unknown @{
  const total = items.length;

  <section className="cart">
    @if (total > 0) {
      @for (const item of items; index i; key item.id) {
        <span>{i}:{item.id}</span>
      } @empty {
        <span>empty</span>
      }
    } @else {
      <span>no cart</span>
    }
    <style>.cart { display: grid; }</style>
  </section>
}

Three things in that file are not TypeScript and not JSX: the @{ } code block that lets statements and markup sit next to each other, the @if / @for directives with their ; index and ; key clauses and @empty branch, and the <style> element holding raw CSS. TSRX Syntax Support has each construct with a real example.

A dialect on Yuku, not a fork#

yuku-tsrx is built as a compile-time dialect on Yuku (opens in new tab), a JavaScript and TypeScript toolchain written in Zig. It is not a fork of Yuku and not a second parser engine beside it.

Yuku does all the JavaScript and TypeScript work. yuku-tsrx owns only the rules that are specific to TSRX, and reaches Yuku through the hook declarations in src/dialect/parser_extension.zig. A dialect is a plain struct of optional hook declarations resolved at compile time, so a Yuku built without a dialect compiles to exactly what it did before.

The other way to get here is to write a parallel engine, which is what the sibling project oxc-tsrx (opens in new tab) had to do: OXC (opens in new tab)'s parser has no extension point, so its TSRX support carries a 17,057-line parser engine that is mostly plain TypeScript parsing done a second time. The dialect design exists to avoid owning that copy. Zig/Yuku Dialect Core has the mechanism.

Five steps take a .tsrx file to the JavaScript API, and the interesting part of each one is who owns it:

The file you wrote. Nothing owns it yet, and nothing on disk changes at any point in what follows.

Yuku owns the JavaScript and TypeScript grammar. yuku-tsrx owns only the 20 answers below, declared in src/dialect/parser_extension.zig and resolved at compile time.

Yuku owns the ordinary nodes. yuku-tsrx owns these records, declared in src/dialect/schema.zig, and the parser produces those exact names rather than lowering TSRX to TSX.

The tree crosses into JavaScript as a single buffer, decoded on the JavaScript side rather than built node by node in the addon.

Three calls on the JavaScript side, each with its own guide.

export function Cart({ items }): unknown @{
  const total = items.length;

  <section className="cart">
    @if (total > 0) {
      @for (const item of items; index i; key item.id) {
        <span>{i}:{item.id}</span>
      } @empty {
        <span>empty</span>
      }
    } @else {
      <span>no cart</span>
    }
    <style>.cart { display: grid; }</style>
  </section>
}

Statement

statement_at_code_block statement_at_control_flow

Expression

expression_at_code_block expression_at_control_flow

Pattern

lazy_assignment_pattern binding_pattern can_start_binding

Function

function_body function_body_starts

For-of

for_of_tail

Module

module_specifier

JSX

jsx_child_at_code_block jsx_child_at_control_flow jsx_element_name jsx_element_after_open jsx_fragment_after_open validate_jsx_element_name jsx_names_match

Text

jsx_text_boundary jsx_text_value

The layout, and what the decoder does with it, is written up in The wire format underneath. The Zig side of it is src/dialect/transfer.zig and src/dialect/semantic_transfer.zig.

The same module compiled to WebAssembly is what runs in the playground and in the figures on the guide pages.

Who it is for#

This is a library for people building tooling that has to understand .tsrx source: a framework's TSRX plugin, a compiler, a bundler plugin, a codemod, an analysis tool.

The output is a real TSRX AST. Consumers pattern-match on the node type names directly, JSXCodeBlock, TSRXExpression, JSXStyleElement, JSXForExpression, and so on, so the parser produces those exact names rather than lowering TSRX to TSX. In Markless (opens in new tab), a function is a component if and only if its body is a JSXCodeBlock, so a lowering that erased the name would erase the answer.

parseModule(source, filename, options) is shaped as a drop-in for @tsrx/core's parseModule, which is the interface Markless already calls, so a consumer on that interface can swap engines without changing its call sites.

Status#

Nothing has been published to npm. The package name is yuku-tsrx and the version in npm/yuku-tsrx/package.json is 0.0.0. The extension points it builds against live in yuku-toolchain/yuku#164 (opens in new tab), which is open, so building today means a Yuku checkout of that branch in a sibling directory. Getting Started has the build, and Limitations has the full list of what does not exist.