baremetal.js 1.0.1 → 1.2.2
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/CHANGELOG.md +9 -0
- package/README.md +32 -37
- package/SECURITY.md +2 -2
- package/dist/baremetal.js +599 -0
- package/dist/baremetal.min.js +1 -0
- package/docs/api.md +17 -35
- package/package.json +22 -5
- package/src/index.js +13 -2
- package/src/loader.js +89 -71
- package/src/router.js +39 -45
- package/src/state.js +20 -20
- package/src/transition.js +7 -12
- package/src/virtualize.js +58 -0
- package/.gitattributes +0 -2
- package/.github/workflows/npm-publish.yml +0 -34
- package/CODE_OF_CONDUCT.md +0 -122
- package/CONTRIBUTING.md +0 -53
- package/demo/assets/audio/darren_hirst-20-474737.mp3 +0 -0
- package/demo/assets/js/media_player.js +0 -9
- package/demo/assets/js/page1_specific.js +0 -23
- package/demo/assets/js/page2_specific.js +0 -15
- package/demo/assets/js/shared.js +0 -56
- package/demo/assets/js/sidebar.js +0 -49
- package/demo/main.js +0 -18
- package/demo/page1.html +0 -139
- package/demo/page2.html +0 -132
- package/demo/page3_normal.html +0 -26
package/docs/api.md
CHANGED
|
@@ -7,41 +7,25 @@ BareMetal exports its core engine as a global configuration object. Here are the
|
|
|
7
7
|
### `BareMetal.init(config)`
|
|
8
8
|
Initializes the engine, sets up the Router, and binds global configurations.
|
|
9
9
|
|
|
10
|
-
**
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
- `config.offline.assets` *(string[])*: An array of URLs to strictly pre-cache upon installation.
|
|
10
|
+
**Configuration Reference:**
|
|
11
|
+
|
|
12
|
+
| Option | Type | Default | Description |
|
|
13
|
+
|--------|------|---------|-------------|
|
|
14
|
+
| `debug` | boolean | `false` | Enable verbose console logs. |
|
|
15
|
+
| `keepAliveSameModules` | boolean | `true` | Prevent destruction of modules shared between routes. |
|
|
16
|
+
| `autoWrap` | boolean | `true` | Automatically wrap modules that do not export a `mount` function. |
|
|
17
|
+
| `hoverPrefetch` | boolean | `false` | Enable 0ms latency pre-fetching on link hover. |
|
|
18
|
+
| `persistState` | boolean | `false` | Automatically serialize `stateManager` to `sessionStorage` for F5 resilience. |
|
|
19
|
+
| `virtualizeDom` | boolean | `false` | Inject the `Virtualizer` helper into module mount contexts. |
|
|
20
|
+
| `showErrorNotification` | boolean | `false` | Enable floating error boundaries on navigation failures. |
|
|
21
|
+
| `transition.enabled` | boolean | `false` | Enable the protected transition module. |
|
|
22
|
+
| `transition.module` | string | `null` | Path to a custom transition module (defaults to `/src/transition.js`). |
|
|
23
|
+
| `transition.simulatedDelay` | number | `0` | Artificial delay (ms) for testing transitions. |
|
|
24
|
+
| `transition.useViewTransitions` | boolean | `false` | Enables the native View Transitions API for smooth cross-fades during navigation. |
|
|
26
25
|
|
|
27
26
|
---
|
|
28
27
|
|
|
29
|
-
## 2.
|
|
30
|
-
|
|
31
|
-
Accessible via `BareMetal.offline` when enabled.
|
|
32
|
-
|
|
33
|
-
### `offline.install()`
|
|
34
|
-
Manually triggers the background download of all resources specified in `config.offline.assets`.
|
|
35
|
-
|
|
36
|
-
### `offline.update()`
|
|
37
|
-
Triggers a background fetch to update the cached assets based on the current `config.offline.version`.
|
|
38
|
-
|
|
39
|
-
### `offline.remove()`
|
|
40
|
-
Unregisters the Service Worker and deletes all offline caches.
|
|
41
|
-
|
|
42
|
-
---
|
|
43
|
-
|
|
44
|
-
## 3. Router Navigation
|
|
28
|
+
## 2. Router Navigation
|
|
45
29
|
|
|
46
30
|
### `BareMetal.router.reload()`
|
|
47
31
|
Executes a hard reload of the current SPA page by calling `window.location.reload()`. This is useful when you want to force the browser to wipe the entire SPA state and fetch fresh assets.
|
|
@@ -78,7 +62,7 @@ Listens for a Pub/Sub event.
|
|
|
78
62
|
|
|
79
63
|
---
|
|
80
64
|
|
|
81
|
-
##
|
|
65
|
+
## 4. Writing Custom Page Transitions
|
|
82
66
|
|
|
83
67
|
You can replace the default progress bar and fade overlay by pointing `config.transition.module` to your own JavaScript file. A custom transition is just a standard BareMetal module that listens to routing events.
|
|
84
68
|
|
|
@@ -128,5 +112,3 @@ export function mount({ state }) {
|
|
|
128
112
|
};
|
|
129
113
|
}
|
|
130
114
|
```
|
|
131
|
-
|
|
132
|
-
|
package/package.json
CHANGED
|
@@ -1,11 +1,22 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "baremetal.js",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.2",
|
|
4
4
|
"description": "A lightweight, dependency-free Vanilla JavaScript SPA engine prioritizing extreme performance, native browser features, and explicit lifecycle management.",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
7
|
+
"files": [
|
|
8
|
+
"src",
|
|
9
|
+
"dist",
|
|
10
|
+
"LICENSE",
|
|
11
|
+
"CHANGELOG.md",
|
|
12
|
+
"docs/api.md",
|
|
13
|
+
"README.md",
|
|
14
|
+
"SECURITY.md"
|
|
15
|
+
],
|
|
7
16
|
"scripts": {
|
|
8
|
-
"
|
|
17
|
+
"build": "rollup -c",
|
|
18
|
+
"test": "vitest run",
|
|
19
|
+
"test:watch": "vitest"
|
|
9
20
|
},
|
|
10
21
|
"repository": {
|
|
11
22
|
"type": "git",
|
|
@@ -20,10 +31,16 @@
|
|
|
20
31
|
"baremetal",
|
|
21
32
|
"performance"
|
|
22
33
|
],
|
|
23
|
-
"author": "
|
|
34
|
+
"author": "dkydivyansh",
|
|
24
35
|
"license": "GPL-3.0",
|
|
25
36
|
"bugs": {
|
|
26
37
|
"url": "https://github.com/dkydivyansh/BareMetal.js/issues"
|
|
27
38
|
},
|
|
28
|
-
"homepage": "https://BareMetal.dkydivyansh.com"
|
|
29
|
-
|
|
39
|
+
"homepage": "https://BareMetal.dkydivyansh.com",
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"@rollup/plugin-terser": "^1.0.0",
|
|
42
|
+
"jsdom": "^29.1.1",
|
|
43
|
+
"rollup": "^4.62.0",
|
|
44
|
+
"vitest": "^4.1.9"
|
|
45
|
+
}
|
|
46
|
+
}
|
package/src/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { stateManager } from './state.js';
|
|
2
2
|
import { Loader, loader } from './loader.js';
|
|
3
3
|
import { Router } from './router.js';
|
|
4
|
+
import { virtualize } from './virtualize.js';
|
|
4
5
|
|
|
5
6
|
const BareMetal = {
|
|
6
7
|
state: stateManager,
|
|
7
|
-
events: stateManager,
|
|
8
|
+
events: stateManager,
|
|
8
9
|
loader: loader,
|
|
9
10
|
router: Router,
|
|
11
|
+
virtualize: virtualize,
|
|
10
12
|
|
|
11
13
|
init(config = {}) {
|
|
12
14
|
if (config.debug !== undefined) Loader.setConfig({ debug: config.debug });
|
|
@@ -16,7 +18,16 @@ const BareMetal = {
|
|
|
16
18
|
if (config.hoverPrefetch !== undefined) Loader.setConfig({ hoverPrefetch: config.hoverPrefetch });
|
|
17
19
|
if (config.showErrorNotification !== undefined) Loader.setConfig({ showErrorNotification: config.showErrorNotification });
|
|
18
20
|
|
|
19
|
-
|
|
21
|
+
if (config.persistState !== undefined) {
|
|
22
|
+
Loader.setConfig({ persistState: config.persistState });
|
|
23
|
+
if (config.persistState) {
|
|
24
|
+
window.__baremetal_persist_state = true;
|
|
25
|
+
stateManager.initPersistence();
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (config.virtualizeDom !== undefined) Loader.setConfig({ virtualizeDom: config.virtualizeDom });
|
|
30
|
+
|
|
20
31
|
Router.init();
|
|
21
32
|
Loader.log("Initialized BareMetal Engine with config:", config);
|
|
22
33
|
}
|
package/src/loader.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { stateManager } from './state.js';
|
|
2
2
|
|
|
3
3
|
export const Loader = {
|
|
4
|
-
activeModules: {},
|
|
5
|
-
config: { keepAliveSameModules: true, debug: false, autoWrap: true, hoverPrefetch: false, showErrorNotification: false, transition: { enabled: false, simulatedDelay: 0, module: null, useViewTransitions: false } },
|
|
6
|
-
|
|
4
|
+
activeModules: {},
|
|
5
|
+
config: { keepAliveSameModules: true, debug: false, autoWrap: true, hoverPrefetch: false, showErrorNotification: false, persistState: false, virtualizeDom: false, transition: { enabled: false, simulatedDelay: 0, module: null, useViewTransitions: false } },
|
|
6
|
+
|
|
7
7
|
setConfig(globalConfig) {
|
|
8
8
|
this.config = { ...this.config, ...globalConfig };
|
|
9
9
|
},
|
|
@@ -12,11 +12,8 @@ export const Loader = {
|
|
|
12
12
|
if (this.config.debug) console.log('[BareMetal Loader]', ...args);
|
|
13
13
|
},
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
* Prepares the transition by destroying obsolete modules and identifying new ones.
|
|
17
|
-
*/
|
|
18
15
|
async prepare(newConfig) {
|
|
19
|
-
|
|
16
|
+
|
|
20
17
|
if (this.config.transition && this.config.transition.enabled) {
|
|
21
18
|
const transitionPath = this.config.transition.module || '/src/transition.js';
|
|
22
19
|
newConfig['__baremetal_transition'] = transitionPath;
|
|
@@ -28,17 +25,17 @@ export const Loader = {
|
|
|
28
25
|
|
|
29
26
|
for (const [key, mod] of Object.entries(this.activeModules)) {
|
|
30
27
|
const isImmortal = key === '__baremetal_transition';
|
|
31
|
-
|
|
32
|
-
|
|
28
|
+
const newPath = typeof newConfig[key] === 'string' ? newConfig[key] : (newConfig[key] ? newConfig[key].path : null);
|
|
29
|
+
if ((this.config.keepAliveSameModules || isImmortal) && newPath === mod.path) {
|
|
30
|
+
|
|
33
31
|
modulesToKeep[key] = mod;
|
|
34
32
|
this.log(`Keep-Alive: ${key} (${mod.path})`);
|
|
35
33
|
} else {
|
|
36
|
-
|
|
34
|
+
|
|
37
35
|
modulesToDestroy.push(mod);
|
|
38
36
|
}
|
|
39
37
|
}
|
|
40
38
|
|
|
41
|
-
// Destroy obsolete modules
|
|
42
39
|
for (const mod of modulesToDestroy) {
|
|
43
40
|
this.log(`Destroying module: ${mod.path}`);
|
|
44
41
|
if (mod.module && typeof mod.module.destroy === 'function') {
|
|
@@ -50,83 +47,108 @@ export const Loader = {
|
|
|
50
47
|
}
|
|
51
48
|
}
|
|
52
49
|
|
|
53
|
-
// Determine what's new
|
|
54
50
|
for (const [key, path] of Object.entries(newConfig)) {
|
|
55
51
|
if (!modulesToKeep[key]) {
|
|
56
52
|
modulesToLoad[key] = path;
|
|
57
53
|
}
|
|
58
54
|
}
|
|
59
55
|
|
|
60
|
-
// Temporarily update active modules to only the kept ones
|
|
61
56
|
this.activeModules = modulesToKeep;
|
|
62
57
|
|
|
63
58
|
return modulesToLoad;
|
|
64
59
|
},
|
|
65
60
|
|
|
66
|
-
/**
|
|
67
|
-
* Loads the prepared modules. Called by Router after DOM swap.
|
|
68
|
-
*/
|
|
69
61
|
async loadPrepared(modulesToLoad) {
|
|
70
62
|
const total = Object.keys(modulesToLoad).length;
|
|
71
63
|
let loaded = 0;
|
|
72
64
|
|
|
73
|
-
const loadPromises = Object.entries(modulesToLoad).map(async ([key,
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
65
|
+
const loadPromises = Object.entries(modulesToLoad).map(async ([key, modDef]) => {
|
|
66
|
+
const path = typeof modDef === 'string' ? modDef : modDef.path;
|
|
67
|
+
const lazySelector = typeof modDef === 'string' ? null : modDef.lazy;
|
|
68
|
+
|
|
69
|
+
this.log(`Preparing module: ${path}`);
|
|
70
|
+
|
|
71
|
+
const doImport = async () => {
|
|
72
|
+
try {
|
|
73
|
+
const resolvedPath = new URL(path, document.baseURI).href;
|
|
74
|
+
const loadPath = this.config.debug ? `${resolvedPath}?t=${Date.now()}` : resolvedPath;
|
|
75
|
+
|
|
76
|
+
let module;
|
|
77
|
+
|
|
78
|
+
if (this.config.autoWrap) {
|
|
79
|
+
const response = await fetch(loadPath);
|
|
80
|
+
if (!response.ok) throw new Error(`Failed to fetch ${loadPath}`);
|
|
81
|
+
const sourceText = await response.text();
|
|
82
|
+
|
|
83
|
+
const hasMount = /export\s+(function|const|let|var)\s+mount\b/.test(sourceText) || /export\s+\{.*?\bmount\b.*?\}/.test(sourceText);
|
|
84
|
+
|
|
85
|
+
if (!hasMount) {
|
|
86
|
+
console.warn(`[BareMetal] WARNING: Module ${path} does not explicitly export a mount() function. Auto-wrapping it...`);
|
|
87
|
+
const wrappedSource = `export async function mount(context) { const { state } = context; ${sourceText} }`;
|
|
88
|
+
const blob = new Blob([wrappedSource], { type: 'application/javascript' });
|
|
89
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
90
|
+
module = await import(blobUrl);
|
|
91
|
+
URL.revokeObjectURL(blobUrl);
|
|
92
|
+
} else {
|
|
93
|
+
module = await import(loadPath);
|
|
94
|
+
}
|
|
103
95
|
} else {
|
|
104
96
|
module = await import(loadPath);
|
|
105
97
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
98
|
+
|
|
99
|
+
if (typeof module.mount === 'function') {
|
|
100
|
+
this.log(`Mounting module: ${path}`);
|
|
101
|
+
|
|
102
|
+
const context = { state: stateManager };
|
|
103
|
+
if (this.config.virtualizeDom) {
|
|
104
|
+
const { virtualize } = await import('./virtualize.js');
|
|
105
|
+
context.virtualize = virtualize;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const instance = await module.mount(context);
|
|
109
|
+
|
|
110
|
+
this.activeModules[key] = {
|
|
111
|
+
path: path,
|
|
112
|
+
module: instance ? { destroy: instance.destroy } : module
|
|
113
|
+
};
|
|
114
|
+
} else {
|
|
115
|
+
console.error(`[BareMetal] Module ${path} failed to provide a mount function even after wrapping.`);
|
|
116
|
+
}
|
|
117
|
+
} catch (err) {
|
|
118
|
+
console.error(`Failed to load module: ${path}`, err);
|
|
109
119
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
this.
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (lazySelector) {
|
|
123
|
+
const element = document.querySelector(lazySelector);
|
|
124
|
+
if (element && window.IntersectionObserver) {
|
|
125
|
+
this.log(`Deferred loading of module ${path} until ${lazySelector} is visible`);
|
|
126
|
+
const observer = new IntersectionObserver((entries) => {
|
|
127
|
+
if (entries[0].isIntersecting) {
|
|
128
|
+
observer.disconnect();
|
|
129
|
+
doImport();
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
observer.observe(element);
|
|
133
|
+
|
|
134
|
+
loaded++;
|
|
135
|
+
if (total > 0) {
|
|
136
|
+
const progress = 50 + (loaded / total) * 50;
|
|
137
|
+
stateManager.publish('ROUTE_PROGRESS', { url: window.location.pathname, progress });
|
|
138
|
+
}
|
|
139
|
+
return Promise.resolve();
|
|
119
140
|
} else {
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
} catch (err) {
|
|
123
|
-
console.error(`Failed to load module: ${path}`, err);
|
|
124
|
-
} finally {
|
|
125
|
-
loaded++;
|
|
126
|
-
if (total > 0) {
|
|
127
|
-
const progress = 50 + (loaded / total) * 50;
|
|
128
|
-
stateManager.publish('ROUTE_PROGRESS', { url: window.location.pathname, progress });
|
|
141
|
+
|
|
142
|
+
await doImport();
|
|
129
143
|
}
|
|
144
|
+
} else {
|
|
145
|
+
await doImport();
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
loaded++;
|
|
149
|
+
if (total > 0) {
|
|
150
|
+
const progress = 50 + (loaded / total) * 50;
|
|
151
|
+
stateManager.publish('ROUTE_PROGRESS', { url: window.location.pathname, progress });
|
|
130
152
|
}
|
|
131
153
|
});
|
|
132
154
|
|
|
@@ -134,16 +156,12 @@ export const Loader = {
|
|
|
134
156
|
stateManager.publish('ROUTE_END', { url: window.location.pathname });
|
|
135
157
|
},
|
|
136
158
|
|
|
137
|
-
/**
|
|
138
|
-
* Legacy standalone load (used on first page load directly)
|
|
139
|
-
*/
|
|
140
159
|
async load(config) {
|
|
141
160
|
const modulesToLoad = await this.prepare(config);
|
|
142
161
|
await this.loadPrepared(modulesToLoad);
|
|
143
162
|
}
|
|
144
163
|
};
|
|
145
164
|
|
|
146
|
-
// The user-facing API
|
|
147
165
|
export function loader(config) {
|
|
148
166
|
return Loader.load(config);
|
|
149
167
|
}
|
package/src/router.js
CHANGED
|
@@ -5,33 +5,30 @@ export const Router = {
|
|
|
5
5
|
htmlCache: {},
|
|
6
6
|
scrollMemory: {},
|
|
7
7
|
historyStack: [],
|
|
8
|
+
currentAbortController: null,
|
|
8
9
|
|
|
9
10
|
init() {
|
|
10
11
|
if ('scrollRestoration' in history) {
|
|
11
12
|
history.scrollRestoration = 'manual';
|
|
12
13
|
}
|
|
13
14
|
window.addEventListener('popstate', this.handleRoute.bind(this));
|
|
14
|
-
|
|
15
|
-
// Intercept all link clicks
|
|
15
|
+
|
|
16
16
|
document.body.addEventListener('click', e => {
|
|
17
|
-
|
|
17
|
+
|
|
18
18
|
const anchor = e.target.closest('a');
|
|
19
19
|
if (!anchor) return;
|
|
20
20
|
|
|
21
|
-
// Ignore external domains, target="_blank", noreferrer, or downloads
|
|
22
21
|
if (
|
|
23
|
-
anchor.origin !== window.location.origin ||
|
|
22
|
+
anchor.origin !== window.location.origin ||
|
|
24
23
|
anchor.target === '_blank' ||
|
|
25
24
|
(anchor.rel && anchor.rel.includes('noreferrer')) ||
|
|
26
25
|
anchor.hasAttribute('download')
|
|
27
26
|
) {
|
|
28
|
-
return;
|
|
27
|
+
return;
|
|
29
28
|
}
|
|
30
29
|
|
|
31
|
-
// Intercept same-origin internal links
|
|
32
30
|
e.preventDefault();
|
|
33
|
-
|
|
34
|
-
// Save current state before navigating away
|
|
31
|
+
|
|
35
32
|
this.historyStack.push(window.location.pathname);
|
|
36
33
|
this.scrollMemory[window.location.pathname] = window.scrollY;
|
|
37
34
|
|
|
@@ -39,18 +36,17 @@ export const Router = {
|
|
|
39
36
|
this.handleRoute();
|
|
40
37
|
});
|
|
41
38
|
|
|
42
|
-
// Hover Pre-fetching
|
|
43
39
|
document.body.addEventListener('mouseover', e => {
|
|
44
40
|
if (!Loader.config.hoverPrefetch) return;
|
|
45
41
|
const anchor = e.target.closest('a');
|
|
46
42
|
if (!anchor) return;
|
|
47
43
|
if (
|
|
48
|
-
anchor.origin === window.location.origin &&
|
|
49
|
-
anchor.target !== '_blank' &&
|
|
44
|
+
anchor.origin === window.location.origin &&
|
|
45
|
+
anchor.target !== '_blank' &&
|
|
50
46
|
!anchor.hasAttribute('download') &&
|
|
51
47
|
!this.htmlCache[anchor.href]
|
|
52
48
|
) {
|
|
53
|
-
this.htmlCache[anchor.href] = 'fetching';
|
|
49
|
+
this.htmlCache[anchor.href] = 'fetching';
|
|
54
50
|
fetch(anchor.href)
|
|
55
51
|
.then(res => {
|
|
56
52
|
if (res.ok) return res.text();
|
|
@@ -63,13 +59,13 @@ export const Router = {
|
|
|
63
59
|
},
|
|
64
60
|
|
|
65
61
|
back() {
|
|
66
|
-
|
|
62
|
+
|
|
67
63
|
if (this.historyStack.length > 0) {
|
|
68
64
|
const prevUrl = this.historyStack.pop();
|
|
69
65
|
history.pushState(null, '', prevUrl);
|
|
70
66
|
this.handleRoute();
|
|
71
67
|
} else {
|
|
72
|
-
history.back();
|
|
68
|
+
history.back();
|
|
73
69
|
}
|
|
74
70
|
},
|
|
75
71
|
|
|
@@ -77,7 +73,14 @@ export const Router = {
|
|
|
77
73
|
window.location.reload();
|
|
78
74
|
},
|
|
79
75
|
|
|
80
|
-
async handleRoute() {
|
|
76
|
+
async handleRoute(e) {
|
|
77
|
+
|
|
78
|
+
if (this.currentAbortController) {
|
|
79
|
+
this.currentAbortController.abort();
|
|
80
|
+
}
|
|
81
|
+
this.currentAbortController = new AbortController();
|
|
82
|
+
const signal = this.currentAbortController.signal;
|
|
83
|
+
|
|
81
84
|
const url = window.location.pathname;
|
|
82
85
|
try {
|
|
83
86
|
Loader.log(`Navigating to ${url}`);
|
|
@@ -92,31 +95,30 @@ export const Router = {
|
|
|
92
95
|
|
|
93
96
|
let htmlText;
|
|
94
97
|
const fullUrl = new URL(url, document.baseURI).href;
|
|
95
|
-
|
|
98
|
+
|
|
96
99
|
if (Loader.config.hoverPrefetch && this.htmlCache[fullUrl] && this.htmlCache[fullUrl] !== 'fetching') {
|
|
97
100
|
htmlText = this.htmlCache[fullUrl];
|
|
98
101
|
Loader.log(`Used pre-fetched cache for ${url}`);
|
|
99
102
|
} else {
|
|
100
|
-
const response = await fetch(url);
|
|
103
|
+
const response = await fetch(url, { signal });
|
|
101
104
|
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
|
102
105
|
htmlText = await response.text();
|
|
103
106
|
}
|
|
104
|
-
|
|
107
|
+
|
|
105
108
|
stateManager.publish('ROUTE_PROGRESS', { url, progress: 50 });
|
|
106
109
|
|
|
107
110
|
const parser = new DOMParser();
|
|
108
111
|
const doc = parser.parseFromString(htmlText, 'text/html');
|
|
109
|
-
|
|
110
|
-
// 1. Extract Config
|
|
112
|
+
|
|
111
113
|
let config = null;
|
|
112
114
|
const scriptTags = doc.querySelectorAll('script');
|
|
113
115
|
for (const script of scriptTags) {
|
|
114
116
|
if (script.textContent.includes('loader(')) {
|
|
115
|
-
|
|
117
|
+
|
|
116
118
|
const match = script.textContent.match(/loader\s*\(\s*(\{[\s\S]*?\})\s*\)/);
|
|
117
119
|
if (match && match[1]) {
|
|
118
120
|
try {
|
|
119
|
-
|
|
121
|
+
|
|
120
122
|
config = new Function('return ' + match[1])();
|
|
121
123
|
} catch (e) {
|
|
122
124
|
console.error("Failed to parse loader config in new page", e);
|
|
@@ -125,61 +127,53 @@ export const Router = {
|
|
|
125
127
|
}
|
|
126
128
|
}
|
|
127
129
|
|
|
128
|
-
// If no BareMetal loader config is found, fallback to standard navigation
|
|
129
130
|
if (!config) {
|
|
130
131
|
Loader.log(`No BareMetal config found on ${url}. Falling back to native navigation.`);
|
|
131
132
|
window.location.assign(url);
|
|
132
133
|
return;
|
|
133
134
|
}
|
|
134
135
|
|
|
135
|
-
// 2. Prepare transition: identify kept vs destroyed modules
|
|
136
136
|
const modulesToLoad = await Loader.prepare(config);
|
|
137
137
|
|
|
138
|
-
// 3. Swap DOM and Head
|
|
139
138
|
document.title = doc.title;
|
|
140
|
-
|
|
141
|
-
// Swap Head Styles (prevent CSS leak)
|
|
139
|
+
|
|
142
140
|
const oldStyles = document.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]');
|
|
143
141
|
oldStyles.forEach(s => s.remove());
|
|
144
142
|
const newStyles = doc.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]');
|
|
145
143
|
newStyles.forEach(s => document.head.appendChild(s.cloneNode(true)));
|
|
146
144
|
|
|
147
|
-
// User Protected Elements (data-baremetal-preserve)
|
|
148
145
|
const preservedNodes = [];
|
|
149
146
|
const persistentElements = document.querySelectorAll('[data-baremetal-preserve]');
|
|
150
|
-
|
|
147
|
+
|
|
151
148
|
persistentElements.forEach(el => {
|
|
152
149
|
if (!el.id) return;
|
|
153
150
|
if (doc.getElementById(el.id)) {
|
|
154
|
-
|
|
151
|
+
|
|
155
152
|
const placeholder = document.createElement('div');
|
|
156
153
|
el.parentNode.replaceChild(placeholder, el);
|
|
157
154
|
preservedNodes.push(el);
|
|
158
155
|
}
|
|
159
156
|
});
|
|
160
157
|
|
|
161
|
-
// Engine Protected Elements (Immortal)
|
|
162
158
|
const transitionRoot = document.getElementById('baremetal-transition-root');
|
|
163
159
|
if (transitionRoot) {
|
|
164
160
|
transitionRoot.parentNode.removeChild(transitionRoot);
|
|
165
161
|
}
|
|
166
162
|
|
|
167
|
-
// The actual synchronous DOM swap and restoration
|
|
168
163
|
const executeDOMSwap = () => {
|
|
169
164
|
document.body.innerHTML = doc.body.innerHTML;
|
|
170
165
|
|
|
171
|
-
// Restore User Protected Elements into their exact new positions
|
|
172
166
|
preservedNodes.forEach(el => {
|
|
173
167
|
const newEl = document.getElementById(el.id);
|
|
174
168
|
if (newEl) {
|
|
175
|
-
|
|
169
|
+
|
|
176
170
|
Array.from(el.attributes).forEach(attr => {
|
|
177
171
|
if (attr.name !== 'id' && attr.name !== 'data-baremetal-preserve') el.removeAttribute(attr.name);
|
|
178
172
|
});
|
|
179
173
|
Array.from(newEl.attributes).forEach(attr => {
|
|
180
174
|
if (attr.name !== 'id') el.setAttribute(attr.name, attr.value);
|
|
181
175
|
});
|
|
182
|
-
|
|
176
|
+
|
|
183
177
|
newEl.parentNode.replaceChild(el, newEl);
|
|
184
178
|
}
|
|
185
179
|
});
|
|
@@ -188,7 +182,6 @@ export const Router = {
|
|
|
188
182
|
document.body.appendChild(transitionRoot);
|
|
189
183
|
}
|
|
190
184
|
|
|
191
|
-
// Notify keep-alive modules that the DOM has been swapped so they can re-bind UI elements
|
|
192
185
|
stateManager.publish('DOM_SWAPPED', null);
|
|
193
186
|
};
|
|
194
187
|
|
|
@@ -205,21 +198,23 @@ export const Router = {
|
|
|
205
198
|
doSwap();
|
|
206
199
|
}
|
|
207
200
|
|
|
208
|
-
// 4. Mount new modules (this emits ROUTE_END)
|
|
209
201
|
await Loader.loadPrepared(modulesToLoad);
|
|
210
202
|
|
|
211
203
|
} catch (err) {
|
|
204
|
+
if (err.name === 'AbortError') {
|
|
205
|
+
Loader.log(`Aborted fetch for ${url} due to new navigation.`);
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
212
208
|
console.error("Routing error:", err);
|
|
213
209
|
stateManager.publish('ROUTE_ERROR', { url, error: err.message });
|
|
214
|
-
|
|
210
|
+
|
|
215
211
|
if (Loader.config.showErrorNotification) {
|
|
216
|
-
|
|
212
|
+
|
|
217
213
|
if (this.historyStack.length > 0) {
|
|
218
214
|
const prev = this.historyStack.pop();
|
|
219
215
|
history.replaceState(null, '', prev);
|
|
220
216
|
}
|
|
221
217
|
|
|
222
|
-
// Show floating notification
|
|
223
218
|
const notif = document.createElement('div');
|
|
224
219
|
notif.style.position = 'fixed';
|
|
225
220
|
notif.style.bottom = '20px';
|
|
@@ -233,16 +228,15 @@ export const Router = {
|
|
|
233
228
|
notif.style.fontFamily = 'sans-serif';
|
|
234
229
|
notif.style.transition = 'opacity 0.3s ease';
|
|
235
230
|
notif.innerHTML = `<strong>Navigation Failed:</strong> ${err.message}`;
|
|
236
|
-
|
|
231
|
+
|
|
237
232
|
document.body.appendChild(notif);
|
|
238
|
-
|
|
233
|
+
|
|
239
234
|
setTimeout(() => {
|
|
240
235
|
notif.style.opacity = '0';
|
|
241
236
|
setTimeout(() => notif.remove(), 300);
|
|
242
237
|
}, 4000);
|
|
243
238
|
} else {
|
|
244
|
-
|
|
245
|
-
// FIRST: Undo the pushState so we don't leave a phantom history entry that breaks the Back button!
|
|
239
|
+
|
|
246
240
|
if (this.historyStack.length > 0) {
|
|
247
241
|
const prev = this.historyStack.pop();
|
|
248
242
|
history.replaceState(null, '', prev);
|