melina 1.1.4 → 1.3.0
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/GUIDE.md +194 -606
- package/README.md +50 -155
- package/package.json +3 -11
- package/src/IslandOrchestrator.tsx +259 -259
- package/src/createIsland.tsx +121 -119
- package/src/loader.ts +226 -137
- package/src/plugin.ts +46 -93
- package/src/router.ts +192 -194
- package/src/runtime/hangar.ts +399 -0
- package/src/runtime.ts +263 -245
- package/src/web.ts +89 -30
- package/ARCHITECTURE.md +0 -115
- package/CHANGELOG.md +0 -53
- package/CONTRIBUTING.md +0 -132
package/GUIDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Melina.js Developer Guide
|
|
2
2
|
|
|
3
|
-
**A
|
|
3
|
+
**A lightweight, islands-architecture framework for Bun**
|
|
4
4
|
|
|
5
5
|
## Table of Contents
|
|
6
6
|
|
|
@@ -9,16 +9,12 @@
|
|
|
9
9
|
3. [Quick Start](#quick-start)
|
|
10
10
|
4. [Server Components](#server-components)
|
|
11
11
|
5. [Client Components (Islands)](#client-components-islands)
|
|
12
|
-
6. [
|
|
12
|
+
6. [The Router](#the-router)
|
|
13
13
|
7. [Layouts](#layouts)
|
|
14
14
|
8. [State Persistence](#state-persistence)
|
|
15
|
-
9. [
|
|
16
|
-
10. [
|
|
17
|
-
11. [API
|
|
18
|
-
12. [Styling](#styling)
|
|
19
|
-
13. [Best Practices & Gotchas](#best-practices--gotchas)
|
|
20
|
-
14. [API Reference](#api-reference)
|
|
21
|
-
15. [Troubleshooting](#troubleshooting)
|
|
15
|
+
9. [Cross-Island Communication](#cross-island-communication)
|
|
16
|
+
10. [Best Practices & Gotchas](#best-practices--gotchas)
|
|
17
|
+
11. [API Reference](#api-reference)
|
|
22
18
|
|
|
23
19
|
---
|
|
24
20
|
|
|
@@ -35,9 +31,7 @@ Melina.js is a Next.js-compatible framework with a simpler architecture, built s
|
|
|
35
31
|
- ✅ Nested layouts with automatic composition
|
|
36
32
|
- ✅ Islands architecture for optimal performance
|
|
37
33
|
- ✅ SPA-like navigation with partial page swaps
|
|
38
|
-
- ✅
|
|
39
|
-
- ✅ Native View Transitions API support
|
|
40
|
-
- ✅ CSS animations that persist across navigation
|
|
34
|
+
- ✅ CSS animations that don't reset on navigation
|
|
41
35
|
- ✅ Streaming HTML responses
|
|
42
36
|
|
|
43
37
|
---
|
|
@@ -46,10 +40,10 @@ Melina.js is a Next.js-compatible framework with a simpler architecture, built s
|
|
|
46
40
|
|
|
47
41
|
### The Two Graphs
|
|
48
42
|
|
|
49
|
-
Melina.js separates your app into two distinct
|
|
43
|
+
Melina.js separates your app into two distinct "graphs":
|
|
50
44
|
|
|
51
|
-
1. **Server Graph**
|
|
52
|
-
2. **Client Graph**
|
|
45
|
+
1. **Server Graph** - Layouts and pages that render on the server
|
|
46
|
+
2. **Client Graph** - Interactive components (islands) that run in the browser
|
|
53
47
|
|
|
54
48
|
```
|
|
55
49
|
┌─────────────────────────────────────────────────────────────┐
|
|
@@ -72,28 +66,9 @@ Melina.js separates your app into two distinct execution contexts:
|
|
|
72
66
|
└─────────────────────────────────────────────────────────────┘
|
|
73
67
|
```
|
|
74
68
|
|
|
75
|
-
### The Two Zones
|
|
76
|
-
|
|
77
|
-
Your layout creates two distinct zones:
|
|
78
|
-
|
|
79
|
-
1. **Persistent Zone** — Elements outside `#melina-page-content` (header, footer, sidebars)
|
|
80
|
-
2. **Swappable Zone** — The `#melina-page-content` container
|
|
81
|
-
|
|
82
|
-
```tsx
|
|
83
|
-
<body>
|
|
84
|
-
<header>...</header> {/* PERSISTENT: Never unmounts */}
|
|
85
|
-
|
|
86
|
-
<main id="melina-page-content">
|
|
87
|
-
{children} {/* SWAPPABLE: Replaced on navigation */}
|
|
88
|
-
</main>
|
|
89
|
-
|
|
90
|
-
<footer>...</footer> {/* PERSISTENT: Never unmounts */}
|
|
91
|
-
</body>
|
|
92
|
-
```
|
|
93
|
-
|
|
94
69
|
### Partial Page Swaps
|
|
95
70
|
|
|
96
|
-
When you navigate between pages, Melina.js doesn't replace the entire
|
|
71
|
+
When you navigate between pages, Melina.js doesn't replace the entire `<body>`. Instead:
|
|
97
72
|
|
|
98
73
|
1. Fetches the new page HTML
|
|
99
74
|
2. Finds `#melina-page-content` in both current and new page
|
|
@@ -107,7 +82,7 @@ When you navigate between pages, Melina.js doesn't replace the entire page. Inst
|
|
|
107
82
|
### Installation
|
|
108
83
|
|
|
109
84
|
```bash
|
|
110
|
-
bun add melina
|
|
85
|
+
bun add @anthropic/melina.js
|
|
111
86
|
```
|
|
112
87
|
|
|
113
88
|
### Create Your App Structure
|
|
@@ -140,40 +115,34 @@ Server Components are the default. They render on the server and send HTML to th
|
|
|
140
115
|
|
|
141
116
|
```tsx
|
|
142
117
|
// app/page.tsx - Server Component (default)
|
|
143
|
-
export default
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
118
|
+
export default function HomePage() {
|
|
119
|
+
const data = await fetchFromDatabase(); // Can use async!
|
|
120
|
+
|
|
121
|
+
return (
|
|
122
|
+
<div>
|
|
123
|
+
<h1>Welcome</h1>
|
|
124
|
+
<p>Data: {data}</p>
|
|
125
|
+
</div>
|
|
126
|
+
);
|
|
152
127
|
}
|
|
153
128
|
```
|
|
154
129
|
|
|
155
|
-
### What Server Components CAN
|
|
156
|
-
|
|
130
|
+
### What Server Components CAN do:
|
|
157
131
|
- ✅ Async/await directly in the component
|
|
158
132
|
- ✅ Access databases, file systems, environment variables
|
|
159
133
|
- ✅ Import other Server Components
|
|
160
134
|
- ✅ Import Client Components (they'll become islands)
|
|
161
135
|
|
|
162
|
-
### What Server Components CANNOT
|
|
163
|
-
|
|
136
|
+
### What Server Components CANNOT do:
|
|
164
137
|
- ❌ Use `useState`, `useEffect`, or other hooks
|
|
165
138
|
- ❌ Use browser APIs (`window`, `localStorage`)
|
|
166
|
-
- ❌ Handle click events
|
|
139
|
+
- ❌ Handle click events
|
|
167
140
|
|
|
168
141
|
---
|
|
169
142
|
|
|
170
143
|
## Client Components (Islands)
|
|
171
144
|
|
|
172
|
-
Add `'use client'` at the top of your file to make it interactive
|
|
173
|
-
|
|
174
|
-
### Auto-Wrapping (Default)
|
|
175
|
-
|
|
176
|
-
Just export your component normally — Melina transforms it during SSR:
|
|
145
|
+
Add `'use client'` at the top of your file to make it interactive:
|
|
177
146
|
|
|
178
147
|
```tsx
|
|
179
148
|
// app/components/Counter.tsx
|
|
@@ -182,39 +151,14 @@ Just export your component normally — Melina transforms it during SSR:
|
|
|
182
151
|
import { useState } from 'react';
|
|
183
152
|
|
|
184
153
|
export function Counter({ initialCount = 0 }) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
}
|
|
193
|
-
```
|
|
194
|
-
|
|
195
|
-
That's it! No extra imports or wrappers required. The framework:
|
|
196
|
-
|
|
197
|
-
1. Detects `'use client'` directive
|
|
198
|
-
2. Automatically transforms exports to island wrappers during SSR
|
|
199
|
-
3. Renders the actual component on the client
|
|
200
|
-
|
|
201
|
-
### Manual Wrapping (Advanced)
|
|
202
|
-
|
|
203
|
-
For more control, you can manually wrap with `island()`:
|
|
204
|
-
|
|
205
|
-
```tsx
|
|
206
|
-
'use client';
|
|
207
|
-
|
|
208
|
-
import { useState } from 'react';
|
|
209
|
-
import { island } from 'melina/island';
|
|
210
|
-
|
|
211
|
-
function CounterImpl({ initialCount = 0 }) {
|
|
212
|
-
const [count, setCount] = useState(initialCount);
|
|
213
|
-
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
|
|
154
|
+
const [count, setCount] = useState(initialCount);
|
|
155
|
+
|
|
156
|
+
return (
|
|
157
|
+
<button onClick={() => setCount(c => c + 1)}>
|
|
158
|
+
Count: {count}
|
|
159
|
+
</button>
|
|
160
|
+
);
|
|
214
161
|
}
|
|
215
|
-
|
|
216
|
-
// Explicit island wrapper
|
|
217
|
-
export const Counter = island(CounterImpl, 'Counter');
|
|
218
162
|
```
|
|
219
163
|
|
|
220
164
|
### Using Islands in Server Components
|
|
@@ -226,32 +170,30 @@ Just import and use them:
|
|
|
226
170
|
import { Counter } from './components/Counter';
|
|
227
171
|
|
|
228
172
|
export default function HomePage() {
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
173
|
+
return (
|
|
174
|
+
<div>
|
|
175
|
+
<h1>My Page</h1>
|
|
176
|
+
<Counter initialCount={10} /> {/* Island! */}
|
|
177
|
+
</div>
|
|
178
|
+
);
|
|
235
179
|
}
|
|
236
180
|
```
|
|
237
181
|
|
|
238
182
|
The framework automatically:
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
2. Adds `data-melina-island` markers with serialized props
|
|
183
|
+
1. Renders the component on the server (for SEO/initial paint)
|
|
184
|
+
2. Adds `data-melina-island` markers
|
|
242
185
|
3. Hydrates on the client with React
|
|
243
186
|
|
|
244
187
|
---
|
|
245
188
|
|
|
246
|
-
##
|
|
189
|
+
## The Router
|
|
247
190
|
|
|
248
191
|
### File-Based Routes
|
|
249
192
|
|
|
250
193
|
```
|
|
251
194
|
app/page.tsx → /
|
|
252
195
|
app/about/page.tsx → /about
|
|
253
|
-
app/blog/[
|
|
254
|
-
app/docs/[...path]/page.tsx → /docs/* (catch-all)
|
|
196
|
+
app/blog/[id]/page.tsx → /blog/:id (dynamic)
|
|
255
197
|
```
|
|
256
198
|
|
|
257
199
|
### The `<Link>` Component
|
|
@@ -259,37 +201,58 @@ app/docs/[...path]/page.tsx → /docs/* (catch-all)
|
|
|
259
201
|
Use `<Link>` for SPA-style navigation:
|
|
260
202
|
|
|
261
203
|
```tsx
|
|
262
|
-
import { Link } from 'melina
|
|
204
|
+
import { Link } from '@anthropic/melina.js';
|
|
263
205
|
|
|
264
206
|
export default function Nav() {
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
);
|
|
207
|
+
return (
|
|
208
|
+
<nav>
|
|
209
|
+
<Link href="/">Home</Link>
|
|
210
|
+
<Link href="/about">About</Link>
|
|
211
|
+
</nav>
|
|
212
|
+
);
|
|
272
213
|
}
|
|
273
214
|
```
|
|
274
215
|
|
|
275
|
-
### Route Precedence
|
|
276
|
-
|
|
277
|
-
| Priority | Type | Example | Description |
|
|
278
|
-
|----------|------|---------|-------------|
|
|
279
|
-
| 1 | Static | `/blog/my-post` | Exact match |
|
|
280
|
-
| 2 | Dynamic | `/blog/[slug]` | Single segment |
|
|
281
|
-
| 3 | Catch-all | `/blog/[...slug]` | Multiple segments |
|
|
282
|
-
|
|
283
216
|
### Navigation Lifecycle
|
|
284
217
|
|
|
285
218
|
When a user clicks a link:
|
|
286
219
|
|
|
287
220
|
1. **Fetch**: Router fetches the new page HTML
|
|
288
221
|
2. **Parse**: Parses it into a DOM tree
|
|
289
|
-
3. **
|
|
290
|
-
4. **
|
|
291
|
-
|
|
292
|
-
|
|
222
|
+
3. **Swap**: Only replaces `#melina-page-content`
|
|
223
|
+
4. **Hydrate**: Finds new islands and hydrates them
|
|
224
|
+
|
|
225
|
+
**Elements outside `#melina-page-content` persist automatically!**
|
|
226
|
+
|
|
227
|
+
### ⚡ What Happens to Islands During Navigation
|
|
228
|
+
|
|
229
|
+
Each island is its own **independent React root**. Here's what happens:
|
|
230
|
+
|
|
231
|
+
| Island Location | On Navigation | State |
|
|
232
|
+
|-----------------|---------------|-------|
|
|
233
|
+
| In Layout (header/footer) | **Persists** - DOM untouched | ✅ Preserved |
|
|
234
|
+
| In Page | **Destroyed** - innerHTML replaced | ❌ Lost |
|
|
235
|
+
| Same island on new page | New React root created | Fresh state |
|
|
236
|
+
|
|
237
|
+
**Example:**
|
|
238
|
+
```
|
|
239
|
+
Navigate from /home to /about:
|
|
240
|
+
|
|
241
|
+
┌─ Layout ────────────────────────────────────────┐
|
|
242
|
+
│ <SearchBar /> ← React Root #1 (STAYS) │
|
|
243
|
+
│ state preserved! ✅ │
|
|
244
|
+
├─────────────────────────────────────────────────┤
|
|
245
|
+
│ <main id="melina-page-content"> │
|
|
246
|
+
│ /home: <Counter /> ← Root #2 (DESTROYED) ❌ │
|
|
247
|
+
│ /about: <ContactForm /> ← Root #3 (NEW) 🆕 │
|
|
248
|
+
│ </main> │
|
|
249
|
+
├─────────────────────────────────────────────────┤
|
|
250
|
+
│ <JobTracker /> ← React Root #4 (STAYS) │
|
|
251
|
+
│ state preserved! ✅ │
|
|
252
|
+
└─────────────────────────────────────────────────┘
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
**Key insight:** Even if both pages use `<Counter />`, they are **different React roots**. State doesn't transfer.
|
|
293
256
|
|
|
294
257
|
---
|
|
295
258
|
|
|
@@ -305,24 +268,24 @@ import { SearchBar } from './components/SearchBar';
|
|
|
305
268
|
import { JobTracker } from './components/JobTracker';
|
|
306
269
|
|
|
307
270
|
export default function RootLayout({ children }) {
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
271
|
+
return (
|
|
272
|
+
<html>
|
|
273
|
+
<body>
|
|
274
|
+
<header>
|
|
275
|
+
<nav>...</nav>
|
|
276
|
+
<SearchBar /> {/* Persists across navigation! */}
|
|
277
|
+
</header>
|
|
278
|
+
|
|
279
|
+
{/* Only this part swaps on navigation */}
|
|
280
|
+
<main id="melina-page-content">
|
|
281
|
+
{children}
|
|
282
|
+
</main>
|
|
283
|
+
|
|
284
|
+
<footer>...</footer>
|
|
285
|
+
<JobTracker /> {/* Persists too! */}
|
|
286
|
+
</body>
|
|
287
|
+
</html>
|
|
288
|
+
);
|
|
326
289
|
}
|
|
327
290
|
```
|
|
328
291
|
|
|
@@ -336,20 +299,6 @@ app/
|
|
|
336
299
|
└── page.tsx # Dashboard page
|
|
337
300
|
```
|
|
338
301
|
|
|
339
|
-
```tsx
|
|
340
|
-
// app/dashboard/layout.tsx
|
|
341
|
-
export default function DashboardLayout({ children }) {
|
|
342
|
-
return (
|
|
343
|
-
<div className="dashboard">
|
|
344
|
-
<Sidebar />
|
|
345
|
-
<div id="melina-page-content">
|
|
346
|
-
{children}
|
|
347
|
-
</div>
|
|
348
|
-
</div>
|
|
349
|
-
);
|
|
350
|
-
}
|
|
351
|
-
```
|
|
352
|
-
|
|
353
302
|
---
|
|
354
303
|
|
|
355
304
|
## State Persistence
|
|
@@ -359,25 +308,25 @@ export default function DashboardLayout({ children }) {
|
|
|
359
308
|
Islands in the layout (outside `#melina-page-content`) are never destroyed during navigation:
|
|
360
309
|
|
|
361
310
|
```tsx
|
|
311
|
+
// This SearchBar's React state survives navigation!
|
|
362
312
|
<header>
|
|
363
|
-
|
|
313
|
+
<SearchBar />
|
|
364
314
|
</header>
|
|
365
315
|
|
|
366
316
|
<main id="melina-page-content">
|
|
367
|
-
|
|
317
|
+
{children} {/* Only this changes */}
|
|
368
318
|
</main>
|
|
369
319
|
```
|
|
370
320
|
|
|
371
321
|
### ⚠️ The Props Desynchronization Trade-off
|
|
372
322
|
|
|
373
|
-
**
|
|
323
|
+
**This is important!** When navigating:
|
|
374
324
|
|
|
375
325
|
1. Server renders new page with new props
|
|
376
|
-
2. Router keeps
|
|
377
|
-
3. **
|
|
326
|
+
2. Router keeps old layout (to preserve state)
|
|
327
|
+
3. **Old islands keep their OLD props!**
|
|
378
328
|
|
|
379
329
|
**Example:**
|
|
380
|
-
|
|
381
330
|
```tsx
|
|
382
331
|
// layout.tsx
|
|
383
332
|
<Header activeTab={pathname} /> // ❌ Props won't update!
|
|
@@ -385,10 +334,10 @@ Islands in the layout (outside `#melina-page-content`) are never destroyed durin
|
|
|
385
334
|
|
|
386
335
|
If you're on `/home` and navigate to `/about`:
|
|
387
336
|
- Server sends `activeTab="about"`
|
|
388
|
-
- Router
|
|
337
|
+
- Router throws that away, keeps old Header
|
|
389
338
|
- Header still shows `activeTab="home"` 😱
|
|
390
339
|
|
|
391
|
-
###
|
|
340
|
+
### Solution: Use URL State
|
|
392
341
|
|
|
393
342
|
Persistent islands should read from URL, not props:
|
|
394
343
|
|
|
@@ -396,165 +345,25 @@ Persistent islands should read from URL, not props:
|
|
|
396
345
|
// ✅ CORRECT - Read from URL
|
|
397
346
|
'use client';
|
|
398
347
|
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
function HeaderImpl() {
|
|
403
|
-
const [activeTab, setActiveTab] = useState('/');
|
|
404
|
-
|
|
405
|
-
useEffect(() => {
|
|
406
|
-
// Read current path
|
|
407
|
-
setActiveTab(window.location.pathname);
|
|
348
|
+
export function Header() {
|
|
349
|
+
const [activeTab, setActiveTab] = useState('/');
|
|
408
350
|
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
export const Header = island(HeaderImpl, 'Header');
|
|
428
|
-
```
|
|
429
|
-
|
|
430
|
-
### Persisting State for Page Islands
|
|
431
|
-
|
|
432
|
-
Page islands (inside `#melina-page-content`) lose state on navigation. Options:
|
|
433
|
-
|
|
434
|
-
**Option 1: Move to Layout**
|
|
435
|
-
```tsx
|
|
436
|
-
// If the island should persist, put it in the layout
|
|
437
|
-
<JobTracker /> // In layout = always persists
|
|
438
|
-
```
|
|
439
|
-
|
|
440
|
-
**Option 2: Use localStorage**
|
|
441
|
-
```tsx
|
|
442
|
-
function CounterImpl({ initialCount = 0 }) {
|
|
443
|
-
const [count, setCount] = useState(() => {
|
|
444
|
-
const saved = localStorage.getItem('counter');
|
|
445
|
-
return saved ? parseInt(saved) : initialCount;
|
|
446
|
-
});
|
|
447
|
-
|
|
448
|
-
useEffect(() => {
|
|
449
|
-
localStorage.setItem('counter', String(count));
|
|
450
|
-
}, [count]);
|
|
451
|
-
|
|
452
|
-
return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
|
|
453
|
-
}
|
|
454
|
-
```
|
|
455
|
-
|
|
456
|
-
**Option 3: Use URL State**
|
|
457
|
-
```tsx
|
|
458
|
-
function FilterPanel() {
|
|
459
|
-
const [filter, setFilter] = useState(() => {
|
|
460
|
-
return new URLSearchParams(window.location.search).get('filter') || 'all';
|
|
461
|
-
});
|
|
462
|
-
|
|
463
|
-
const updateFilter = (newFilter) => {
|
|
464
|
-
const url = new URL(window.location.href);
|
|
465
|
-
url.searchParams.set('filter', newFilter);
|
|
466
|
-
window.history.replaceState({}, '', url);
|
|
467
|
-
setFilter(newFilter);
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
return (
|
|
471
|
-
<select value={filter} onChange={e => updateFilter(e.target.value)}>
|
|
472
|
-
<option value="all">All</option>
|
|
473
|
-
<option value="active">Active</option>
|
|
474
|
-
</select>
|
|
475
|
-
);
|
|
476
|
-
}
|
|
477
|
-
```
|
|
478
|
-
|
|
479
|
-
---
|
|
480
|
-
|
|
481
|
-
## View Transitions
|
|
482
|
-
|
|
483
|
-
Melina.js leverages the browser-native [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API) for smooth, morphing animations between pages.
|
|
484
|
-
|
|
485
|
-
### Basic Setup
|
|
486
|
-
|
|
487
|
-
```css
|
|
488
|
-
/* app/globals.css */
|
|
489
|
-
|
|
490
|
-
/* Give elements a shared identity */
|
|
491
|
-
.album-cover {
|
|
492
|
-
view-transition-name: album-cover;
|
|
493
|
-
}
|
|
494
|
-
|
|
495
|
-
/* Customize the transition */
|
|
496
|
-
::view-transition-old(album-cover),
|
|
497
|
-
::view-transition-new(album-cover) {
|
|
498
|
-
animation-duration: 0.3s;
|
|
499
|
-
animation-timing-function: ease-in-out;
|
|
500
|
-
}
|
|
501
|
-
```
|
|
502
|
-
|
|
503
|
-
### Dynamic Transition Names
|
|
504
|
-
|
|
505
|
-
Use inline styles for dynamic transition names:
|
|
506
|
-
|
|
507
|
-
```tsx
|
|
508
|
-
// List view
|
|
509
|
-
<div
|
|
510
|
-
className="album-thumbnail"
|
|
511
|
-
style={{ viewTransitionName: `album-${album.id}` }}
|
|
512
|
-
>
|
|
513
|
-
<img src={album.cover} />
|
|
514
|
-
</div>
|
|
515
|
-
|
|
516
|
-
// Detail view
|
|
517
|
-
<div
|
|
518
|
-
className="album-hero"
|
|
519
|
-
style={{ viewTransitionName: `album-${params.id}` }}
|
|
520
|
-
>
|
|
521
|
-
<img src={album.cover} />
|
|
522
|
-
</div>
|
|
523
|
-
```
|
|
524
|
-
|
|
525
|
-
### Single-Instance Morphing
|
|
526
|
-
|
|
527
|
-
Layout-level islands can transform between states:
|
|
528
|
-
|
|
529
|
-
```tsx
|
|
530
|
-
// MusicPlayer that morphs between mini and expanded
|
|
531
|
-
function MusicPlayerImpl() {
|
|
532
|
-
const [path, setPath] = useState('/');
|
|
533
|
-
|
|
534
|
-
useEffect(() => {
|
|
535
|
-
setPath(window.location.pathname);
|
|
536
|
-
const handler = () => setPath(window.location.pathname);
|
|
537
|
-
window.addEventListener('melina:navigated', handler);
|
|
538
|
-
return () => window.removeEventListener('melina:navigated', handler);
|
|
539
|
-
}, []);
|
|
540
|
-
|
|
541
|
-
const isExpanded = path.startsWith('/album/');
|
|
542
|
-
|
|
543
|
-
return (
|
|
544
|
-
<div
|
|
545
|
-
className={isExpanded ? 'player-expanded' : 'player-mini'}
|
|
546
|
-
style={{ viewTransitionName: 'music-player' }}
|
|
547
|
-
>
|
|
548
|
-
{isExpanded ? <ExpandedView /> : <MiniView />}
|
|
549
|
-
</div>
|
|
550
|
-
);
|
|
551
|
-
}
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
```css
|
|
555
|
-
::view-transition-old(music-player),
|
|
556
|
-
::view-transition-new(music-player) {
|
|
557
|
-
animation-duration: 0.4s;
|
|
351
|
+
useEffect(() => {
|
|
352
|
+
// Read current path
|
|
353
|
+
setActiveTab(window.location.pathname);
|
|
354
|
+
|
|
355
|
+
// Update on navigation
|
|
356
|
+
const handleNav = () => setActiveTab(window.location.pathname);
|
|
357
|
+
window.addEventListener('melina:navigated', handleNav);
|
|
358
|
+
return () => window.removeEventListener('melina:navigated', handleNav);
|
|
359
|
+
}, []);
|
|
360
|
+
|
|
361
|
+
return (
|
|
362
|
+
<nav>
|
|
363
|
+
<Link href="/" className={activeTab === '/' ? 'active' : ''}>Home</Link>
|
|
364
|
+
<Link href="/about" className={activeTab === '/about' ? 'active' : ''}>About</Link>
|
|
365
|
+
</nav>
|
|
366
|
+
);
|
|
558
367
|
}
|
|
559
368
|
```
|
|
560
369
|
|
|
@@ -562,212 +371,103 @@ function MusicPlayerImpl() {
|
|
|
562
371
|
|
|
563
372
|
## Cross-Island Communication
|
|
564
373
|
|
|
565
|
-
Islands are independent React roots. They can't share state directly
|
|
374
|
+
Islands are independent React roots. They can't share state directly.
|
|
566
375
|
|
|
567
|
-
### Option 1:
|
|
376
|
+
### Option 1: localStorage + Events
|
|
568
377
|
|
|
569
378
|
```tsx
|
|
570
379
|
// Island A: Emit event
|
|
571
380
|
function submitJob(name) {
|
|
572
|
-
|
|
573
|
-
|
|
381
|
+
const job = { id: Date.now(), name };
|
|
382
|
+
localStorage.setItem('lastJob', JSON.stringify(job));
|
|
383
|
+
window.dispatchEvent(new CustomEvent('job:created', { detail: job }));
|
|
574
384
|
}
|
|
575
385
|
|
|
576
386
|
// Island B: Listen for events
|
|
577
387
|
useEffect(() => {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
388
|
+
const handler = (e) => setJobs(prev => [...prev, e.detail]);
|
|
389
|
+
window.addEventListener('job:created', handler);
|
|
390
|
+
return () => window.removeEventListener('job:created', handler);
|
|
581
391
|
}, []);
|
|
582
392
|
```
|
|
583
393
|
|
|
584
394
|
### Option 2: Event Bus
|
|
585
395
|
|
|
586
396
|
```tsx
|
|
587
|
-
//
|
|
397
|
+
// eventBus.ts
|
|
588
398
|
export const eventBus = {
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
399
|
+
emit(event: string, data: any) {
|
|
400
|
+
window.dispatchEvent(new CustomEvent(`app:${event}`, { detail: data }));
|
|
401
|
+
},
|
|
402
|
+
|
|
403
|
+
on(event: string, callback: (data: any) => void) {
|
|
404
|
+
const handler = (e: CustomEvent) => callback(e.detail);
|
|
405
|
+
window.addEventListener(`app:${event}`, handler);
|
|
406
|
+
return () => window.removeEventListener(`app:${event}`, handler);
|
|
407
|
+
}
|
|
598
408
|
};
|
|
599
|
-
|
|
600
|
-
// Usage
|
|
601
|
-
eventBus.emit('user:login', { id: 1, name: 'Alice' });
|
|
602
|
-
eventBus.on('user:login', (user) => console.log('Logged in:', user));
|
|
603
409
|
```
|
|
604
410
|
|
|
605
|
-
### Option 3:
|
|
411
|
+
### Option 3: URL State
|
|
606
412
|
|
|
607
|
-
|
|
608
|
-
// Island A: Write
|
|
609
|
-
localStorage.setItem('user', JSON.stringify(user));
|
|
610
|
-
window.dispatchEvent(new Event('storage'));
|
|
611
|
-
|
|
612
|
-
// Island B: Listen
|
|
613
|
-
const [user, setUser] = useState(() =>
|
|
614
|
-
JSON.parse(localStorage.getItem('user') || 'null')
|
|
615
|
-
);
|
|
616
|
-
|
|
617
|
-
useEffect(() => {
|
|
618
|
-
const handler = () => {
|
|
619
|
-
setUser(JSON.parse(localStorage.getItem('user') || 'null'));
|
|
620
|
-
};
|
|
621
|
-
window.addEventListener('storage', handler);
|
|
622
|
-
return () => window.removeEventListener('storage', handler);
|
|
623
|
-
}, []);
|
|
624
|
-
```
|
|
625
|
-
|
|
626
|
-
---
|
|
627
|
-
|
|
628
|
-
## API Routes
|
|
629
|
-
|
|
630
|
-
### Basic API Route
|
|
631
|
-
|
|
632
|
-
```tsx
|
|
633
|
-
// app/api/hello/route.ts
|
|
634
|
-
export async function GET(req: Request) {
|
|
635
|
-
return Response.json({
|
|
636
|
-
message: 'Hello from API!',
|
|
637
|
-
serverTime: new Date().toISOString()
|
|
638
|
-
});
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
export async function POST(req: Request) {
|
|
642
|
-
const body = await req.json();
|
|
643
|
-
// Process data...
|
|
644
|
-
return Response.json({ success: true });
|
|
645
|
-
}
|
|
646
|
-
```
|
|
647
|
-
|
|
648
|
-
### Full Server Capabilities
|
|
649
|
-
|
|
650
|
-
```tsx
|
|
651
|
-
// app/api/data/route.ts
|
|
652
|
-
import { readFileSync } from 'fs';
|
|
653
|
-
import { db } from '../../../lib/database';
|
|
654
|
-
|
|
655
|
-
export async function GET() {
|
|
656
|
-
// Read from file system
|
|
657
|
-
const config = readFileSync('./config.json', 'utf-8');
|
|
658
|
-
|
|
659
|
-
// Query database
|
|
660
|
-
const users = await db.query('SELECT * FROM users');
|
|
661
|
-
|
|
662
|
-
return Response.json({ config: JSON.parse(config), users });
|
|
663
|
-
}
|
|
664
|
-
```
|
|
665
|
-
|
|
666
|
-
### Dynamic API Routes
|
|
667
|
-
|
|
668
|
-
```tsx
|
|
669
|
-
// app/api/users/[id]/route.ts
|
|
670
|
-
export async function GET(req: Request, { params }) {
|
|
671
|
-
const user = await db.findUser(params.id);
|
|
672
|
-
|
|
673
|
-
if (!user) {
|
|
674
|
-
return Response.json({ error: 'Not found' }, { status: 404 });
|
|
675
|
-
}
|
|
676
|
-
|
|
677
|
-
return Response.json(user);
|
|
678
|
-
}
|
|
679
|
-
```
|
|
680
|
-
|
|
681
|
-
---
|
|
682
|
-
|
|
683
|
-
## Styling
|
|
684
|
-
|
|
685
|
-
### Global CSS
|
|
413
|
+
For state that should survive refresh:
|
|
686
414
|
|
|
687
415
|
```tsx
|
|
688
|
-
//
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
export default function Layout({ children }) {
|
|
692
|
-
return (
|
|
693
|
-
<html>
|
|
694
|
-
<head>
|
|
695
|
-
{/* CSS is automatically injected */}
|
|
696
|
-
</head>
|
|
697
|
-
<body>...</body>
|
|
698
|
-
</html>
|
|
699
|
-
);
|
|
700
|
-
}
|
|
701
|
-
```
|
|
702
|
-
|
|
703
|
-
### Tailwind CSS v4
|
|
416
|
+
// Read from URL
|
|
417
|
+
const searchParams = new URLSearchParams(window.location.search);
|
|
418
|
+
const filter = searchParams.get('filter');
|
|
704
419
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
/* Custom theme via CSS */
|
|
712
|
-
@theme {
|
|
713
|
-
--color-primary: #667eea;
|
|
714
|
-
--color-accent: #764ba2;
|
|
420
|
+
// Update URL
|
|
421
|
+
function setFilter(value) {
|
|
422
|
+
const url = new URL(window.location.href);
|
|
423
|
+
url.searchParams.set('filter', value);
|
|
424
|
+
window.history.replaceState({}, '', url);
|
|
715
425
|
}
|
|
716
426
|
```
|
|
717
427
|
|
|
718
|
-
No `tailwind.config.js` needed!
|
|
719
|
-
|
|
720
428
|
---
|
|
721
429
|
|
|
722
430
|
## Best Practices & Gotchas
|
|
723
431
|
|
|
724
432
|
### ✅ Do's
|
|
725
433
|
|
|
726
|
-
1. **Keep layouts lean**
|
|
727
|
-
2. **Use URL state for persistent islands**
|
|
728
|
-
3. **Use `#melina-page-content`**
|
|
729
|
-
4. **Listen for `melina:navigated`**
|
|
730
|
-
5. **Use `island()` wrapper** — Required for client components
|
|
434
|
+
1. **Keep layouts lean** - Only put truly persistent elements there
|
|
435
|
+
2. **Use URL state for persistent islands** - Don't rely on server props
|
|
436
|
+
3. **Use `#melina-page-content`** - The router needs this container
|
|
437
|
+
4. **Listen for `melina:navigated`** - Keep persistent islands synced
|
|
731
438
|
|
|
732
439
|
### ❌ Don'ts
|
|
733
440
|
|
|
734
|
-
1. **Don't pass dynamic props to persistent islands**
|
|
735
|
-
2. **Don't use global CSS transitions on body**
|
|
736
|
-
3. **Don't
|
|
737
|
-
4. **Don't forget cleanup** — Remove event listeners in useEffect cleanup
|
|
441
|
+
1. **Don't pass dynamic props to persistent islands** - They won't update
|
|
442
|
+
2. **Don't use global CSS transitions on body** - They'll restart on swap
|
|
443
|
+
3. **Don't forget `display: contents`** - If island wrapper breaks layout
|
|
738
444
|
|
|
739
445
|
### Common Patterns
|
|
740
446
|
|
|
741
447
|
```tsx
|
|
742
448
|
// ✅ Good: Persistent island reads URL
|
|
743
|
-
function
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
return <nav>...</nav>;
|
|
449
|
+
function NavTabs() {
|
|
450
|
+
const [path, setPath] = useState('/');
|
|
451
|
+
useEffect(() => {
|
|
452
|
+
setPath(window.location.pathname);
|
|
453
|
+
window.addEventListener('melina:navigated', () => {
|
|
454
|
+
setPath(window.location.pathname);
|
|
455
|
+
});
|
|
456
|
+
}, []);
|
|
457
|
+
return <nav>...</nav>;
|
|
754
458
|
}
|
|
755
459
|
|
|
756
|
-
export const NavTabs = island(NavTabsImpl, 'NavTabs');
|
|
757
|
-
|
|
758
460
|
// ✅ Good: Cross-page data via localStorage
|
|
759
|
-
function
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
461
|
+
function JobTracker() {
|
|
462
|
+
const [jobs, setJobs] = useState(() => {
|
|
463
|
+
return JSON.parse(localStorage.getItem('jobs') || '[]');
|
|
464
|
+
});
|
|
465
|
+
// ...
|
|
764
466
|
}
|
|
765
467
|
|
|
766
|
-
export const JobTracker = island(JobTrackerImpl, 'JobTracker');
|
|
767
|
-
|
|
768
468
|
// ❌ Bad: Relying on props in persistent island
|
|
769
|
-
function
|
|
770
|
-
|
|
469
|
+
function Header({ activeTab }) { // Won't update on navigation!
|
|
470
|
+
return <nav className={activeTab}>...</nav>;
|
|
771
471
|
}
|
|
772
472
|
```
|
|
773
473
|
|
|
@@ -782,118 +482,32 @@ melina init <project-name> # Create new project
|
|
|
782
482
|
melina start # Start dev server
|
|
783
483
|
```
|
|
784
484
|
|
|
785
|
-
###
|
|
786
|
-
|
|
787
|
-
```tsx
|
|
788
|
-
// Programmatic API
|
|
789
|
-
import { start, createApp, serve, createAppRouter } from 'melina';
|
|
790
|
-
|
|
791
|
-
// Navigation component
|
|
792
|
-
import { Link } from 'melina/Link';
|
|
793
|
-
|
|
794
|
-
// Manual island wrapper (optional - auto-wrapping is default)
|
|
795
|
-
import { island } from 'melina/island';
|
|
796
|
-
```
|
|
797
|
-
|
|
798
|
-
### Programmatic API
|
|
799
|
-
|
|
800
|
-
#### `start(options?)` — Quickstart Function
|
|
801
|
-
|
|
802
|
-
The easiest way to run Melina from your own script:
|
|
485
|
+
### Components
|
|
803
486
|
|
|
804
487
|
```tsx
|
|
805
|
-
import {
|
|
488
|
+
import { Link } from '@anthropic/melina.js';
|
|
806
489
|
|
|
807
|
-
|
|
808
|
-
await start();
|
|
809
|
-
|
|
810
|
-
// With options
|
|
811
|
-
await start({
|
|
812
|
-
appDir: './app', // Default: './app'
|
|
813
|
-
port: 8080, // Default: 3000
|
|
814
|
-
defaultTitle: 'My App', // Default: 'Melina App'
|
|
815
|
-
globalCss: './app/styles.css',
|
|
816
|
-
});
|
|
817
|
-
```
|
|
818
|
-
|
|
819
|
-
#### `createAppRouter(options)` — Router Factory
|
|
820
|
-
|
|
821
|
-
Creates a request handler with file-based routing:
|
|
822
|
-
|
|
823
|
-
```tsx
|
|
824
|
-
import { serve, createAppRouter } from 'melina';
|
|
825
|
-
|
|
826
|
-
const router = createAppRouter({
|
|
827
|
-
appDir: './app',
|
|
828
|
-
defaultTitle: 'My App',
|
|
829
|
-
globalCss: './app/globals.css',
|
|
830
|
-
});
|
|
831
|
-
|
|
832
|
-
// Use with custom middleware
|
|
833
|
-
serve(async (req, measure) => {
|
|
834
|
-
// Custom logic before routing
|
|
835
|
-
if (req.url.endsWith('/health')) {
|
|
836
|
-
return new Response('OK');
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
return router(req, measure);
|
|
840
|
-
}, { port: 3000 });
|
|
841
|
-
```
|
|
842
|
-
|
|
843
|
-
#### `createApp(options)` — Alias for `createAppRouter`
|
|
844
|
-
|
|
845
|
-
```tsx
|
|
846
|
-
import { createApp } from 'melina';
|
|
847
|
-
const app = createApp({ appDir: './app' });
|
|
490
|
+
<Link href="/path">Text</Link>
|
|
848
491
|
```
|
|
849
492
|
|
|
850
|
-
|
|
493
|
+
### Events
|
|
851
494
|
|
|
852
|
-
```
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
// Your custom handler
|
|
857
|
-
return new Response('Hello');
|
|
858
|
-
}, {
|
|
859
|
-
port: 3000,
|
|
860
|
-
// Or unix socket for production
|
|
861
|
-
unix: '/tmp/melina.sock'
|
|
495
|
+
```js
|
|
496
|
+
// Fired after SPA navigation completes
|
|
497
|
+
window.addEventListener('melina:navigated', () => {
|
|
498
|
+
console.log('Navigation complete!');
|
|
862
499
|
});
|
|
863
500
|
```
|
|
864
501
|
|
|
865
|
-
###
|
|
866
|
-
|
|
867
|
-
Manual island wrapper (usually not needed with auto-wrapping):
|
|
502
|
+
### Server Functions
|
|
868
503
|
|
|
869
504
|
```tsx
|
|
870
|
-
import {
|
|
505
|
+
import { serve, createAppRouter } from '@anthropic/melina.js';
|
|
871
506
|
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
### The `<Link>` Component
|
|
877
|
-
|
|
878
|
-
```tsx
|
|
879
|
-
import { Link } from 'melina/Link';
|
|
880
|
-
|
|
881
|
-
<Link href="/path">Text</Link>
|
|
882
|
-
<Link href="/path" className="nav-link">Styled Link</Link>
|
|
883
|
-
```
|
|
884
|
-
|
|
885
|
-
### Navigation Events
|
|
886
|
-
|
|
887
|
-
```javascript
|
|
888
|
-
// Fired when navigation starts
|
|
889
|
-
window.addEventListener('melina:navigation-start', (e) => {
|
|
890
|
-
console.log('From:', e.detail.from, 'To:', e.detail.to);
|
|
891
|
-
});
|
|
892
|
-
|
|
893
|
-
// Fired after SPA navigation completes
|
|
894
|
-
window.addEventListener('melina:navigated', () => {
|
|
895
|
-
console.log('Navigation complete!');
|
|
896
|
-
});
|
|
507
|
+
serve(createAppRouter({
|
|
508
|
+
appDir: './app',
|
|
509
|
+
defaultTitle: 'My App',
|
|
510
|
+
}), { port: 3000 });
|
|
897
511
|
```
|
|
898
512
|
|
|
899
513
|
---
|
|
@@ -904,8 +518,6 @@ window.addEventListener('melina:navigated', () => {
|
|
|
904
518
|
|
|
905
519
|
You're probably relying on server props. See [Props Desynchronization](#️-the-props-desynchronization-trade-off).
|
|
906
520
|
|
|
907
|
-
**Solution:** Read from URL + listen for `melina:navigated`
|
|
908
|
-
|
|
909
521
|
### "CSS animations restart on navigation"
|
|
910
522
|
|
|
911
523
|
Make sure your animated elements are **outside** `#melina-page-content`.
|
|
@@ -914,37 +526,13 @@ Make sure your animated elements are **outside** `#melina-page-content`.
|
|
|
914
526
|
|
|
915
527
|
Islands are separate React roots. Use localStorage/events. See [Cross-Island Communication](#cross-island-communication).
|
|
916
528
|
|
|
917
|
-
### "View Transitions aren't working"
|
|
918
|
-
|
|
919
|
-
1. Ensure browser supports View Transitions API (Chrome 111+)
|
|
920
|
-
2. Check that `view-transition-name` values match between pages
|
|
921
|
-
3. Ensure no duplicate `view-transition-name` on the same page
|
|
922
|
-
|
|
923
|
-
### "EADDRINUSE error on restart"
|
|
924
|
-
|
|
925
|
-
The Unix socket wasn't cleaned up. Melina handles this automatically, but if it persists:
|
|
926
|
-
|
|
927
|
-
```bash
|
|
928
|
-
rm /tmp/melina.sock
|
|
929
|
-
```
|
|
930
|
-
|
|
931
|
-
### "Missing 'app' directory"
|
|
932
|
-
|
|
933
|
-
Make sure you have an `app/` directory with at least a `page.tsx` or `layout.tsx`:
|
|
934
|
-
|
|
935
|
-
```bash
|
|
936
|
-
mkdir -p app
|
|
937
|
-
echo 'export default () => <h1>Hello!</h1>;' > app/page.tsx
|
|
938
|
-
```
|
|
939
|
-
|
|
940
529
|
---
|
|
941
530
|
|
|
942
531
|
## Version History
|
|
943
532
|
|
|
944
|
-
- **
|
|
945
|
-
- **v0.
|
|
946
|
-
- **v0.1.0** — Initial release with file-based routing
|
|
533
|
+
- **v0.2.0** - Partial page swap architecture, persistent islands
|
|
534
|
+
- **v0.1.0** - Initial release with file-based routing
|
|
947
535
|
|
|
948
536
|
---
|
|
949
537
|
|
|
950
|
-
Built with ❤️ using
|
|
538
|
+
Built with ❤️ using Bun
|