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
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Melina.js Client-Side Runtime
|
|
3
|
+
*
|
|
4
|
+
* Single bundle that handles:
|
|
5
|
+
* 1. Island Orchestrator initialization (single React root + portals)
|
|
6
|
+
* 2. Client-side navigation with View Transitions
|
|
7
|
+
* 3. Link interception
|
|
8
|
+
*
|
|
9
|
+
* This is the ONLY client-side script needed - no per-island boilerplate.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
// ============================================================================
|
|
13
|
+
// NAVIGATION
|
|
14
|
+
// ============================================================================
|
|
15
|
+
|
|
16
|
+
async function navigate(href: string) {
|
|
17
|
+
const fromPath = window.location.pathname;
|
|
18
|
+
const toPath = new URL(href, window.location.origin).pathname;
|
|
19
|
+
|
|
20
|
+
// Skip if same page
|
|
21
|
+
if (fromPath === toPath) return;
|
|
22
|
+
|
|
23
|
+
// Update URL immediately
|
|
24
|
+
window.history.pushState({}, '', href);
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
// Fetch new page HTML
|
|
28
|
+
const response = await fetch(href, {
|
|
29
|
+
headers: { 'X-Melina-Nav': '1' }
|
|
30
|
+
});
|
|
31
|
+
const htmlText = await response.text();
|
|
32
|
+
|
|
33
|
+
// Parse new document
|
|
34
|
+
const parser = new DOMParser();
|
|
35
|
+
const newDoc = parser.parseFromString(htmlText, 'text/html');
|
|
36
|
+
|
|
37
|
+
// DOM update function - runs inside View Transition callback
|
|
38
|
+
const updateDOM = () => {
|
|
39
|
+
// Dispatch navigation-start INSIDE transition callback
|
|
40
|
+
// This lets islands update synchronously for view transition snapshot
|
|
41
|
+
window.dispatchEvent(new CustomEvent('melina:navigation-start', {
|
|
42
|
+
detail: { from: fromPath, to: toPath }
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
// Update title
|
|
46
|
+
document.title = newDoc.title;
|
|
47
|
+
|
|
48
|
+
// PARTIAL SWAP: Only replace #melina-page-content
|
|
49
|
+
// Islands container (#melina-islands) is OUTSIDE, so React root persists
|
|
50
|
+
const currentContent = document.getElementById('melina-page-content');
|
|
51
|
+
const newContent = newDoc.getElementById('melina-page-content');
|
|
52
|
+
|
|
53
|
+
if (currentContent && newContent) {
|
|
54
|
+
currentContent.innerHTML = newContent.innerHTML;
|
|
55
|
+
console.log(`[Melina] Navigation ${fromPath} → ${toPath}`);
|
|
56
|
+
} else {
|
|
57
|
+
// Fallback: full body swap (but try to preserve islands container)
|
|
58
|
+
const islandsContainer = document.getElementById('melina-islands');
|
|
59
|
+
document.body.innerHTML = newDoc.body.innerHTML;
|
|
60
|
+
if (islandsContainer) {
|
|
61
|
+
document.body.insertBefore(islandsContainer, document.body.firstChild);
|
|
62
|
+
}
|
|
63
|
+
console.log('[Melina] Full body swap (fallback)');
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Update island meta if present in new page
|
|
67
|
+
const newMeta = newDoc.getElementById('__MELINA_META__');
|
|
68
|
+
const currentMeta = document.getElementById('__MELINA_META__');
|
|
69
|
+
if (newMeta && currentMeta) {
|
|
70
|
+
currentMeta.textContent = newMeta.textContent;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Scroll to top
|
|
74
|
+
window.scrollTo(0, 0);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
// Execute update with View Transition if available
|
|
78
|
+
if (document.startViewTransition) {
|
|
79
|
+
const transition = document.startViewTransition(() => {
|
|
80
|
+
updateDOM();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// After transition completes, notify islands to re-scan
|
|
84
|
+
transition.finished.then(() => {
|
|
85
|
+
window.dispatchEvent(new CustomEvent('melina:navigated'));
|
|
86
|
+
});
|
|
87
|
+
} else {
|
|
88
|
+
updateDOM();
|
|
89
|
+
window.dispatchEvent(new CustomEvent('melina:navigated'));
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
} catch (error) {
|
|
93
|
+
console.error('[Melina] Navigation failed:', error);
|
|
94
|
+
// Fallback to full page load
|
|
95
|
+
window.location.href = href;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Expose navigate globally for programmatic use
|
|
100
|
+
(window as any).melinaNavigate = navigate;
|
|
101
|
+
|
|
102
|
+
// ============================================================================
|
|
103
|
+
// LINK INTERCEPTION
|
|
104
|
+
// ============================================================================
|
|
105
|
+
|
|
106
|
+
function initializeLinkInterception() {
|
|
107
|
+
document.addEventListener('click', (e) => {
|
|
108
|
+
const link = (e.target as Element).closest('a[href]');
|
|
109
|
+
if (!link) return;
|
|
110
|
+
|
|
111
|
+
const href = link.getAttribute('href');
|
|
112
|
+
|
|
113
|
+
// Skip: external links, downloads, modified clicks, non-local hrefs
|
|
114
|
+
if (
|
|
115
|
+
e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0 ||
|
|
116
|
+
link.hasAttribute('download') ||
|
|
117
|
+
link.hasAttribute('target') ||
|
|
118
|
+
link.hasAttribute('data-no-intercept') ||
|
|
119
|
+
!href || !href.startsWith('/')
|
|
120
|
+
) {
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
e.preventDefault();
|
|
125
|
+
navigate(href);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
// Handle back/forward buttons
|
|
129
|
+
window.addEventListener('popstate', () => {
|
|
130
|
+
// On popstate, we need to fetch and render the page for the current URL
|
|
131
|
+
const href = window.location.pathname + window.location.search;
|
|
132
|
+
|
|
133
|
+
// Don't use navigate() since history is already updated
|
|
134
|
+
fetch(href, { headers: { 'X-Melina-Nav': '1' } })
|
|
135
|
+
.then(res => res.text())
|
|
136
|
+
.then(htmlText => {
|
|
137
|
+
const parser = new DOMParser();
|
|
138
|
+
const newDoc = parser.parseFromString(htmlText, 'text/html');
|
|
139
|
+
|
|
140
|
+
const updateDOM = () => {
|
|
141
|
+
window.dispatchEvent(new CustomEvent('melina:navigation-start', {
|
|
142
|
+
detail: { to: href }
|
|
143
|
+
}));
|
|
144
|
+
|
|
145
|
+
document.title = newDoc.title;
|
|
146
|
+
|
|
147
|
+
const currentContent = document.getElementById('melina-page-content');
|
|
148
|
+
const newContent = newDoc.getElementById('melina-page-content');
|
|
149
|
+
|
|
150
|
+
if (currentContent && newContent) {
|
|
151
|
+
currentContent.innerHTML = newContent.innerHTML;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
if (document.startViewTransition) {
|
|
156
|
+
document.startViewTransition(() => updateDOM()).finished.then(() => {
|
|
157
|
+
window.dispatchEvent(new CustomEvent('melina:navigated'));
|
|
158
|
+
});
|
|
159
|
+
} else {
|
|
160
|
+
updateDOM();
|
|
161
|
+
window.dispatchEvent(new CustomEvent('melina:navigated'));
|
|
162
|
+
}
|
|
163
|
+
})
|
|
164
|
+
.catch(err => {
|
|
165
|
+
console.error('[Melina] Popstate navigation failed:', err);
|
|
166
|
+
window.location.reload();
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
console.log('[Melina] Link interception active');
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============================================================================
|
|
174
|
+
// ISLAND ORCHESTRATOR INITIALIZATION
|
|
175
|
+
// ============================================================================
|
|
176
|
+
|
|
177
|
+
async function initializeOrchestrator() {
|
|
178
|
+
const React = await import('react');
|
|
179
|
+
const { createRoot } = await import('react-dom/client');
|
|
180
|
+
|
|
181
|
+
// Create container if it doesn't exist
|
|
182
|
+
let container = document.getElementById('melina-islands');
|
|
183
|
+
if (!container) {
|
|
184
|
+
container = document.createElement('div');
|
|
185
|
+
container.id = 'melina-islands';
|
|
186
|
+
container.style.display = 'contents'; // Won't affect layout
|
|
187
|
+
document.body.insertBefore(container, document.body.firstChild);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Import and instantiate the orchestrator
|
|
191
|
+
const { IslandOrchestrator } = await import('./IslandOrchestrator');
|
|
192
|
+
|
|
193
|
+
// Create single React root
|
|
194
|
+
const root = createRoot(container);
|
|
195
|
+
root.render(React.createElement(IslandOrchestrator));
|
|
196
|
+
|
|
197
|
+
console.log('[Melina] Island Orchestrator initialized');
|
|
198
|
+
|
|
199
|
+
return root;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ============================================================================
|
|
203
|
+
// BOOTSTRAP
|
|
204
|
+
// ============================================================================
|
|
205
|
+
|
|
206
|
+
async function bootstrap() {
|
|
207
|
+
// Initialize link interception first (synchronous)
|
|
208
|
+
initializeLinkInterception();
|
|
209
|
+
|
|
210
|
+
// Then initialize the Island Orchestrator (async)
|
|
211
|
+
await initializeOrchestrator();
|
|
212
|
+
|
|
213
|
+
console.log('[Melina] Runtime ready (Single Root + Portals architecture)');
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// Run on DOM ready
|
|
217
|
+
if (document.readyState === 'loading') {
|
|
218
|
+
document.addEventListener('DOMContentLoaded', bootstrap);
|
|
219
|
+
} else {
|
|
220
|
+
bootstrap();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export { navigate, initializeOrchestrator };
|
package/src/transform.ts
ADDED
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Melina.js Import Transformer
|
|
3
|
+
*
|
|
4
|
+
* Transforms 'use client' modules on import during SSR.
|
|
5
|
+
* Uses Bun's Transpiler API for fast transformation.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync } from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { createHash } from 'crypto';
|
|
11
|
+
|
|
12
|
+
// Cache for transformed modules
|
|
13
|
+
const transformCache = new Map<string, any>();
|
|
14
|
+
const CACHE_DIR = path.join(process.cwd(), '.melina', 'cache');
|
|
15
|
+
|
|
16
|
+
// Ensure cache dir exists
|
|
17
|
+
try {
|
|
18
|
+
if (!existsSync(CACHE_DIR)) {
|
|
19
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
} catch (e) {
|
|
22
|
+
// Ignore
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Island wrapper helper code
|
|
27
|
+
*/
|
|
28
|
+
const ISLAND_HELPER = `
|
|
29
|
+
const __MELINA_SSR__ = typeof window === 'undefined';
|
|
30
|
+
function __island__(Impl, name) {
|
|
31
|
+
if (__MELINA_SSR__) {
|
|
32
|
+
const R = require('react');
|
|
33
|
+
return (p) => R.createElement('div', {
|
|
34
|
+
'data-melina-island': name,
|
|
35
|
+
'data-props': JSON.stringify(p||{}).replace(/"/g,'"')
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
return Impl;
|
|
39
|
+
}
|
|
40
|
+
`;
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Check for 'use client' directive
|
|
44
|
+
*/
|
|
45
|
+
export function isClientComponent(content: string): boolean {
|
|
46
|
+
const lines = content.split('\n').slice(0, 5).join('\n');
|
|
47
|
+
return lines.includes("'use client'") || lines.includes('"use client"');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Extract component exports
|
|
52
|
+
*/
|
|
53
|
+
export function extractExports(content: string): string[] {
|
|
54
|
+
const exports: string[] = [];
|
|
55
|
+
|
|
56
|
+
for (const m of content.matchAll(/export\s+function\s+([A-Z]\w*)/g)) exports.push(m[1]);
|
|
57
|
+
for (const m of content.matchAll(/export\s+const\s+([A-Z]\w*)\s*=/g)) exports.push(m[1]);
|
|
58
|
+
for (const m of content.matchAll(/export\s+default\s+function\s+([A-Z]\w*)/g)) exports.push(m[1]);
|
|
59
|
+
|
|
60
|
+
return [...new Set(exports)];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Transform client component source
|
|
65
|
+
*/
|
|
66
|
+
export function transformSource(content: string): string {
|
|
67
|
+
const exports = extractExports(content);
|
|
68
|
+
if (exports.length === 0) return content;
|
|
69
|
+
|
|
70
|
+
let out = content;
|
|
71
|
+
|
|
72
|
+
// Insert helper
|
|
73
|
+
out = out.replace(/(['"]use client['"];?\s*\n)/, `$1${ISLAND_HELPER}\n`);
|
|
74
|
+
|
|
75
|
+
// Rename exports
|
|
76
|
+
for (const name of exports) {
|
|
77
|
+
out = out.replace(new RegExp(`export\\s+function\\s+${name}\\b`), `function ${name}$impl`);
|
|
78
|
+
out = out.replace(new RegExp(`export\\s+const\\s+${name}\\s*=`), `const ${name}$impl =`);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Add wrapped exports
|
|
82
|
+
for (const name of exports) {
|
|
83
|
+
out += `\nexport const ${name} = __island__(${name}$impl, '${name}');`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Handle default
|
|
87
|
+
const defaultMatch = content.match(/export\s+default\s+function\s+([A-Z]\w*)/);
|
|
88
|
+
if (defaultMatch) {
|
|
89
|
+
const name = defaultMatch[1];
|
|
90
|
+
out = out.replace(/export\s+default\s+function\s+([A-Z]\w*)/, `function ${name}$impl`);
|
|
91
|
+
out += `\nexport default ${name};`;
|
|
92
|
+
} else if (content.match(/export\s+default\s+([A-Z]\w*)\s*;?$/m)) {
|
|
93
|
+
const m = content.match(/export\s+default\s+([A-Z]\w*)\s*;?$/m);
|
|
94
|
+
if (m) {
|
|
95
|
+
out = out.replace(/export\s+default\s+([A-Z]\w*)\s*;?$/m, `export default __island__(${m[1]}$impl, '${m[1]}');`);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return out;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Get cache file path
|
|
104
|
+
*/
|
|
105
|
+
function getCachePath(filePath: string, content: string): string {
|
|
106
|
+
const hash = createHash('sha256').update(content).digest('hex').slice(0, 12);
|
|
107
|
+
const basename = path.basename(filePath, path.extname(filePath));
|
|
108
|
+
return path.join(CACHE_DIR, `${basename}.${hash}.mjs`);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Import a client component with transformation
|
|
113
|
+
*/
|
|
114
|
+
export async function importClientComponent(filePath: string): Promise<any> {
|
|
115
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(filePath);
|
|
116
|
+
|
|
117
|
+
// Check memory cache
|
|
118
|
+
if (transformCache.has(absolutePath)) {
|
|
119
|
+
return transformCache.get(absolutePath);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const content = readFileSync(absolutePath, 'utf-8');
|
|
123
|
+
|
|
124
|
+
// If not client component, import normally
|
|
125
|
+
if (!isClientComponent(content)) {
|
|
126
|
+
const mod = await import(absolutePath);
|
|
127
|
+
transformCache.set(absolutePath, mod);
|
|
128
|
+
return mod;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// Check disk cache
|
|
132
|
+
const cachePath = getCachePath(absolutePath, content);
|
|
133
|
+
|
|
134
|
+
if (!existsSync(cachePath)) {
|
|
135
|
+
// Transform and write to cache
|
|
136
|
+
const transformed = transformSource(content);
|
|
137
|
+
|
|
138
|
+
// Use Bun's transpiler to convert TSX to JS
|
|
139
|
+
const transpiler = new Bun.Transpiler({
|
|
140
|
+
loader: 'tsx',
|
|
141
|
+
target: 'bun',
|
|
142
|
+
});
|
|
143
|
+
const js = transpiler.transformSync(transformed);
|
|
144
|
+
|
|
145
|
+
writeFileSync(cachePath, js);
|
|
146
|
+
console.log(`🏝️ [Melina] Transformed: ${path.basename(filePath)}`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Import from cache
|
|
150
|
+
const mod = await import(cachePath);
|
|
151
|
+
transformCache.set(absolutePath, mod);
|
|
152
|
+
return mod;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Clear transform cache
|
|
157
|
+
*/
|
|
158
|
+
export function clearCache(): void {
|
|
159
|
+
transformCache.clear();
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export default {
|
|
163
|
+
isClientComponent,
|
|
164
|
+
extractExports,
|
|
165
|
+
transformSource,
|
|
166
|
+
importClientComponent,
|
|
167
|
+
clearCache,
|
|
168
|
+
};
|