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