Custom JavaScript plugins
Installing oxc-tsrx puts an oxlint on your PATH that already lints .tsrx.
Write one ordinary Oxlint JavaScript plugin, list it in .oxlintrc.json, and it
runs on .js, .ts, .jsx, and .tsx directly and on .tsrx through a TSX
copy, reporting at the line and column you wrote. Two things to know first:
- The
.tsrxhalf costs one extra parse per file, andoxlintsays so on stderr. See turning the extra parse off. - Your rule sees TSRX control syntax as the JavaScript it compiles to, not as
@ifand@fornodes. See what your rule sees on.tsrx.
On a Vite+ app, read in a Vite+ project as well: the
oxlint on your PATH there is Vite+'s, not this package's.
Set up the project#
You need Node.js 20.19 or newer, and one install (Getting Started has the per-host details):
npm install oxc-tsrx@0.2.3pnpm add oxc-tsrx@0.2.3yarn add oxc-tsrx@0.2.3bun add oxc-tsrx@0.2.3Save this as src/TaskList.tsrx:
type Task = { id: string; label: string; done: boolean };
export function TaskList({ tasks, ready }: { tasks: Task[]; ready: boolean }) @{
debugger;
@if (ready) {
<ul class="tasks">
@for (const task of tasks) {
<li>{task.label}</li>;
}
</ul>;
} @else {
<p>Loading tasks</p>;
}
}# The oxlint that oxc-tsrx installs already reads .tsrx npx oxlint src/TaskList.tsrx src/TaskList.tsrx:4:3: warning eslint(no-debugger): `debugger` statement is not allowed Found 1 warning and 0 errors. Finished in 7ms on 1 file with 95 rules using 1 thread.
The missing key and the debugger are both there on purpose. With no config
file and no build step, oxlint reported a built-in OXC rule at the line you
wrote. Those rules are Rust, so you cannot add one. What you can add is a plugin.
What are the nodes called?#
A lint rule is a set of callbacks named after node types. Every TSRX control block has its own node type, shaped like the JSX nodes you already know:
| You write | The node you visit |
|---|---|
@if / @else |
JSXIfExpression |
@for / @empty |
JSXForExpression |
@switch / @case / @default |
JSXSwitchExpression |
@try / @pending / @catch |
JSXTryExpression |
@{ } statement containers |
JSXCodeBlock |
Everything else uses the same shapes as oxc-parser; the Parsing
guide covers the tree, and
explore-tsrx-ast.mjs (opens in new tab)
prints the node types in a file of your own.
Write an oxlint JavaScript plugin#
An Oxlint plugin is an ES module exporting { meta, rules }, where each rule's
create(context) returns a visitor keyed by node type. Pass node to
context.report to put the diagnostic on the code you wrote, and meta.name is
the prefix the rules are configured under.
Copy
oxlint-demo-plugin.mjs (opens in new tab)
into your project. It has one rule, require-keyed-map, which visits
CallExpression and reports JSX returned from .map() without a key.
Oxlint only loads a plugin you list, and only enables a rule you turn on. Save
this as .oxlintrc.json:
{
"jsPlugins": ["./oxlint-demo-plugin.mjs"],
"rules": {
"tsrx-demo/require-keyed-map": "error"
}
}The rule needs an ordinary file to run on, so add
src/TaskRow.tsx (opens in new tab),
a React component whose .map() call has the missing-key problem.
# Your own JavaScript rule, running inside the oxlint you installed npx oxlint src/TaskRow.tsx src/TaskRow.tsx:11:9: error tsrx-demo(require-keyed-map): JSX returned from .map() should declare a `key` prop.
The same plugin on .tsrx#
Leave everything as it is and point the same command at the .tsrx file:
# Same plugin, same config, one .tsrx file npx oxlint src/TaskList.tsrx src/TaskList.tsrx:4:3: warning eslint(no-debugger): `debugger` statement is not allowed Found 1 warning and 0 errors. Finished in 63ms on 1 file with 95 rules using 1 thread. oxlint (oxc-tsrx): running JS plugins on 1 .tsrx file(s) by linting the TSX projection; this parses each of those files once more. Disable with "settings": { "oxcTsrx": { "jsPluginsOnTsrx": false } }.
That oxlint (oxc-tsrx): line is the disclosure: the .tsrx half costs one
more parse, and the command says so every time, naming the setting that turns it
off. Your rule found nothing here, because require-keyed-map wants a .map()
call and this file has an @for block. Give it something to find, as
src/TaskFeed.tsrx:
type Task = { id: string; label: string; done: boolean };
export function TaskFeed({ tasks }: { tasks: Task[] }) @{
const rows = tasks.map((task) => <li>{task.label}</li>);
<ul class="feed">{rows}</ul>;
}# Your own rule, reporting on the .tsrx file you wrote npx oxlint src/TaskFeed.tsrx src/TaskFeed.tsrx:4:36: error tsrx-demo(require-keyed-map): JSX returned from .map() should declare a `key` prop. Found 0 warnings and 1 error. Finished in 63ms on 1 file with 95 rules using 1 thread. oxlint (oxc-tsrx): running JS plugins on 1 .tsrx file(s) by linting the TSX projection; this parses each of those files once more. Disable with "settings": { "oxcTsrx": { "jsPluginsOnTsrx": false } }.
Your own rule, at line 4, column 36: the column of the <li> you wrote. One
command over a directory does both halves, and the editor needs nothing extra,
since the language server runs the same plugin on the buffer. Editor
integration names
its one activation step.
In a Vite+ project#
Two things differ, and both are Vite+'s doing rather than this package's.
node_modules/.bin/oxlint belongs to Vite+. Running it exits 1 and tells
you to run vp lint. Do that, or use this package's own
node_modules/oxc-tsrx/bin/oxlint. The editor needs nothing further: setup
points the extension at this package.
vp lint does not read .oxlintrc.json. Vite+ keeps lint configuration in
the lint block of vite.config.ts, so a rule you want on both surfaces is
declared twice. Add { name: "house-rules", specifier: "./house-rules.mjs" } to
lint.jsPlugins and your rule to lint.rules, deleting nothing the scaffold
wrote. The finished
file (opens in new tab)
is in the examples directory.
Making .tsrx a language in the editor is a separate job owned by the TSRX
toolchain. Vite and
Vite+ has that
list and the type-aware dependency the scaffold needs.
What your rule sees on .tsrx#
A .tsrx file is linted by a Rust process with no Node.js runtime, so oxlint
runs the published Oxlint binary over a legal TSX copy of your file, with your
own .oxlintrc.json. Severities, rule options, extends, and overrides
resolve as they do elsewhere, and an installed Oxlint outside >=1.74.0 <2.0.0
is refused rather than used.
Your rule sees that copy. Four things follow from it:
TSRX control syntax is already compiled away, so a rule keyed on JSXForExpression never fires. Your rule does visit the compiled statement, but a report on one is dropped, because its span covers text the copy wrote.
@for (const task of tasks) {
<li>{task.label}</li>;
}for (const task of tasks) {
<li>{task.label}</li>;
}Measured one rule per node type: JSXElement reported 7 and dropped 0, while IfStatement, SwitchStatement, and FunctionDeclaration each reported 0 and dropped 1.
Your rule is handed the copy’s path. The path relative to your working directory survives, so a rule testing for src/ works, but one comparing an absolute path or expecting a .tsrx extension does not. The diagnostic still lands on your file.
src/View.tsrx<temporary directory>/src/View.tsrx.tsxThe copy adds markers and wrappers matching nothing you typed, and a report on one of those has no position in your file to point at. It is dropped, and the count is never silent.
The count goes to stderr on a second oxlint (oxc-tsrx): line, into oxcTsrx.jsPluginProjection.unmapped under --format=json, and into one js-plugins-unmapped warning in your editor.
The copy is named View.tsrx.tsx, which **/*.tsrx does not match, so oxlint emits each of your overrides[].files and excludeFiles globs with .tsx appended as well.
"files": ["**/*.tsrx"]"files": ["**/*.tsrx", "**/*.tsrx.tsx"]A config reached through extends does not get that rewrite yet, so put .tsrx-targeted overrides in the config that names jsPlugins.
For a rule that must report on TSRX control flow itself, see the ESLint route.
Turning the extra parse off#
To stop paying the second parse, add "settings": { "oxcTsrx": { "jsPluginsOnTsrx": false } } to .oxlintrc.json. Your plugins keep running on
ordinary files, and on .tsrx the command now refuses out loud rather than
dropping your rule and reporting success:
# With the lane switched off, the .tsrx half refuses out loud npx oxlint src/TaskFeed.tsrx oxlint (oxc-tsrx): JavaScript plugins are not hosted by the native TSRX lint target itself: it is a Rust process with no Node runtime. The `oxlint` command OXC for TSRX installs runs them on .tsrx for you, by linting the TSX projection with the published Oxlint binary and mapping every diagnostic back to your authored source. Run `oxlint` instead of this target, or remove the settings.oxcTsrx.jsPluginsOnTsrx false opt-out that turned that lane off
The same setting turns the editor's half off, where the refusal arrives as one
lint-unavailable diagnostic carrying the same text. The configuration
guide has the full
support matrix for the native .tsrx path.
When your rule must see @if and @for as TSRX nodes#
There is no released route for that today. Your rule always sees the TSX copy, where the control blocks have already become ordinary statements.
The repository carries a local ESLint adapter that does hand a rule the authored
tree, in
examples/custom-js-plugins (opens in new tab).
It is AST-only, since the parser API exposes no token stream. Running the same
rule inside Oxlint waits on OXC PR
#24262 (opens in new tab), a draft as of
2026-07-26.
The runnable version of this page is in
examples/custom-js-plugins (opens in new tab).
Oxlint is pinned and tested at 1.74.0. Last audited: 2026-07-27.