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/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(/"/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(/"/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 };
|