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.
@@ -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
+ }