rogue

Quickstart

From zero to a rendered, reactive custom element in about a minute. By the end of this page you'll have a working dev server with a counter component on a routed page.

1. Install

Two packages: rogue itself and Vite (which runs the dev server and the build). Vite is a peer dependency rather than something we bundle so you control its version.

npm install @jjordy/rogue
npm install -D vite

2. Configure Vite

Three plugins, listed in the order they should run:

  • rogue() — the JSX → custom-element compiler. Looks at every .jsx / .tsx file and produces a hoisted template plus runtime bindings.
  • rogueRouter() — scans src/pages/ at build time and exposes a route manifest as the virtual module virtual:jsx-wc/routes.
  • rogueSsr() — gives the dev server SSR + server-action handling so npm run dev behaves like production. Optional for pure client-side apps; required if you have loader functions or actions.

Create vite.config.js at the project root:

import { defineConfig } from 'vite'
import { rogue, rogueRouter, rogueSsr } from '@jjordy/rogue/vite'

export default defineConfig({
  plugins: [rogue(), rogueRouter(), rogueSsr()],
  // Tell Vite's built-in esbuild to leave JSX alone — the rogue plugin
  // owns JSX transformation. Without this, esbuild would compile JSX
  // into React.createElement calls before our plugin sees it.
  esbuild: { jsx: 'preserve' },
})

3. Add the HTML shell

Vite needs an index.html at the project root. It's the entry point both in dev and in production — the file references your client-side mount script, and Vite injects asset URLs into it during build.

doctype html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>My rogue apptitle>
  head>
  <body>
    <div id="app">div>
    <script type="module" src="/src/main.ts">script>
  body>
html>

4. Mount the router

The mount script reads any SSR-prefetched data from window.__JSX_WC_DATA__ (set by the server during SSR) and hands it to the router so the first paint doesn't re-fetch. For a pure client-side app the conditional just degrades to mount(target, {}).

src/main.ts:

import { mount } from '@jjordy/rogue/router'

const target = document.getElementById('app')
if (!target) throw new Error('mount target #app not found')

const ssr = window.__JSX_WC_DATA__
mount(target, ssr ? {
  initialData: ssr.data,
  initialParams: ssr.params,
} : {})

5. Your first component

Components live in src/components/. The filename determines the custom-element tag name:

src/components/my-counter.jsx  →  <my-counter>

Inside defineComponent, the destructured props become observed attributes. Their types are inferred from the default values (number, boolean, string, array, object), so the browser automatically parses as start = 5 (a number) — not "5" (a string).

src/components/my-counter.jsx:

import { defineComponent, signal } from '@jjordy/rogue'

defineComponent(({ start = 0 }) => {
  const [count, setCount] = signal(start)
  return (
    <div>
      <button onClick={() => setCount(count() - 1)}>−button>
      <span>Count: {count()}span>
      <button onClick={() => setCount(count() + 1)}>+button>
      <style>{`
        div { display: inline-flex; gap: .5rem; align-items: center }
        button { width: 2rem }
      `}style>
    div>
  )
})

A few things happen automatically here:

  • The filename becomes the tag name. No string passed to defineComponent — the compiler reads it from the file path.
  • { start = 0 } registers start as an observed attribute with type number. The browser coerces attribute strings to that type before passing them to your function.
  • The