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/islands.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Check if a module is a Client Component (has 'use client' directive)
|
|
6
|
+
*/
|
|
7
|
+
export function isClientComponent(filePath: string): boolean {
|
|
8
|
+
if (!existsSync(filePath)) return false;
|
|
9
|
+
|
|
10
|
+
try {
|
|
11
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
12
|
+
// Check first few lines for 'use client' directive
|
|
13
|
+
const firstLines = content.split('\n').slice(0, 5).join('\n');
|
|
14
|
+
return firstLines.includes("'use client'") || firstLines.includes('"use client"');
|
|
15
|
+
} catch {
|
|
16
|
+
return false;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Island metadata for client-side hydration
|
|
22
|
+
*/
|
|
23
|
+
export interface IslandMeta {
|
|
24
|
+
/** Component name (export name) */
|
|
25
|
+
name: string;
|
|
26
|
+
/** Path to the built client chunk */
|
|
27
|
+
chunkPath: string;
|
|
28
|
+
/** Props to pass to the component */
|
|
29
|
+
props: Record<string, any>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Registry of islands discovered during SSR
|
|
34
|
+
* Maps island ID to metadata
|
|
35
|
+
*/
|
|
36
|
+
export const islandRegistry: Map<string, IslandMeta> = new Map();
|
|
37
|
+
|
|
38
|
+
let islandCounter = 0;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Generate a unique island ID
|
|
42
|
+
*/
|
|
43
|
+
export function generateIslandId(): string {
|
|
44
|
+
return `melina-island-${++islandCounter}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Reset island registry (call at start of each request)
|
|
49
|
+
*/
|
|
50
|
+
export function resetIslandRegistry(): void {
|
|
51
|
+
islandRegistry.clear();
|
|
52
|
+
islandCounter = 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Create an island marker element for SSR output
|
|
57
|
+
*
|
|
58
|
+
* @param componentName - Name of the component
|
|
59
|
+
* @param chunkPath - Path to the client JS bundle
|
|
60
|
+
* @param props - Props to serialize
|
|
61
|
+
* @param children - SSR'd HTML of the component (for progressive enhancement)
|
|
62
|
+
*/
|
|
63
|
+
export function createIslandMarker(
|
|
64
|
+
componentName: string,
|
|
65
|
+
chunkPath: string,
|
|
66
|
+
props: Record<string, any>,
|
|
67
|
+
children: string = ''
|
|
68
|
+
): string {
|
|
69
|
+
const id = generateIslandId();
|
|
70
|
+
const propsJson = JSON.stringify(props).replace(/"/g, '"');
|
|
71
|
+
|
|
72
|
+
islandRegistry.set(id, {
|
|
73
|
+
name: componentName,
|
|
74
|
+
chunkPath,
|
|
75
|
+
props
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
return `<div data-melina-island="${componentName}" data-chunk="${chunkPath}" data-props="${propsJson}" data-island-id="${id}">${children}</div>`;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Generate the client manifest script tag
|
|
83
|
+
* This provides the client runtime with information about all islands on the page
|
|
84
|
+
*/
|
|
85
|
+
export function generateManifestScript(): string {
|
|
86
|
+
const manifest: Record<string, IslandMeta> = {};
|
|
87
|
+
|
|
88
|
+
for (const [id, meta] of islandRegistry) {
|
|
89
|
+
manifest[id] = meta;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return `<script>window.__MELINA_MANIFEST__ = ${JSON.stringify(manifest)};</script>`;
|
|
93
|
+
}
|
package/src/loader.ts
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Melina.js Client Component Loader
|
|
3
|
+
*
|
|
4
|
+
* Transforms 'use client' components on-the-fly during SSR.
|
|
5
|
+
* Uses Bun's transpiler to inject island wrappers automatically.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { readFileSync, existsSync, writeFileSync, mkdirSync } from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { createHash } from 'crypto';
|
|
11
|
+
|
|
12
|
+
// Cache directory for transformed modules
|
|
13
|
+
const CACHE_DIR = path.join(process.cwd(), '.melina', 'cache');
|
|
14
|
+
|
|
15
|
+
// Ensure cache directory exists
|
|
16
|
+
if (!existsSync(CACHE_DIR)) {
|
|
17
|
+
mkdirSync(CACHE_DIR, { recursive: true });
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Check if a file has 'use client' directive
|
|
22
|
+
*/
|
|
23
|
+
export function hasUseClientDirective(content: string): boolean {
|
|
24
|
+
const firstLines = content.split('\n').slice(0, 5).join('\n');
|
|
25
|
+
return firstLines.includes("'use client'") || firstLines.includes('"use client"');
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract exported component names from a file
|
|
30
|
+
*/
|
|
31
|
+
export function extractExportedComponents(content: string): string[] {
|
|
32
|
+
const exports: string[] = [];
|
|
33
|
+
|
|
34
|
+
// Match: export function ComponentName
|
|
35
|
+
const funcMatches = content.matchAll(/export\s+function\s+([A-Z][a-zA-Z0-9]*)/g);
|
|
36
|
+
for (const match of funcMatches) {
|
|
37
|
+
exports.push(match[1]);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Match: export const ComponentName =
|
|
41
|
+
const constMatches = content.matchAll(/export\s+const\s+([A-Z][a-zA-Z0-9]*)\s*=/g);
|
|
42
|
+
for (const match of constMatches) {
|
|
43
|
+
exports.push(match[1]);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Match: export default function ComponentName
|
|
47
|
+
const defaultFuncMatches = content.matchAll(/export\s+default\s+function\s+([A-Z][a-zA-Z0-9]*)/g);
|
|
48
|
+
for (const match of defaultFuncMatches) {
|
|
49
|
+
exports.push(match[1]);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return [...new Set(exports)];
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The island helper code that gets injected
|
|
57
|
+
*/
|
|
58
|
+
const ISLAND_HELPER = `
|
|
59
|
+
// Auto-injected by Melina.js
|
|
60
|
+
const __melina_isServer = typeof window === 'undefined';
|
|
61
|
+
function __melina_island(Component, name) {
|
|
62
|
+
if (__melina_isServer) {
|
|
63
|
+
const React = require('react');
|
|
64
|
+
return function IslandWrapper(props) {
|
|
65
|
+
return React.createElement('div', {
|
|
66
|
+
'data-melina-island': name,
|
|
67
|
+
'data-props': JSON.stringify(props || {}).replace(/"/g, '"'),
|
|
68
|
+
}, React.createElement('div', {
|
|
69
|
+
className: 'island-loading',
|
|
70
|
+
style: { opacity: 0.7, padding: '0.5rem' }
|
|
71
|
+
}, 'Loading ' + name + '...'));
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
return Component;
|
|
75
|
+
}
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Transform a client component for SSR
|
|
80
|
+
*/
|
|
81
|
+
export function transformClientComponent(content: string, filePath: string): string {
|
|
82
|
+
if (!hasUseClientDirective(content)) {
|
|
83
|
+
return content;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const components = extractExportedComponents(content);
|
|
87
|
+
if (components.length === 0) {
|
|
88
|
+
return content;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let transformed = content;
|
|
92
|
+
|
|
93
|
+
// Insert island helper after 'use client' directive
|
|
94
|
+
transformed = transformed.replace(
|
|
95
|
+
/(['"]use client['"];?\s*\n)/,
|
|
96
|
+
`$1${ISLAND_HELPER}\n`
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
// Transform each exported function
|
|
100
|
+
for (const name of components) {
|
|
101
|
+
// export function Name() -> function Name__impl()
|
|
102
|
+
transformed = transformed.replace(
|
|
103
|
+
new RegExp(`export\\s+function\\s+${name}\\s*\\(`),
|
|
104
|
+
`function ${name}__impl(`
|
|
105
|
+
);
|
|
106
|
+
|
|
107
|
+
// export const Name = -> const Name__impl =
|
|
108
|
+
transformed = transformed.replace(
|
|
109
|
+
new RegExp(`export\\s+const\\s+${name}\\s*=`),
|
|
110
|
+
`const ${name}__impl =`
|
|
111
|
+
);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Add wrapped exports at the end
|
|
115
|
+
for (const name of components) {
|
|
116
|
+
transformed += `\nexport const ${name} = __melina_island(${name}__impl, '${name}');`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Handle default export
|
|
120
|
+
const defaultMatch = content.match(/export\s+default\s+function\s+([A-Z][a-zA-Z0-9]*)/);
|
|
121
|
+
if (defaultMatch) {
|
|
122
|
+
const name = defaultMatch[1];
|
|
123
|
+
transformed = transformed.replace(
|
|
124
|
+
/export\s+default\s+function\s+([A-Z][a-zA-Z0-9]*)/,
|
|
125
|
+
`function ${name}__impl`
|
|
126
|
+
);
|
|
127
|
+
// Don't duplicate if already added above
|
|
128
|
+
if (!components.includes(name)) {
|
|
129
|
+
transformed += `\nexport const ${name} = __melina_island(${name}__impl, '${name}');`;
|
|
130
|
+
}
|
|
131
|
+
transformed += `\nexport default ${name};`;
|
|
132
|
+
} else if (content.includes('export default')) {
|
|
133
|
+
// export default SomeComponent - add wrapper
|
|
134
|
+
const simpleDefaultMatch = content.match(/export\s+default\s+([A-Z][a-zA-Z0-9]*)\s*;?$/m);
|
|
135
|
+
if (simpleDefaultMatch) {
|
|
136
|
+
const name = simpleDefaultMatch[1];
|
|
137
|
+
transformed = transformed.replace(
|
|
138
|
+
/export\s+default\s+([A-Z][a-zA-Z0-9]*)\s*;?$/m,
|
|
139
|
+
`export default __melina_island(${name}__impl, '${name}');`
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return transformed;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get cache key for a file
|
|
149
|
+
*/
|
|
150
|
+
function getCacheKey(filePath: string, content: string): string {
|
|
151
|
+
return createHash('sha256')
|
|
152
|
+
.update(filePath)
|
|
153
|
+
.update(content)
|
|
154
|
+
.digest('hex')
|
|
155
|
+
.slice(0, 16);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Load a module with client component transformation
|
|
160
|
+
*
|
|
161
|
+
* This is the main entry point for loading components during SSR.
|
|
162
|
+
* It will transform 'use client' components automatically.
|
|
163
|
+
*/
|
|
164
|
+
export async function loadWithTransform(filePath: string): Promise<any> {
|
|
165
|
+
const absolutePath = path.isAbsolute(filePath) ? filePath : path.resolve(process.cwd(), filePath);
|
|
166
|
+
|
|
167
|
+
if (!existsSync(absolutePath)) {
|
|
168
|
+
throw new Error(`File not found: ${absolutePath}`);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const content = readFileSync(absolutePath, 'utf-8');
|
|
172
|
+
|
|
173
|
+
// If not a client component, import normally
|
|
174
|
+
if (!hasUseClientDirective(content)) {
|
|
175
|
+
return import(absolutePath);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Transform and cache
|
|
179
|
+
const cacheKey = getCacheKey(absolutePath, content);
|
|
180
|
+
const cachedPath = path.join(CACHE_DIR, `${cacheKey}.tsx`);
|
|
181
|
+
|
|
182
|
+
if (!existsSync(cachedPath)) {
|
|
183
|
+
const transformed = transformClientComponent(content, absolutePath);
|
|
184
|
+
writeFileSync(cachedPath, transformed);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return import(cachedPath);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* Scan a directory for client components and return their names
|
|
192
|
+
*/
|
|
193
|
+
export function scanClientComponents(dir: string): Map<string, string[]> {
|
|
194
|
+
const components = new Map<string, string[]>();
|
|
195
|
+
|
|
196
|
+
if (!existsSync(dir)) {
|
|
197
|
+
return components;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const files = Bun.file(dir).exists;
|
|
201
|
+
// Use readdirSync for simplicity
|
|
202
|
+
const { readdirSync } = require('fs');
|
|
203
|
+
const fileList = readdirSync(dir);
|
|
204
|
+
|
|
205
|
+
for (const file of fileList) {
|
|
206
|
+
if (!file.endsWith('.tsx') && !file.endsWith('.jsx')) continue;
|
|
207
|
+
|
|
208
|
+
const filePath = path.join(dir, file);
|
|
209
|
+
const content = readFileSync(filePath, 'utf-8');
|
|
210
|
+
|
|
211
|
+
if (hasUseClientDirective(content)) {
|
|
212
|
+
const exports = extractExportedComponents(content);
|
|
213
|
+
components.set(filePath, exports);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
return components;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
export default {
|
|
221
|
+
hasUseClientDirective,
|
|
222
|
+
extractExportedComponents,
|
|
223
|
+
transformClientComponent,
|
|
224
|
+
loadWithTransform,
|
|
225
|
+
scanClientComponents,
|
|
226
|
+
};
|