baremetal.js 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/.gitattributes +2 -0
- package/.github/workflows/npm-publish.yml +34 -0
- package/CHANGELOG.md +18 -0
- package/CODE_OF_CONDUCT.md +122 -0
- package/CONTRIBUTING.md +53 -0
- package/LICENSE +674 -0
- package/README.md +154 -0
- package/SECURITY.md +18 -0
- package/demo/assets/audio/darren_hirst-20-474737.mp3 +0 -0
- package/demo/assets/js/media_player.js +9 -0
- package/demo/assets/js/page1_specific.js +23 -0
- package/demo/assets/js/page2_specific.js +15 -0
- package/demo/assets/js/shared.js +56 -0
- package/demo/assets/js/sidebar.js +49 -0
- package/demo/main.js +18 -0
- package/demo/page1.html +139 -0
- package/demo/page2.html +132 -0
- package/demo/page3_normal.html +26 -0
- package/docs/api.md +109 -0
- package/package.json +29 -0
- package/src/index.js +25 -0
- package/src/loader.js +149 -0
- package/src/router.js +240 -0
- package/src/state.js +83 -0
- package/src/transition.js +90 -0
package/docs/api.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# BareMetal.js - Exposed Methods
|
|
2
|
+
|
|
3
|
+
BareMetal exports its core engine as a global configuration object. Here are the documented methods available for developers.
|
|
4
|
+
|
|
5
|
+
## 1. Core Engine Initialization
|
|
6
|
+
|
|
7
|
+
### `BareMetal.init(config)`
|
|
8
|
+
Initializes the engine, sets up the Router, and binds global configurations.
|
|
9
|
+
|
|
10
|
+
**Parameters:**
|
|
11
|
+
- `config.debug` *(boolean)*: Enable verbose console logs (default: `false`).
|
|
12
|
+
- `config.keepAliveSameModules` *(boolean)*: If true, modules shared between routes are not destroyed during navigation (default: `true`).
|
|
13
|
+
- `config.autoWrap` *(boolean)*: If true, modules that do not export a `mount` function are automatically wrapped in a dynamic Blob URL to prevent syntax errors (default: `true`).
|
|
14
|
+
- `config.transition` *(object)*: Configuration for the native page transition API.
|
|
15
|
+
- `config.transition.enabled` *(boolean)*: Enables the protected transition module.
|
|
16
|
+
- `config.transition.module` *(string)*: Optional path to a custom transition module (defaults to `/src/transition.js`).
|
|
17
|
+
- `config.transition.simulatedDelay` *(number)*: Optional artificial delay in milliseconds (useful for testing animations locally).
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## 2. Router Navigation
|
|
22
|
+
|
|
23
|
+
### `BareMetal.router.reload()`
|
|
24
|
+
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.
|
|
25
|
+
|
|
26
|
+
### `BareMetal.router.handleRoute()`
|
|
27
|
+
Forces the router to evaluate the current `window.location.pathname`, fetch the corresponding HTML, and swap the DOM. Usually called automatically via `popstate` or link clicks.
|
|
28
|
+
|
|
29
|
+
---
|
|
30
|
+
|
|
31
|
+
## 3. Reactive State & Event Bus
|
|
32
|
+
|
|
33
|
+
The state manager is accessible via `BareMetal.state` (or its alias `BareMetal.events`).
|
|
34
|
+
|
|
35
|
+
### `state.init(key, defaultValue)`
|
|
36
|
+
Initializes a piece of state. If the key already exists, it is ignored.
|
|
37
|
+
|
|
38
|
+
### `state.update(key, value)`
|
|
39
|
+
Updates a state variable and synchronously triggers all callbacks subscribed to this specific key.
|
|
40
|
+
|
|
41
|
+
### `state.get(key)`
|
|
42
|
+
Returns the current synchronous value of the state key.
|
|
43
|
+
|
|
44
|
+
### `state.subscribe(key, callback)`
|
|
45
|
+
Subscribes a function to changes on a specific state key.
|
|
46
|
+
- **Returns:** A function that, when called, unsubscribes the callback.
|
|
47
|
+
- *Note:* The callback is invoked immediately upon subscription with the current value.
|
|
48
|
+
|
|
49
|
+
### `state.publish(event, payload)`
|
|
50
|
+
Fires a standard, one-off Pub/Sub event across the application.
|
|
51
|
+
|
|
52
|
+
### `state.on(event, callback)`
|
|
53
|
+
Listens for a Pub/Sub event.
|
|
54
|
+
- **Returns:** An unsubscribe function.
|
|
55
|
+
|
|
56
|
+
---
|
|
57
|
+
|
|
58
|
+
## 5. Writing Custom Page Transitions
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
**Rules for Custom Transitions:**
|
|
63
|
+
1. **The Protected Node:** You must inject your transition UI into an element with `id="baremetal-transition-root"`. The Router explicitly protects this specific ID from being destroyed when it swaps the `document.body.innerHTML`.
|
|
64
|
+
2. **Listen to Events:** Use `state.on()` to listen to `ROUTE_START`, `ROUTE_PROGRESS`, `ROUTE_END`, and `ROUTE_ERROR`.
|
|
65
|
+
|
|
66
|
+
**Example:**
|
|
67
|
+
```javascript
|
|
68
|
+
// custom_transition.js
|
|
69
|
+
export function mount({ state }) {
|
|
70
|
+
// 1. Create or get the protected root node
|
|
71
|
+
let root = document.getElementById('baremetal-transition-root');
|
|
72
|
+
if (!root) {
|
|
73
|
+
root = document.createElement('div');
|
|
74
|
+
root.id = 'baremetal-transition-root';
|
|
75
|
+
document.body.appendChild(root);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// 2. Build your custom UI (e.g., a spinner)
|
|
79
|
+
const spinner = document.createElement('div');
|
|
80
|
+
spinner.innerText = "Loading...";
|
|
81
|
+
spinner.style.display = 'none';
|
|
82
|
+
root.appendChild(spinner);
|
|
83
|
+
|
|
84
|
+
const unsubs = [];
|
|
85
|
+
|
|
86
|
+
// 3. Listen to Router events
|
|
87
|
+
unsubs.push(state.on('ROUTE_START', () => {
|
|
88
|
+
spinner.style.display = 'block';
|
|
89
|
+
}));
|
|
90
|
+
|
|
91
|
+
unsubs.push(state.on('ROUTE_PROGRESS', (payload) => {
|
|
92
|
+
// payload.progress goes from 0 to 100
|
|
93
|
+
spinner.innerText = `Loading... ${Math.round(payload.progress)}%`;
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
unsubs.push(state.on('ROUTE_END', () => {
|
|
97
|
+
spinner.style.display = 'none';
|
|
98
|
+
}));
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
destroy: () => {
|
|
102
|
+
unsubs.forEach(u => u());
|
|
103
|
+
if (root.parentNode) root.parentNode.removeChild(root);
|
|
104
|
+
}
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "baremetal.js",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "A lightweight, dependency-free Vanilla JavaScript SPA engine prioritizing extreme performance, native browser features, and explicit lifecycle management.",
|
|
5
|
+
"main": "src/index.js",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"test": "echo \"No tests written yet.\" && exit 0"
|
|
9
|
+
},
|
|
10
|
+
"repository": {
|
|
11
|
+
"type": "git",
|
|
12
|
+
"url": "git+https://github.com/dkydivyansh/BareMetal.js.git"
|
|
13
|
+
},
|
|
14
|
+
"keywords": [
|
|
15
|
+
"spa",
|
|
16
|
+
"vanilla",
|
|
17
|
+
"framework",
|
|
18
|
+
"router",
|
|
19
|
+
"state-management",
|
|
20
|
+
"baremetal",
|
|
21
|
+
"performance"
|
|
22
|
+
],
|
|
23
|
+
"author": "Divyansh",
|
|
24
|
+
"license": "GPL-3.0",
|
|
25
|
+
"bugs": {
|
|
26
|
+
"url": "https://github.com/dkydivyansh/BareMetal.js/issues"
|
|
27
|
+
},
|
|
28
|
+
"homepage": "https://BareMetal.dkydivyansh.com"
|
|
29
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { stateManager } from './state.js';
|
|
2
|
+
import { Loader, loader } from './loader.js';
|
|
3
|
+
import { Router } from './router.js';
|
|
4
|
+
|
|
5
|
+
const BareMetal = {
|
|
6
|
+
state: stateManager,
|
|
7
|
+
events: stateManager, // Aliased for backward compatibility with Pub/Sub mental model
|
|
8
|
+
loader: loader,
|
|
9
|
+
router: Router,
|
|
10
|
+
|
|
11
|
+
init(config = {}) {
|
|
12
|
+
if (config.debug !== undefined) Loader.setConfig({ debug: config.debug });
|
|
13
|
+
if (config.keepAliveSameModules !== undefined) Loader.setConfig({ keepAliveSameModules: config.keepAliveSameModules });
|
|
14
|
+
if (config.transition !== undefined) Loader.setConfig({ transition: config.transition });
|
|
15
|
+
if (config.autoWrap !== undefined) Loader.setConfig({ autoWrap: config.autoWrap });
|
|
16
|
+
if (config.hoverPrefetch !== undefined) Loader.setConfig({ hoverPrefetch: config.hoverPrefetch });
|
|
17
|
+
if (config.showErrorNotification !== undefined) Loader.setConfig({ showErrorNotification: config.showErrorNotification });
|
|
18
|
+
|
|
19
|
+
// Initialize Router
|
|
20
|
+
Router.init();
|
|
21
|
+
Loader.log("Initialized BareMetal Engine with config:", config);
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
export { BareMetal, loader };
|
package/src/loader.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { stateManager } from './state.js';
|
|
2
|
+
|
|
3
|
+
export const Loader = {
|
|
4
|
+
activeModules: {}, // { key: { path: "./dash.js", module: exportedModule } }
|
|
5
|
+
config: { keepAliveSameModules: true, debug: false, autoWrap: true, hoverPrefetch: false, showErrorNotification: false, transition: { enabled: false, simulatedDelay: 0, module: null } },
|
|
6
|
+
|
|
7
|
+
setConfig(globalConfig) {
|
|
8
|
+
this.config = { ...this.config, ...globalConfig };
|
|
9
|
+
},
|
|
10
|
+
|
|
11
|
+
log(...args) {
|
|
12
|
+
if (this.config.debug) console.log('[BareMetal Loader]', ...args);
|
|
13
|
+
},
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Prepares the transition by destroying obsolete modules and identifying new ones.
|
|
17
|
+
*/
|
|
18
|
+
async prepare(newConfig) {
|
|
19
|
+
// Auto-inject transition module if enabled
|
|
20
|
+
if (this.config.transition && this.config.transition.enabled) {
|
|
21
|
+
const transitionPath = this.config.transition.module || '/src/transition.js';
|
|
22
|
+
newConfig['__baremetal_transition'] = transitionPath;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const modulesToKeep = {};
|
|
26
|
+
const modulesToDestroy = [];
|
|
27
|
+
const modulesToLoad = {};
|
|
28
|
+
|
|
29
|
+
for (const [key, mod] of Object.entries(this.activeModules)) {
|
|
30
|
+
const isImmortal = key === '__baremetal_transition';
|
|
31
|
+
if ((this.config.keepAliveSameModules || isImmortal) && newConfig[key] === mod.path) {
|
|
32
|
+
// Keep alive
|
|
33
|
+
modulesToKeep[key] = mod;
|
|
34
|
+
this.log(`Keep-Alive: ${key} (${mod.path})`);
|
|
35
|
+
} else {
|
|
36
|
+
// Mark for destruction
|
|
37
|
+
modulesToDestroy.push(mod);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Destroy obsolete modules
|
|
42
|
+
for (const mod of modulesToDestroy) {
|
|
43
|
+
this.log(`Destroying module: ${mod.path}`);
|
|
44
|
+
if (mod.module && typeof mod.module.destroy === 'function') {
|
|
45
|
+
try {
|
|
46
|
+
mod.module.destroy();
|
|
47
|
+
} catch (e) {
|
|
48
|
+
console.error(`Error destroying module ${mod.path}`, e);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// Determine what's new
|
|
54
|
+
for (const [key, path] of Object.entries(newConfig)) {
|
|
55
|
+
if (!modulesToKeep[key]) {
|
|
56
|
+
modulesToLoad[key] = path;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Temporarily update active modules to only the kept ones
|
|
61
|
+
this.activeModules = modulesToKeep;
|
|
62
|
+
|
|
63
|
+
return modulesToLoad;
|
|
64
|
+
},
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Loads the prepared modules. Called by Router after DOM swap.
|
|
68
|
+
*/
|
|
69
|
+
async loadPrepared(modulesToLoad) {
|
|
70
|
+
const total = Object.keys(modulesToLoad).length;
|
|
71
|
+
let loaded = 0;
|
|
72
|
+
|
|
73
|
+
const loadPromises = Object.entries(modulesToLoad).map(async ([key, path]) => {
|
|
74
|
+
this.log(`Importing module: ${path}`);
|
|
75
|
+
try {
|
|
76
|
+
const resolvedPath = new URL(path, document.baseURI).href;
|
|
77
|
+
const noCachePath = `${resolvedPath}?t=${Date.now()}`;
|
|
78
|
+
|
|
79
|
+
let module;
|
|
80
|
+
|
|
81
|
+
if (this.config.autoWrap) {
|
|
82
|
+
// Fetch source to check if it needs auto-wrapping
|
|
83
|
+
const response = await fetch(noCachePath);
|
|
84
|
+
if (!response.ok) throw new Error(`Failed to fetch ${noCachePath}`);
|
|
85
|
+
const sourceText = await response.text();
|
|
86
|
+
|
|
87
|
+
const hasMount = /export\s+(function|const|let|var)\s+mount\b/.test(sourceText) || /export\s+\{.*?\bmount\b.*?\}/.test(sourceText);
|
|
88
|
+
|
|
89
|
+
if (!hasMount) {
|
|
90
|
+
console.warn(`[BareMetal] WARNING: Module ${path} does not explicitly export a mount() function. Auto-wrapping it...`);
|
|
91
|
+
|
|
92
|
+
const wrappedSource = `
|
|
93
|
+
export async function mount(context) {
|
|
94
|
+
const { state } = context;
|
|
95
|
+
${sourceText}
|
|
96
|
+
}
|
|
97
|
+
`;
|
|
98
|
+
|
|
99
|
+
const blob = new Blob([wrappedSource], { type: 'application/javascript' });
|
|
100
|
+
const blobUrl = URL.createObjectURL(blob);
|
|
101
|
+
module = await import(blobUrl);
|
|
102
|
+
URL.revokeObjectURL(blobUrl);
|
|
103
|
+
} else {
|
|
104
|
+
module = await import(noCachePath);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
// Auto-wrapping disabled, load natively
|
|
108
|
+
module = await import(noCachePath);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (typeof module.mount === 'function') {
|
|
112
|
+
this.log(`Mounting module: ${path}`);
|
|
113
|
+
const instance = await module.mount({ state: stateManager });
|
|
114
|
+
|
|
115
|
+
this.activeModules[key] = {
|
|
116
|
+
path: path,
|
|
117
|
+
module: instance ? { destroy: instance.destroy } : module
|
|
118
|
+
};
|
|
119
|
+
} else {
|
|
120
|
+
console.error(`[BareMetal] Module ${path} failed to provide a mount function even after wrapping.`);
|
|
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 });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
await Promise.all(loadPromises);
|
|
134
|
+
stateManager.publish('ROUTE_END', { url: window.location.pathname });
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Legacy standalone load (used on first page load directly)
|
|
139
|
+
*/
|
|
140
|
+
async load(config) {
|
|
141
|
+
const modulesToLoad = await this.prepare(config);
|
|
142
|
+
await this.loadPrepared(modulesToLoad);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
// The user-facing API
|
|
147
|
+
export function loader(config) {
|
|
148
|
+
return Loader.load(config);
|
|
149
|
+
}
|
package/src/router.js
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { Loader } from './loader.js';
|
|
2
|
+
import { stateManager } from './state.js';
|
|
3
|
+
|
|
4
|
+
export const Router = {
|
|
5
|
+
htmlCache: {},
|
|
6
|
+
scrollMemory: {},
|
|
7
|
+
historyStack: [],
|
|
8
|
+
|
|
9
|
+
init() {
|
|
10
|
+
if ('scrollRestoration' in history) {
|
|
11
|
+
history.scrollRestoration = 'manual';
|
|
12
|
+
}
|
|
13
|
+
window.addEventListener('popstate', this.handleRoute.bind(this));
|
|
14
|
+
|
|
15
|
+
// Intercept all link clicks
|
|
16
|
+
document.body.addEventListener('click', e => {
|
|
17
|
+
// Find closest anchor tag
|
|
18
|
+
const anchor = e.target.closest('a');
|
|
19
|
+
if (!anchor) return;
|
|
20
|
+
|
|
21
|
+
// Ignore external domains, target="_blank", noreferrer, or downloads
|
|
22
|
+
if (
|
|
23
|
+
anchor.origin !== window.location.origin ||
|
|
24
|
+
anchor.target === '_blank' ||
|
|
25
|
+
(anchor.rel && anchor.rel.includes('noreferrer')) ||
|
|
26
|
+
anchor.hasAttribute('download')
|
|
27
|
+
) {
|
|
28
|
+
return; // Let browser handle it naturally
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Intercept same-origin internal links
|
|
32
|
+
e.preventDefault();
|
|
33
|
+
|
|
34
|
+
// Save current state before navigating away
|
|
35
|
+
this.historyStack.push(window.location.pathname);
|
|
36
|
+
this.scrollMemory[window.location.pathname] = window.scrollY;
|
|
37
|
+
|
|
38
|
+
history.pushState(null, '', anchor.href);
|
|
39
|
+
this.handleRoute();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// Hover Pre-fetching
|
|
43
|
+
document.body.addEventListener('mouseover', e => {
|
|
44
|
+
if (!Loader.config.hoverPrefetch) return;
|
|
45
|
+
const anchor = e.target.closest('a');
|
|
46
|
+
if (!anchor) return;
|
|
47
|
+
if (
|
|
48
|
+
anchor.origin === window.location.origin &&
|
|
49
|
+
anchor.target !== '_blank' &&
|
|
50
|
+
!anchor.hasAttribute('download') &&
|
|
51
|
+
!this.htmlCache[anchor.href]
|
|
52
|
+
) {
|
|
53
|
+
this.htmlCache[anchor.href] = 'fetching'; // Prevent duplicate fetches
|
|
54
|
+
fetch(anchor.href)
|
|
55
|
+
.then(res => {
|
|
56
|
+
if (res.ok) return res.text();
|
|
57
|
+
throw new Error('Failed to prefetch');
|
|
58
|
+
})
|
|
59
|
+
.then(html => this.htmlCache[anchor.href] = html)
|
|
60
|
+
.catch(() => delete this.htmlCache[anchor.href]);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
back() {
|
|
66
|
+
// Custom programmatic back button
|
|
67
|
+
if (this.historyStack.length > 0) {
|
|
68
|
+
const prevUrl = this.historyStack.pop();
|
|
69
|
+
history.pushState(null, '', prevUrl);
|
|
70
|
+
this.handleRoute();
|
|
71
|
+
} else {
|
|
72
|
+
history.back(); // Fallback to browser's native back
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
|
|
76
|
+
reload() {
|
|
77
|
+
window.location.reload();
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async handleRoute() {
|
|
81
|
+
const url = window.location.pathname;
|
|
82
|
+
try {
|
|
83
|
+
Loader.log(`Navigating to ${url}`);
|
|
84
|
+
stateManager.publish('ROUTE_START', { url });
|
|
85
|
+
|
|
86
|
+
if (Loader.config.transition && Loader.config.transition.simulatedDelay) {
|
|
87
|
+
stateManager.publish('ROUTE_PROGRESS', { url, progress: 10 });
|
|
88
|
+
await new Promise(r => setTimeout(r, Loader.config.transition.simulatedDelay / 2));
|
|
89
|
+
stateManager.publish('ROUTE_PROGRESS', { url, progress: 30 });
|
|
90
|
+
await new Promise(r => setTimeout(r, Loader.config.transition.simulatedDelay / 2));
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
let htmlText;
|
|
94
|
+
const fullUrl = new URL(url, document.baseURI).href;
|
|
95
|
+
|
|
96
|
+
if (Loader.config.hoverPrefetch && this.htmlCache[fullUrl] && this.htmlCache[fullUrl] !== 'fetching') {
|
|
97
|
+
htmlText = this.htmlCache[fullUrl];
|
|
98
|
+
Loader.log(`Used pre-fetched cache for ${url}`);
|
|
99
|
+
} else {
|
|
100
|
+
const response = await fetch(url);
|
|
101
|
+
if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`);
|
|
102
|
+
htmlText = await response.text();
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
stateManager.publish('ROUTE_PROGRESS', { url, progress: 50 });
|
|
106
|
+
|
|
107
|
+
const parser = new DOMParser();
|
|
108
|
+
const doc = parser.parseFromString(htmlText, 'text/html');
|
|
109
|
+
|
|
110
|
+
// 1. Extract Config
|
|
111
|
+
let config = null;
|
|
112
|
+
const scriptTags = doc.querySelectorAll('script');
|
|
113
|
+
for (const script of scriptTags) {
|
|
114
|
+
if (script.textContent.includes('loader(')) {
|
|
115
|
+
// Extract the JSON-like object from loader({...})
|
|
116
|
+
const match = script.textContent.match(/loader\s*\(\s*(\{[\s\S]*?\})\s*\)/);
|
|
117
|
+
if (match && match[1]) {
|
|
118
|
+
try {
|
|
119
|
+
// Using Function to safely parse object string that might not be strict JSON
|
|
120
|
+
config = new Function('return ' + match[1])();
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error("Failed to parse loader config in new page", e);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// If no BareMetal loader config is found, fallback to standard navigation
|
|
129
|
+
if (!config) {
|
|
130
|
+
Loader.log(`No BareMetal config found on ${url}. Falling back to native navigation.`);
|
|
131
|
+
window.location.assign(url);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// 2. Prepare transition: identify kept vs destroyed modules
|
|
136
|
+
const modulesToLoad = await Loader.prepare(config);
|
|
137
|
+
|
|
138
|
+
// 3. Swap DOM and Head
|
|
139
|
+
document.title = doc.title;
|
|
140
|
+
|
|
141
|
+
// Swap Head Styles (prevent CSS leak)
|
|
142
|
+
const oldStyles = document.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]');
|
|
143
|
+
oldStyles.forEach(s => s.remove());
|
|
144
|
+
const newStyles = doc.head.querySelectorAll('link[data-baremetal="style"], style[data-baremetal="style"]');
|
|
145
|
+
newStyles.forEach(s => document.head.appendChild(s.cloneNode(true)));
|
|
146
|
+
|
|
147
|
+
// User Protected Elements (data-baremetal-preserve)
|
|
148
|
+
const preservedNodes = [];
|
|
149
|
+
const persistentElements = document.querySelectorAll('[data-baremetal-preserve]');
|
|
150
|
+
|
|
151
|
+
persistentElements.forEach(el => {
|
|
152
|
+
if (!el.id) return;
|
|
153
|
+
if (doc.getElementById(el.id)) {
|
|
154
|
+
// Synchronously detach the live node
|
|
155
|
+
const placeholder = document.createElement('div');
|
|
156
|
+
el.parentNode.replaceChild(placeholder, el);
|
|
157
|
+
preservedNodes.push(el);
|
|
158
|
+
}
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Engine Protected Elements (Immortal)
|
|
162
|
+
const transitionRoot = document.getElementById('baremetal-transition-root');
|
|
163
|
+
if (transitionRoot) {
|
|
164
|
+
transitionRoot.parentNode.removeChild(transitionRoot);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// The actual synchronous DOM swap and restoration
|
|
168
|
+
const executeDOMSwap = () => {
|
|
169
|
+
document.body.innerHTML = doc.body.innerHTML;
|
|
170
|
+
|
|
171
|
+
// Restore User Protected Elements into their exact new positions
|
|
172
|
+
preservedNodes.forEach(el => {
|
|
173
|
+
const newEl = document.getElementById(el.id);
|
|
174
|
+
if (newEl) {
|
|
175
|
+
// Sync attributes from the new HTML node so classes/styles update
|
|
176
|
+
Array.from(el.attributes).forEach(attr => {
|
|
177
|
+
if (attr.name !== 'id' && attr.name !== 'data-baremetal-preserve') el.removeAttribute(attr.name);
|
|
178
|
+
});
|
|
179
|
+
Array.from(newEl.attributes).forEach(attr => {
|
|
180
|
+
if (attr.name !== 'id') el.setAttribute(attr.name, attr.value);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
newEl.parentNode.replaceChild(el, newEl);
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
if (transitionRoot) {
|
|
188
|
+
document.body.appendChild(transitionRoot);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Notify keep-alive modules that the DOM has been swapped so they can re-bind UI elements
|
|
192
|
+
stateManager.publish('DOM_SWAPPED', null);
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
// Execute DOM swap and restore scroll synchronously
|
|
196
|
+
executeDOMSwap();
|
|
197
|
+
window.scrollTo(0, this.scrollMemory[url] || 0);
|
|
198
|
+
|
|
199
|
+
// 4. Mount new modules (this emits ROUTE_END)
|
|
200
|
+
await Loader.loadPrepared(modulesToLoad);
|
|
201
|
+
|
|
202
|
+
} catch (err) {
|
|
203
|
+
console.error("Routing error:", err);
|
|
204
|
+
stateManager.publish('ROUTE_ERROR', { url, error: err.message });
|
|
205
|
+
|
|
206
|
+
if (Loader.config.showErrorNotification) {
|
|
207
|
+
// Revert URL bar since we are aborting
|
|
208
|
+
if (this.historyStack.length > 0) {
|
|
209
|
+
const prev = this.historyStack.pop();
|
|
210
|
+
history.replaceState(null, '', prev);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Show floating notification
|
|
214
|
+
const notif = document.createElement('div');
|
|
215
|
+
notif.style.position = 'fixed';
|
|
216
|
+
notif.style.bottom = '20px';
|
|
217
|
+
notif.style.left = '20px';
|
|
218
|
+
notif.style.background = '#e74c3c';
|
|
219
|
+
notif.style.color = 'white';
|
|
220
|
+
notif.style.padding = '15px 20px';
|
|
221
|
+
notif.style.borderRadius = '8px';
|
|
222
|
+
notif.style.boxShadow = '0 4px 12px rgba(0,0,0,0.15)';
|
|
223
|
+
notif.style.zIndex = '999999';
|
|
224
|
+
notif.style.fontFamily = 'sans-serif';
|
|
225
|
+
notif.style.transition = 'opacity 0.3s ease';
|
|
226
|
+
notif.innerHTML = `<strong>Navigation Failed:</strong> ${err.message}`;
|
|
227
|
+
|
|
228
|
+
document.body.appendChild(notif);
|
|
229
|
+
|
|
230
|
+
setTimeout(() => {
|
|
231
|
+
notif.style.opacity = '0';
|
|
232
|
+
setTimeout(() => notif.remove(), 300);
|
|
233
|
+
}, 4000);
|
|
234
|
+
} else {
|
|
235
|
+
// Formal redirect to let server handle the error
|
|
236
|
+
window.location.assign(url);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
};
|
package/src/state.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
class StateManager {
|
|
2
|
+
constructor() {
|
|
3
|
+
this.state = {};
|
|
4
|
+
this.listeners = {};
|
|
5
|
+
this.eventBus = {};
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
// --- Reactive State ---
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Initialize a state property with a default value
|
|
12
|
+
*/
|
|
13
|
+
init(key, defaultValue) {
|
|
14
|
+
if (this.state[key] === undefined) {
|
|
15
|
+
this.state[key] = defaultValue;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Subscribe to changes on a specific state key
|
|
21
|
+
*/
|
|
22
|
+
subscribe(key, callback) {
|
|
23
|
+
if (!this.listeners[key]) {
|
|
24
|
+
this.listeners[key] = [];
|
|
25
|
+
}
|
|
26
|
+
this.listeners[key].push(callback);
|
|
27
|
+
|
|
28
|
+
// Call immediately with current value if exists
|
|
29
|
+
if (this.state[key] !== undefined) {
|
|
30
|
+
callback(this.state[key]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return () => this.unsubscribe(key, callback);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Unsubscribe from a state key
|
|
38
|
+
*/
|
|
39
|
+
unsubscribe(key, callback) {
|
|
40
|
+
if (!this.listeners[key]) return;
|
|
41
|
+
this.listeners[key] = this.listeners[key].filter(cb => cb !== callback);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Update a state value and trigger listeners
|
|
46
|
+
*/
|
|
47
|
+
update(key, value) {
|
|
48
|
+
this.state[key] = value;
|
|
49
|
+
if (this.listeners[key]) {
|
|
50
|
+
this.listeners[key].forEach(callback => callback(value));
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Get current state value
|
|
56
|
+
*/
|
|
57
|
+
get(key) {
|
|
58
|
+
return this.state[key];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// --- Event Bus (Pub/Sub) for one-off actions ---
|
|
62
|
+
|
|
63
|
+
on(event, callback) {
|
|
64
|
+
if (!this.eventBus[event]) {
|
|
65
|
+
this.eventBus[event] = [];
|
|
66
|
+
}
|
|
67
|
+
this.eventBus[event].push(callback);
|
|
68
|
+
return () => this.off(event, callback);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
off(event, callback) {
|
|
72
|
+
if (!this.eventBus[event]) return;
|
|
73
|
+
this.eventBus[event] = this.eventBus[event].filter(cb => cb !== callback);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
publish(event, data) {
|
|
77
|
+
if (this.eventBus[event]) {
|
|
78
|
+
this.eventBus[event].forEach(callback => callback(data));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export const stateManager = new StateManager();
|