melina 1.0.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 +538 -0
- package/README.md +132 -0
- package/bin/melina +95 -0
- package/docs/ARCHITECTURE.md +856 -0
- package/docs/islands-hydration.png +0 -0
- package/package.json +68 -0
- package/src/IslandOrchestrator.tsx +259 -0
- package/src/Link.tsx +65 -0
- package/src/clientRegistry.ts +81 -0
- package/src/createIsland.tsx +121 -0
- package/src/island.ts +69 -0
- package/src/islands.ts +93 -0
- package/src/loader.ts +226 -0
- package/src/mcp.ts +716 -0
- package/src/plugin.ts +236 -0
- package/src/preload.ts +124 -0
- package/src/preprocess.ts +204 -0
- package/src/router.ts +192 -0
- package/src/runtime/hangar.ts +399 -0
- package/src/runtime.ts +223 -0
- package/src/transform.ts +168 -0
- package/src/web.ts +1324 -0
|
Binary file
|
package/package.json
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "melina",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A lightweight, islands-architecture web framework for Bun with Next.js-style routing.",
|
|
5
|
+
"module": "./src/web.ts",
|
|
6
|
+
"main": "./src/web.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"melina": "./bin/melina"
|
|
9
|
+
},
|
|
10
|
+
"files": [
|
|
11
|
+
"src",
|
|
12
|
+
"bin",
|
|
13
|
+
"docs",
|
|
14
|
+
"GUIDE.md",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"exports": {
|
|
18
|
+
".": "./src/web.ts",
|
|
19
|
+
"./web": "./src/web.ts",
|
|
20
|
+
"./Link": "./src/Link.tsx",
|
|
21
|
+
"./island": "./src/island.ts"
|
|
22
|
+
},
|
|
23
|
+
"repository": {
|
|
24
|
+
"type": "git",
|
|
25
|
+
"url": "https://github.com/mements/melina.js"
|
|
26
|
+
},
|
|
27
|
+
"author": {
|
|
28
|
+
"name": "Mements Team"
|
|
29
|
+
},
|
|
30
|
+
"bugs": "https://github.com/mements/melina.js/issues",
|
|
31
|
+
"homepage": "https://github.com/mements/melina.js#readme",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"keywords": [
|
|
34
|
+
"bun",
|
|
35
|
+
"server",
|
|
36
|
+
"framework",
|
|
37
|
+
"typescript",
|
|
38
|
+
"web",
|
|
39
|
+
"ssr",
|
|
40
|
+
"islands",
|
|
41
|
+
"islands-architecture",
|
|
42
|
+
"hydration",
|
|
43
|
+
"nextjs",
|
|
44
|
+
"app-router",
|
|
45
|
+
"react",
|
|
46
|
+
"partial-hydration"
|
|
47
|
+
],
|
|
48
|
+
"type": "module",
|
|
49
|
+
"private": false,
|
|
50
|
+
"sideEffects": false,
|
|
51
|
+
"devDependencies": {
|
|
52
|
+
"@types/bun": "latest",
|
|
53
|
+
"@types/react": "^19.2.10",
|
|
54
|
+
"@types/react-dom": "^19.2.3"
|
|
55
|
+
},
|
|
56
|
+
"dependencies": {
|
|
57
|
+
"@ments/utils": "^1.2.1",
|
|
58
|
+
"@modelcontextprotocol/sdk": "^1.11.0",
|
|
59
|
+
"@solana/kit": "^2.1.0",
|
|
60
|
+
"@tailwindcss/postcss": "^4.1.10",
|
|
61
|
+
"autoprefixer": "^10.4.21",
|
|
62
|
+
"postcss": "^8.5.6",
|
|
63
|
+
"react": "^19.1.1",
|
|
64
|
+
"react-dom": "^19.1.1",
|
|
65
|
+
"ts-dedent": "^2.2.0",
|
|
66
|
+
"zod": "^3.24.4"
|
|
67
|
+
}
|
|
68
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* IslandOrchestrator - Single React Root + Portals Architecture
|
|
5
|
+
*
|
|
6
|
+
* This component manages ALL islands in the application from a single React root.
|
|
7
|
+
* Each island is rendered as a React Portal into its placeholder in the DOM.
|
|
8
|
+
*
|
|
9
|
+
* Benefits:
|
|
10
|
+
* - Single React instance (no duplicate bundles)
|
|
11
|
+
* - Shared state context across all islands
|
|
12
|
+
* - Islands persist across navigation (portals just re-target)
|
|
13
|
+
* - View Transitions work naturally (same React tree)
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import React, { useState, useEffect, useCallback, useSyncExternalStore } from 'react';
|
|
17
|
+
import { createPortal, flushSync } from 'react-dom';
|
|
18
|
+
|
|
19
|
+
// Types
|
|
20
|
+
interface IslandConfig {
|
|
21
|
+
name: string;
|
|
22
|
+
props: Record<string, any>;
|
|
23
|
+
target: HTMLElement;
|
|
24
|
+
instanceId: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface LoadedIsland extends IslandConfig {
|
|
28
|
+
Component: React.ComponentType<any>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Global component cache - shared across all orchestrator instances
|
|
32
|
+
const componentCache: Record<string, React.ComponentType<any>> = {};
|
|
33
|
+
|
|
34
|
+
// Get island meta from the page
|
|
35
|
+
function getIslandMeta(): Record<string, string> {
|
|
36
|
+
const metaEl = document.getElementById('__MELINA_META__');
|
|
37
|
+
if (!metaEl) return {};
|
|
38
|
+
try {
|
|
39
|
+
return JSON.parse(metaEl.textContent || '{}');
|
|
40
|
+
} catch {
|
|
41
|
+
return {};
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Collect all island placeholders from the DOM
|
|
46
|
+
function collectIslandPlaceholders(): IslandConfig[] {
|
|
47
|
+
const elements = document.querySelectorAll('[data-island]');
|
|
48
|
+
const islands: IslandConfig[] = [];
|
|
49
|
+
|
|
50
|
+
elements.forEach((el, index) => {
|
|
51
|
+
const name = el.getAttribute('data-island');
|
|
52
|
+
if (!name) return;
|
|
53
|
+
|
|
54
|
+
const propsStr = (el.getAttribute('data-props') || '{}').replace(/"/g, '"');
|
|
55
|
+
let props = {};
|
|
56
|
+
try {
|
|
57
|
+
props = JSON.parse(propsStr);
|
|
58
|
+
} catch (e) {
|
|
59
|
+
console.warn(`[Melina] Invalid props for island ${name}:`, e);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Generate stable instance ID
|
|
63
|
+
const instanceId = el.getAttribute('data-instance') || `${name}-${index}`;
|
|
64
|
+
|
|
65
|
+
islands.push({
|
|
66
|
+
name,
|
|
67
|
+
props,
|
|
68
|
+
target: el as HTMLElement,
|
|
69
|
+
instanceId
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
return islands;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Load a component module dynamically
|
|
77
|
+
async function loadComponent(name: string): Promise<React.ComponentType<any> | null> {
|
|
78
|
+
// Check cache first
|
|
79
|
+
if (componentCache[name]) {
|
|
80
|
+
return componentCache[name];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const meta = getIslandMeta();
|
|
84
|
+
const bundlePath = meta[name];
|
|
85
|
+
|
|
86
|
+
if (!bundlePath) {
|
|
87
|
+
console.warn(`[Melina] No bundle found for island: ${name}`);
|
|
88
|
+
return null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const module = await import(/* @vite-ignore */ bundlePath);
|
|
93
|
+
// Try named export first, then default
|
|
94
|
+
const Component = module[name] || module.default;
|
|
95
|
+
|
|
96
|
+
if (Component) {
|
|
97
|
+
componentCache[name] = Component;
|
|
98
|
+
console.log(`[Melina] Loaded component: ${name}`);
|
|
99
|
+
return Component;
|
|
100
|
+
} else {
|
|
101
|
+
console.warn(`[Melina] No export found for component: ${name}`);
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
} catch (e) {
|
|
105
|
+
console.error(`[Melina] Failed to load component ${name}:`, e);
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Island wrapper - renders the component in a portal
|
|
111
|
+
function IslandPortal({ island }: { island: LoadedIsland }) {
|
|
112
|
+
const { Component, props, target, name, instanceId } = island;
|
|
113
|
+
|
|
114
|
+
// Mark as hydrated
|
|
115
|
+
useEffect(() => {
|
|
116
|
+
target.setAttribute('data-hydrated', 'true');
|
|
117
|
+
return () => {
|
|
118
|
+
target.removeAttribute('data-hydrated');
|
|
119
|
+
};
|
|
120
|
+
}, [target]);
|
|
121
|
+
|
|
122
|
+
return createPortal(
|
|
123
|
+
<Component {...props} />,
|
|
124
|
+
target
|
|
125
|
+
);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Navigation state store (external store pattern for React 18)
|
|
129
|
+
let currentPath = typeof window !== 'undefined' ? window.location.pathname : '/';
|
|
130
|
+
const pathListeners = new Set<() => void>();
|
|
131
|
+
|
|
132
|
+
function subscribePath(callback: () => void) {
|
|
133
|
+
pathListeners.add(callback);
|
|
134
|
+
return () => pathListeners.delete(callback);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function getPath() {
|
|
138
|
+
return currentPath;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function setPath(newPath: string) {
|
|
142
|
+
currentPath = newPath;
|
|
143
|
+
pathListeners.forEach(cb => cb());
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
// Listen for navigation events
|
|
147
|
+
if (typeof window !== 'undefined') {
|
|
148
|
+
window.addEventListener('melina:navigation-start', (e: any) => {
|
|
149
|
+
const to = e.detail?.to || window.location.pathname;
|
|
150
|
+
setPath(to);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
window.addEventListener('popstate', () => {
|
|
154
|
+
setPath(window.location.pathname);
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* IslandOrchestrator Component
|
|
160
|
+
*
|
|
161
|
+
* Manages all islands from a single React root using portals.
|
|
162
|
+
*/
|
|
163
|
+
export function IslandOrchestrator() {
|
|
164
|
+
const [islands, setIslands] = useState<LoadedIsland[]>([]);
|
|
165
|
+
const [isLoading, setIsLoading] = useState(true);
|
|
166
|
+
|
|
167
|
+
// Track current path for re-rendering on navigation
|
|
168
|
+
const path = useSyncExternalStore(subscribePath, getPath, () => '/');
|
|
169
|
+
|
|
170
|
+
// Scan and load islands
|
|
171
|
+
const scanAndLoadIslands = useCallback(async () => {
|
|
172
|
+
const placeholders = collectIslandPlaceholders();
|
|
173
|
+
|
|
174
|
+
// Load all components in parallel
|
|
175
|
+
const loaded: LoadedIsland[] = [];
|
|
176
|
+
|
|
177
|
+
await Promise.all(
|
|
178
|
+
placeholders.map(async (config) => {
|
|
179
|
+
const Component = await loadComponent(config.name);
|
|
180
|
+
if (Component) {
|
|
181
|
+
loaded.push({ ...config, Component });
|
|
182
|
+
}
|
|
183
|
+
})
|
|
184
|
+
);
|
|
185
|
+
|
|
186
|
+
// Use flushSync during navigation to ensure view transition captures correct state
|
|
187
|
+
if (document.startViewTransition) {
|
|
188
|
+
flushSync(() => setIslands(loaded));
|
|
189
|
+
} else {
|
|
190
|
+
setIslands(loaded);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
setIsLoading(false);
|
|
194
|
+
console.log(`[Melina] Orchestrator: ${loaded.length} islands active`);
|
|
195
|
+
}, []);
|
|
196
|
+
|
|
197
|
+
// Initial scan
|
|
198
|
+
useEffect(() => {
|
|
199
|
+
scanAndLoadIslands();
|
|
200
|
+
}, [scanAndLoadIslands]);
|
|
201
|
+
|
|
202
|
+
// Re-scan on navigation
|
|
203
|
+
useEffect(() => {
|
|
204
|
+
const handleNavigated = () => {
|
|
205
|
+
// Small delay to let DOM update complete
|
|
206
|
+
requestAnimationFrame(() => {
|
|
207
|
+
scanAndLoadIslands();
|
|
208
|
+
});
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
window.addEventListener('melina:navigated', handleNavigated);
|
|
212
|
+
return () => window.removeEventListener('melina:navigated', handleNavigated);
|
|
213
|
+
}, [scanAndLoadIslands]);
|
|
214
|
+
|
|
215
|
+
// Also re-scan when path changes (for layout islands that conditionally render)
|
|
216
|
+
useEffect(() => {
|
|
217
|
+
scanAndLoadIslands();
|
|
218
|
+
}, [path, scanAndLoadIslands]);
|
|
219
|
+
|
|
220
|
+
// Render all islands as portals
|
|
221
|
+
return (
|
|
222
|
+
<>
|
|
223
|
+
{islands.map((island) => (
|
|
224
|
+
<IslandPortal
|
|
225
|
+
key={island.instanceId}
|
|
226
|
+
island={island}
|
|
227
|
+
/>
|
|
228
|
+
))}
|
|
229
|
+
</>
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Initialize the Island Orchestrator
|
|
235
|
+
* Call this from the runtime script to bootstrap the single React root
|
|
236
|
+
*/
|
|
237
|
+
export async function initializeOrchestrator() {
|
|
238
|
+
const React = await import('react');
|
|
239
|
+
const { createRoot } = await import('react-dom/client');
|
|
240
|
+
|
|
241
|
+
// Create container if it doesn't exist
|
|
242
|
+
let container = document.getElementById('melina-islands');
|
|
243
|
+
if (!container) {
|
|
244
|
+
container = document.createElement('div');
|
|
245
|
+
container.id = 'melina-islands';
|
|
246
|
+
container.style.display = 'contents'; // Won't affect layout
|
|
247
|
+
document.body.insertBefore(container, document.body.firstChild);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Create single React root
|
|
251
|
+
const root = createRoot(container);
|
|
252
|
+
root.render(React.createElement(IslandOrchestrator));
|
|
253
|
+
|
|
254
|
+
console.log('[Melina] Island Orchestrator initialized (Single Root + Portals)');
|
|
255
|
+
|
|
256
|
+
return root;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export default IslandOrchestrator;
|
package/src/Link.tsx
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
export interface LinkProps {
|
|
4
|
+
href: string;
|
|
5
|
+
children: React.ReactNode;
|
|
6
|
+
className?: string;
|
|
7
|
+
style?: React.CSSProperties;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Link Component
|
|
12
|
+
*
|
|
13
|
+
* Uses the Hangar runtime's navigation function (window.melinaNavigate)
|
|
14
|
+
* for proper View Transitions and island state preservation.
|
|
15
|
+
*/
|
|
16
|
+
export function Link({ href, children, className, style }: LinkProps) {
|
|
17
|
+
const handleClick = (e: React.MouseEvent<HTMLAnchorElement>) => {
|
|
18
|
+
// Allow default behavior for special clicks or external links
|
|
19
|
+
if (
|
|
20
|
+
e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0 ||
|
|
21
|
+
!href.startsWith('/') ||
|
|
22
|
+
e.currentTarget.hasAttribute('download')
|
|
23
|
+
) {
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
e.preventDefault();
|
|
28
|
+
|
|
29
|
+
// Use Hangar runtime's navigation function
|
|
30
|
+
if (typeof (window as any).melinaNavigate === 'function') {
|
|
31
|
+
(window as any).melinaNavigate(href);
|
|
32
|
+
} else {
|
|
33
|
+
// Fallback to regular navigation if runtime not loaded
|
|
34
|
+
console.warn('[Link] melinaNavigate not available, using direct navigation');
|
|
35
|
+
window.location.href = href;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<a href={href} onClick={handleClick} className={className} style={style}>
|
|
41
|
+
{children}
|
|
42
|
+
</a>
|
|
43
|
+
);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Hook for programmatic navigation
|
|
47
|
+
export function useNavigate() {
|
|
48
|
+
return (href: string) => {
|
|
49
|
+
if (typeof (window as any).melinaNavigate === 'function') {
|
|
50
|
+
(window as any).melinaNavigate(href);
|
|
51
|
+
} else {
|
|
52
|
+
window.location.href = href;
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Direct navigation function
|
|
58
|
+
export const clientNavigate = (href: string) => {
|
|
59
|
+
if (typeof (window as any).melinaNavigate === 'function') {
|
|
60
|
+
(window as any).melinaNavigate(href);
|
|
61
|
+
} else {
|
|
62
|
+
window.location.href = href;
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Client Component Registry
|
|
3
|
+
*
|
|
4
|
+
* This registry stores information about client components (islands).
|
|
5
|
+
* When a Server Component tries to render a Client Component,
|
|
6
|
+
* we render an island marker instead of the actual component.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from 'react';
|
|
10
|
+
|
|
11
|
+
// Registry of client component files
|
|
12
|
+
const clientComponents = new Map<string, Set<string>>();
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Register a file as containing client components
|
|
16
|
+
*/
|
|
17
|
+
export function registerClientComponent(filePath: string, exportNames: string[]) {
|
|
18
|
+
clientComponents.set(filePath, new Set(exportNames));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Check if a file is a client component
|
|
23
|
+
*/
|
|
24
|
+
export function isClientComponent(filePath: string): boolean {
|
|
25
|
+
return clientComponents.has(filePath);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Create an island wrapper for a client component
|
|
30
|
+
* This renders the island marker on the server
|
|
31
|
+
*/
|
|
32
|
+
export function createIslandWrapper<P extends object>(
|
|
33
|
+
componentName: string,
|
|
34
|
+
_OriginalComponent?: React.ComponentType<P>
|
|
35
|
+
): React.FC<P> {
|
|
36
|
+
const IslandMarker: React.FC<P> = (props) => {
|
|
37
|
+
const propsJson = JSON.stringify(props).replace(/"/g, '"');
|
|
38
|
+
|
|
39
|
+
// Return the island marker element
|
|
40
|
+
return React.createElement('div', {
|
|
41
|
+
'data-melina-island': componentName,
|
|
42
|
+
'data-props': propsJson,
|
|
43
|
+
children: React.createElement('div', {
|
|
44
|
+
style: {
|
|
45
|
+
padding: '1rem',
|
|
46
|
+
background: '#f0f0f0',
|
|
47
|
+
borderRadius: '4px',
|
|
48
|
+
opacity: 0.7
|
|
49
|
+
}
|
|
50
|
+
}, `Loading ${componentName}...`)
|
|
51
|
+
});
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
IslandMarker.displayName = `Island(${componentName})`;
|
|
55
|
+
return IslandMarker;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Transform a client component module for server-side use
|
|
60
|
+
* Replaces all exports with island wrappers
|
|
61
|
+
*/
|
|
62
|
+
export function wrapClientModule(
|
|
63
|
+
originalModule: any,
|
|
64
|
+
componentNames: string[]
|
|
65
|
+
): any {
|
|
66
|
+
const wrappedModule: any = {};
|
|
67
|
+
|
|
68
|
+
for (const name of componentNames) {
|
|
69
|
+
if (originalModule[name]) {
|
|
70
|
+
wrappedModule[name] = createIslandWrapper(name, originalModule[name]);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// Handle default export
|
|
75
|
+
if (originalModule.default) {
|
|
76
|
+
const defaultName = componentNames[0] || 'Default';
|
|
77
|
+
wrappedModule.default = createIslandWrapper(defaultName, originalModule.default);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return wrappedModule;
|
|
81
|
+
}
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
// Check if we're on the server
|
|
4
|
+
const isServer = typeof window === 'undefined';
|
|
5
|
+
|
|
6
|
+
// Global manifest of island chunks (populated by server)
|
|
7
|
+
declare global {
|
|
8
|
+
interface Window {
|
|
9
|
+
__MELINA_MANIFEST__?: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* createIsland - Wrap a Client Component for automatic island hydration
|
|
15
|
+
*
|
|
16
|
+
* This is the bridge between Server and Client components in MelinaJS.
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* ```tsx
|
|
20
|
+
* // components/Counter.tsx
|
|
21
|
+
* 'use client';
|
|
22
|
+
* import { createIsland } from '@ments/web';
|
|
23
|
+
*
|
|
24
|
+
* function CounterImpl({ initialCount = 0 }) {
|
|
25
|
+
* const [count, setCount] = useState(initialCount);
|
|
26
|
+
* return <button onClick={() => setCount(c => c + 1)}>{count}</button>;
|
|
27
|
+
* }
|
|
28
|
+
*
|
|
29
|
+
* // Export the island-wrapped version
|
|
30
|
+
* export const Counter = createIsland(CounterImpl, 'Counter');
|
|
31
|
+
* ```
|
|
32
|
+
*
|
|
33
|
+
* On the Server:
|
|
34
|
+
* - Renders: <div data-melina-island="Counter" data-props='{"initialCount":0}'>...</div>
|
|
35
|
+
*
|
|
36
|
+
* On the Client:
|
|
37
|
+
* - Hydrates the component with full interactivity
|
|
38
|
+
*/
|
|
39
|
+
export function createIsland<P extends object>(
|
|
40
|
+
Component: React.ComponentType<P>,
|
|
41
|
+
name: string
|
|
42
|
+
): React.FC<P> {
|
|
43
|
+
// Return a wrapper component
|
|
44
|
+
const IslandWrapper: React.FC<P> = (props) => {
|
|
45
|
+
if (isServer) {
|
|
46
|
+
// SERVER: Render the island marker
|
|
47
|
+
// We also try to render the component for SEO/initial content
|
|
48
|
+
let innerHtml = '';
|
|
49
|
+
try {
|
|
50
|
+
// Attempt to render for progressive enhancement
|
|
51
|
+
// This might fail if the component uses hooks
|
|
52
|
+
const ReactDOMServer = require('react-dom/server');
|
|
53
|
+
innerHtml = ReactDOMServer.renderToString(
|
|
54
|
+
React.createElement(Component, props)
|
|
55
|
+
);
|
|
56
|
+
} catch (e) {
|
|
57
|
+
// Component uses hooks - can't SSR, just show placeholder
|
|
58
|
+
innerHtml = `<div class="melina-island-placeholder">Loading ${name}...</div>`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// Return the island marker with serialized props
|
|
62
|
+
const propsJson = JSON.stringify(props).replace(/"/g, '"');
|
|
63
|
+
|
|
64
|
+
return React.createElement('div', {
|
|
65
|
+
'data-melina-island': name,
|
|
66
|
+
'data-props': propsJson,
|
|
67
|
+
dangerouslySetInnerHTML: { __html: innerHtml }
|
|
68
|
+
});
|
|
69
|
+
} else {
|
|
70
|
+
// CLIENT: Render the actual component
|
|
71
|
+
return React.createElement(Component, props);
|
|
72
|
+
}
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
IslandWrapper.displayName = `Island(${name})`;
|
|
76
|
+
return IslandWrapper;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Island component - Alternative syntax for rendering islands
|
|
81
|
+
*
|
|
82
|
+
* Usage:
|
|
83
|
+
* ```tsx
|
|
84
|
+
* // In a Server Component
|
|
85
|
+
* import { Island } from '@ments/web';
|
|
86
|
+
* import { Counter } from './components/Counter';
|
|
87
|
+
*
|
|
88
|
+
* export default function Page() {
|
|
89
|
+
* return <Island component={Counter} name="Counter" initialCount={5} />;
|
|
90
|
+
* }
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function Island<P extends object>({
|
|
94
|
+
component: Component,
|
|
95
|
+
name,
|
|
96
|
+
...props
|
|
97
|
+
}: {
|
|
98
|
+
component: React.ComponentType<P>;
|
|
99
|
+
name: string;
|
|
100
|
+
} & P): React.ReactElement {
|
|
101
|
+
const WrappedComponent = createIsland(Component, name);
|
|
102
|
+
return React.createElement(WrappedComponent, props as P);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* ClientOnly - Render children only on the client
|
|
107
|
+
*
|
|
108
|
+
* Useful for components that absolutely cannot be SSR'd
|
|
109
|
+
*/
|
|
110
|
+
export function ClientOnly({
|
|
111
|
+
children,
|
|
112
|
+
fallback = null
|
|
113
|
+
}: {
|
|
114
|
+
children: React.ReactNode;
|
|
115
|
+
fallback?: React.ReactNode;
|
|
116
|
+
}): React.ReactElement | null {
|
|
117
|
+
if (isServer) {
|
|
118
|
+
return fallback as React.ReactElement | null;
|
|
119
|
+
}
|
|
120
|
+
return children as React.ReactElement;
|
|
121
|
+
}
|
package/src/island.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Island Helper - Automatic SSR detection and island marker rendering
|
|
3
|
+
*
|
|
4
|
+
* This is used internally by client components to automatically render
|
|
5
|
+
* as island markers when used in Server Components.
|
|
6
|
+
*
|
|
7
|
+
* Usage in a client component:
|
|
8
|
+
* ```tsx
|
|
9
|
+
* 'use client';
|
|
10
|
+
* import { island } from '@ments/web';
|
|
11
|
+
*
|
|
12
|
+
* function SearchBarImpl() {
|
|
13
|
+
* const [query, setQuery] = useState('');
|
|
14
|
+
* return <input value={query} onChange={e => setQuery(e.target.value)} />;
|
|
15
|
+
* }
|
|
16
|
+
*
|
|
17
|
+
* // Export the island-wrapped version
|
|
18
|
+
* export const SearchBar = island(SearchBarImpl, 'SearchBar');
|
|
19
|
+
* ```
|
|
20
|
+
*
|
|
21
|
+
* Or simpler - just export normally and the framework detects 'use client':
|
|
22
|
+
* ```tsx
|
|
23
|
+
* 'use client';
|
|
24
|
+
*
|
|
25
|
+
* export function SearchBar() {
|
|
26
|
+
* const [query, setQuery] = useState('');
|
|
27
|
+
* return <input value={query} onChange={e => setQuery(e.target.value)} />;
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
|
|
32
|
+
import React from 'react';
|
|
33
|
+
|
|
34
|
+
const isServer = typeof window === 'undefined';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Wrap a component to auto-detect SSR and render island marker
|
|
38
|
+
*/
|
|
39
|
+
export function island<P extends object>(
|
|
40
|
+
Component: React.ComponentType<P>,
|
|
41
|
+
name: string
|
|
42
|
+
): React.FC<P> {
|
|
43
|
+
const IslandComponent: React.FC<P> = (props) => {
|
|
44
|
+
if (isServer) {
|
|
45
|
+
// SERVER: Render EMPTY placeholder - the Hangar portal will fill it
|
|
46
|
+
// We do NOT SSR the component because:
|
|
47
|
+
// 1. It may have view-transition-name which would conflict with portal content
|
|
48
|
+
// 2. The component may use client-only APIs (window.location, etc.)
|
|
49
|
+
// 3. The portal will render immediately on hydration anyway
|
|
50
|
+
const propsJson = JSON.stringify(props).replace(/"/g, '"');
|
|
51
|
+
|
|
52
|
+
return React.createElement('div', {
|
|
53
|
+
'data-melina-island': name,
|
|
54
|
+
'data-props': propsJson,
|
|
55
|
+
style: { display: 'contents' } // No layout impact
|
|
56
|
+
});
|
|
57
|
+
// No children - portal will fill this
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// CLIENT: Render actual component
|
|
61
|
+
return React.createElement(Component, props);
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
IslandComponent.displayName = `Island(${name})`;
|
|
65
|
+
return IslandComponent;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Re-export for convenience
|
|
69
|
+
export { island as createIsland };
|