feat(plugins): compiled-plugin support — expose React/ReactDOM/jsx-runtime/Fragment as externalization targets, add settings_section slot (addSettingsSection) wired into Settings, + plugin-template/ (Vite build with React externalized) and docs

This commit is contained in:
reverse 2026-06-20 05:42:02 +00:00
parent 0032471faf
commit 71473bd478
No known key found for this signature in database
13 changed files with 279 additions and 16 deletions

View file

@ -49,7 +49,7 @@ bound to the app's React) — runtime template markup, no build step. Prefer
| `Orbit.plugin(name, fn)` | register a plugin; `fn(orbit, log)` |
| `Orbit.on/once/off/emit(event, …)` | the app event bus |
| `Orbit.config()` | the resolved runtime config |
| `Orbit.React` / `Orbit.h` / `Orbit.html` | render primitives |
| `Orbit.React` / `Orbit.ReactDOM` / `Orbit.jsxRuntime` / `Orbit.Fragment` / `Orbit.h` / `Orbit.html` | render primitives (externalization targets for compiled plugins) |
### Inside `Orbit.plugin(name, (orbit) => …)`
| Member | Description |
@ -67,6 +67,7 @@ bound to the app's React) — runtime template markup, no build step. Prefer
| `orbit.themes.current()/list()/set(id)` | read/set the theme |
| `orbit.storage.get(key, def)/set(key, val)` | namespaced persistence |
| `orbit.addUi(slot, render)` | add UI to a slot (returns a remover) |
| `orbit.addSettingsSection({label, icon?, render})` | add a whole Settings section |
| `orbit.h / orbit.html` | render helpers |
| `log(…)` | namespaced console logger |
@ -79,9 +80,34 @@ bound to the app's React) — runtime template markup, no build step. Prefer
| Slot | Where |
|---|---|
| `composer_button` | a button in the message composer toolbar |
| `settings_section` | a whole section in Settings (own nav entry + pane) — use `orbit.addSettingsSection()` |
More slots (settings panel, message decorators, side panels) will be added as
the core grows stable homes for them.
More slots (message decorators, side panels) will be added as the core grows
stable homes for them.
## Compiled plugins (write real React)
The example above is an *uncompiled* `.js` plugin. For anything substantial,
build a plugin like a normal project and compile it to one droppable file — the
same model as KiwiIRC's webpack plugins.
The trick: mark `react`, `react-dom` and `react/jsx-runtime` **external** and map
them to `Orbit.React` / `Orbit.ReactDOM` / `Orbit.jsxRuntime`, so your bundle
shares Orbit's single React instance and never carries its own. (Bundling your
own React breaks hooks with "invalid hook call".) Then author normal TSX with
hooks/state and render it into a slot:
```tsx
import { useState } from 'react';
Orbit.plugin('my-plugin', (orbit) => {
orbit.addSettingsSection({ label: 'My plugin', icon: '🧩', render: () => <Panel orbit={orbit} /> });
});
```
A ready-to-copy starter (Vite config with the externals already set up, tsconfig,
ambient types and an example) lives in [`plugin-template/`](../plugin-template).
`npm install && npm run build` → one `dist/*.js` you drop in and list in
`config.json`.
## Intentionally not exposed

View file

@ -6,7 +6,7 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', 'plugin-template']),
{
files: ['**/*.{ts,tsx}'],
extends: [

2
plugin-template/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

37
plugin-template/README.md Normal file
View file

@ -0,0 +1,37 @@
# Orbit plugin template
A starter for a **compiled** Orbit plugin — write real React/TSX with hooks and
build it down to a single droppable `.js` file (the same model as KiwiIRC's
webpack plugins, in React/Vite).
## Build
```bash
npm install
npm run build # → dist/orbit-plugin-template.js
```
`react`, `react-dom` and `react/jsx-runtime` are marked **external** in
`vite.config.ts` and mapped to `Orbit.React` / `Orbit.ReactDOM` /
`Orbit.jsxRuntime`. So your bundle does **not** contain its own React — at
runtime it shares Orbit's single instance. (Bundling your own React would break
hooks with "invalid hook call".)
## Deploy
1. Copy `dist/orbit-plugin-template.js` to where Orbit can fetch it, e.g.
`/app/plugins/orbit-plugin-template.js`.
2. List it in the deployed `config.json` (no rebuild of Orbit needed):
```json
{ "plugins": ["/app/plugins/orbit-plugin-template.js"] }
```
3. Reload. The example adds a 🤷 composer button and a **Template** section in
Settings.
## Write your plugin
Edit `src/index.tsx`. The `Orbit` global and the per-plugin `orbit` API are typed
in `src/orbit.d.ts`. See Orbit's [`docs/PLUGINS.md`](../docs/PLUGINS.md) for the
full API and event list.

View file

@ -0,0 +1,20 @@
{
"name": "orbit-plugin-template",
"private": true,
"version": "0.1.0",
"type": "module",
"description": "Starter for a compiled Orbit plugin (React/TSX → one droppable JS file).",
"scripts": {
"build": "vite build",
"watch": "vite build --watch"
},
"devDependencies": {
"@types/react": "^19",
"@types/react-dom": "^19",
"@vitejs/plugin-react": "^6.0.1",
"react": "^19.2.6",
"react-dom": "^19.2.6",
"typescript": "^5.6.0",
"vite": "^8.0.12"
}
}

View file

@ -0,0 +1,52 @@
import { useState } from 'react';
import type { OrbitPluginApi } from './orbit';
// A COMPILED Orbit plugin: real React/TSX with hooks, built to one droppable file
// (npm run build → dist/orbit-plugin-template.js). React is shared with the host,
// so hooks/state/context all work. Register against the Orbit global:
Orbit.plugin('orbit-template', (orbit, log) => {
log('template plugin loaded — Orbit v' + orbit.version);
// 1) A button in the composer (a real component).
orbit.addUi('composer_button', () => <ShrugButton orbit={orbit} />);
// 2) A whole Settings section (own nav entry + pane), with stateful UI.
orbit.addSettingsSection({
label: 'Template',
icon: '🧩',
render: () => <TemplatePanel orbit={orbit} />,
});
});
function ShrugButton({ orbit }: { orbit: OrbitPluginApi }) {
return (
<button
className="composer__emoji"
title="Shrug (template plugin)"
onClick={() => orbit.irc.say('¯\\_(ツ)_/¯')}
>🤷</button>
);
}
function TemplatePanel({ orbit }: { orbit: OrbitPluginApi }) {
// Hooks work because the plugin shares Orbit's React instance.
const [count, setCount] = useState<number>(() => orbit.storage.get('count', 0) ?? 0);
const bump = () => { const n = count + 1; setCount(n); orbit.storage.set('count', n); };
return (
<div className="scard">
<div className="scard__body">
<div className="sfield">
<div className="sfield__intro">
This panel is a <b>compiled</b> plugin real React with hooks, built to a
single file and loaded via <code>config.json</code>. You're chatting as{' '}
<b>{orbit.state.nick()}</b>.
</div>
</div>
<div className="modal__actions">
<button className="upbtn upbtn--primary" onClick={bump}>Clicked {count}×</button>
</div>
</div>
</div>
);
}

47
plugin-template/src/orbit.d.ts vendored Normal file
View file

@ -0,0 +1,47 @@
// Minimal ambient types for the Orbit plugin global. Hand-written for plugin
// authors; the host's full types live in Orbit's src/plugins/api.ts.
import type * as React from 'react';
type Handler = (...args: unknown[]) => void;
export interface OrbitPluginApi {
name: string;
version: string;
commit: string;
React: typeof React;
on(event: string, fn: Handler): () => void;
once(event: string, fn: Handler): () => void;
off(event: string, fn: Handler): void;
emit(event: string, ...args: unknown[]): void;
state: {
active(): string;
nick(): string;
account(): string;
buffers(): string[];
get(): unknown;
};
irc: {
send(line: string): void;
msg(target: string, text: string): void;
say(text: string): void;
join(channel: string): void;
part(channel: string): void;
list(): void;
};
themes: { current(): string; list(): string[]; set(theme: string): void };
storage: { get<T>(key: string, fallback?: T): T | undefined; set(key: string, value: unknown): void };
addUi(slot: 'composer_button', render: () => React.ReactNode): () => void;
addSettingsSection(opts: { label: string; icon?: string; render: () => React.ReactNode }): () => void;
log(...args: unknown[]): void;
}
declare global {
const Orbit: {
version: string;
commit: string;
React: typeof React;
plugin(name: string, fn: (orbit: OrbitPluginApi, log: Handler) => void): void;
};
}
export {};

View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2021",
"lib": ["ES2021", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"jsx": "react-jsx",
"strict": true,
"skipLibCheck": true,
"noEmit": true,
"types": ["react", "react-dom"]
},
"include": ["src"]
}

View file

@ -0,0 +1,28 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// Builds the plugin to ONE droppable IIFE file. React, react-dom and the JSX
// runtime are EXTERNAL — at runtime they resolve to Orbit's single React instance
// (window.Orbit.*), never bundled. Bundling your own React would break hooks.
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: 'src/index.tsx',
formats: ['iife'],
name: 'OrbitPluginTemplate',
fileName: () => 'orbit-plugin-template.js',
},
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime'],
output: {
globals: {
react: 'Orbit.React',
'react-dom': 'Orbit.ReactDOM',
'react/jsx-runtime': 'Orbit.jsxRuntime',
},
},
},
emptyOutDir: true,
},
});

View file

@ -1,7 +1,7 @@
// Tchatou service worker — installable PWA + offline app shell.
// Scope: /app/. Only handles same-origin /app/ GETs; the IRC websocket and all
// API calls (cloudflare, change_password, upload) pass straight through.
const CACHE = 'tchatou-v38';
const CACHE = 'tchatou-v39';
const SHELL = ['/app/', '/app/index.html', '/app/favicon.svg', '/app/orbit-icon.svg', '/app/manifest.webmanifest'];
self.addEventListener('install', (e) => {

View file

@ -6,9 +6,15 @@ import { getConfig } from '../../config';
import { getTheme, setTheme, type Theme } from '../../ui/theme';
import { isPushSupported, pushEnabledPref, enablePush, disablePush } from '../../services/push';
import { CAP_INFO } from '../../irc/cap-info';
import { usePluginRegistry } from '../../plugins/registry';
import { Avatar } from '../Avatar';
import { Turnstile } from '../Turnstile';
// Renders one plugin-contributed settings section, isolating render errors.
function PluginSettingsSlot({ render }: { render: () => ReactNode }) {
try { return <>{render()}</>; } catch (e) { console.error('[plugins] settings_section render error', e); return null; }
}
// Labels & descriptions are resolved via i18n (SEC_KEY / SEC_DESC).
const SETTINGS_SECTIONS = [
{ id: 'profil', icon: '👤' },
@ -67,7 +73,10 @@ export function SettingsModal() {
const { t } = useTranslation();
const setModal = useChat((s) => s.setModal);
const account = useChat((s) => s.account);
const [section, setSection] = useState<SettingsSection>('profil');
// Stable-ref selector (zustand v5 loops on a new array each render); filter in body.
const pluginUi = usePluginRegistry((s) => s.ui);
const pluginSections = pluginUi.filter((u) => u.slot === 'settings_section');
const [section, setSection] = useState<string>('profil');
const [drilled, setDrilled] = useState(false); // mobile: are we inside a section?
const close = () => setModal('');
@ -78,7 +87,8 @@ export function SettingsModal() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const cur = SETTINGS_SECTIONS.find((s) => s.id === section)!;
const curFixed = SETTINGS_SECTIONS.find((s) => s.id === section);
const curPlugin = pluginSections.find((p) => p.id === section);
return (
<div className="settings-backdrop" onClick={close}>
@ -101,6 +111,17 @@ export function SettingsModal() {
<span className="settings__navchev" aria-hidden></span>
</button>
))}
{pluginSections.map((ps) => (
<button key={ps.id} className={`settings__navitem ${section === ps.id ? 'is-on' : ''}`}
onClick={() => { setSection(ps.id); setDrilled(true); }}>
<span className="settings__navic" aria-hidden>{ps.meta?.icon ?? '🧩'}</span>
<span className="settings__navtxt">
<span className="settings__navlabel">{ps.meta?.label ?? ps.plugin}</span>
<span className="settings__navdesc">{ps.plugin}</span>
</span>
<span className="settings__navchev" aria-hidden></span>
</button>
))}
</nav>
<a className="settings__about" href={getConfig().branding.projectUrl} target="_blank" rel="noopener noreferrer">
<span className="settings__about-mark" aria-hidden></span>
@ -115,8 +136,8 @@ export function SettingsModal() {
<section className="settings__pane">
<header className="settings__top">
<button className="settings__back" onClick={() => setDrilled(false)} aria-label={t('settings.misc.back')}></button>
<span className="settings__top-ic" aria-hidden><SecIcon id={cur.id} icon={cur.icon} /></span>
<h3 className="settings__top-title">{t(SEC_KEY[cur.id])}</h3>
<span className="settings__top-ic" aria-hidden>{curFixed ? <SecIcon id={curFixed.id} icon={curFixed.icon} /> : (curPlugin?.meta?.icon ?? '🧩')}</span>
<h3 className="settings__top-title">{curFixed ? t(SEC_KEY[curFixed.id]) : (curPlugin?.meta?.label ?? '')}</h3>
<button className="settings__close settings__close--pane" onClick={close} aria-label={t('modals.closeButton')}></button>
</header>
<div className="settings__content" key={section}>
@ -127,6 +148,7 @@ export function SettingsModal() {
{section === 'server' && <ServerSection />}
{section === 'ircv3' && <CapabilitiesSection />}
{section === 'about' && <AboutSection />}
{curPlugin && <PluginSettingsSlot render={curPlugin.render} />}
</div>
</section>
</div>

View file

@ -6,6 +6,8 @@
// change. UI authoring uses HTM (htm.bind to the app's React) so plugins write
// template-literal markup at runtime, with no build step.
import React, { type ReactNode } from 'react';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import * as ReactDOM from 'react-dom';
import htm from 'htm';
import { useChat } from '../store';
import { getTheme, setTheme, type Theme } from '../ui/theme';
@ -24,8 +26,13 @@ export interface OrbitPluginApi {
/** App build version + git commit. */
version: string;
commit: string;
/** The app's React instance + render helpers (single instance — nodes interop). */
/** The app's React instance + render helpers (single instance nodes interop).
* Compiled plugins externalize `react`/`react-dom`/`react/jsx-runtime` to these
* so they share Orbit's React (never bundle their own see docs/PLUGINS.md). */
React: typeof React;
ReactDOM: typeof ReactDOM;
jsxRuntime: typeof ReactJSXRuntime;
Fragment: typeof React.Fragment;
h: typeof React.createElement;
/** Tagged-template markup, e.g. html`<button onClick=${fn}>Hi</button>`. */
html: typeof html;
@ -58,6 +65,8 @@ export interface OrbitPluginApi {
storage: { get: <T>(key: string, fallback?: T) => T | undefined; set: (key: string, value: unknown) => void };
// ── UI extension (named slots — see registry.ts) ─────────────────────────
addUi: (slot: UiSlot, render: () => ReactNode) => () => void;
/** Add a whole section to Settings (own nav entry + pane). */
addSettingsSection: (opts: { label: string; icon?: string; render: () => ReactNode }) => () => void;
}
function makeApi(name: string): OrbitPluginApi {
@ -66,7 +75,7 @@ function makeApi(name: string): OrbitPluginApi {
name,
version: __APP_VERSION__,
commit: __GIT_COMMIT__,
React, h: React.createElement, html,
React, ReactDOM, jsxRuntime: ReactJSXRuntime, Fragment: React.Fragment, h: React.createElement, html,
log: (...a) => console.log(`%c[plugin:${name}]`, 'color:#2ea043', ...a),
on: bus.on, once: bus.once, off: bus.off, emit: bus.emit,
state: {
@ -93,6 +102,8 @@ function makeApi(name: string): OrbitPluginApi {
set: (key, value) => { try { localStorage.setItem(ns + key, JSON.stringify(value)); } catch { /* quota */ } },
},
addUi: (slot, render) => usePluginRegistry.getState().addUi(slot, name, render),
addSettingsSection: (opts) =>
usePluginRegistry.getState().addUi('settings_section', name, opts.render, { label: opts.label, icon: opts.icon }),
};
}
@ -100,7 +111,9 @@ function makeApi(name: string): OrbitPluginApi {
export const Orbit = {
version: __APP_VERSION__,
commit: __GIT_COMMIT__,
React, h: React.createElement, html,
// Render primitives — compiled plugins externalize react/react-dom/jsx-runtime
// to these (one shared React instance). See plugin-template/ + docs/PLUGINS.md.
React, ReactDOM, jsxRuntime: ReactJSXRuntime, Fragment: React.Fragment, h: React.createElement, html,
on: bus.on, once: bus.once, off: bus.off, emit: bus.emit,
config: () => getConfig(),
plugin(name: string, fn: (orbit: OrbitPluginApi, log: OrbitPluginApi['log']) => void): void {

View file

@ -5,25 +5,27 @@ import { create } from 'zustand';
import type { ReactNode } from 'react';
// The UI slots the core currently exposes. Add more as components grow homes.
export type UiSlot = 'composer_button';
export type UiSlot = 'composer_button' | 'settings_section';
export interface PluginUi {
id: string;
plugin: string;
slot: UiSlot;
render: () => ReactNode;
// Nav metadata for slots that need a label/icon (e.g. settings_section).
meta?: { label?: string; icon?: string };
}
interface RegistryState {
ui: PluginUi[];
addUi: (slot: UiSlot, plugin: string, render: () => ReactNode) => () => void;
addUi: (slot: UiSlot, plugin: string, render: () => ReactNode, meta?: PluginUi['meta']) => () => void;
}
export const usePluginRegistry = create<RegistryState>((set) => ({
ui: [],
addUi: (slot, plugin, render) => {
addUi: (slot, plugin, render, meta) => {
const id = `${plugin}:${slot}:${Math.random().toString(36).slice(2, 8)}`;
set((s) => ({ ui: [...s.ui, { id, plugin, slot, render }] }));
set((s) => ({ ui: [...s.ui, { id, plugin, slot, render, meta }] }));
return () => set((s) => ({ ui: s.ui.filter((u) => u.id !== id) }));
},
}));