zero-query 0.9.9 → 1.0.1
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/README.md +34 -33
- package/cli/args.js +1 -1
- package/cli/commands/build.js +2 -2
- package/cli/commands/bundle.js +21 -18
- package/cli/commands/create.js +9 -2
- package/cli/commands/dev/devtools/index.js +1 -1
- package/cli/commands/dev/devtools/js/core.js +14 -14
- package/cli/commands/dev/devtools/js/elements.js +4 -4
- package/cli/commands/dev/devtools/js/stats.js +1 -1
- package/cli/commands/dev/devtools/styles.css +2 -2
- package/cli/commands/dev/index.js +2 -2
- package/cli/commands/dev/logger.js +1 -1
- package/cli/commands/dev/overlay.js +21 -14
- package/cli/commands/dev/server.js +5 -5
- package/cli/commands/dev/validator.js +7 -7
- package/cli/commands/dev/watcher.js +6 -6
- package/cli/help.js +3 -3
- package/cli/index.js +1 -1
- package/cli/scaffold/default/app/app.js +17 -18
- package/cli/scaffold/default/app/components/about.js +9 -9
- package/cli/scaffold/default/app/components/api-demo.js +6 -6
- package/cli/scaffold/default/app/components/contact-card.js +4 -4
- package/cli/scaffold/default/app/components/contacts/contacts.css +2 -2
- package/cli/scaffold/default/app/components/contacts/contacts.html +3 -3
- package/cli/scaffold/default/app/components/contacts/contacts.js +11 -11
- package/cli/scaffold/default/app/components/counter.js +8 -8
- package/cli/scaffold/default/app/components/home.js +13 -13
- package/cli/scaffold/default/app/components/not-found.js +1 -1
- package/cli/scaffold/default/app/components/playground/playground.css +1 -1
- package/cli/scaffold/default/app/components/playground/playground.html +11 -11
- package/cli/scaffold/default/app/components/playground/playground.js +11 -11
- package/cli/scaffold/default/app/components/todos.js +8 -8
- package/cli/scaffold/default/app/components/toolkit/toolkit.css +1 -1
- package/cli/scaffold/default/app/components/toolkit/toolkit.html +4 -4
- package/cli/scaffold/default/app/components/toolkit/toolkit.js +7 -7
- package/cli/scaffold/default/app/routes.js +1 -1
- package/cli/scaffold/default/app/store.js +1 -1
- package/cli/scaffold/default/global.css +2 -2
- package/cli/scaffold/default/index.html +2 -2
- package/cli/scaffold/minimal/app/app.js +6 -7
- package/cli/scaffold/minimal/app/components/about.js +5 -5
- package/cli/scaffold/minimal/app/components/counter.js +6 -6
- package/cli/scaffold/minimal/app/components/home.js +8 -8
- package/cli/scaffold/minimal/app/components/not-found.js +1 -1
- package/cli/scaffold/minimal/app/routes.js +1 -1
- package/cli/scaffold/minimal/app/store.js +1 -1
- package/cli/scaffold/minimal/global.css +2 -2
- package/cli/scaffold/minimal/index.html +1 -1
- package/cli/scaffold/ssr/app/app.js +1 -2
- package/cli/scaffold/ssr/app/components/about.js +5 -5
- package/cli/scaffold/ssr/app/components/home.js +2 -2
- package/cli/scaffold/ssr/app/components/not-found.js +2 -2
- package/cli/scaffold/ssr/app/routes.js +1 -1
- package/cli/scaffold/ssr/global.css +3 -4
- package/cli/scaffold/ssr/index.html +2 -2
- package/cli/scaffold/ssr/server/index.js +26 -25
- package/cli/utils.js +6 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +508 -227
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +16 -13
- package/index.js +7 -5
- package/package.json +3 -3
- package/src/component.js +64 -63
- package/src/core.js +15 -15
- package/src/diff.js +38 -38
- package/src/errors.js +17 -17
- package/src/expression.js +15 -17
- package/src/http.js +4 -4
- package/src/reactive.js +75 -9
- package/src/router.js +104 -24
- package/src/ssr.js +28 -28
- package/src/store.js +103 -21
- package/src/utils.js +64 -12
- package/tests/audit.test.js +143 -15
- package/tests/cli.test.js +20 -20
- package/tests/component.test.js +121 -121
- package/tests/core.test.js +56 -56
- package/tests/diff.test.js +42 -42
- package/tests/errors.test.js +5 -5
- package/tests/expression.test.js +58 -53
- package/tests/http.test.js +20 -20
- package/tests/reactive.test.js +185 -24
- package/tests/router.test.js +501 -74
- package/tests/ssr.test.js +15 -13
- package/tests/store.test.js +264 -23
- package/tests/test-minifier.js +153 -0
- package/tests/test-ssr.js +27 -0
- package/tests/utils.test.js +163 -26
- package/types/collection.d.ts +2 -2
- package/types/component.d.ts +5 -5
- package/types/errors.d.ts +3 -3
- package/types/http.d.ts +3 -3
- package/types/misc.d.ts +9 -9
- package/types/reactive.d.ts +25 -3
- package/types/router.d.ts +10 -6
- package/types/ssr.d.ts +2 -2
- package/types/store.d.ts +40 -5
- package/types/utils.d.ts +1 -1
package/src/reactive.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* zQuery Reactive
|
|
2
|
+
* zQuery Reactive - Proxy-based deep reactivity system
|
|
3
3
|
*
|
|
4
4
|
* Creates observable objects that trigger callbacks on mutation.
|
|
5
5
|
* Used internally by components and store for auto-updates.
|
|
@@ -67,7 +67,7 @@ export function reactive(target, onChange, _path = '') {
|
|
|
67
67
|
|
|
68
68
|
|
|
69
69
|
// ---------------------------------------------------------------------------
|
|
70
|
-
// Signal
|
|
70
|
+
// Signal - lightweight reactive primitive (inspired by Solid/Preact signals)
|
|
71
71
|
// ---------------------------------------------------------------------------
|
|
72
72
|
export class Signal {
|
|
73
73
|
constructor(value) {
|
|
@@ -96,7 +96,11 @@ export class Signal {
|
|
|
96
96
|
peek() { return this._value; }
|
|
97
97
|
|
|
98
98
|
_notify() {
|
|
99
|
-
|
|
99
|
+
if (Signal._batching) {
|
|
100
|
+
Signal._batchQueue.add(this);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
// Snapshot subscribers before iterating - a subscriber might modify
|
|
100
104
|
// the set (e.g., an effect re-running, adding itself back)
|
|
101
105
|
const subs = [...this._subscribers];
|
|
102
106
|
for (let i = 0; i < subs.length; i++) {
|
|
@@ -117,10 +121,13 @@ export class Signal {
|
|
|
117
121
|
|
|
118
122
|
// Active effect tracking
|
|
119
123
|
Signal._activeEffect = null;
|
|
124
|
+
// Batch state
|
|
125
|
+
Signal._batching = false;
|
|
126
|
+
Signal._batchQueue = new Set();
|
|
120
127
|
|
|
121
128
|
/**
|
|
122
129
|
* Create a signal
|
|
123
|
-
* @param {*} initial
|
|
130
|
+
* @param {*} initial - initial value
|
|
124
131
|
* @returns {Signal}
|
|
125
132
|
*/
|
|
126
133
|
export function signal(initial) {
|
|
@@ -129,7 +136,7 @@ export function signal(initial) {
|
|
|
129
136
|
|
|
130
137
|
/**
|
|
131
138
|
* Create a computed signal (derived from other signals)
|
|
132
|
-
* @param {Function} fn
|
|
139
|
+
* @param {Function} fn - computation function
|
|
133
140
|
* @returns {Signal}
|
|
134
141
|
*/
|
|
135
142
|
export function computed(fn) {
|
|
@@ -147,10 +154,10 @@ export function computed(fn) {
|
|
|
147
154
|
/**
|
|
148
155
|
* Create a side-effect that auto-tracks signal dependencies.
|
|
149
156
|
* Returns a dispose function that removes the effect from all
|
|
150
|
-
* signals it subscribed to
|
|
157
|
+
* signals it subscribed to - prevents memory leaks.
|
|
151
158
|
*
|
|
152
|
-
* @param {Function} fn
|
|
153
|
-
* @returns {Function}
|
|
159
|
+
* @param {Function} fn - effect function
|
|
160
|
+
* @returns {Function} - dispose function
|
|
154
161
|
*/
|
|
155
162
|
export function effect(fn) {
|
|
156
163
|
const execute = () => {
|
|
@@ -183,6 +190,65 @@ export function effect(fn) {
|
|
|
183
190
|
}
|
|
184
191
|
execute._deps.clear();
|
|
185
192
|
}
|
|
186
|
-
// Don't clobber _activeEffect
|
|
193
|
+
// Don't clobber _activeEffect - another effect may be running
|
|
187
194
|
};
|
|
188
195
|
}
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
// ---------------------------------------------------------------------------
|
|
199
|
+
// batch() - defer signal notifications until the batch completes
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Batch multiple signal writes - subscribers and effects fire once at the end.
|
|
204
|
+
* @param {Function} fn - function that performs signal writes
|
|
205
|
+
*/
|
|
206
|
+
export function batch(fn) {
|
|
207
|
+
if (Signal._batching) {
|
|
208
|
+
// Already inside a batch, just run
|
|
209
|
+
fn();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
Signal._batching = true;
|
|
213
|
+
Signal._batchQueue.clear();
|
|
214
|
+
try {
|
|
215
|
+
fn();
|
|
216
|
+
} finally {
|
|
217
|
+
Signal._batching = false;
|
|
218
|
+
// Collect all unique subscribers across all queued signals
|
|
219
|
+
// so each subscriber/effect runs exactly once
|
|
220
|
+
const subs = new Set();
|
|
221
|
+
for (const sig of Signal._batchQueue) {
|
|
222
|
+
for (const sub of sig._subscribers) {
|
|
223
|
+
subs.add(sub);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
Signal._batchQueue.clear();
|
|
227
|
+
for (const sub of subs) {
|
|
228
|
+
try { sub(); }
|
|
229
|
+
catch (err) {
|
|
230
|
+
reportError(ErrorCode.SIGNAL_CALLBACK, 'Signal subscriber threw', {}, err);
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
// untracked() - read signals without creating dependencies
|
|
239
|
+
// ---------------------------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Execute a function without tracking signal reads as dependencies.
|
|
243
|
+
* @param {Function} fn - function to run
|
|
244
|
+
* @returns {*} the return value of fn
|
|
245
|
+
*/
|
|
246
|
+
export function untracked(fn) {
|
|
247
|
+
const prev = Signal._activeEffect;
|
|
248
|
+
Signal._activeEffect = null;
|
|
249
|
+
try {
|
|
250
|
+
return fn();
|
|
251
|
+
} finally {
|
|
252
|
+
Signal._activeEffect = prev;
|
|
253
|
+
}
|
|
254
|
+
}
|
package/src/router.js
CHANGED
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* zQuery Router
|
|
2
|
+
* zQuery Router - Client-side SPA router
|
|
3
3
|
*
|
|
4
4
|
* Supports hash mode (#/path) and history mode (/path).
|
|
5
5
|
* Route params, query strings, navigation guards, and lazy loading.
|
|
6
6
|
* Sub-route history substates for in-page UI changes (modals, tabs, etc.).
|
|
7
7
|
*
|
|
8
8
|
* Usage:
|
|
9
|
+
* // HTML: <z-outlet></z-outlet>
|
|
9
10
|
* $.router({
|
|
10
|
-
* el: '#app',
|
|
11
|
-
* mode: 'hash',
|
|
12
11
|
* routes: [
|
|
13
12
|
* { path: '/', component: 'home-page' },
|
|
14
13
|
* { path: '/user/:id', component: 'user-profile' },
|
|
@@ -44,7 +43,7 @@ function _shallowEqual(a, b) {
|
|
|
44
43
|
class Router {
|
|
45
44
|
constructor(config = {}) {
|
|
46
45
|
this._el = null;
|
|
47
|
-
// file:// protocol can't use pushState
|
|
46
|
+
// file:// protocol can't use pushState - always force hash mode
|
|
48
47
|
const isFile = typeof location !== 'undefined' && location.protocol === 'file:';
|
|
49
48
|
this._mode = isFile ? 'hash' : (config.mode || 'history');
|
|
50
49
|
|
|
@@ -79,8 +78,30 @@ class Router {
|
|
|
79
78
|
this._inSubstate = false; // true while substate entries are in the history stack
|
|
80
79
|
|
|
81
80
|
// Set outlet element
|
|
81
|
+
// Priority: explicit config.el → <z-outlet> tag in the DOM
|
|
82
82
|
if (config.el) {
|
|
83
83
|
this._el = typeof config.el === 'string' ? document.querySelector(config.el) : config.el;
|
|
84
|
+
} else if (typeof document !== 'undefined') {
|
|
85
|
+
const outlet = document.querySelector('z-outlet');
|
|
86
|
+
if (outlet) {
|
|
87
|
+
this._el = outlet;
|
|
88
|
+
// Read inline attribute overrides from <z-outlet> (config takes priority)
|
|
89
|
+
if (!config.fallback && outlet.getAttribute('fallback')) {
|
|
90
|
+
this._fallback = outlet.getAttribute('fallback');
|
|
91
|
+
}
|
|
92
|
+
if (!config.mode && outlet.getAttribute('mode')) {
|
|
93
|
+
const attrMode = outlet.getAttribute('mode');
|
|
94
|
+
if (attrMode === 'hash' || attrMode === 'history') {
|
|
95
|
+
this._mode = isFile ? 'hash' : attrMode;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
if (config.base == null && outlet.getAttribute('base')) {
|
|
99
|
+
let ob = outlet.getAttribute('base');
|
|
100
|
+
ob = String(ob).replace(/\/+$/, '');
|
|
101
|
+
if (ob && !ob.startsWith('/')) ob = '/' + ob;
|
|
102
|
+
this._base = ob;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
84
105
|
}
|
|
85
106
|
|
|
86
107
|
// Register routes
|
|
@@ -88,21 +109,43 @@ class Router {
|
|
|
88
109
|
config.routes.forEach(r => this.add(r));
|
|
89
110
|
}
|
|
90
111
|
|
|
91
|
-
// Listen for navigation
|
|
112
|
+
// Listen for navigation - store handler references for cleanup in destroy()
|
|
92
113
|
if (this._mode === 'hash') {
|
|
93
114
|
this._onNavEvent = () => this._resolve();
|
|
94
115
|
window.addEventListener('hashchange', this._onNavEvent);
|
|
116
|
+
// Hash mode also needs popstate for substates (pushSubstate uses pushState)
|
|
117
|
+
this._onPopState = (e) => {
|
|
118
|
+
const st = e.state;
|
|
119
|
+
if (st && st[_ZQ_STATE_KEY] === 'substate') {
|
|
120
|
+
const handled = this._fireSubstate(st.key, st.data, 'pop');
|
|
121
|
+
if (handled) return;
|
|
122
|
+
this._resolve().then(() => {
|
|
123
|
+
this._fireSubstate(st.key, st.data, 'pop');
|
|
124
|
+
});
|
|
125
|
+
return;
|
|
126
|
+
} else if (this._inSubstate) {
|
|
127
|
+
this._inSubstate = false;
|
|
128
|
+
this._fireSubstate(null, null, 'reset');
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
window.addEventListener('popstate', this._onPopState);
|
|
95
132
|
} else {
|
|
96
133
|
this._onNavEvent = (e) => {
|
|
97
|
-
// Check for substate pop first
|
|
134
|
+
// Check for substate pop first - if a listener handles it, don't route
|
|
98
135
|
const st = e.state;
|
|
99
136
|
if (st && st[_ZQ_STATE_KEY] === 'substate') {
|
|
100
137
|
const handled = this._fireSubstate(st.key, st.data, 'pop');
|
|
101
138
|
if (handled) return;
|
|
102
|
-
// Unhandled substate —
|
|
103
|
-
//
|
|
139
|
+
// Unhandled substate — the owning component was likely destroyed
|
|
140
|
+
// (e.g. user navigated away then pressed back). Resolve the route
|
|
141
|
+
// first (which may mount a fresh component that registers a listener),
|
|
142
|
+
// then retry the substate so the new listener can restore the UI.
|
|
143
|
+
this._resolve().then(() => {
|
|
144
|
+
this._fireSubstate(st.key, st.data, 'pop');
|
|
145
|
+
});
|
|
146
|
+
return;
|
|
104
147
|
} else if (this._inSubstate) {
|
|
105
|
-
// Popped past all substates
|
|
148
|
+
// Popped past all substates - notify listeners to reset to defaults
|
|
106
149
|
this._inSubstate = false;
|
|
107
150
|
this._fireSubstate(null, null, 'reset');
|
|
108
151
|
}
|
|
@@ -120,13 +163,17 @@ class Router {
|
|
|
120
163
|
if (link.getAttribute('target') === '_blank') return;
|
|
121
164
|
e.preventDefault();
|
|
122
165
|
let href = link.getAttribute('z-link');
|
|
166
|
+
// Reject absolute URLs and dangerous protocols — z-link is for internal routes only
|
|
167
|
+
if (href && /^[a-z][a-z0-9+.-]*:/i.test(href)) return;
|
|
123
168
|
// Support z-link-params for dynamic :param interpolation
|
|
124
169
|
const paramsAttr = link.getAttribute('z-link-params');
|
|
125
170
|
if (paramsAttr) {
|
|
126
171
|
try {
|
|
127
172
|
const params = JSON.parse(paramsAttr);
|
|
128
173
|
href = this._interpolateParams(href, params);
|
|
129
|
-
} catch {
|
|
174
|
+
} catch (err) {
|
|
175
|
+
reportError(ErrorCode.ROUTER_RESOLVE, 'Malformed JSON in z-link-params', { href, paramsAttr }, err);
|
|
176
|
+
}
|
|
130
177
|
}
|
|
131
178
|
this.navigate(href);
|
|
132
179
|
// z-to-top modifier: scroll to top after navigation
|
|
@@ -180,8 +227,8 @@ class Router {
|
|
|
180
227
|
|
|
181
228
|
/**
|
|
182
229
|
* Interpolate :param placeholders in a path with the given values.
|
|
183
|
-
* @param {string} path
|
|
184
|
-
* @param {Object} params
|
|
230
|
+
* @param {string} path - e.g. '/user/:id/posts/:pid'
|
|
231
|
+
* @param {Object} params - e.g. { id: 42, pid: 7 }
|
|
185
232
|
* @returns {string}
|
|
186
233
|
*/
|
|
187
234
|
_interpolateParams(path, params) {
|
|
@@ -225,7 +272,7 @@ class Router {
|
|
|
225
272
|
const currentURL = (window.location.pathname || '/') + (window.location.hash || '');
|
|
226
273
|
|
|
227
274
|
if (targetURL === currentURL && !options.force) {
|
|
228
|
-
// Same full URL (path + hash)
|
|
275
|
+
// Same full URL (path + hash) - don't push duplicate entry.
|
|
229
276
|
// If only the hash changed to a fragment target, scroll to it.
|
|
230
277
|
if (fragment) {
|
|
231
278
|
const el = document.getElementById(fragment);
|
|
@@ -234,7 +281,7 @@ class Router {
|
|
|
234
281
|
return this;
|
|
235
282
|
}
|
|
236
283
|
|
|
237
|
-
// Same route path but different hash fragment
|
|
284
|
+
// Same route path but different hash fragment - use replaceState
|
|
238
285
|
// so back goes to the previous *route*, not the previous scroll position.
|
|
239
286
|
const targetPathOnly = this._base + normalized;
|
|
240
287
|
const currentPathOnly = window.location.pathname || '/';
|
|
@@ -249,7 +296,7 @@ class Router {
|
|
|
249
296
|
const el = document.getElementById(fragment);
|
|
250
297
|
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
|
251
298
|
}
|
|
252
|
-
// Don't re-resolve
|
|
299
|
+
// Don't re-resolve - same route, just a hash change
|
|
253
300
|
return this;
|
|
254
301
|
}
|
|
255
302
|
|
|
@@ -285,8 +332,8 @@ class Router {
|
|
|
285
332
|
|
|
286
333
|
/**
|
|
287
334
|
* Normalize an app-relative path and guard against double base-prefixing.
|
|
288
|
-
* @param {string} path
|
|
289
|
-
* @returns {string}
|
|
335
|
+
* @param {string} path - e.g. '/docs', 'docs', or '/app/docs' when base is '/app'
|
|
336
|
+
* @returns {string} - always starts with '/'
|
|
290
337
|
*/
|
|
291
338
|
_normalizePath(path) {
|
|
292
339
|
let p = path && path.startsWith('/') ? path : (path ? `/${path}` : '/');
|
|
@@ -336,12 +383,12 @@ class Router {
|
|
|
336
383
|
|
|
337
384
|
/**
|
|
338
385
|
* Push a lightweight history entry for in-component UI state.
|
|
339
|
-
* The URL path does NOT change
|
|
386
|
+
* The URL path does NOT change - only a history entry is added so the
|
|
340
387
|
* back button can undo the UI change (close modal, revert tab, etc.)
|
|
341
388
|
* before navigating away.
|
|
342
389
|
*
|
|
343
|
-
* @param {string} key
|
|
344
|
-
* @param {*} data
|
|
390
|
+
* @param {string} key - identifier (e.g. 'modal', 'tab', 'panel')
|
|
391
|
+
* @param {*} data - arbitrary state (serializable)
|
|
345
392
|
* @returns {Router}
|
|
346
393
|
*
|
|
347
394
|
* @example
|
|
@@ -352,7 +399,7 @@ class Router {
|
|
|
352
399
|
pushSubstate(key, data) {
|
|
353
400
|
this._inSubstate = true;
|
|
354
401
|
if (this._mode === 'hash') {
|
|
355
|
-
// Hash mode: stash the substate in a global
|
|
402
|
+
// Hash mode: stash the substate in a global - hashchange will check.
|
|
356
403
|
// We still push a history entry via a sentinel hash suffix.
|
|
357
404
|
const current = window.location.hash || '#/';
|
|
358
405
|
window.history.pushState(
|
|
@@ -468,12 +515,12 @@ class Router {
|
|
|
468
515
|
async __resolve() {
|
|
469
516
|
// Check if we're landing on a substate entry (e.g. page refresh on a
|
|
470
517
|
// substate bookmark, or hash-mode popstate). Fire listeners and bail
|
|
471
|
-
// if handled
|
|
518
|
+
// if handled - the URL hasn't changed so there's no route to resolve.
|
|
472
519
|
const histState = window.history.state;
|
|
473
520
|
if (histState && histState[_ZQ_STATE_KEY] === 'substate') {
|
|
474
521
|
const handled = this._fireSubstate(histState.key, histState.data, 'resolve');
|
|
475
522
|
if (handled) return;
|
|
476
|
-
// No listener handled it
|
|
523
|
+
// No listener handled it - fall through to normal routing
|
|
477
524
|
}
|
|
478
525
|
|
|
479
526
|
const fullPath = this.path;
|
|
@@ -510,7 +557,7 @@ class Router {
|
|
|
510
557
|
const sameParams = _shallowEqual(params, from.params);
|
|
511
558
|
const sameQuery = _shallowEqual(query, from.query);
|
|
512
559
|
if (sameParams && sameQuery) {
|
|
513
|
-
// Identical navigation
|
|
560
|
+
// Identical navigation - nothing to do
|
|
514
561
|
return;
|
|
515
562
|
}
|
|
516
563
|
}
|
|
@@ -598,6 +645,9 @@ class Router {
|
|
|
598
645
|
}
|
|
599
646
|
}
|
|
600
647
|
|
|
648
|
+
// Update z-active-route elements
|
|
649
|
+
this._updateActiveRoutes(path);
|
|
650
|
+
|
|
601
651
|
// Run after guards
|
|
602
652
|
for (const guard of this._guards.after) {
|
|
603
653
|
await guard(to, from);
|
|
@@ -607,6 +657,32 @@ class Router {
|
|
|
607
657
|
this._listeners.forEach(fn => fn(to, from));
|
|
608
658
|
}
|
|
609
659
|
|
|
660
|
+
// --- Active route class management ----------------------------------------
|
|
661
|
+
|
|
662
|
+
/**
|
|
663
|
+
* Update all elements with z-active-route to toggle their active class
|
|
664
|
+
* based on the current path.
|
|
665
|
+
*
|
|
666
|
+
* Usage:
|
|
667
|
+
* <a z-link="/docs" z-active-route="/docs">Docs</a>
|
|
668
|
+
* <a z-link="/about" z-active-route="/about" z-active-class="selected">About</a>
|
|
669
|
+
* <a z-link="/" z-active-route="/" z-active-exact>Home</a>
|
|
670
|
+
*/
|
|
671
|
+
_updateActiveRoutes(currentPath) {
|
|
672
|
+
if (typeof document === 'undefined') return;
|
|
673
|
+
const els = document.querySelectorAll('[z-active-route]');
|
|
674
|
+
for (let i = 0; i < els.length; i++) {
|
|
675
|
+
const el = els[i];
|
|
676
|
+
const route = el.getAttribute('z-active-route');
|
|
677
|
+
const cls = el.getAttribute('z-active-class') || 'active';
|
|
678
|
+
const exact = el.hasAttribute('z-active-exact');
|
|
679
|
+
const isActive = exact
|
|
680
|
+
? currentPath === route
|
|
681
|
+
: (route === '/' ? currentPath === '/' : currentPath.startsWith(route));
|
|
682
|
+
el.classList.toggle(cls, isActive);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
610
686
|
// --- Destroy -------------------------------------------------------------
|
|
611
687
|
|
|
612
688
|
destroy() {
|
|
@@ -615,6 +691,10 @@ class Router {
|
|
|
615
691
|
window.removeEventListener(this._mode === 'hash' ? 'hashchange' : 'popstate', this._onNavEvent);
|
|
616
692
|
this._onNavEvent = null;
|
|
617
693
|
}
|
|
694
|
+
if (this._onPopState) {
|
|
695
|
+
window.removeEventListener('popstate', this._onPopState);
|
|
696
|
+
this._onPopState = null;
|
|
697
|
+
}
|
|
618
698
|
if (this._onLinkClick) {
|
|
619
699
|
document.removeEventListener('click', this._onLinkClick);
|
|
620
700
|
this._onLinkClick = null;
|
package/src/ssr.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* zQuery SSR
|
|
2
|
+
* zQuery SSR - Server-side rendering to HTML string
|
|
3
3
|
*
|
|
4
4
|
* Renders registered components to static HTML strings for SEO,
|
|
5
5
|
* initial page load performance, and static site generation.
|
|
6
6
|
*
|
|
7
|
-
* Works in Node.js
|
|
7
|
+
* Works in Node.js - no DOM required for basic rendering.
|
|
8
8
|
* Supports hydration markers for client-side takeover.
|
|
9
9
|
*
|
|
10
10
|
* Usage (Node.js):
|
|
@@ -67,7 +67,7 @@ class SSRComponent {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Init lifecycle
|
|
70
|
+
// Init lifecycle - guarded so a broken init doesn't crash the whole render
|
|
71
71
|
if (definition.init) {
|
|
72
72
|
try {
|
|
73
73
|
definition.init.call(this);
|
|
@@ -85,7 +85,7 @@ class SSRComponent {
|
|
|
85
85
|
return html;
|
|
86
86
|
} catch (err) {
|
|
87
87
|
reportError(ErrorCode.SSR_RENDER, 'Component render() threw during SSR', {}, err);
|
|
88
|
-
return `<!-- SSR render error
|
|
88
|
+
return `<!-- SSR render error -->`;
|
|
89
89
|
}
|
|
90
90
|
}
|
|
91
91
|
return '';
|
|
@@ -123,7 +123,7 @@ function _escapeHtml(str) {
|
|
|
123
123
|
}
|
|
124
124
|
|
|
125
125
|
// ---------------------------------------------------------------------------
|
|
126
|
-
// SSR App
|
|
126
|
+
// SSR App - component registry + renderer
|
|
127
127
|
// ---------------------------------------------------------------------------
|
|
128
128
|
class SSRApp {
|
|
129
129
|
constructor() {
|
|
@@ -158,12 +158,12 @@ class SSRApp {
|
|
|
158
158
|
/**
|
|
159
159
|
* Render a component to an HTML string.
|
|
160
160
|
*
|
|
161
|
-
* @param {string} componentName
|
|
162
|
-
* @param {object} [props]
|
|
163
|
-
* @param {object} [options]
|
|
164
|
-
* @param {boolean} [options.hydrate=true]
|
|
165
|
-
* @param {string} [options.mode='html']
|
|
166
|
-
* @returns {Promise<string>}
|
|
161
|
+
* @param {string} componentName - registered component name
|
|
162
|
+
* @param {object} [props] - props to pass
|
|
163
|
+
* @param {object} [options] - rendering options
|
|
164
|
+
* @param {boolean} [options.hydrate=true] - add hydration marker
|
|
165
|
+
* @param {string} [options.mode='html'] - 'html' (default) or 'fragment' (no wrapper tag)
|
|
166
|
+
* @returns {Promise<string>} - rendered HTML
|
|
167
167
|
*/
|
|
168
168
|
async renderToString(componentName, props = {}, options = {}) {
|
|
169
169
|
const def = this._registry.get(componentName);
|
|
@@ -185,7 +185,7 @@ class SSRApp {
|
|
|
185
185
|
html = html.replace(/\s*@[\w.]+="[^"]*"/g, ''); // Remove event bindings
|
|
186
186
|
html = html.replace(/\s*z-on:[\w.]+="[^"]*"/g, '');
|
|
187
187
|
|
|
188
|
-
// Fragment mode
|
|
188
|
+
// Fragment mode - return inner HTML without wrapper tag
|
|
189
189
|
if (options.mode === 'fragment') return html;
|
|
190
190
|
|
|
191
191
|
const hydrate = options.hydrate !== false;
|
|
@@ -198,7 +198,7 @@ class SSRApp {
|
|
|
198
198
|
* Render multiple components as a batch.
|
|
199
199
|
*
|
|
200
200
|
* @param {Array<{ name: string, props?: object, options?: object }>} entries
|
|
201
|
-
* @returns {Promise<string[]>}
|
|
201
|
+
* @returns {Promise<string[]>} - array of rendered HTML strings
|
|
202
202
|
*/
|
|
203
203
|
async renderBatch(entries) {
|
|
204
204
|
return Promise.all(
|
|
@@ -210,18 +210,18 @@ class SSRApp {
|
|
|
210
210
|
* Render a full HTML page with a component mounted in a shell.
|
|
211
211
|
*
|
|
212
212
|
* @param {object} options
|
|
213
|
-
* @param {string} options.component
|
|
214
|
-
* @param {object} [options.props]
|
|
215
|
-
* @param {string} [options.title]
|
|
216
|
-
* @param {string} [options.description]
|
|
217
|
-
* @param {string[]} [options.styles]
|
|
218
|
-
* @param {string[]} [options.scripts]
|
|
219
|
-
* @param {string} [options.lang]
|
|
220
|
-
* @param {string} [options.meta]
|
|
221
|
-
* @param {string} [options.bodyAttrs]
|
|
222
|
-
* @param {object} [options.head]
|
|
223
|
-
* @param {string} [options.head.canonical]
|
|
224
|
-
* @param {object} [options.head.og]
|
|
213
|
+
* @param {string} options.component - component name to render
|
|
214
|
+
* @param {object} [options.props] - props
|
|
215
|
+
* @param {string} [options.title] - page title
|
|
216
|
+
* @param {string} [options.description] - meta description for SEO
|
|
217
|
+
* @param {string[]} [options.styles] - CSS file paths
|
|
218
|
+
* @param {string[]} [options.scripts] - JS file paths
|
|
219
|
+
* @param {string} [options.lang] - html lang attribute
|
|
220
|
+
* @param {string} [options.meta] - additional head content
|
|
221
|
+
* @param {string} [options.bodyAttrs] - extra body attributes
|
|
222
|
+
* @param {object} [options.head] - structured head options
|
|
223
|
+
* @param {string} [options.head.canonical] - canonical URL
|
|
224
|
+
* @param {object} [options.head.og] - Open Graph tags
|
|
225
225
|
* @returns {Promise<string>}
|
|
226
226
|
*/
|
|
227
227
|
async renderPage(options = {}) {
|
|
@@ -296,8 +296,8 @@ export function createSSRApp() {
|
|
|
296
296
|
|
|
297
297
|
/**
|
|
298
298
|
* Quick one-shot render of a component definition to string.
|
|
299
|
-
* @param {object} definition
|
|
300
|
-
* @param {object} [props]
|
|
299
|
+
* @param {object} definition - component definition
|
|
300
|
+
* @param {object} [props] - props
|
|
301
301
|
* @returns {string}
|
|
302
302
|
*/
|
|
303
303
|
export function renderToString(definition, props = {}) {
|
|
@@ -309,7 +309,7 @@ export function renderToString(definition, props = {}) {
|
|
|
309
309
|
}
|
|
310
310
|
|
|
311
311
|
/**
|
|
312
|
-
* Escape HTML entities
|
|
312
|
+
* Escape HTML entities - exposed for use in SSR templates.
|
|
313
313
|
* @param {string} str
|
|
314
314
|
* @returns {string}
|
|
315
315
|
*/
|