reactolith 1.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Franz Mayr-Wilding
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,523 @@
1
+ # ⚡️ reactolith
2
+
3
+ > **Proof of Concept** – This project is still experimental and **not ready for production**.
4
+
5
+ `reactolith` lets you **write React components directly in HTML** — making it possible to render and hydrate a React app using server-generated HTML from any backend (e.g. Symfony/Twig, Rails, Laravel, Django, etc.).
6
+
7
+ ✨ Instead of manually wiring React components everywhere, just return HTML from your backend and `reactolith` will transform it into a live, interactive React application.
8
+
9
+ It even includes a **built-in router** that intercepts link clicks and form submissions, fetches the next page via AJAX, and updates only what changed — **keeping React state intact between navigations**.
10
+
11
+ ---
12
+
13
+ ## 🚀 Features
14
+ - 🔌 **Backend-agnostic** – Works with any backend (Symfony, Rails, Laravel, etc.)
15
+ - 🛠 **Use existing backend helpers** (Twig path functions, permission checks, etc.)
16
+ - 🔄 **State preserved across pages** – No resets on navigation
17
+ - 📋 **Form support** – Modify forms dynamically (e.g., add buttons on checkbox click) **without losing state or focus**
18
+ - 🪶 **Lightweight** – Just a few lines of setup, no heavy dependencies
19
+ - 📡 **Real-time updates** – Works with Mercure Server-Sent-Events to push updates from the backend to the frontend
20
+
21
+ ---
22
+
23
+ ## 📦 Installation
24
+
25
+ ```bash
26
+ npm install reactolith
27
+ ```
28
+
29
+ Since react and react-dom are peer dependencies, make sure to also install them:
30
+ ```bash
31
+ npm install react react-dom
32
+ ```
33
+
34
+ ---
35
+
36
+ ### How It Works
37
+
38
+ 1. **Initial Load**: Symfony renders HTML with Twig, `reactolith` hydrates it into React components
39
+ 2. **Navigation**: Clicking links fetches new HTML via AJAX, React reconciles the differences
40
+ 3. **Real-time**: Mercure pushes HTML updates from server, UI updates automatically
41
+ 4. **State Preserved**: React component state survives both navigation and real-time updates
42
+
43
+ ---
44
+
45
+ ## 💡 Usage
46
+
47
+ Your backend returns simple HTML:
48
+
49
+ ```html
50
+ <html lang="en">
51
+ <body>
52
+ <div id="reactolith-app">
53
+ <h1>Hello world</h1>
54
+ <ui-button type="primary">This will be a shadcn button</ui-button>
55
+ </div>
56
+ </body>
57
+ </html>
58
+ ```
59
+
60
+ Your frontend mounts the reactolith app:
61
+ ```ts
62
+ // app.ts
63
+ import loadable from '@loadable/component'
64
+ import { App } from 'reactolith'
65
+
66
+ const component = loadable(
67
+ async ({ is }: { is: string }) => {
68
+ return import(`./components/ui/${is.substring(3)}.tsx`)
69
+ },
70
+ {
71
+ cacheKey: ({ is }) => is,
72
+ // Since shadcn files don’t export a default,
73
+ // we resolve the correct named export
74
+ resolveComponent: (mod, { is }: { is: string }) => {
75
+ const cmpName = is
76
+ .substring(3)
77
+ .replace(/(^\w|-\w)/g, match => match.replace(/-/, '').toUpperCase())
78
+ return mod[cmpName]
79
+ },
80
+ }
81
+ )
82
+
83
+ // Uses the HTML element with id="reactolith-app" as root
84
+ new App(component)
85
+ ```
86
+
87
+ ### 🎨 Example with Custom Root Component & Selector
88
+ ```ts
89
+ // app.ts
90
+ import loadable from '@loadable/component'
91
+ import { App } from 'reactolith'
92
+ import { AppProvider } from './providers/app-provider.tsx'
93
+
94
+ const component = loadable(
95
+ async ({ is }: { is: string }) => import(`./components/${is}.tsx`),
96
+ { cacheKey: ({ is }) => is }
97
+ )
98
+
99
+ new App(component, AppProvider, '#app')
100
+ ```
101
+
102
+ ```tsx
103
+ // providers/app-provider.tsx
104
+ import React, { ElementType } from "react"
105
+ import { App, RootComponent } from "reactolith"
106
+ import { RouterProvider } from "react-aria-components"
107
+ import { ThemeProvider } from "./theme-provider"
108
+
109
+ export const AppProvider: React.FC<{
110
+ app: App
111
+ element: HTMLElement
112
+ component: ElementType
113
+ }> = ({ app, element, component }) => (
114
+ <React.StrictMode>
115
+ <RouterProvider navigate={app.router.navigate}>
116
+ <ThemeProvider>
117
+ <RootComponent element={element} component={component} />
118
+ </ThemeProvider>
119
+ </RouterProvider>
120
+ </React.StrictMode>
121
+ )
122
+ ```
123
+
124
+ ---
125
+
126
+ ## 🔄 Navigation Without Losing State
127
+
128
+ When navigating, `reactolith` fetches the **next HTML page** and applies **only the differences** using React’s reconciliation algorithm.
129
+ 👉 This means component state is preserved (e.g., toggles, inputs, focus).
130
+
131
+ ```html
132
+ <!-- page1.html -->
133
+ <div id="reactolith-app">
134
+ <h1>Page 1</h1>
135
+ <ui-toggle json-pressed="false">Toggle</ui-toggle>
136
+ <a href="page2.html">Go to page 2</a>
137
+ </div>
138
+ ```
139
+
140
+ ```html
141
+ <!-- page2.html -->
142
+ <div id="reactolith-app">
143
+ <h1>Page 2</h1>
144
+ <ui-toggle json-pressed="true">Toggle</ui-toggle>
145
+ <a href="page1.html">Go to page 1</a>
146
+ </div>
147
+ ```
148
+
149
+ Only the `<h1>` text and the `pressed` prop are updated — everything else remains untouched ✅.
150
+
151
+ ---
152
+
153
+ ## Props
154
+
155
+ If you pass props to your reactolith components like this:
156
+ ```html
157
+ <my-component enabled name="test" data-foo="baa" as="{my-other-component}" json-config='{ "foo": "baa" }'
158
+ ```
159
+
160
+ your components will get this props:
161
+ ```tsx
162
+ const props = {
163
+ enabled: true,
164
+ name: 'test',
165
+ foot: 'baa',
166
+ as: <MyOtherComponent />,
167
+ config: { foo: 'baa' },
168
+ }
169
+ ```
170
+
171
+ ---
172
+
173
+ ## Slots
174
+
175
+ reactolith also provides a simple slot mechanism: Every child if a reactolith-component with a slot attribute will be
176
+ transformed to a slot property, holding the children of the element:
177
+
178
+ ```html
179
+ <my-component>
180
+ <template slot="header"><h1>My header content</h1></template>
181
+ <div slot="footer">My footer content</div>
182
+ </my-component>
183
+ ```
184
+
185
+ your components will get this props:
186
+
187
+ ```tsx
188
+ function MyComponent({ header, footer } : { header : ReactNode, footer : ReactNode }) {
189
+ <article>
190
+ <header>{header}</header>
191
+ <div>My content</div>
192
+ <footer>{footer}</footer>
193
+ <aside>
194
+ <footer>{footer}</footer>
195
+ </aside>
196
+ </article>
197
+ }
198
+ ```
199
+
200
+ ---
201
+
202
+ ## 📡 Real-time Updates with Mercure
203
+
204
+ `reactolith` supports **Server-Sent Events (SSE)** via [Mercure](https://mercure.rocks/) for real-time updates from your backend. When the server publishes an update, the HTML is automatically rendered — just like with router navigation.
205
+
206
+ Mercure automatically subscribes to the **current URL pathname** as the topic and re-subscribes when the route changes.
207
+
208
+ ### Auto-Configuration (Recommended)
209
+
210
+ The easiest way to configure Mercure is to add the `data-mercure-hub-url` attribute to your root element:
211
+
212
+ ```html
213
+ <div id="reactolith-app" data-mercure-hub-url="https://example.com/.well-known/mercure">
214
+ <!-- Your content -->
215
+ </div>
216
+
217
+ <!-- With credentials (cookies): -->
218
+ <div id="reactolith-app"
219
+ data-mercure-hub-url="https://example.com/.well-known/mercure"
220
+ data-mercure-with-credentials>
221
+ <!-- Your content -->
222
+ </div>
223
+ ```
224
+
225
+ ```typescript
226
+ import { App, Mercure } from "reactolith";
227
+
228
+ const app = new App(component);
229
+ // mercureConfig is automatically set from data-mercure-hub-url attribute
230
+
231
+ const mercure = new Mercure(app);
232
+ mercure.subscribe(app.mercureConfig!);
233
+
234
+ // optional listen to events
235
+ mercure.on("sse:connected", (url) => {
236
+ console.log("Connected to Mercure hub");
237
+ });
238
+ ```
239
+
240
+ ### Manual Configuration
241
+
242
+ Alternatively, you can configure Mercure programmatically:
243
+
244
+ ```typescript
245
+ import { App, Mercure } from "reactolith";
246
+
247
+ const app = new App(component);
248
+ const mercure = new Mercure(app);
249
+
250
+ // Subscribe to Mercure hub (uses current pathname as topic)
251
+ mercure.subscribe({
252
+ hubUrl: "https://example.com/.well-known/mercure",
253
+ withCredentials: true, // Include cookies for authentication
254
+ });
255
+ ```
256
+
257
+ When the user navigates to a different route, Mercure automatically reconnects with the new pathname as the topic.
258
+
259
+ ### Auto-Refetch on Empty Messages
260
+
261
+ When Mercure receives an **empty message** (or whitespace-only), it automatically refetches the current route. This makes it easy to invalidate the current page from the backend without having to render and send the full HTML:
262
+
263
+ **Backend (simple invalidation):**
264
+ ```php
265
+ // Just notify that the page should refresh - no HTML needed
266
+ $hub->publish(new Update('/dashboard', ''));
267
+ ```
268
+
269
+ Instead of:
270
+ ```php
271
+ // Old way: render and send full HTML
272
+ $html = $twig->render('dashboard.html.twig', $data);
273
+ $hub->publish(new Update('/dashboard', $html));
274
+ ```
275
+
276
+ This triggers a GET request to the current URL and renders the response.
277
+
278
+ ### Mercure Events
279
+
280
+ | Event | Arguments | Description |
281
+ |-------|-----------|-------------|
282
+ | `sse:connected` | `url` | Connection established |
283
+ | `sse:disconnected` | `url` | Connection closed |
284
+ | `sse:message` | `event, html` | Message received |
285
+ | `render:success` | `event, html` | HTML rendered successfully |
286
+ | `render:failed` | `event, html` | Render failed (no root element) |
287
+ | `refetch:started` | `event` | Auto-refetch triggered (empty message) |
288
+ | `refetch:success` | `event, html` | Auto-refetch completed successfully |
289
+ | `refetch:failed` | `event, error` | Auto-refetch failed |
290
+ | `sse:error` | `error` | Connection error |
291
+
292
+ ### Live Data with useMercureTopic
293
+
294
+ For simple live values (like notification counts, user status), use the `useMercureTopic` hook to subscribe to Mercure topics that send JSON data:
295
+
296
+ ```tsx
297
+ import { useMercureTopic } from 'reactolith';
298
+
299
+ // Simple types - inferred from initial value
300
+ function NotificationBadge() {
301
+ const count = useMercureTopic('/notifications/count', 0);
302
+
303
+ if (count === 0) return null;
304
+ return <span className="badge">{count}</span>;
305
+ }
306
+
307
+ // Explicit type parameter
308
+ function UserStatus({ userId }: { userId: number }) {
309
+ const status = useMercureTopic<'online' | 'offline' | 'away'>(
310
+ `/user/${userId}/status`,
311
+ 'offline'
312
+ );
313
+ return <span className={status}>{status}</span>;
314
+ }
315
+
316
+ // Complex types with interfaces
317
+ interface DashboardStats {
318
+ visitors: number;
319
+ sales: number;
320
+ conversion: number;
321
+ }
322
+
323
+ function Dashboard() {
324
+ const stats = useMercureTopic<DashboardStats>('/dashboard/stats', {
325
+ visitors: 0,
326
+ sales: 0,
327
+ conversion: 0,
328
+ });
329
+
330
+ return (
331
+ <div>
332
+ <span>Visitors: {stats.visitors}</span>
333
+ <span>Sales: {stats.sales}</span>
334
+ <span>Conversion: {stats.conversion}%</span>
335
+ </div>
336
+ );
337
+ }
338
+ ```
339
+
340
+ **Backend:**
341
+ ```php
342
+ // Push JSON data to topic
343
+ $hub->publish(new Update(
344
+ '/notifications/count',
345
+ json_encode(42)
346
+ ));
347
+ ```
348
+
349
+ **Note:** When using `useMercureTopic`, make sure `app.mercureConfig` is set. You can either:
350
+ - Use the auto-configuration by adding `data-mercure-hub-url` to your root element (recommended), or
351
+ - Set it manually:
352
+ ```typescript
353
+ const app = new App(component);
354
+ app.mercureConfig = {
355
+ hubUrl: "/.well-known/mercure",
356
+ withCredentials: true,
357
+ };
358
+ ```
359
+
360
+ ### Custom Live Regions (Partial Updates)
361
+
362
+ For partial updates (e.g., updating a sidebar across all pages), you can create your own live region component. The `mercureConfig` is accessible via `useApp()`:
363
+
364
+ **Setup:**
365
+ ```typescript
366
+ import { App, Mercure, MercureLive } from 'reactolith';
367
+ import loadable from '@loadable/component';
368
+
369
+ const component = loadable(
370
+ async ({ is }: { is: string }) => {
371
+ // The mapping is up to you, reactolith only provides the MercureLive Component (don't lazy load it!)
372
+ if (is === 'mercure-live') {
373
+ return MercureLive;
374
+ }
375
+
376
+ // Your default implementaiton
377
+ return import(`./components/${is}.tsx`);
378
+ },
379
+ {
380
+ cacheKey: ({ is }) => is,
381
+ resolveComponent: (mod, { is }) => {
382
+ if (is === 'mercure-live') {
383
+ return mod;
384
+ }
385
+ return mod.default || mod[is];
386
+ }
387
+ }
388
+ );
389
+
390
+
391
+ const app = new App(component);
392
+ const mercure = new Mercure(app);
393
+
394
+ // Store config for components to access
395
+ app.mercureConfig = {
396
+ hubUrl: "/.well-known/mercure",
397
+ withCredentials: true,
398
+ };
399
+ mercure.subscribe(app.mercureConfig);
400
+ ```
401
+
402
+ ### Beispiel: Live Sidebar
403
+
404
+ ```tsx
405
+ // components/sidebar.tsx
406
+ export function Sidebar({ children }: { children: React.ReactNode }) {
407
+ return (
408
+ <aside className="sidebar">
409
+ {children}
410
+ </aside>
411
+ );
412
+ }
413
+ ```
414
+
415
+ **HTML Usage:**
416
+ ```html
417
+ <div id="reactolith-app">
418
+ <nav>...</nav>
419
+
420
+ <!-- Diese Region wird live aktualisiert -->
421
+ <mercure-live topic="/sidebar">
422
+ <sidebar>
423
+ <ul>
424
+ <li>Initial menu item 1</li>
425
+ <li>Initial menu item 2</li>
426
+ </ul>
427
+ </sidebar>
428
+ </mercure-live>
429
+
430
+ <main>...</main>
431
+ </div>
432
+ ```
433
+
434
+ **Backend:**
435
+ ```php
436
+ // Render die Sidebar neu
437
+ $html = $twig->render('_sidebar.html.twig', [
438
+ 'menuItems' => $updatedMenuItems
439
+ ]);
440
+
441
+ // Push zu allen Clients
442
+ $hub->publish(new Update('/sidebar', $html));
443
+ ```
444
+
445
+ **Template (_sidebar.html.twig):**
446
+ ```twig
447
+ <sidebar>
448
+ <ul>
449
+ {% for item in menuItems %}
450
+ <li>{{ item.label }}</li>
451
+ {% endfor %}
452
+ </ul>
453
+ </sidebar>
454
+ ```
455
+
456
+ ---
457
+
458
+ ## 🧩 IDE Autocomplete (Web-Types)
459
+
460
+ `reactolith` includes a CLI tool to generate [web-types](https://github.com/JetBrains/web-types) for your custom components. This enables **autocomplete and validation** in IDEs like WebStorm, PhpStorm, and VS Code (with appropriate plugins).
461
+
462
+ ### Generate web-types.json
463
+
464
+ ```bash
465
+ npx generate-web-types -c src/components/ui -o web-types.json -n my-app
466
+ ```
467
+
468
+ **Options:**
469
+ | Option | Short | Description | Default |
470
+ |--------|-------|-------------|---------|
471
+ | `--components` | `-c` | Components directory | `components/ui` |
472
+ | `--tsconfig` | `-t` | TypeScript config file | `tsconfig.app.json` (or `tsconfig.json`) |
473
+ | `--out` | `-o` | Output file | `web-types.json` |
474
+ | `--name` | `-n` | Library name | `reactolith-components` |
475
+ | `--version` | `-v` | Library version | `1.0.0` |
476
+ | `--prefix` | `-p` | Element name prefix | `""` |
477
+ | `--help` | `-h` | Show help | |
478
+
479
+ ### Configure your project
480
+
481
+ Add the generated file to your `package.json`:
482
+
483
+ ```json
484
+ {
485
+ "name": "my-app",
486
+ "web-types": "./web-types.json"
487
+ }
488
+ ```
489
+
490
+ ### Result
491
+
492
+ After restarting your IDE, you'll get:
493
+ - ✅ **Autocomplete** for custom element names (e.g., `<ui-button>`)
494
+ - ✅ **Prop suggestions** with types and descriptions
495
+ - ✅ **Slot hints** for components with children/slots
496
+ - ✅ **Validation** for required props and valid values
497
+
498
+ **Tip:** Add `npx generate-web-types ...` to your build script to keep web-types in sync:
499
+
500
+ ```json
501
+ {
502
+ "scripts": {
503
+ "build": "vite build && npx generate-web-types -c src/components/ui -o web-types.json"
504
+ }
505
+ }
506
+ ```
507
+
508
+ ---
509
+
510
+ ## 🤝 Contributing
511
+
512
+ Contributions are welcome!
513
+ Feel free to open an issue or submit a PR.
514
+
515
+ ---
516
+
517
+ ## 🛠 Development Build
518
+ If you’re contributing to this library:
519
+
520
+ ```bash
521
+ npm install
522
+ npm run build
523
+ ```