@zhinnx/core 2.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/index.js +8 -0
- package/package.json +15 -0
- package/src/API.js +55 -0
- package/src/Component.js +117 -0
- package/src/Lazy.js +69 -0
- package/src/Router.js +109 -0
- package/src/Store.js +45 -0
- package/src/diff.js +284 -0
- package/src/reactive.js +142 -0
- package/src/vdom.js +118 -0
package/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export * from './src/reactive.js';
|
|
2
|
+
export * from './src/vdom.js';
|
|
3
|
+
export * from './src/diff.js';
|
|
4
|
+
export * from './src/Component.js';
|
|
5
|
+
export * from './src/Lazy.js';
|
|
6
|
+
export * from './src/Router.js';
|
|
7
|
+
export * from './src/Store.js';
|
|
8
|
+
export * from './src/API.js';
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@zhinnx/core",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Core library for zhinnx framework",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "./index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js"
|
|
9
|
+
},
|
|
10
|
+
"publishConfig": {
|
|
11
|
+
"access": "public"
|
|
12
|
+
},
|
|
13
|
+
"keywords": ["zhinnx", "framework", "vdom", "reactive"],
|
|
14
|
+
"license": "MIT"
|
|
15
|
+
}
|
package/src/API.js
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zhinnx Core - API
|
|
3
|
+
* A unified wrapper for making API requests.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class API {
|
|
7
|
+
constructor(baseURL = '') {
|
|
8
|
+
this.baseURL = baseURL;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async request(endpoint, method = 'GET', data = null) {
|
|
12
|
+
const config = {
|
|
13
|
+
method,
|
|
14
|
+
headers: {
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
},
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
if (data) {
|
|
20
|
+
config.body = JSON.stringify(data);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
try {
|
|
24
|
+
const response = await fetch(`${this.baseURL}${endpoint}`, config);
|
|
25
|
+
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error(`API Error: ${response.status} ${response.statusText}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return await response.json();
|
|
31
|
+
} catch (error) {
|
|
32
|
+
console.error('API Request Failed:', error);
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
get(endpoint) {
|
|
38
|
+
return this.request(endpoint, 'GET');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
post(endpoint, data) {
|
|
42
|
+
return this.request(endpoint, 'POST', data);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
put(endpoint, data) {
|
|
46
|
+
return this.request(endpoint, 'PUT', data);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
delete(endpoint) {
|
|
50
|
+
return this.request(endpoint, 'DELETE');
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Export a default instance
|
|
55
|
+
export default new API();
|
package/src/Component.js
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zhinnx Core - Component
|
|
3
|
+
* Base class with Reactivity and VDOM integration.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { reactive, effect } from './reactive.js';
|
|
7
|
+
import { html } from './vdom.js';
|
|
8
|
+
import { diffChildren, unmount, hydrate } from './diff.js';
|
|
9
|
+
|
|
10
|
+
export { html };
|
|
11
|
+
|
|
12
|
+
export class Component {
|
|
13
|
+
constructor(props = {}) {
|
|
14
|
+
this.props = props;
|
|
15
|
+
this.state = reactive({});
|
|
16
|
+
this.isMounted = false;
|
|
17
|
+
|
|
18
|
+
// Internal
|
|
19
|
+
this._container = null;
|
|
20
|
+
this._vnodes = []; // Supports Fragments (multiple roots)
|
|
21
|
+
this._updateEffect = null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Override to return VNodes.
|
|
26
|
+
*/
|
|
27
|
+
render() {
|
|
28
|
+
return html`<div></div>`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Mounts the component to a DOM element.
|
|
33
|
+
*/
|
|
34
|
+
mount(container) {
|
|
35
|
+
if (this.isMounted) return;
|
|
36
|
+
this._container = container;
|
|
37
|
+
|
|
38
|
+
// Check for Hydration Need (SSR Content Present)
|
|
39
|
+
// Only hydrate if we haven't mounted yet and container has children
|
|
40
|
+
const shouldHydrate = !this.isMounted && container.hasChildNodes();
|
|
41
|
+
|
|
42
|
+
// Reactive update loop
|
|
43
|
+
this._updateEffect = effect(() => {
|
|
44
|
+
// If it's the first run and shouldHydrate is true, run hydration
|
|
45
|
+
// Note: In effect, this runs immediately.
|
|
46
|
+
if (shouldHydrate && !this.isMounted) {
|
|
47
|
+
this.hydrate();
|
|
48
|
+
} else {
|
|
49
|
+
this.update();
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
this.isMounted = true;
|
|
54
|
+
this.onMount();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
hydrate() {
|
|
58
|
+
if (!this._container) return;
|
|
59
|
+
const rendered = this.render();
|
|
60
|
+
const newVNodes = Array.isArray(rendered) ? rendered : [rendered];
|
|
61
|
+
|
|
62
|
+
// Hydrate logic in diff.js
|
|
63
|
+
hydrate(newVNodes, this._container);
|
|
64
|
+
|
|
65
|
+
this._vnodes = newVNodes;
|
|
66
|
+
this.afterRender();
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Force update (automatically called by reactive state).
|
|
71
|
+
*/
|
|
72
|
+
update() {
|
|
73
|
+
if (!this._container) return;
|
|
74
|
+
|
|
75
|
+
const rendered = this.render();
|
|
76
|
+
// Normalize to array to support Fragments (multiple root nodes)
|
|
77
|
+
const newVNodes = Array.isArray(rendered) ? rendered : [rendered];
|
|
78
|
+
|
|
79
|
+
// Use diffChildren to reconcile the container's content
|
|
80
|
+
diffChildren(this._vnodes, newVNodes, this._container);
|
|
81
|
+
|
|
82
|
+
this._vnodes = newVNodes;
|
|
83
|
+
|
|
84
|
+
// Lifecycle
|
|
85
|
+
this.afterRender();
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
unmount() {
|
|
89
|
+
if (!this.isMounted) return;
|
|
90
|
+
|
|
91
|
+
this._vnodes.forEach(vnode => unmount(vnode));
|
|
92
|
+
this._vnodes = [];
|
|
93
|
+
this.isMounted = false;
|
|
94
|
+
this.onUnmount();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Legacy State Support
|
|
99
|
+
*/
|
|
100
|
+
setState(newState) {
|
|
101
|
+
Object.assign(this.state, newState);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Lifecycle Hooks
|
|
106
|
+
*/
|
|
107
|
+
onMount() {}
|
|
108
|
+
onUnmount() {}
|
|
109
|
+
afterRender() {}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Helper
|
|
113
|
+
*/
|
|
114
|
+
$(selector) {
|
|
115
|
+
return this._container ? this._container.querySelector(selector) : null;
|
|
116
|
+
}
|
|
117
|
+
}
|
package/src/Lazy.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* zhinnx Lazy Hydration Wrapper
|
|
4
|
+
*/
|
|
5
|
+
import { Component, html } from './Component.js';
|
|
6
|
+
|
|
7
|
+
export class Lazy extends Component {
|
|
8
|
+
constructor(props) {
|
|
9
|
+
super(props);
|
|
10
|
+
this.state = {
|
|
11
|
+
isLoaded: false,
|
|
12
|
+
component: null
|
|
13
|
+
};
|
|
14
|
+
this.observer = null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
render() {
|
|
18
|
+
if (this.state.isLoaded && this.state.component) {
|
|
19
|
+
// Render the actual loaded component
|
|
20
|
+
// We need to instantiate it if it's a class
|
|
21
|
+
const Comp = this.state.component;
|
|
22
|
+
return new Comp(this.props).render();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Placeholder
|
|
26
|
+
return html`<div class="zhin-lazy-placeholder" style="min-height: 100px">Loading...</div>`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
onMount() {
|
|
30
|
+
// Use IntersectionObserver to detect visibility
|
|
31
|
+
if ('IntersectionObserver' in window) {
|
|
32
|
+
this.observer = new IntersectionObserver((entries) => {
|
|
33
|
+
entries.forEach(entry => {
|
|
34
|
+
if (entry.isIntersecting) {
|
|
35
|
+
this.loadComponent();
|
|
36
|
+
this.observer.disconnect();
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
const el = this.$('.zhin-lazy-placeholder');
|
|
42
|
+
if (el) this.observer.observe(el);
|
|
43
|
+
else this.loadComponent(); // If no placeholder (maybe rendered already?), load immediately
|
|
44
|
+
} else {
|
|
45
|
+
// Fallback for no observer
|
|
46
|
+
this.loadComponent();
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async loadComponent() {
|
|
51
|
+
if (this.state.isLoaded) return;
|
|
52
|
+
|
|
53
|
+
const loader = this.props.loader;
|
|
54
|
+
if (loader) {
|
|
55
|
+
try {
|
|
56
|
+
// Expect loader to be a function returning a Promise that resolves to a Module or Component
|
|
57
|
+
const module = await loader();
|
|
58
|
+
const Comp = module.default || module;
|
|
59
|
+
this.setState({ isLoaded: true, component: Comp });
|
|
60
|
+
} catch (e) {
|
|
61
|
+
console.error('Lazy Load Error:', e);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
onUnmount() {
|
|
67
|
+
if (this.observer) this.observer.disconnect();
|
|
68
|
+
}
|
|
69
|
+
}
|
package/src/Router.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zhinnx Core - Router
|
|
3
|
+
* specific Simple client-side routing.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class Router {
|
|
7
|
+
/**
|
|
8
|
+
* @param {Object} routeMap - Map of routes from server { '/path': { regex, importPath, params } }
|
|
9
|
+
* @param {HTMLElement} rootElement - The DOM element to render pages into.
|
|
10
|
+
*/
|
|
11
|
+
constructor(routeMap, rootElement) {
|
|
12
|
+
this.routeMap = routeMap || {};
|
|
13
|
+
this.root = rootElement;
|
|
14
|
+
this.hydrated = false;
|
|
15
|
+
|
|
16
|
+
// Use History API for standard routing
|
|
17
|
+
window.addEventListener('popstate', () => this.resolve());
|
|
18
|
+
|
|
19
|
+
// Initial load - if DOM already loaded (scripts at end of body), resolve immediately
|
|
20
|
+
if (document.readyState === 'loading') {
|
|
21
|
+
window.addEventListener('DOMContentLoaded', () => this.resolve());
|
|
22
|
+
} else {
|
|
23
|
+
this.resolve();
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the current route and render the component.
|
|
29
|
+
*/
|
|
30
|
+
resolve() {
|
|
31
|
+
const path = window.location.pathname || '/';
|
|
32
|
+
|
|
33
|
+
// Find matching route using Regex
|
|
34
|
+
let matchedRoute = null;
|
|
35
|
+
let params = {};
|
|
36
|
+
let matchedKey = null;
|
|
37
|
+
|
|
38
|
+
// routeMap keys are simple paths, but values contain regex
|
|
39
|
+
for (const [key, route] of Object.entries(this.routeMap)) {
|
|
40
|
+
const re = new RegExp(route.regex);
|
|
41
|
+
const match = path.match(re);
|
|
42
|
+
if (match) {
|
|
43
|
+
matchedKey = key;
|
|
44
|
+
matchedRoute = route;
|
|
45
|
+
// Extract params
|
|
46
|
+
if (route.params) {
|
|
47
|
+
route.params.forEach((key, index) => {
|
|
48
|
+
params[key] = match[index + 1];
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
break;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (matchedRoute) {
|
|
56
|
+
const handleComponent = (ComponentClass) => {
|
|
57
|
+
const shouldHydrate = this.root.hasChildNodes() && !this.hydrated;
|
|
58
|
+
|
|
59
|
+
if (!shouldHydrate) {
|
|
60
|
+
this.root.innerHTML = '';
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Instantiate and mount the page component with Params
|
|
64
|
+
const page = new ComponentClass({ params });
|
|
65
|
+
page.mount(this.root);
|
|
66
|
+
|
|
67
|
+
this.hydrated = true;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Check if we have the module loaded (client-side mapping)
|
|
71
|
+
// For file-based routing, `route.importPath` is server-side relative.
|
|
72
|
+
// Client needs to know how to import it.
|
|
73
|
+
// We assume `window.__ROUTES__` contains `importFn` or we Map it in `app.js`.
|
|
74
|
+
// Actually, passing functions in JSON (window.__ROUTES__) is impossible.
|
|
75
|
+
// So `app.js` must construct the Router with a mapping of `key -> import()`.
|
|
76
|
+
|
|
77
|
+
// If `routeMap` passed to constructor has a `loader` property (function), use it.
|
|
78
|
+
if (matchedRoute.loader) {
|
|
79
|
+
if (!this.hydrated && !this.root.hasChildNodes()) {
|
|
80
|
+
this.root.innerHTML = '<div>Loading route...</div>';
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
matchedRoute.loader().then(module => {
|
|
84
|
+
const Comp = module.default || module;
|
|
85
|
+
handleComponent(Comp);
|
|
86
|
+
}).catch(err => {
|
|
87
|
+
console.error('Route Loading Error', err);
|
|
88
|
+
this.root.innerHTML = '<h1>Error Loading Page</h1>';
|
|
89
|
+
});
|
|
90
|
+
} else {
|
|
91
|
+
// Fallback or Error
|
|
92
|
+
console.error('Route found but no loader defined on client:', matchedKey);
|
|
93
|
+
this.root.innerHTML = '<h1>Error: Route Configuration Missing</h1>';
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
} else {
|
|
97
|
+
this.root.innerHTML = '<h1>404 - Page Not Found</h1>';
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Programmatic navigation
|
|
103
|
+
* @param {string} path
|
|
104
|
+
*/
|
|
105
|
+
navigate(path) {
|
|
106
|
+
window.history.pushState({}, '', path);
|
|
107
|
+
this.resolve();
|
|
108
|
+
}
|
|
109
|
+
}
|
package/src/Store.js
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zhinnx Core - Store
|
|
3
|
+
* A lightweight global state management system.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export class Store {
|
|
7
|
+
constructor(initialState = {}) {
|
|
8
|
+
this.listeners = new Set();
|
|
9
|
+
|
|
10
|
+
// Proxy allows us to detect changes automatically
|
|
11
|
+
this.state = new Proxy(initialState, {
|
|
12
|
+
set: (target, property, value) => {
|
|
13
|
+
target[property] = value;
|
|
14
|
+
this.notify();
|
|
15
|
+
return true;
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Subscribe to state changes.
|
|
22
|
+
* @param {Function} listener - Function to call when state changes.
|
|
23
|
+
* @returns {Function} - Unsubscribe function.
|
|
24
|
+
*/
|
|
25
|
+
subscribe(listener) {
|
|
26
|
+
this.listeners.add(listener);
|
|
27
|
+
// Return an unsubscribe function for cleanup
|
|
28
|
+
return () => this.listeners.delete(listener);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Notify all subscribers of a change.
|
|
33
|
+
*/
|
|
34
|
+
notify() {
|
|
35
|
+
this.listeners.forEach(listener => listener(this.state));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Action helper to mutate state in a structured way (optional pattern)
|
|
40
|
+
* @param {Function} actionFn
|
|
41
|
+
*/
|
|
42
|
+
dispatch(actionFn) {
|
|
43
|
+
actionFn(this.state);
|
|
44
|
+
}
|
|
45
|
+
}
|
package/src/diff.js
ADDED
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* Zhinnx VDOM Diffing Engine
|
|
4
|
+
* Optimized for Fragments and Arrays
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function mount(vnode, container, anchor = null) {
|
|
8
|
+
if (vnode === null || vnode === undefined || vnode === false) return;
|
|
9
|
+
|
|
10
|
+
if (Array.isArray(vnode)) {
|
|
11
|
+
vnode.forEach(child => mount(child, container, anchor));
|
|
12
|
+
return; // Fragments don't return a single EL
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const el = createDOM(vnode);
|
|
16
|
+
vnode.el = el;
|
|
17
|
+
container.insertBefore(el, anchor);
|
|
18
|
+
return el;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function unmount(vnode) {
|
|
22
|
+
if (vnode === null || vnode === undefined || vnode === false) return;
|
|
23
|
+
|
|
24
|
+
if (Array.isArray(vnode)) {
|
|
25
|
+
vnode.forEach(unmount);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (vnode.el && vnode.el.parentNode) {
|
|
30
|
+
vnode.el.parentNode.removeChild(vnode.el);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function createDOM(vnode) {
|
|
35
|
+
if (vnode.text !== undefined) {
|
|
36
|
+
return document.createTextNode(vnode.text);
|
|
37
|
+
}
|
|
38
|
+
const el = document.createElement(vnode.tag);
|
|
39
|
+
if (vnode.props) {
|
|
40
|
+
for (const key in vnode.props) {
|
|
41
|
+
patchProp(el, key, null, vnode.props[key]);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
if (vnode.children) {
|
|
45
|
+
vnode.children.forEach(child => mount(child, el));
|
|
46
|
+
}
|
|
47
|
+
return el;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Hydrates a VNode into an existing DOM Node.
|
|
52
|
+
*/
|
|
53
|
+
export function hydrate(vnode, container) {
|
|
54
|
+
let domNode = container.firstChild;
|
|
55
|
+
|
|
56
|
+
// Normalize vnode to array for consistent handling
|
|
57
|
+
const nodes = Array.isArray(vnode) ? vnode : [vnode];
|
|
58
|
+
|
|
59
|
+
nodes.forEach(child => {
|
|
60
|
+
domNode = hydrateNode(child, domNode);
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hydrateNode(vnode, domNode) {
|
|
65
|
+
if (vnode === null || vnode === undefined || vnode === false) return domNode;
|
|
66
|
+
|
|
67
|
+
if (Array.isArray(vnode)) {
|
|
68
|
+
vnode.forEach(child => {
|
|
69
|
+
domNode = hydrateNode(child, domNode);
|
|
70
|
+
});
|
|
71
|
+
return domNode;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!domNode) {
|
|
75
|
+
mount(vnode, domNode ? domNode.parentNode : null);
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
vnode.el = domNode;
|
|
80
|
+
|
|
81
|
+
// Text Node
|
|
82
|
+
if (vnode.text !== undefined) {
|
|
83
|
+
if (domNode.nodeType !== Node.TEXT_NODE) {
|
|
84
|
+
// console.warn('Hydration Mismatch: Expected Text, found Element');
|
|
85
|
+
const newEl = createDOM(vnode);
|
|
86
|
+
domNode.parentNode.replaceChild(newEl, domNode);
|
|
87
|
+
vnode.el = newEl;
|
|
88
|
+
return newEl.nextSibling;
|
|
89
|
+
}
|
|
90
|
+
if (domNode.nodeValue !== vnode.text) {
|
|
91
|
+
domNode.nodeValue = vnode.text;
|
|
92
|
+
}
|
|
93
|
+
return domNode.nextSibling;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Element Node
|
|
97
|
+
if (domNode.nodeType !== Node.ELEMENT_NODE || domNode.tagName.toLowerCase() !== vnode.tag.toLowerCase()) {
|
|
98
|
+
// console.warn(`Hydration Mismatch: Expected <${vnode.tag}>, found <${domNode.tagName}>`);
|
|
99
|
+
const newEl = createDOM(vnode);
|
|
100
|
+
domNode.parentNode.replaceChild(newEl, domNode);
|
|
101
|
+
vnode.el = newEl;
|
|
102
|
+
return newEl.nextSibling;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Patch Props
|
|
106
|
+
if (vnode.props) {
|
|
107
|
+
for (const key in vnode.props) {
|
|
108
|
+
if (key.startsWith('on')) {
|
|
109
|
+
patchProp(domNode, key, null, vnode.props[key]);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Hydrate Children
|
|
115
|
+
let childDomNode = domNode.firstChild;
|
|
116
|
+
if (vnode.children) {
|
|
117
|
+
vnode.children.forEach(child => {
|
|
118
|
+
childDomNode = hydrateNode(child, childDomNode);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return domNode.nextSibling;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
export function patch(n1, n2, container) {
|
|
127
|
+
if (n1 === n2) return;
|
|
128
|
+
|
|
129
|
+
// Handle Arrays (Fragments)
|
|
130
|
+
if (Array.isArray(n1) || Array.isArray(n2)) {
|
|
131
|
+
const c1 = Array.isArray(n1) ? n1 : [n1];
|
|
132
|
+
const c2 = Array.isArray(n2) ? n2 : [n2];
|
|
133
|
+
diffChildren(c1, c2, container);
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Replace if different types
|
|
138
|
+
if (n1.tag !== n2.tag || (n1.text !== undefined && n2.text === undefined) || (n1.text === undefined && n2.text !== undefined)) {
|
|
139
|
+
const anchor = n1.el.nextSibling;
|
|
140
|
+
unmount(n1);
|
|
141
|
+
mount(n2, container, anchor);
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Text Update
|
|
146
|
+
if (n2.text !== undefined) {
|
|
147
|
+
n2.el = n1.el;
|
|
148
|
+
if (n2.text !== n1.text) {
|
|
149
|
+
n2.el.nodeValue = n2.text;
|
|
150
|
+
}
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Element Update
|
|
155
|
+
const el = (n2.el = n1.el);
|
|
156
|
+
|
|
157
|
+
// Patch Props
|
|
158
|
+
const oldProps = n1.props || {};
|
|
159
|
+
const newProps = n2.props || {};
|
|
160
|
+
for (const key in newProps) {
|
|
161
|
+
const oldValue = oldProps[key];
|
|
162
|
+
const newValue = newProps[key];
|
|
163
|
+
if (newValue !== oldValue) {
|
|
164
|
+
patchProp(el, key, oldValue, newValue);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
for (const key in oldProps) {
|
|
168
|
+
if (!(key in newProps)) {
|
|
169
|
+
patchProp(el, key, oldProps[key], null);
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// Patch Children
|
|
174
|
+
diffChildren(n1.children, n2.children, el);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function diffChildren(oldChildren, newChildren, container) {
|
|
178
|
+
// Optimization: fast path for empty
|
|
179
|
+
if (oldChildren.length === 0) {
|
|
180
|
+
newChildren.forEach(c => mount(c, container));
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (newChildren.length === 0) {
|
|
184
|
+
oldChildren.forEach(c => unmount(c));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const oldMap = new Map();
|
|
189
|
+
const unkeyed = [];
|
|
190
|
+
|
|
191
|
+
oldChildren.forEach((c, i) => {
|
|
192
|
+
if (c.key != null) oldMap.set(c.key, c);
|
|
193
|
+
else unkeyed.push({ vnode: c, index: i });
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
let unkeyedIndex = 0;
|
|
197
|
+
|
|
198
|
+
// We need to track where we are inserting in the DOM.
|
|
199
|
+
// Since we are patching in place, we can use the old children's locations,
|
|
200
|
+
// but if we move things, it gets complex.
|
|
201
|
+
// For this implementation, we use `insertBefore` with a reference node.
|
|
202
|
+
// But getting the reference node is hard if the old list is shuffled.
|
|
203
|
+
|
|
204
|
+
// Simplified Reconciler:
|
|
205
|
+
// 1. Walk new children.
|
|
206
|
+
// 2. If matched (key or type), patch. Move if needed.
|
|
207
|
+
// 3. If new, mount.
|
|
208
|
+
|
|
209
|
+
// To properly place nodes, we can use `container.childNodes` but that includes text nodes and comments
|
|
210
|
+
// that might not be in VDOM if VDOM stripped them (vdom.js keeps text).
|
|
211
|
+
|
|
212
|
+
// We'll trust the VDOM order matches DOM order initially.
|
|
213
|
+
|
|
214
|
+
// Pointer to the *next* sibling for insertion
|
|
215
|
+
let nextSibling = oldChildren[0]?.el;
|
|
216
|
+
|
|
217
|
+
// Wait, this is getting into React Fiber complexity.
|
|
218
|
+
// Fallback to the previous implementation which was decent for keyed,
|
|
219
|
+
// but ensure we handle `mount` correctly.
|
|
220
|
+
|
|
221
|
+
// Let's use the previous implementation logic but fix the anchor.
|
|
222
|
+
|
|
223
|
+
const newChildrenSize = newChildren.length;
|
|
224
|
+
|
|
225
|
+
// First, process keyed updates and moves
|
|
226
|
+
for (let i = 0; i < newChildrenSize; i++) {
|
|
227
|
+
const newChild = newChildren[i];
|
|
228
|
+
let oldChild;
|
|
229
|
+
|
|
230
|
+
if (newChild.key != null) {
|
|
231
|
+
oldChild = oldMap.get(newChild.key);
|
|
232
|
+
oldMap.delete(newChild.key);
|
|
233
|
+
} else {
|
|
234
|
+
if (unkeyedIndex < unkeyed.length) {
|
|
235
|
+
oldChild = unkeyed[unkeyedIndex].vnode;
|
|
236
|
+
unkeyedIndex++;
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (oldChild) {
|
|
241
|
+
patch(oldChild, newChild, container);
|
|
242
|
+
// Move: check if the DOM node at this position is correct
|
|
243
|
+
// The DOM node at index `i` (ignoring non-vdom nodes? No, we assume 1-to-1)
|
|
244
|
+
// But if we have fragments, index `i` is meaningless.
|
|
245
|
+
|
|
246
|
+
// Critical fix: We assume Component doesn't return Fragments for now in diffChildren logic
|
|
247
|
+
// OR we accept that reordering Fragments is expensive/not supported.
|
|
248
|
+
// Component.render() returning Array is handled by `patch` -> `diffChildren` on container.
|
|
249
|
+
|
|
250
|
+
// Let's rely on `container.childNodes[i]` assuming 1-to-1 mapping for Elements/Text.
|
|
251
|
+
const currentNode = container.childNodes[i];
|
|
252
|
+
if (oldChild.el && currentNode !== oldChild.el) {
|
|
253
|
+
container.insertBefore(oldChild.el, currentNode);
|
|
254
|
+
}
|
|
255
|
+
} else {
|
|
256
|
+
// Mount new
|
|
257
|
+
// We want to insert it at index `i`.
|
|
258
|
+
const anchor = container.childNodes[i];
|
|
259
|
+
mount(newChild, container, anchor);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Cleanup
|
|
264
|
+
oldMap.forEach(c => unmount(c));
|
|
265
|
+
while (unkeyedIndex < unkeyed.length) {
|
|
266
|
+
unmount(unkeyed[unkeyedIndex].vnode);
|
|
267
|
+
unkeyedIndex++;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function patchProp(el, key, prev, next) {
|
|
272
|
+
if (key.startsWith('on')) {
|
|
273
|
+
const name = key.slice(2).toLowerCase();
|
|
274
|
+
if (prev) el.removeEventListener(name, prev);
|
|
275
|
+
if (next) el.addEventListener(name, next);
|
|
276
|
+
} else if (key === 'value' || key === 'checked') {
|
|
277
|
+
el[key] = next;
|
|
278
|
+
} else if (key === 'className') {
|
|
279
|
+
el.className = next || '';
|
|
280
|
+
} else {
|
|
281
|
+
if (next == null || next === false) el.removeAttribute(key);
|
|
282
|
+
else el.setAttribute(key, next);
|
|
283
|
+
}
|
|
284
|
+
}
|
package/src/reactive.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// Zhinnx v2 Reactivity System
|
|
2
|
+
// Optimized with dependency cleanup and lazy computed properties
|
|
3
|
+
|
|
4
|
+
let activeEffect = null;
|
|
5
|
+
const targetMap = new WeakMap();
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Tracks dependencies for a reactive property.
|
|
9
|
+
* @param {Object} target
|
|
10
|
+
* @param {string} key
|
|
11
|
+
*/
|
|
12
|
+
export function track(target, key) {
|
|
13
|
+
if (activeEffect) {
|
|
14
|
+
let depsMap = targetMap.get(target);
|
|
15
|
+
if (!depsMap) {
|
|
16
|
+
depsMap = new Map();
|
|
17
|
+
targetMap.set(target, depsMap);
|
|
18
|
+
}
|
|
19
|
+
let dep = depsMap.get(key);
|
|
20
|
+
if (!dep) {
|
|
21
|
+
dep = new Set();
|
|
22
|
+
depsMap.set(key, dep);
|
|
23
|
+
}
|
|
24
|
+
dep.add(activeEffect);
|
|
25
|
+
activeEffect.deps.push(dep);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Triggers effects associated with a reactive property.
|
|
31
|
+
* @param {Object} target
|
|
32
|
+
* @param {string} key
|
|
33
|
+
*/
|
|
34
|
+
export function trigger(target, key) {
|
|
35
|
+
const depsMap = targetMap.get(target);
|
|
36
|
+
if (!depsMap) return;
|
|
37
|
+
const dep = depsMap.get(key);
|
|
38
|
+
if (dep) {
|
|
39
|
+
const effectsToRun = new Set(dep);
|
|
40
|
+
effectsToRun.forEach(eff => {
|
|
41
|
+
if (eff.scheduler) {
|
|
42
|
+
eff.scheduler();
|
|
43
|
+
} else {
|
|
44
|
+
eff();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Creates a reactive proxy for an object.
|
|
52
|
+
* @param {Object} target
|
|
53
|
+
* @returns {Proxy}
|
|
54
|
+
*/
|
|
55
|
+
export function reactive(target) {
|
|
56
|
+
return new Proxy(target, {
|
|
57
|
+
get(obj, prop) {
|
|
58
|
+
track(obj, prop);
|
|
59
|
+
return Reflect.get(obj, prop);
|
|
60
|
+
},
|
|
61
|
+
set(obj, prop, value) {
|
|
62
|
+
const oldValue = obj[prop];
|
|
63
|
+
const result = Reflect.set(obj, prop, value);
|
|
64
|
+
if (oldValue !== value) {
|
|
65
|
+
trigger(obj, prop);
|
|
66
|
+
}
|
|
67
|
+
return result;
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cleanupEffect(effectFn) {
|
|
73
|
+
const { deps } = effectFn;
|
|
74
|
+
if (deps.length) {
|
|
75
|
+
for (let i = 0; i < deps.length; i++) {
|
|
76
|
+
deps[i].delete(effectFn);
|
|
77
|
+
}
|
|
78
|
+
deps.length = 0;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Registers a side effect that runs when dependencies change.
|
|
84
|
+
* @param {Function} fn - The function to run
|
|
85
|
+
* @param {Object} options - { lazy: boolean, scheduler: Function }
|
|
86
|
+
*/
|
|
87
|
+
export function effect(fn, options = {}) {
|
|
88
|
+
const effectFn = () => {
|
|
89
|
+
// cleanup previous dependencies to avoid memory leaks and stale deps
|
|
90
|
+
cleanupEffect(effectFn);
|
|
91
|
+
|
|
92
|
+
const previousEffect = activeEffect;
|
|
93
|
+
activeEffect = effectFn;
|
|
94
|
+
try {
|
|
95
|
+
return fn();
|
|
96
|
+
} finally {
|
|
97
|
+
activeEffect = previousEffect;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
effectFn.deps = [];
|
|
102
|
+
effectFn.scheduler = options.scheduler;
|
|
103
|
+
|
|
104
|
+
if (!options.lazy) {
|
|
105
|
+
effectFn();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return effectFn;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Creates a computed property.
|
|
113
|
+
* @param {Function} getter
|
|
114
|
+
* @returns {Object} { value }
|
|
115
|
+
*/
|
|
116
|
+
export function computed(getter) {
|
|
117
|
+
let value;
|
|
118
|
+
let dirty = true;
|
|
119
|
+
|
|
120
|
+
const effectFn = effect(getter, {
|
|
121
|
+
lazy: true,
|
|
122
|
+
scheduler: () => {
|
|
123
|
+
if (!dirty) {
|
|
124
|
+
dirty = true;
|
|
125
|
+
trigger(computedObj, 'value');
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
const computedObj = {
|
|
131
|
+
get value() {
|
|
132
|
+
if (dirty) {
|
|
133
|
+
value = effectFn();
|
|
134
|
+
dirty = false;
|
|
135
|
+
}
|
|
136
|
+
track(computedObj, 'value');
|
|
137
|
+
return value;
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
return computedObj;
|
|
142
|
+
}
|
package/src/vdom.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
|
|
2
|
+
/**
|
|
3
|
+
* zhinnx VDOM
|
|
4
|
+
* Lightweight Virtual DOM implementation.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
// VNode Factory
|
|
8
|
+
export function h(tag, props, ...children) {
|
|
9
|
+
return {
|
|
10
|
+
tag,
|
|
11
|
+
props: props || {},
|
|
12
|
+
children: children.flat(Infinity)
|
|
13
|
+
.filter(c => c != null && c !== false)
|
|
14
|
+
.map(c => (typeof c === 'string' || typeof c === 'number') ? { text: String(c) } : c),
|
|
15
|
+
key: props?.key
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// Token to identify dynamic values in HTML
|
|
20
|
+
const UID = '__ZHIN_VAL__';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Tagged template function to create VNodes.
|
|
24
|
+
* Uses the browser's native HTML parser (via <template>).
|
|
25
|
+
* Note: This implementation requires a browser environment (DOM).
|
|
26
|
+
*/
|
|
27
|
+
export function html(strings, ...values) {
|
|
28
|
+
// SSR Check - Return SSR Object
|
|
29
|
+
if (typeof document === 'undefined' || (typeof global !== 'undefined' && global.IS_SSR_TEST)) {
|
|
30
|
+
return {
|
|
31
|
+
isSSR: true,
|
|
32
|
+
strings,
|
|
33
|
+
values
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// 1. Interleave strings and placeholders
|
|
38
|
+
let htmlString = '';
|
|
39
|
+
const valMap = new Map();
|
|
40
|
+
|
|
41
|
+
strings.forEach((str, i) => {
|
|
42
|
+
htmlString += str;
|
|
43
|
+
if (i < values.length) {
|
|
44
|
+
const key = UID + i;
|
|
45
|
+
valMap.set(key, values[i]);
|
|
46
|
+
htmlString += key;
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const template = document.createElement('template');
|
|
51
|
+
template.innerHTML = htmlString;
|
|
52
|
+
|
|
53
|
+
// 3. Convert DOM to VNodes
|
|
54
|
+
return walk(template.content, valMap);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function walk(node, valMap) {
|
|
58
|
+
if (node.nodeType === Node.TEXT_NODE) {
|
|
59
|
+
const text = node.textContent;
|
|
60
|
+
// Check if the text is exactly a placeholder
|
|
61
|
+
// Note: This logic assumes placeholders are the entire text content or properly separated.
|
|
62
|
+
// For mixed text "Hello ${name}", the parser might see "Hello __ZHIN_VAL__0".
|
|
63
|
+
// We need to split.
|
|
64
|
+
|
|
65
|
+
if (text.includes(UID)) {
|
|
66
|
+
// Split by UID regex
|
|
67
|
+
const parts = text.split(new RegExp(`(${UID}\\d+)`));
|
|
68
|
+
const nodes = parts.map(part => {
|
|
69
|
+
if (part.startsWith(UID)) {
|
|
70
|
+
return valMap.get(part);
|
|
71
|
+
} else if (part) {
|
|
72
|
+
return { text: part }; // plain text normalized to VNode
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}).filter(n => n !== null);
|
|
76
|
+
|
|
77
|
+
// If single item, return it
|
|
78
|
+
if (nodes.length === 1) return nodes[0];
|
|
79
|
+
return nodes;
|
|
80
|
+
}
|
|
81
|
+
return { text };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
|
|
85
|
+
// If it's a fragment, just return children
|
|
86
|
+
if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
|
|
87
|
+
let children = Array.from(node.childNodes).map(c => walk(c, valMap)).flat();
|
|
88
|
+
// Normalize strings if any leaked (though walk should handle them now)
|
|
89
|
+
children = children.map(c => (typeof c === 'string') ? { text: c } : c);
|
|
90
|
+
|
|
91
|
+
// If the fragment results in a single VNode, return it.
|
|
92
|
+
if (children.length === 1) return children[0];
|
|
93
|
+
return children;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const tag = node.tagName.toLowerCase();
|
|
97
|
+
const props = {};
|
|
98
|
+
|
|
99
|
+
// Attributes
|
|
100
|
+
Array.from(node.attributes).forEach(attr => {
|
|
101
|
+
let name = attr.name;
|
|
102
|
+
let value = attr.value;
|
|
103
|
+
|
|
104
|
+
// Check if value is a placeholder
|
|
105
|
+
if (value.startsWith(UID) && valMap.has(value)) {
|
|
106
|
+
value = valMap.get(value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
props[name] = value;
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const children = Array.from(node.childNodes).map(c => walk(c, valMap)).flat();
|
|
113
|
+
|
|
114
|
+
return h(tag, props, ...children);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return null;
|
|
118
|
+
}
|