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/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, '&quot;');
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, '&quot;'),
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
+ };