@jjordy/rogue
v0.7.0
Published
JSX → web components compiler with fine-grained reactivity, file-system routing, and SSR/hydration. Vite plugin + tiny runtime.
Downloads
1,976
Maintainers
Readme
rogue
JSX → web components compiler with fine-grained reactivity, file-system routing, and SSR/hydration. Vite plugin + tiny runtime.
npm install @jjordy/rogueWhy
- Web components are the output. Each
defineComponentregisters a real<my-thing>custom element with shadow DOM, constructible stylesheets, and form-association viaElementInternals. No virtual DOM, no diff. Your components are just custom elements other tools can consume. - Compile-time templates. JSX compiles to a hoisted
<template>once, then clones per render. Dynamic spots are bound viafirstChild/nextSiblingpaths computed at build time — no runtime walking, no key prop, no reconciliation. - Fine-grained reactivity. Signals + effects wrapped in a
Scopelifetime that auto-cascades cleanup. No re-renders; only the bytes that need to change, change. - SSR + real hydration.
server/render.jsSSRs to Declarative Shadow DOM. The runtime adopts existing DOM via pair-anchor markers (<!--[-->...<!---->) — text nodes mutate in place, structure is preserved. - File-system routing.
src/pages/with[slug].jsx/[...rest].jsx/_layout.jsx/_404.jsx/_error.jsx. Each route is its own code-split chunk. - Forms that work without JavaScript.
useForm+defineActionsschema validation, server-side mutations, auto-revalidated loaders. With JS the form posts viafetchand patches state in place; without JS, native form POST + server re-render with errors embedded. One declaration site for client + server. - End-to-end types. The router plugin emits typed
./<stem>.typesmodules per page soparamsanddataare inferred from the file path and the action's schema, with no annotation that duplicates either.
Quickstart
vite.config.js:
import { defineConfig } from 'vite'
import { rogue, rogueRouter, rogueSsr } from '@jjordy/rogue/vite'
export default defineConfig({
plugins: [rogue(), rogueRouter(), rogueSsr()],
esbuild: { jsx: 'preserve' }, // let the rogue plugin handle JSX
})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>{`
button { width: 2rem }
span { margin: 0 .5rem }
`}</style>
</div>
)
})src/pages/index.jsx:
export default function Home() {
return (
<div>
<h1>Hello</h1>
<my-counter start={5} />
</div>
)
}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 } : {})Forms
// src/pages/signup.tsx
import { useForm, defineActions, redirect, invalid } from '@jjordy/rogue/forms'
export const actions = defineActions({
signup: {
schema: {
email: { type: 'email', required: 'Email is required' },
password: { type: 'password', required: true, minLength: 8 },
},
async run({ data }) {
if (await isTaken(data.email)) return invalid({ email: 'already registered' })
await createUser(data)
return redirect('/welcome')
},
},
})
export default function Signup() {
const form = useForm(actions.signup)
return (
<form {...form.props}>
{form.formError() && <div class="alert">{form.formError()}</div>}
<input {...form.field('email')} />
<span class="err">{form.errors().email}</span>
<input {...form.field('password')} />
<span class="err">{form.errors().password}</span>
<button disabled={form.submitting()}>
{form.submitting() ? 'Creating…' : 'Sign up'}
</button>
</form>
)
}Schemas are validated client-side (in useForm) and server-side (in the action dispatcher) from one definition. The form works without JavaScript — native form POST, server re-render with errors embedded — and the auto-revalidated loader means the page picks up fresh data after a successful mutation.
API surface
// @jjordy/rogue
import {
signal, effect, memo, untrack, batch,
onCleanup, onMount,
createContext, provide, useContext,
defineComponent, emit,
For,
} from '@jjordy/rogue'
// @jjordy/rogue/router — client-side routing
import { useRoute, navigate, mount, findMatch, renderRoute } from '@jjordy/rogue/router'
// @jjordy/rogue/testing — linkedom-backed DOM + mount/query/fireEvent for tests
import { setupDOM, mount, unmount, fireEvent, query, queryAll } from '@jjordy/rogue/testing'
// @jjordy/rogue/forms — form state, validation, submit lifecycle, server actions
import { useForm, defineActions, redirect, invalid, formError } from '@jjordy/rogue/forms'
// @jjordy/rogue/vite — Vite plugins
import { rogue, rogueRouter, rogueSsr } from '@jjordy/rogue/vite'
// @jjordy/rogue/server — dev SSR + action handler + JSON loader endpoint
import { render, handleAction, loadData } from '@jjordy/rogue/server'
// @jjordy/rogue/server/prod — production renderer (no Vite at request time)
import { loadProdRenderer } from '@jjordy/rogue/server/prod'See runtime/index.d.ts and the .d.ts siblings for full types.
Conventions
- Components live in
src/components/<name>.jsx. The filename is the kebab-case tag (my-counter.jsx→<my-counter>). defineComponentshorthand infers the tag name from the filename:defineComponent((props) => …)is equivalent todefineComponent('my-counter', (props) => …).- Defaults from destructuring:
({ count = 0, label = 'x' })makescountandlabelobserved attributes with those defaults. Type inferred from the default value. - Co-located CSS: a
my-counter.cssnext tomy-counter.jsxis auto-imported as a constructible stylesheet. - Hoisted styles:
<style>children of the component root are extracted to a sheet shared across instances. - Auto-import: kebab tags used in JSX auto-import their component file; PascalCase tags auto-import as named imports.
SSR
In dev, rogueSsr() (the Vite plugin) handles SSR + server actions inside the dev server — npm run dev does it all. For one-off renders:
import { createServer } from 'vite'
import { render } from '@jjordy/rogue/server'
const vite = await createServer({ server: { middlewareMode: true }, appType: 'custom' })
const { html, status } = await render('/blog/hello', vite)The output uses Declarative Shadow DOM; the runtime hydrates by adopting existing DOM via pair-anchor markers — text nodes mutate in place, no rebuild.
Production deployment
Node only — Workers / Lambda / edge runtimes are deliberately out of scope (ADR-0002). Two vite build invocations plus a thin node:http shell:
vite build --outDir dist/client # browser bundle
vite build --ssr src/entry-server.ts --outDir dist/server # SSR bundle
node server.js # the prod serverWhere src/entry-server.ts is one line:
export * from 'virtual:jsx-wc/routes'And server.js wires the renderer into Node's http module:
import { loadProdRenderer } from '@jjordy/rogue/server/prod'
const { render, handleAction, loadData } = await loadProdRenderer({
clientDir: 'dist/client',
serverDir: 'dist/server',
})
// dispatch: POST ?_action=… → handleAction
// GET text/html → render
// GET application/json → loadData (client-side router fetches data via JSON)
// GET /assets/* → static fileA copy-pasteable reference shell lives at rogue-lab/server/serve.prod.js (~120 LOC, zero dependencies).
Security
Server-only code in page modules (loader bodies, actions[*].run bodies) is automatically stripped from the client bundle at build time (ADR-0003). The transform refuses unfamiliar AST shapes rather than best-effort handling them — conservative refusal beats permissive leak. See SECURITY.md for vulnerability reporting.
More
- Docs site — concepts, conventions, API reference, live showcase
AGENTS.md— project conventions for AI coding agentsCONTEXT.md— domain glossarydocs/adr/— architectural decision records
License
MIT
