Installation
The quickest way: create-ramonda
The scaffolder sets up a runnable project for you, with the build already configured:
npm create ramonda@latest my-app
It asks a couple of questions — a client-side app or a server-rendered one, and which packages and tooling to add (router, lens, testing, devtools, and Biome for lint + format) — then:
cd my-app
npm install # unless you let it install for you
npm run dev
Open the address it prints and you have a working Ramonda app. (pnpm create ramonda
and yarn create ramonda work too.)
If you're starting fresh, stop here — the rest of this page is for adding Ramonda to a project you already have.
Adding Ramonda to an existing project
npm install @ramonda/core
npm install -D @ramonda/build
Ramonda has no runtime dependencies. It needs three settings from your bundler, and
@ramonda/build carries them so you name none:
// vite.config.ts
import { defineConfig } from "vite";
import { ramonda } from "@ramonda/build/vite";
export default defineConfig({ plugins: [ramonda()] });
For a build that is a script rather than a config — an esbuild bundle for a server —
the same settings spread in, and the plugin refuses a target that would break them:
import { build } from "esbuild";
import { ramonda, ramondaOptions } from "@ramonda/build/esbuild";
await build({
plugins: [ramonda()],
...ramondaOptions,
entryPoints: ["src/entry-server.tsx"],
bundle: true,
platform: "node",
outfile: "dist/server/entry-server.js",
});
The package is ESM only, so your project needs "type": "module" in its
package.json. A project from create-ramonda already has all of this.
You do not need to define
__DEV__. The published@ramonda/coreships separate development and production builds and picks the right one automatically — you get the diagnostics ondevand a stripped build onbuild.
What those settings are, and why they are not yours to keep
The JSX transform is the automatic one, pointed at @ramonda/core. The compiler
writes one import per file; nothing is injected into your module scope. The section
below says why that matters.
Decorators have to be compiled away. @state, @compute and the rest are TC39
stage-3, and no engine parses them — so the build has to lower them, and whether it
does comes down to target. esbuild lowers for every target except esnext, and
esnext is also esbuild's default.
So a build that says nothing about a target has already chosen the one value that
breaks, and nothing tells you: it succeeds, prints no warning, and emits a file that
dies with SyntaxError: Invalid or unexpected token the first time a browser reads
it. The build and the browser are the two ends of that gap — everything in between
looks like it worked.
That shipped here once. It is why the settings live in a package rather than in a paragraph asking you to copy three lines correctly, in every bundler config, forever.
By hand, if you must
For a bundler @ramonda/build does not cover, the three settings are:
// vite.config.ts — the long way
import { defineConfig } from "vite";
export default defineConfig({
esbuild: {
jsx: "automatic",
jsxImportSource: "@ramonda/core",
// Anything except `esnext`. This is the one that decides whether the app runs.
target: "es2022",
},
});
ramonda-check-bundle, from @ramonda/check, parses what your
build emitted and fails if any of it is unparseable — which is the second line of
defence for exactly this, and worth running whichever way you configure it.
tsconfig
{
"compilerOptions": {
"target": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"jsxImportSource": "@ramonda/core",
"strict": true
}
}
That is the whole of it. There is no factory to name, nothing to inject into every module, and no
global.d.ts declaring an identifier your source never mentions.
"react-jsx" is TypeScript's own name for the automatic runtime — the name is historical and
nothing comes with it. The compiler adds one import per file, and jsxImportSource says where from.
So a .tsx file that writes <p/> gets import { jsx } from "@ramonda/core/jsx-runtime" at the
top, written for it.
The classic transform is what needs a name in scope, and a name in scope is a name that can be
taken. A bundler injects an identifier only if it is not already bound, so a helper of your own
called h silently won and every tag in that file called it — no error, no warning, a page quietly
built out of whatever your function returned. An import the compiler writes cannot be shadowed,
which is why this is the arrangement now.
Building a vnode by hand
For the rare thing JSX cannot say — a tag name that is a value, children you have to spread — the
factory is exported as __h:
import { __h } from "@ramonda/core";
const node = __h(tag, attributes, ...children);
Compiled JSX never calls it; that goes through the runtime import above. The name is deliberately one you would not reach for by habit, because reaching for it usually means a tag would have been clearer.
Check it works
If that counts up, you're set.