zero-query 0.1.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/LICENSE +21 -0
- package/README.md +1271 -0
- package/index.js +173 -0
- package/package.json +48 -0
- package/src/component.js +800 -0
- package/src/core.js +508 -0
- package/src/http.js +177 -0
- package/src/reactive.js +125 -0
- package/src/router.js +334 -0
- package/src/store.js +169 -0
- package/src/utils.js +271 -0
package/src/reactive.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zQuery Reactive — Proxy-based deep reactivity system
|
|
3
|
+
*
|
|
4
|
+
* Creates observable objects that trigger callbacks on mutation.
|
|
5
|
+
* Used internally by components and store for auto-updates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// ---------------------------------------------------------------------------
|
|
9
|
+
// Deep reactive proxy
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
export function reactive(target, onChange, _path = '') {
|
|
12
|
+
if (typeof target !== 'object' || target === null) return target;
|
|
13
|
+
|
|
14
|
+
const proxyCache = new WeakMap();
|
|
15
|
+
|
|
16
|
+
const handler = {
|
|
17
|
+
get(obj, key) {
|
|
18
|
+
if (key === '__isReactive') return true;
|
|
19
|
+
if (key === '__raw') return obj;
|
|
20
|
+
|
|
21
|
+
const value = obj[key];
|
|
22
|
+
if (typeof value === 'object' && value !== null) {
|
|
23
|
+
// Return cached proxy or create new one
|
|
24
|
+
if (proxyCache.has(value)) return proxyCache.get(value);
|
|
25
|
+
const childProxy = new Proxy(value, handler);
|
|
26
|
+
proxyCache.set(value, childProxy);
|
|
27
|
+
return childProxy;
|
|
28
|
+
}
|
|
29
|
+
return value;
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
set(obj, key, value) {
|
|
33
|
+
const old = obj[key];
|
|
34
|
+
if (old === value) return true;
|
|
35
|
+
obj[key] = value;
|
|
36
|
+
onChange(key, value, old);
|
|
37
|
+
return true;
|
|
38
|
+
},
|
|
39
|
+
|
|
40
|
+
deleteProperty(obj, key) {
|
|
41
|
+
const old = obj[key];
|
|
42
|
+
delete obj[key];
|
|
43
|
+
onChange(key, undefined, old);
|
|
44
|
+
return true;
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
return new Proxy(target, handler);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
// ---------------------------------------------------------------------------
|
|
53
|
+
// Signal — lightweight reactive primitive (inspired by Solid/Preact signals)
|
|
54
|
+
// ---------------------------------------------------------------------------
|
|
55
|
+
export class Signal {
|
|
56
|
+
constructor(value) {
|
|
57
|
+
this._value = value;
|
|
58
|
+
this._subscribers = new Set();
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
get value() {
|
|
62
|
+
// Track dependency if there's an active effect
|
|
63
|
+
if (Signal._activeEffect) {
|
|
64
|
+
this._subscribers.add(Signal._activeEffect);
|
|
65
|
+
}
|
|
66
|
+
return this._value;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
set value(newVal) {
|
|
70
|
+
if (this._value === newVal) return;
|
|
71
|
+
this._value = newVal;
|
|
72
|
+
this._notify();
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
peek() { return this._value; }
|
|
76
|
+
|
|
77
|
+
_notify() {
|
|
78
|
+
this._subscribers.forEach(fn => fn());
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
subscribe(fn) {
|
|
82
|
+
this._subscribers.add(fn);
|
|
83
|
+
return () => this._subscribers.delete(fn);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
toString() { return String(this._value); }
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Active effect tracking
|
|
90
|
+
Signal._activeEffect = null;
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Create a signal
|
|
94
|
+
* @param {*} initial — initial value
|
|
95
|
+
* @returns {Signal}
|
|
96
|
+
*/
|
|
97
|
+
export function signal(initial) {
|
|
98
|
+
return new Signal(initial);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Create a computed signal (derived from other signals)
|
|
103
|
+
* @param {Function} fn — computation function
|
|
104
|
+
* @returns {Signal}
|
|
105
|
+
*/
|
|
106
|
+
export function computed(fn) {
|
|
107
|
+
const s = new Signal(undefined);
|
|
108
|
+
effect(() => { s._value = fn(); s._notify(); });
|
|
109
|
+
return s;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Create a side-effect that auto-tracks signal dependencies
|
|
114
|
+
* @param {Function} fn — effect function
|
|
115
|
+
* @returns {Function} — dispose function
|
|
116
|
+
*/
|
|
117
|
+
export function effect(fn) {
|
|
118
|
+
const execute = () => {
|
|
119
|
+
Signal._activeEffect = execute;
|
|
120
|
+
try { fn(); }
|
|
121
|
+
finally { Signal._activeEffect = null; }
|
|
122
|
+
};
|
|
123
|
+
execute();
|
|
124
|
+
return () => { /* Signals will hold weak refs if needed */ };
|
|
125
|
+
}
|
package/src/router.js
ADDED
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zQuery Router — Client-side SPA router
|
|
3
|
+
*
|
|
4
|
+
* Supports hash mode (#/path) and history mode (/path).
|
|
5
|
+
* Route params, query strings, navigation guards, and lazy loading.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* $.router({
|
|
9
|
+
* el: '#app',
|
|
10
|
+
* mode: 'hash',
|
|
11
|
+
* routes: [
|
|
12
|
+
* { path: '/', component: 'home-page' },
|
|
13
|
+
* { path: '/user/:id', component: 'user-profile' },
|
|
14
|
+
* { path: '/lazy', load: () => import('./pages/lazy.js'), component: 'lazy-page' },
|
|
15
|
+
* ],
|
|
16
|
+
* fallback: 'not-found'
|
|
17
|
+
* });
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import { mount, destroy } from './component.js';
|
|
21
|
+
|
|
22
|
+
class Router {
|
|
23
|
+
constructor(config = {}) {
|
|
24
|
+
this._el = null;
|
|
25
|
+
this._mode = config.mode || 'history'; // 'history' | 'hash'
|
|
26
|
+
|
|
27
|
+
// Base path for sub-path deployments
|
|
28
|
+
// Priority: explicit config.base → window.__ZQ_BASE → <base href> tag
|
|
29
|
+
let rawBase = config.base;
|
|
30
|
+
if (rawBase == null) {
|
|
31
|
+
rawBase = (typeof window !== 'undefined' && window.__ZQ_BASE) || '';
|
|
32
|
+
if (!rawBase && typeof document !== 'undefined') {
|
|
33
|
+
const baseEl = document.querySelector('base');
|
|
34
|
+
if (baseEl) {
|
|
35
|
+
try { rawBase = new URL(baseEl.href).pathname; }
|
|
36
|
+
catch { rawBase = baseEl.getAttribute('href') || ''; }
|
|
37
|
+
if (rawBase === '/') rawBase = ''; // root = no sub-path
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
// Normalize: ensure leading /, strip trailing /
|
|
42
|
+
this._base = String(rawBase).replace(/\/+$/, '');
|
|
43
|
+
if (this._base && !this._base.startsWith('/')) this._base = '/' + this._base;
|
|
44
|
+
|
|
45
|
+
this._routes = [];
|
|
46
|
+
this._fallback = config.fallback || null;
|
|
47
|
+
this._current = null; // { route, params, query, path }
|
|
48
|
+
this._guards = { before: [], after: [] };
|
|
49
|
+
this._listeners = new Set();
|
|
50
|
+
this._instance = null; // current mounted component
|
|
51
|
+
|
|
52
|
+
// Set outlet element
|
|
53
|
+
if (config.el) {
|
|
54
|
+
this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Register routes
|
|
58
|
+
if (config.routes) {
|
|
59
|
+
config.routes.forEach(r => this.add(r));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Listen for navigation
|
|
63
|
+
if (this._mode === 'hash') {
|
|
64
|
+
window.addEventListener('hashchange', () => this._resolve());
|
|
65
|
+
} else {
|
|
66
|
+
window.addEventListener('popstate', () => this._resolve());
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Intercept link clicks for SPA navigation
|
|
70
|
+
document.addEventListener('click', (e) => {
|
|
71
|
+
// Don't intercept modified clicks (Ctrl/Cmd+click = new tab)
|
|
72
|
+
if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
|
|
73
|
+
const link = e.target.closest('[z-link]');
|
|
74
|
+
if (!link) return;
|
|
75
|
+
if (link.getAttribute('target') === '_blank') return;
|
|
76
|
+
e.preventDefault();
|
|
77
|
+
this.navigate(link.getAttribute('z-link'));
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Initial resolve
|
|
81
|
+
if (this._el) {
|
|
82
|
+
// Defer to allow all components to register
|
|
83
|
+
queueMicrotask(() => this._resolve());
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// --- Route management ----------------------------------------------------
|
|
88
|
+
|
|
89
|
+
add(route) {
|
|
90
|
+
// Compile path pattern into regex
|
|
91
|
+
const keys = [];
|
|
92
|
+
const pattern = route.path
|
|
93
|
+
.replace(/:(\w+)/g, (_, key) => { keys.push(key); return '([^/]+)'; })
|
|
94
|
+
.replace(/\*/g, '(.*)');
|
|
95
|
+
const regex = new RegExp(`^${pattern}$`);
|
|
96
|
+
|
|
97
|
+
this._routes.push({ ...route, _regex: regex, _keys: keys });
|
|
98
|
+
|
|
99
|
+
// Per-route fallback: register an alias path for the same component.
|
|
100
|
+
// e.g. { path: '/docs/:section', fallback: '/docs', component: 'docs-page' }
|
|
101
|
+
// When matched via fallback, missing params are undefined → pages `default` kicks in.
|
|
102
|
+
if (route.fallback) {
|
|
103
|
+
const fbKeys = [];
|
|
104
|
+
const fbPattern = route.fallback
|
|
105
|
+
.replace(/:(\w+)/g, (_, key) => { fbKeys.push(key); return '([^/]+)'; })
|
|
106
|
+
.replace(/\*/g, '(.*)');
|
|
107
|
+
const fbRegex = new RegExp(`^${fbPattern}$`);
|
|
108
|
+
this._routes.push({ ...route, path: route.fallback, _regex: fbRegex, _keys: fbKeys });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return this;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
remove(path) {
|
|
115
|
+
this._routes = this._routes.filter(r => r.path !== path);
|
|
116
|
+
return this;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// --- Navigation ----------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
navigate(path, options = {}) {
|
|
122
|
+
let normalized = this._normalizePath(path);
|
|
123
|
+
if (this._mode === 'hash') {
|
|
124
|
+
window.location.hash = '#' + normalized;
|
|
125
|
+
} else {
|
|
126
|
+
window.history.pushState(options.state || {}, '', this._base + normalized);
|
|
127
|
+
this._resolve();
|
|
128
|
+
}
|
|
129
|
+
return this;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
replace(path, options = {}) {
|
|
133
|
+
let normalized = this._normalizePath(path);
|
|
134
|
+
if (this._mode === 'hash') {
|
|
135
|
+
window.location.replace('#' + normalized);
|
|
136
|
+
} else {
|
|
137
|
+
window.history.replaceState(options.state || {}, '', this._base + normalized);
|
|
138
|
+
this._resolve();
|
|
139
|
+
}
|
|
140
|
+
return this;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Normalize an app-relative path and guard against double base-prefixing.
|
|
145
|
+
* @param {string} path — e.g. '/docs', 'docs', or '/app/docs' when base is '/app'
|
|
146
|
+
* @returns {string} — always starts with '/'
|
|
147
|
+
*/
|
|
148
|
+
_normalizePath(path) {
|
|
149
|
+
let p = path && path.startsWith('/') ? path : (path ? `/${path}` : '/');
|
|
150
|
+
// Strip base prefix if caller accidentally included it
|
|
151
|
+
if (this._base) {
|
|
152
|
+
if (p === this._base) return '/';
|
|
153
|
+
if (p.startsWith(this._base + '/')) p = p.slice(this._base.length) || '/';
|
|
154
|
+
}
|
|
155
|
+
return p;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Resolve an app-relative path to a full URL path (including base).
|
|
160
|
+
* Useful for programmatic link generation.
|
|
161
|
+
* @param {string} path
|
|
162
|
+
* @returns {string}
|
|
163
|
+
*/
|
|
164
|
+
resolve(path) {
|
|
165
|
+
const normalized = path && path.startsWith('/') ? path : (path ? `/${path}` : '/');
|
|
166
|
+
return this._base + normalized;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
back() { window.history.back(); return this; }
|
|
170
|
+
forward() { window.history.forward(); return this; }
|
|
171
|
+
go(n) { window.history.go(n); return this; }
|
|
172
|
+
|
|
173
|
+
// --- Guards --------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
beforeEach(fn) {
|
|
176
|
+
this._guards.before.push(fn);
|
|
177
|
+
return this;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
afterEach(fn) {
|
|
181
|
+
this._guards.after.push(fn);
|
|
182
|
+
return this;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// --- Events --------------------------------------------------------------
|
|
186
|
+
|
|
187
|
+
onChange(fn) {
|
|
188
|
+
this._listeners.add(fn);
|
|
189
|
+
return () => this._listeners.delete(fn);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// --- Current state -------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
get current() { return this._current; }
|
|
195
|
+
|
|
196
|
+
/** The detected or configured base path (read-only) */
|
|
197
|
+
get base() { return this._base; }
|
|
198
|
+
|
|
199
|
+
get path() {
|
|
200
|
+
if (this._mode === 'hash') {
|
|
201
|
+
return window.location.hash.slice(1) || '/';
|
|
202
|
+
}
|
|
203
|
+
let pathname = window.location.pathname || '/';
|
|
204
|
+
// Strip trailing slash for consistency (except root '/')
|
|
205
|
+
if (pathname.length > 1 && pathname.endsWith('/')) {
|
|
206
|
+
pathname = pathname.slice(0, -1);
|
|
207
|
+
}
|
|
208
|
+
if (this._base) {
|
|
209
|
+
// Exact match: /app
|
|
210
|
+
if (pathname === this._base) return '/';
|
|
211
|
+
// Prefix match with boundary: /app/page (but NOT /application)
|
|
212
|
+
if (pathname.startsWith(this._base + '/')) {
|
|
213
|
+
return pathname.slice(this._base.length) || '/';
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
return pathname;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
get query() {
|
|
220
|
+
const search = this._mode === 'hash'
|
|
221
|
+
? (window.location.hash.split('?')[1] || '')
|
|
222
|
+
: window.location.search.slice(1);
|
|
223
|
+
return Object.fromEntries(new URLSearchParams(search));
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// --- Internal resolve ----------------------------------------------------
|
|
227
|
+
|
|
228
|
+
async _resolve() {
|
|
229
|
+
const fullPath = this.path;
|
|
230
|
+
const [pathPart, queryString] = fullPath.split('?');
|
|
231
|
+
const path = pathPart || '/';
|
|
232
|
+
const query = Object.fromEntries(new URLSearchParams(queryString || ''));
|
|
233
|
+
|
|
234
|
+
// Match route
|
|
235
|
+
let matched = null;
|
|
236
|
+
let params = {};
|
|
237
|
+
for (const route of this._routes) {
|
|
238
|
+
const m = path.match(route._regex);
|
|
239
|
+
if (m) {
|
|
240
|
+
matched = route;
|
|
241
|
+
route._keys.forEach((key, i) => { params[key] = m[i + 1]; });
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Fallback
|
|
247
|
+
if (!matched && this._fallback) {
|
|
248
|
+
matched = { component: this._fallback, path: '*', _keys: [], _regex: /.*/ };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
if (!matched) return;
|
|
252
|
+
|
|
253
|
+
const to = { route: matched, params, query, path };
|
|
254
|
+
const from = this._current;
|
|
255
|
+
|
|
256
|
+
// Run before guards
|
|
257
|
+
for (const guard of this._guards.before) {
|
|
258
|
+
const result = await guard(to, from);
|
|
259
|
+
if (result === false) return; // Cancel
|
|
260
|
+
if (typeof result === 'string') { // Redirect
|
|
261
|
+
return this.navigate(result);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Lazy load module if needed
|
|
266
|
+
if (matched.load) {
|
|
267
|
+
try { await matched.load(); }
|
|
268
|
+
catch (err) {
|
|
269
|
+
console.error(`zQuery Router: Failed to load module for "${matched.path}"`, err);
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
this._current = to;
|
|
275
|
+
|
|
276
|
+
// Mount component into outlet
|
|
277
|
+
if (this._el && matched.component) {
|
|
278
|
+
// Destroy previous
|
|
279
|
+
if (this._instance) {
|
|
280
|
+
this._instance.destroy();
|
|
281
|
+
this._instance = null;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Create container
|
|
285
|
+
this._el.innerHTML = '';
|
|
286
|
+
|
|
287
|
+
// Pass route params and query as props
|
|
288
|
+
const props = { ...params, $route: to, $query: query, $params: params };
|
|
289
|
+
|
|
290
|
+
// If component is a string (registered name), mount it
|
|
291
|
+
if (typeof matched.component === 'string') {
|
|
292
|
+
const container = document.createElement(matched.component);
|
|
293
|
+
this._el.appendChild(container);
|
|
294
|
+
this._instance = mount(container, matched.component, props);
|
|
295
|
+
}
|
|
296
|
+
// If component is a render function
|
|
297
|
+
else if (typeof matched.component === 'function') {
|
|
298
|
+
this._el.innerHTML = matched.component(to);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// Run after guards
|
|
303
|
+
for (const guard of this._guards.after) {
|
|
304
|
+
await guard(to, from);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Notify listeners
|
|
308
|
+
this._listeners.forEach(fn => fn(to, from));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// --- Destroy -------------------------------------------------------------
|
|
312
|
+
|
|
313
|
+
destroy() {
|
|
314
|
+
if (this._instance) this._instance.destroy();
|
|
315
|
+
this._listeners.clear();
|
|
316
|
+
this._routes = [];
|
|
317
|
+
this._guards = { before: [], after: [] };
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// Factory
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
let _activeRouter = null;
|
|
326
|
+
|
|
327
|
+
export function createRouter(config) {
|
|
328
|
+
_activeRouter = new Router(config);
|
|
329
|
+
return _activeRouter;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function getRouter() {
|
|
333
|
+
return _activeRouter;
|
|
334
|
+
}
|
package/src/store.js
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* zQuery Store — Global reactive state management
|
|
3
|
+
*
|
|
4
|
+
* A lightweight Redux/Vuex-inspired store with:
|
|
5
|
+
* - Reactive state via Proxy
|
|
6
|
+
* - Named actions for mutations
|
|
7
|
+
* - Key-specific subscriptions
|
|
8
|
+
* - Computed getters
|
|
9
|
+
* - Middleware support
|
|
10
|
+
* - DevTools-friendly (logs actions in dev mode)
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* const store = $.store({
|
|
14
|
+
* state: { count: 0, user: null },
|
|
15
|
+
* actions: {
|
|
16
|
+
* increment(state) { state.count++; },
|
|
17
|
+
* setUser(state, user) { state.user = user; }
|
|
18
|
+
* },
|
|
19
|
+
* getters: {
|
|
20
|
+
* doubleCount: (state) => state.count * 2
|
|
21
|
+
* }
|
|
22
|
+
* });
|
|
23
|
+
*
|
|
24
|
+
* store.dispatch('increment');
|
|
25
|
+
* store.subscribe('count', (val, old) => console.log(val));
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { reactive } from './reactive.js';
|
|
29
|
+
|
|
30
|
+
class Store {
|
|
31
|
+
constructor(config = {}) {
|
|
32
|
+
this._subscribers = new Map(); // key → Set<fn>
|
|
33
|
+
this._wildcards = new Set(); // subscribe to all changes
|
|
34
|
+
this._actions = config.actions || {};
|
|
35
|
+
this._getters = config.getters || {};
|
|
36
|
+
this._middleware = [];
|
|
37
|
+
this._history = []; // action log
|
|
38
|
+
this._debug = config.debug || false;
|
|
39
|
+
|
|
40
|
+
// Create reactive state
|
|
41
|
+
const initial = typeof config.state === 'function' ? config.state() : { ...(config.state || {}) };
|
|
42
|
+
|
|
43
|
+
this.state = reactive(initial, (key, value, old) => {
|
|
44
|
+
// Notify key-specific subscribers
|
|
45
|
+
const subs = this._subscribers.get(key);
|
|
46
|
+
if (subs) subs.forEach(fn => fn(value, old, key));
|
|
47
|
+
// Notify wildcard subscribers
|
|
48
|
+
this._wildcards.forEach(fn => fn(key, value, old));
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
// Build getters as computed properties
|
|
52
|
+
this.getters = {};
|
|
53
|
+
for (const [name, fn] of Object.entries(this._getters)) {
|
|
54
|
+
Object.defineProperty(this.getters, name, {
|
|
55
|
+
get: () => fn(this.state.__raw || this.state),
|
|
56
|
+
enumerable: true
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Dispatch a named action
|
|
63
|
+
* @param {string} name — action name
|
|
64
|
+
* @param {...any} args — payload
|
|
65
|
+
*/
|
|
66
|
+
dispatch(name, ...args) {
|
|
67
|
+
const action = this._actions[name];
|
|
68
|
+
if (!action) {
|
|
69
|
+
console.warn(`zQuery Store: Unknown action "${name}"`);
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Run middleware
|
|
74
|
+
for (const mw of this._middleware) {
|
|
75
|
+
const result = mw(name, args, this.state);
|
|
76
|
+
if (result === false) return; // blocked by middleware
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
if (this._debug) {
|
|
80
|
+
console.log(`%c[Store] ${name}`, 'color: #4CAF50; font-weight: bold;', ...args);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const result = action(this.state, ...args);
|
|
84
|
+
this._history.push({ action: name, args, timestamp: Date.now() });
|
|
85
|
+
return result;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Subscribe to changes on a specific state key
|
|
90
|
+
* @param {string|Function} keyOrFn — state key, or function for all changes
|
|
91
|
+
* @param {Function} [fn] — callback (value, oldValue, key)
|
|
92
|
+
* @returns {Function} — unsubscribe
|
|
93
|
+
*/
|
|
94
|
+
subscribe(keyOrFn, fn) {
|
|
95
|
+
if (typeof keyOrFn === 'function') {
|
|
96
|
+
// Wildcard — listen to all changes
|
|
97
|
+
this._wildcards.add(keyOrFn);
|
|
98
|
+
return () => this._wildcards.delete(keyOrFn);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!this._subscribers.has(keyOrFn)) {
|
|
102
|
+
this._subscribers.set(keyOrFn, new Set());
|
|
103
|
+
}
|
|
104
|
+
this._subscribers.get(keyOrFn).add(fn);
|
|
105
|
+
return () => this._subscribers.get(keyOrFn)?.delete(fn);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Get current state snapshot (plain object)
|
|
110
|
+
*/
|
|
111
|
+
snapshot() {
|
|
112
|
+
return JSON.parse(JSON.stringify(this.state.__raw || this.state));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Replace entire state
|
|
117
|
+
*/
|
|
118
|
+
replaceState(newState) {
|
|
119
|
+
const raw = this.state.__raw || this.state;
|
|
120
|
+
for (const key of Object.keys(raw)) {
|
|
121
|
+
delete this.state[key];
|
|
122
|
+
}
|
|
123
|
+
Object.assign(this.state, newState);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Add middleware: fn(actionName, args, state) → false to block
|
|
128
|
+
*/
|
|
129
|
+
use(fn) {
|
|
130
|
+
this._middleware.push(fn);
|
|
131
|
+
return this;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Get action history
|
|
136
|
+
*/
|
|
137
|
+
get history() {
|
|
138
|
+
return [...this._history];
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Reset state to initial values
|
|
143
|
+
*/
|
|
144
|
+
reset(initialState) {
|
|
145
|
+
this.replaceState(initialState);
|
|
146
|
+
this._history = [];
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
// ---------------------------------------------------------------------------
|
|
152
|
+
// Factory
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
let _stores = new Map();
|
|
155
|
+
|
|
156
|
+
export function createStore(name, config) {
|
|
157
|
+
// If called with just config (no name), use 'default'
|
|
158
|
+
if (typeof name === 'object') {
|
|
159
|
+
config = name;
|
|
160
|
+
name = 'default';
|
|
161
|
+
}
|
|
162
|
+
const store = new Store(config);
|
|
163
|
+
_stores.set(name, store);
|
|
164
|
+
return store;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function getStore(name = 'default') {
|
|
168
|
+
return _stores.get(name) || null;
|
|
169
|
+
}
|