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/router.ts ADDED
@@ -0,0 +1,192 @@
1
+ import { readdirSync, statSync, existsSync } from 'fs';
2
+ import path from 'path';
3
+
4
+ export interface Route {
5
+ /** File path to the page component */
6
+ filePath: string;
7
+ /** URL pattern (e.g., "/posts/:id") */
8
+ pattern: string;
9
+ /** URL pathname (e.g., "/posts/123") */
10
+ pathname: string;
11
+ /** Parameter names in order (e.g., ["id"]) */
12
+ paramNames: string[];
13
+ /** Regex for matching URLs */
14
+ regex: RegExp;
15
+ /** Layout file paths from root to page (for nested layouts) */
16
+ layouts: string[];
17
+ /** Route type: 'page' or 'api' */
18
+ type: 'page' | 'api';
19
+ }
20
+
21
+ export interface RouteMatch {
22
+ /** The matched route */
23
+ route: Route;
24
+ /** Extracted parameters from URL */
25
+ params: Record<string, string>;
26
+ }
27
+
28
+ /**
29
+ * Convert file path to URL pattern
30
+ * Examples:
31
+ * app/page.tsx -> /
32
+ * app/about/page.tsx -> /about
33
+ * app/posts/[id]/page.tsx -> /posts/:id
34
+ * app/blog/[year]/[month]/page.tsx -> /blog/:year/:month
35
+ */
36
+ function filePathToPattern(filePath: string, appDir: string): { pattern: string; paramNames: string[] } {
37
+ // Remove appDir prefix and page.tsx/page.ts suffix
38
+ let relativePath = path.relative(appDir, filePath);
39
+ // Remove page.tsx/page.ts or route.ts suffix
40
+ relativePath = relativePath.replace(/(^|\/)(page|route)\.(tsx?|jsx?)$/, '');
41
+
42
+ // Handle root page
43
+ if (!relativePath || relativePath === '.') {
44
+ return { pattern: '/', paramNames: [] };
45
+ }
46
+
47
+ // Convert [param] to :param and collect param names
48
+ const paramNames: string[] = [];
49
+ const pattern = '/' + relativePath
50
+ .split(path.sep)
51
+ .map(segment => {
52
+ // Handle dynamic segments like [id] or [slug]
53
+ const match = segment.match(/^\[([^\]]+)\]$/);
54
+ if (match) {
55
+ paramNames.push(match[1]);
56
+ return `:${match[1]}`;
57
+ }
58
+ // Handle route groups like (group) - ignore them in URL
59
+ if (segment.match(/^\([^)]+\)$/)) {
60
+ return null;
61
+ }
62
+ return segment;
63
+ })
64
+ .filter(Boolean)
65
+ .join('/');
66
+
67
+ return { pattern: pattern || '/', paramNames };
68
+ }
69
+
70
+ /**
71
+ * Convert URL pattern to RegExp for matching
72
+ * /posts/:id -> /^\/posts\/([^\/]+)$/
73
+ */
74
+ function patternToRegex(pattern: string): RegExp {
75
+ const regexStr = pattern
76
+ .replace(/\//g, '\\/')
77
+ .replace(/:([^\/]+)/g, '([^\\/]+)');
78
+ return new RegExp(`^${regexStr}$`);
79
+ }
80
+
81
+ /**
82
+ * Find all layout.tsx files from appDir to the page's directory
83
+ */
84
+ function findLayouts(pageFilePath: string, appDir: string): string[] {
85
+ const layouts: string[] = [];
86
+ let currentDir = path.dirname(pageFilePath);
87
+
88
+ // Walk up from page directory to appDir, collecting layouts
89
+ while (currentDir.startsWith(appDir) || currentDir === appDir) {
90
+ const layoutPath = path.join(currentDir, 'layout.tsx');
91
+ if (existsSync(layoutPath)) {
92
+ layouts.unshift(layoutPath); // Add to front (root layouts first)
93
+ }
94
+
95
+ if (currentDir === appDir) break;
96
+ currentDir = path.dirname(currentDir);
97
+ }
98
+
99
+ return layouts;
100
+ }
101
+
102
+ /**
103
+ * Recursively discover all page.tsx/page.ts and route.ts files in app directory
104
+ */
105
+ export function discoverRoutes(appDir: string): Route[] {
106
+ const routes: Route[] = [];
107
+
108
+ function scanDir(dir: string) {
109
+ try {
110
+ const entries = readdirSync(dir);
111
+
112
+ for (const entry of entries) {
113
+ const fullPath = path.join(dir, entry);
114
+ const stats = statSync(fullPath);
115
+
116
+ if (stats.isDirectory()) {
117
+ scanDir(fullPath);
118
+ } else if (entry.match(/^page\.(tsx?|jsx?)$/)) {
119
+ const { pattern, paramNames } = filePathToPattern(fullPath, appDir);
120
+ const regex = patternToRegex(pattern);
121
+ const layouts = findLayouts(fullPath, appDir);
122
+
123
+ routes.push({
124
+ filePath: fullPath,
125
+ pattern,
126
+ pathname: pattern,
127
+ paramNames,
128
+ regex,
129
+ layouts,
130
+ type: 'page',
131
+ });
132
+ } else if (entry.match(/^route\.(tsx?|js)$/)) {
133
+ // API route
134
+ const { pattern, paramNames } = filePathToPattern(fullPath, appDir);
135
+ const regex = patternToRegex(pattern);
136
+
137
+ routes.push({
138
+ filePath: fullPath,
139
+ pattern,
140
+ pathname: pattern,
141
+ paramNames,
142
+ regex,
143
+ layouts: [],
144
+ type: 'api',
145
+ });
146
+ }
147
+ }
148
+ } catch (error: any) {
149
+ // Directory doesn't exist or can't be read
150
+ console.warn(`Could not scan directory ${dir}:`, error.message);
151
+ }
152
+ }
153
+
154
+ scanDir(appDir);
155
+
156
+ // Sort routes by specificity (more specific routes first)
157
+ // Static routes before dynamic routes
158
+ routes.sort((a, b) => {
159
+ const aStatic = !a.pattern.includes(':');
160
+ const bStatic = !b.pattern.includes(':');
161
+
162
+ if (aStatic && !bStatic) return -1;
163
+ if (!aStatic && bStatic) return 1;
164
+
165
+ // If both static or both dynamic, sort by depth (deeper first)
166
+ const aDepth = a.pattern.split('/').length;
167
+ const bDepth = b.pattern.split('/').length;
168
+ return bDepth - aDepth;
169
+ });
170
+
171
+ return routes;
172
+ }
173
+
174
+ /**
175
+ * Match a pathname against discovered routes
176
+ * Returns the first matching route with extracted parameters
177
+ */
178
+ export function matchRoute(pathname: string, routes: Route[]): RouteMatch | null {
179
+ for (const route of routes) {
180
+ const match = pathname.match(route.regex);
181
+ if (match) {
182
+ const params: Record<string, string> = {};
183
+ route.paramNames.forEach((name, index) => {
184
+ params[name] = match[index + 1];
185
+ });
186
+
187
+ return { route, params };
188
+ }
189
+ }
190
+
191
+ return null;
192
+ }
@@ -0,0 +1,399 @@
1
+ /**
2
+ * Melina.js "Hangar" Runtime
3
+ *
4
+ * Single Hidden Root + Portal Registry Architecture
5
+ *
6
+ * This runtime manages client-side islands using a persistent React root
7
+ * that survives body.innerHTML replacements during navigation.
8
+ *
9
+ * Key concepts:
10
+ * - Hangar: The container appended to `<html>` (outside body), never destroyed
11
+ * - Island Registry: Map of instanceId -> { Component, props, targetNode, storageNode }
12
+ * - Storage Node: Persistent div that physically moves between placeholders
13
+ * - Physical Reparenting: DOM nodes are moved, not recreated, preserving all state
14
+ */
15
+
16
+ declare global {
17
+ interface Window {
18
+ __MELINA_META__?: Record<string, string>;
19
+ melinaNavigate: (href: string) => Promise<void>;
20
+ }
21
+ }
22
+
23
+ // ========== TYPES ==========
24
+ interface IslandEntry {
25
+ name: string;
26
+ Component: React.ComponentType<any>;
27
+ props: Record<string, any>;
28
+ targetNode: Element;
29
+ storageNode: HTMLDivElement;
30
+ }
31
+
32
+ // ========== STATE ==========
33
+ const componentCache: Record<string, React.ComponentType<any>> = {};
34
+ const islandRegistry = new Map<string, IslandEntry>();
35
+ const registryListeners = new Set<() => void>();
36
+
37
+ function notifyRegistry() {
38
+ registryListeners.forEach(fn => fn());
39
+ }
40
+
41
+ // ========== UTILITIES ==========
42
+ function getIslandMeta(): Record<string, string> {
43
+ const metaEl = document.getElementById('__MELINA_META__');
44
+ if (!metaEl) return {};
45
+ try {
46
+ return JSON.parse(metaEl.textContent || '{}');
47
+ } catch {
48
+ return {};
49
+ }
50
+ }
51
+
52
+ async function loadComponent(name: string): Promise<React.ComponentType<any> | null> {
53
+ if (componentCache[name]) return componentCache[name];
54
+
55
+ const meta = getIslandMeta();
56
+ if (!meta[name]) return null;
57
+
58
+ try {
59
+ const module = await import(/* @vite-ignore */ meta[name]);
60
+ componentCache[name] = module[name] || module.default;
61
+ return componentCache[name];
62
+ } catch (e) {
63
+ console.error('[Melina] Failed to load', name, e);
64
+ return null;
65
+ }
66
+ }
67
+
68
+ // ========== PRE-LOAD MODULES ==========
69
+ // Load all island components upfront to prevent pop-in
70
+ async function preloadModules(): Promise<void> {
71
+ const meta = getIslandMeta();
72
+ const names = Object.keys(meta);
73
+
74
+ await Promise.all(names.map(name => loadComponent(name)));
75
+ console.log('[Melina] Pre-loaded', names.length, 'island modules');
76
+ }
77
+
78
+ // ========== HYDRATE ISLANDS ==========
79
+ // Scans DOM for placeholders, registers/updates them in registry
80
+ async function hydrateIslands(): Promise<void> {
81
+ const placeholders = document.querySelectorAll('[data-melina-island]');
82
+ const seenIds = new Set<string>();
83
+
84
+ for (let i = 0; i < placeholders.length; i++) {
85
+ const el = placeholders[i] as Element;
86
+ const name = el.getAttribute('data-melina-island');
87
+ if (!name) continue;
88
+
89
+ const propsStr = (el.getAttribute('data-props') || '{}').replace(/&quot;/g, '"');
90
+ const props = JSON.parse(propsStr);
91
+ const instanceId = el.getAttribute('data-instance') || el.getAttribute('data-island-key') || `${name}-${i}`;
92
+
93
+ seenIds.add(instanceId);
94
+
95
+ const existing = islandRegistry.get(instanceId);
96
+
97
+ if (existing) {
98
+ // Island exists - update targetNode and MOVE storageNode physically
99
+ existing.targetNode = el;
100
+ existing.props = props;
101
+
102
+ // Physical Reparenting: Move the storage node to new placeholder
103
+ el.appendChild(existing.storageNode);
104
+ console.log('[Melina] Moved island:', instanceId);
105
+ } else {
106
+ // New island - load component and register
107
+ const Component = await loadComponent(name);
108
+ if (Component) {
109
+ // Create persistent storage node (the "lifeboat")
110
+ const storageNode = document.createElement('div');
111
+ storageNode.style.display = 'contents';
112
+ storageNode.setAttribute('data-storage', instanceId);
113
+ el.appendChild(storageNode);
114
+
115
+ islandRegistry.set(instanceId, {
116
+ name,
117
+ Component,
118
+ props,
119
+ targetNode: el,
120
+ storageNode
121
+ });
122
+ console.log('[Melina] Registered island:', instanceId);
123
+ }
124
+ }
125
+ }
126
+
127
+ // Garbage collection: Remove islands that no longer have placeholders
128
+ for (const [id, item] of islandRegistry.entries()) {
129
+ if (!seenIds.has(id)) {
130
+ // Move to holding pen or just delete
131
+ const holdingPen = document.getElementById('melina-holding-pen');
132
+ if (holdingPen) {
133
+ holdingPen.appendChild(item.storageNode);
134
+ }
135
+ islandRegistry.delete(id);
136
+ console.log('[Melina] Collected island:', id);
137
+ }
138
+ }
139
+
140
+ notifyRegistry();
141
+ }
142
+
143
+ // ========== SYNC ISLANDS (synchronous) ==========
144
+ // Re-scans DOM for placeholders, updates targets for existing islands
145
+ // This is SYNC so it can run inside View Transition callback
146
+ function syncIslands(): void {
147
+ console.log('[Melina] syncIslands called, registry size:', islandRegistry.size);
148
+ const placeholders = document.querySelectorAll('[data-melina-island]');
149
+ console.log('[Melina] Found placeholders:', placeholders.length);
150
+
151
+ for (let i = 0; i < placeholders.length; i++) {
152
+ const el = placeholders[i] as Element;
153
+ const name = el.getAttribute('data-melina-island');
154
+ if (!name) continue;
155
+
156
+ const propsStr = (el.getAttribute('data-props') || '{}').replace(/&quot;/g, '"');
157
+ const props = JSON.parse(propsStr);
158
+ const instanceId = el.getAttribute('data-instance') || el.getAttribute('data-island-key') || `${name}-${i}`;
159
+
160
+ console.log('[Melina] Looking for island:', instanceId, 'in registry');
161
+
162
+ const existing = islandRegistry.get(instanceId);
163
+
164
+ if (existing) {
165
+ // CRITICAL: Update targetNode to NEW DOM element and move storageNode
166
+ console.log('[Melina] Found island in registry, moving storageNode');
167
+ existing.targetNode = el;
168
+ existing.props = props;
169
+ el.appendChild(existing.storageNode);
170
+ console.log('[Melina] Moved island:', instanceId, 'storageNode children:', existing.storageNode.childNodes.length);
171
+ } else {
172
+ console.log('[Melina] Island NOT found in registry:', instanceId);
173
+ console.log('[Melina] Registry keys:', Array.from(islandRegistry.keys()));
174
+ }
175
+ // New islands are ignored here - they need async component loading
176
+ // which happens after transition via hydrateIslands()
177
+ }
178
+
179
+ notifyRegistry(); // Triggers React re-render immediately
180
+ console.log('[Melina] syncIslands complete');
181
+ }
182
+
183
+ // ========== ISLAND MANAGER COMPONENT ==========
184
+ async function initializeHangar(): Promise<void> {
185
+ const React = await import('react');
186
+ const ReactDOM = await import('react-dom/client');
187
+ const { createPortal } = await import('react-dom');
188
+
189
+ // Pre-load all island modules first
190
+ await preloadModules();
191
+
192
+ // Create hangar root OUTSIDE body (survives body.innerHTML replacement)
193
+ let hangar = document.getElementById('melina-hangar');
194
+ if (!hangar) {
195
+ hangar = document.createElement('div');
196
+ hangar.id = 'melina-hangar';
197
+ hangar.style.display = 'contents';
198
+ document.documentElement.appendChild(hangar);
199
+ }
200
+
201
+ // Holding pen for garbage collected islands (temporary storage)
202
+ let holdingPen = document.getElementById('melina-holding-pen');
203
+ if (!holdingPen) {
204
+ holdingPen = document.createElement('div');
205
+ holdingPen.id = 'melina-holding-pen';
206
+ holdingPen.style.display = 'none';
207
+ hangar.appendChild(holdingPen);
208
+ }
209
+
210
+ // Root container for React
211
+ let rootContainer = document.getElementById('melina-hangar-root');
212
+ if (!rootContainer) {
213
+ rootContainer = document.createElement('div');
214
+ rootContainer.id = 'melina-hangar-root';
215
+ rootContainer.style.display = 'contents';
216
+ hangar.appendChild(rootContainer);
217
+ }
218
+
219
+ // Island Manager Component
220
+ function IslandManager(): React.ReactNode {
221
+ const [, forceUpdate] = React.useReducer((x: number) => x + 1, 0);
222
+
223
+ // Subscribe to registry changes
224
+ React.useEffect(() => {
225
+ registryListeners.add(forceUpdate);
226
+ return () => { registryListeners.delete(forceUpdate); };
227
+ }, []);
228
+
229
+ // Render all registered islands as portals INTO their storageNodes
230
+ const portals: React.ReactNode[] = [];
231
+ islandRegistry.forEach((island, instanceId) => {
232
+ if (island.storageNode && island.storageNode.isConnected) {
233
+ portals.push(
234
+ createPortal(
235
+ React.createElement(island.Component, {
236
+ ...island.props,
237
+ key: instanceId
238
+ }),
239
+ island.storageNode,
240
+ instanceId
241
+ )
242
+ );
243
+ }
244
+ });
245
+
246
+ return React.createElement(React.Fragment, null, portals);
247
+ }
248
+
249
+ // Create single React root
250
+ const root = ReactDOM.createRoot(rootContainer);
251
+ root.render(React.createElement(IslandManager));
252
+
253
+ // Initial hydration
254
+ await hydrateIslands();
255
+
256
+ console.log('[Melina] Hangar initialized with', islandRegistry.size, 'islands');
257
+ }
258
+
259
+ // ========== NAVIGATION ==========
260
+ async function navigate(href: string): Promise<void> {
261
+ const fromPath = window.location.pathname;
262
+ const toPath = new URL(href, window.location.origin).pathname;
263
+ if (fromPath === toPath) return;
264
+
265
+ console.log('[Melina] Navigate start:', fromPath, '->', toPath);
266
+
267
+ // Step 1: FETCH new page content FIRST (before any transitions)
268
+ let newDoc: Document;
269
+ try {
270
+ const response = await fetch(href, { headers: { 'X-Melina-Nav': '1' } });
271
+ const htmlText = await response.text();
272
+ const parser = new DOMParser();
273
+ newDoc = parser.parseFromString(htmlText, 'text/html');
274
+ console.log('[Melina] Fetched new page, body length:', newDoc.body.innerHTML.length);
275
+ } catch (error) {
276
+ console.error('[Melina] Fetch failed:', error);
277
+ window.location.href = href;
278
+ return;
279
+ }
280
+
281
+ // Step 2: Update URL (but DON'T dispatch navigation event yet!)
282
+ // The event must fire INSIDE the View Transition callback so the
283
+ // "old" snapshot captures the current state before the update
284
+ window.history.pushState({}, '', href);
285
+
286
+ // Step 3: SYNC update function - dispatches event INSIDE for proper animation
287
+ const performUpdate = () => {
288
+ // CRITICAL: Dispatch navigation-start INSIDE the View Transition callback
289
+ // This way:
290
+ // 1. "Old" snapshot was already taken (mini player)
291
+ // 2. Event fires → MusicPlayer flushSync updates to expanded view
292
+ // 3. DOM swap happens
293
+ // 4. "New" snapshot is taken (expanded player)
294
+ // 5. Browser animates between old and new!
295
+ window.dispatchEvent(new CustomEvent('melina:navigation-start', {
296
+ detail: { from: fromPath, to: toPath }
297
+ }));
298
+
299
+ document.title = newDoc.title;
300
+ document.body.innerHTML = newDoc.body.innerHTML;
301
+ console.log('[Melina] Full body swap');
302
+ syncIslands();
303
+ window.scrollTo(0, 0);
304
+ };
305
+
306
+ try {
307
+ // Start View Transition with SYNC callback
308
+ if (document.startViewTransition) {
309
+ console.log('[Melina] Starting View Transition');
310
+ const transition = document.startViewTransition(performUpdate);
311
+ await transition.finished;
312
+ console.log('[Melina] View Transition finished');
313
+ } else {
314
+ performUpdate();
315
+ }
316
+
317
+ // After transition, hydrate any NEW islands
318
+ await hydrateIslands();
319
+ console.log('[Melina] Navigation complete:', fromPath, '->', toPath);
320
+
321
+ } catch (error) {
322
+ console.error('[Melina] Navigation failed:', error);
323
+ window.location.href = href;
324
+ }
325
+ }
326
+
327
+ // Expose navigate globally
328
+ window.melinaNavigate = navigate;
329
+
330
+ // ========== LINK INTERCEPTION ==========
331
+ document.addEventListener('click', (e: MouseEvent) => {
332
+ // Skip if already handled by another handler (e.g., Link component)
333
+ if (e.defaultPrevented) return;
334
+
335
+ const link = (e.target as Element).closest('a[href]') as HTMLAnchorElement | null;
336
+ if (!link) return;
337
+
338
+ const href = link.getAttribute('href');
339
+ if (
340
+ e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0 ||
341
+ link.hasAttribute('download') || link.hasAttribute('target') ||
342
+ link.hasAttribute('data-no-intercept') ||
343
+ !href || !href.startsWith('/')
344
+ ) return;
345
+
346
+ if (window.location.pathname === href) {
347
+ e.preventDefault();
348
+ return;
349
+ }
350
+
351
+ e.preventDefault();
352
+ navigate(href);
353
+ });
354
+
355
+ // ========== POPSTATE ==========
356
+ window.addEventListener('popstate', async () => {
357
+ const href = window.location.pathname;
358
+
359
+ // Core update logic - runs INSIDE View Transition callback
360
+ const performUpdate = async () => {
361
+ const response = await fetch(href, { headers: { 'X-Melina-Nav': '1' } });
362
+ const html = await response.text();
363
+ const newDoc = new DOMParser().parseFromString(html, 'text/html');
364
+
365
+ document.title = newDoc.title;
366
+
367
+ // FULL BODY REPLACEMENT
368
+ document.body.innerHTML = newDoc.body.innerHTML;
369
+
370
+ // SYNC: Find island placeholders, move portals
371
+ syncIslands();
372
+ };
373
+
374
+ try {
375
+ // Start View Transition - captures old state, then animates to new
376
+ if (document.startViewTransition) {
377
+ await document.startViewTransition(performUpdate).finished;
378
+ await hydrateIslands();
379
+ } else {
380
+ await performUpdate();
381
+ await hydrateIslands();
382
+ }
383
+ } catch (e) {
384
+ console.error('[Melina] Popstate failed:', e);
385
+ window.location.reload();
386
+ }
387
+ });
388
+
389
+ // ========== INIT ==========
390
+ if (document.readyState === 'loading') {
391
+ document.addEventListener('DOMContentLoaded', initializeHangar);
392
+ } else {
393
+ initializeHangar();
394
+ }
395
+
396
+ console.log('[Melina] Hangar Runtime loaded');
397
+
398
+ // Export for testing
399
+ export { hydrateIslands, syncIslands, navigate, islandRegistry };