zero-query 0.9.8 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -31
- package/cli/args.js +1 -1
- package/cli/commands/build.js +2 -2
- package/cli/commands/bundle.js +15 -15
- package/cli/commands/create.js +41 -7
- 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 +4 -2
- package/cli/index.js +2 -2
- 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 +29 -0
- package/cli/scaffold/ssr/app/components/about.js +28 -0
- package/cli/scaffold/ssr/app/components/home.js +37 -0
- package/cli/scaffold/ssr/app/components/not-found.js +15 -0
- package/cli/scaffold/ssr/app/routes.js +6 -0
- package/cli/scaffold/ssr/global.css +113 -0
- package/cli/scaffold/ssr/index.html +31 -0
- package/cli/scaffold/ssr/package.json +8 -0
- package/cli/scaffold/ssr/server/index.js +118 -0
- package/cli/utils.js +6 -6
- package/dist/zquery.dist.zip +0 -0
- package/dist/zquery.js +565 -228
- package/dist/zquery.min.js +2 -2
- package/index.d.ts +25 -12
- package/index.js +11 -7
- package/package.json +9 -3
- package/src/component.js +64 -63
- package/src/core.js +15 -15
- package/src/diff.js +38 -38
- package/src/errors.js +72 -18
- package/src/expression.js +15 -17
- package/src/http.js +4 -4
- package/src/package.json +1 -0
- package/src/reactive.js +75 -9
- package/src/router.js +104 -24
- package/src/ssr.js +133 -39
- 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 +425 -147
- 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 +444 -10
- package/tests/store.test.js +264 -23
- 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 +36 -4
- 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 +22 -2
- package/types/store.d.ts +40 -5
- package/types/utils.d.ts +1 -1
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):
|
|
@@ -21,14 +21,7 @@
|
|
|
21
21
|
*/
|
|
22
22
|
|
|
23
23
|
import { safeEval } from './expression.js';
|
|
24
|
-
|
|
25
|
-
// ---------------------------------------------------------------------------
|
|
26
|
-
// Minimal reactive proxy for SSR (no scheduling, no DOM)
|
|
27
|
-
// ---------------------------------------------------------------------------
|
|
28
|
-
function ssrReactive(target) {
|
|
29
|
-
// In SSR, state is plain objects — no Proxy needed since we don't re-render
|
|
30
|
-
return target;
|
|
31
|
-
}
|
|
24
|
+
import { reportError, ErrorCode, ZQueryError } from './errors.js';
|
|
32
25
|
|
|
33
26
|
// ---------------------------------------------------------------------------
|
|
34
27
|
// SSR Component renderer
|
|
@@ -54,7 +47,14 @@ class SSRComponent {
|
|
|
54
47
|
if (definition.computed) {
|
|
55
48
|
for (const [name, fn] of Object.entries(definition.computed)) {
|
|
56
49
|
Object.defineProperty(this.computed, name, {
|
|
57
|
-
get: () =>
|
|
50
|
+
get: () => {
|
|
51
|
+
try {
|
|
52
|
+
return fn.call(this, this.state);
|
|
53
|
+
} catch (err) {
|
|
54
|
+
reportError(ErrorCode.SSR_RENDER, `Computed property "${name}" threw during SSR`, { property: name }, err);
|
|
55
|
+
return undefined;
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
58
|
enumerable: true
|
|
59
59
|
});
|
|
60
60
|
}
|
|
@@ -67,15 +67,26 @@ class SSRComponent {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
-
// Init
|
|
71
|
-
if (definition.init)
|
|
70
|
+
// Init lifecycle - guarded so a broken init doesn't crash the whole render
|
|
71
|
+
if (definition.init) {
|
|
72
|
+
try {
|
|
73
|
+
definition.init.call(this);
|
|
74
|
+
} catch (err) {
|
|
75
|
+
reportError(ErrorCode.SSR_RENDER, 'Component init() threw during SSR', {}, err);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
render() {
|
|
75
81
|
if (this._def.render) {
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
82
|
+
try {
|
|
83
|
+
let html = this._def.render.call(this);
|
|
84
|
+
html = this._interpolate(html);
|
|
85
|
+
return html;
|
|
86
|
+
} catch (err) {
|
|
87
|
+
reportError(ErrorCode.SSR_RENDER, 'Component render() threw during SSR', {}, err);
|
|
88
|
+
return `<!-- SSR render error -->`;
|
|
89
|
+
}
|
|
79
90
|
}
|
|
80
91
|
return '';
|
|
81
92
|
}
|
|
@@ -83,11 +94,16 @@ class SSRComponent {
|
|
|
83
94
|
// Basic {{expression}} interpolation for SSR
|
|
84
95
|
_interpolate(html) {
|
|
85
96
|
return html.replace(/\{\{(.+?)\}\}/g, (_, expr) => {
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
97
|
+
try {
|
|
98
|
+
const result = safeEval(expr.trim(), [
|
|
99
|
+
this.state,
|
|
100
|
+
{ props: this.props, computed: this.computed }
|
|
101
|
+
]);
|
|
102
|
+
return result != null ? _escapeHtml(String(result)) : '';
|
|
103
|
+
} catch (err) {
|
|
104
|
+
reportError(ErrorCode.SSR_RENDER, `Expression "{{${expr.trim()}}}" failed during SSR`, { expression: expr.trim() }, err);
|
|
105
|
+
return '';
|
|
106
|
+
}
|
|
91
107
|
});
|
|
92
108
|
}
|
|
93
109
|
}
|
|
@@ -107,7 +123,7 @@ function _escapeHtml(str) {
|
|
|
107
123
|
}
|
|
108
124
|
|
|
109
125
|
// ---------------------------------------------------------------------------
|
|
110
|
-
// SSR App
|
|
126
|
+
// SSR App - component registry + renderer
|
|
111
127
|
// ---------------------------------------------------------------------------
|
|
112
128
|
class SSRApp {
|
|
113
129
|
constructor() {
|
|
@@ -120,22 +136,44 @@ class SSRApp {
|
|
|
120
136
|
* @param {object} definition
|
|
121
137
|
*/
|
|
122
138
|
component(name, definition) {
|
|
139
|
+
if (typeof name !== 'string' || !name) {
|
|
140
|
+
throw new ZQueryError(ErrorCode.SSR_COMPONENT, 'Component name must be a non-empty string');
|
|
141
|
+
}
|
|
142
|
+
if (!definition || typeof definition !== 'object') {
|
|
143
|
+
throw new ZQueryError(ErrorCode.SSR_COMPONENT, `Invalid definition for component "${name}"`);
|
|
144
|
+
}
|
|
123
145
|
this._registry.set(name, definition);
|
|
124
146
|
return this;
|
|
125
147
|
}
|
|
126
148
|
|
|
149
|
+
/**
|
|
150
|
+
* Check whether a component is registered.
|
|
151
|
+
* @param {string} name
|
|
152
|
+
* @returns {boolean}
|
|
153
|
+
*/
|
|
154
|
+
has(name) {
|
|
155
|
+
return this._registry.has(name);
|
|
156
|
+
}
|
|
157
|
+
|
|
127
158
|
/**
|
|
128
159
|
* Render a component to an HTML string.
|
|
129
160
|
*
|
|
130
|
-
* @param {string} componentName
|
|
131
|
-
* @param {object} [props]
|
|
132
|
-
* @param {object} [options]
|
|
133
|
-
* @param {boolean} [options.hydrate=true]
|
|
134
|
-
* @
|
|
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
|
|
135
167
|
*/
|
|
136
168
|
async renderToString(componentName, props = {}, options = {}) {
|
|
137
169
|
const def = this._registry.get(componentName);
|
|
138
|
-
if (!def)
|
|
170
|
+
if (!def) {
|
|
171
|
+
throw new ZQueryError(
|
|
172
|
+
ErrorCode.SSR_COMPONENT,
|
|
173
|
+
`SSR: Component "${componentName}" not registered`,
|
|
174
|
+
{ component: componentName }
|
|
175
|
+
);
|
|
176
|
+
}
|
|
139
177
|
|
|
140
178
|
const instance = new SSRComponent(def, props);
|
|
141
179
|
let html = instance.render();
|
|
@@ -147,23 +185,43 @@ class SSRApp {
|
|
|
147
185
|
html = html.replace(/\s*@[\w.]+="[^"]*"/g, ''); // Remove event bindings
|
|
148
186
|
html = html.replace(/\s*z-on:[\w.]+="[^"]*"/g, '');
|
|
149
187
|
|
|
188
|
+
// Fragment mode - return inner HTML without wrapper tag
|
|
189
|
+
if (options.mode === 'fragment') return html;
|
|
190
|
+
|
|
150
191
|
const hydrate = options.hydrate !== false;
|
|
151
192
|
const marker = hydrate ? ' data-zq-ssr' : '';
|
|
152
193
|
|
|
153
194
|
return `<${componentName}${marker}>${html}</${componentName}>`;
|
|
154
195
|
}
|
|
155
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Render multiple components as a batch.
|
|
199
|
+
*
|
|
200
|
+
* @param {Array<{ name: string, props?: object, options?: object }>} entries
|
|
201
|
+
* @returns {Promise<string[]>} - array of rendered HTML strings
|
|
202
|
+
*/
|
|
203
|
+
async renderBatch(entries) {
|
|
204
|
+
return Promise.all(
|
|
205
|
+
entries.map(({ name, props, options }) => this.renderToString(name, props, options))
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
156
209
|
/**
|
|
157
210
|
* Render a full HTML page with a component mounted in a shell.
|
|
158
211
|
*
|
|
159
212
|
* @param {object} options
|
|
160
|
-
* @param {string} options.component
|
|
161
|
-
* @param {object} [options.props]
|
|
162
|
-
* @param {string} [options.title]
|
|
163
|
-
* @param {string
|
|
164
|
-
* @param {string[]} [options.
|
|
165
|
-
* @param {string} [options.
|
|
166
|
-
* @param {string} [options.
|
|
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
|
|
167
225
|
* @returns {Promise<string>}
|
|
168
226
|
*/
|
|
169
227
|
async renderPage(options = {}) {
|
|
@@ -171,25 +229,49 @@ class SSRApp {
|
|
|
171
229
|
component: comp,
|
|
172
230
|
props = {},
|
|
173
231
|
title = '',
|
|
232
|
+
description = '',
|
|
174
233
|
styles = [],
|
|
175
234
|
scripts = [],
|
|
176
235
|
lang = 'en',
|
|
177
236
|
meta = '',
|
|
178
237
|
bodyAttrs = '',
|
|
238
|
+
head = {},
|
|
179
239
|
} = options;
|
|
180
240
|
|
|
181
|
-
|
|
241
|
+
let content = '';
|
|
242
|
+
if (comp) {
|
|
243
|
+
try {
|
|
244
|
+
content = await this.renderToString(comp, props);
|
|
245
|
+
} catch (err) {
|
|
246
|
+
reportError(ErrorCode.SSR_PAGE, `renderPage failed for component "${comp}"`, { component: comp }, err);
|
|
247
|
+
content = `<!-- SSR error: ${_escapeHtml(err.message)} -->`;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
182
250
|
|
|
183
251
|
const styleLinks = styles.map(s => `<link rel="stylesheet" href="${_escapeHtml(s)}">`).join('\n ');
|
|
184
252
|
const scriptTags = scripts.map(s => `<script src="${_escapeHtml(s)}"></script>`).join('\n ');
|
|
185
253
|
|
|
254
|
+
// Build SEO / structured head tags
|
|
255
|
+
let headExtra = meta;
|
|
256
|
+
if (description) {
|
|
257
|
+
headExtra += `\n <meta name="description" content="${_escapeHtml(description)}">`;
|
|
258
|
+
}
|
|
259
|
+
if (head.canonical) {
|
|
260
|
+
headExtra += `\n <link rel="canonical" href="${_escapeHtml(head.canonical)}">`;
|
|
261
|
+
}
|
|
262
|
+
if (head.og) {
|
|
263
|
+
for (const [key, val] of Object.entries(head.og)) {
|
|
264
|
+
headExtra += `\n <meta property="og:${_escapeHtml(key)}" content="${_escapeHtml(String(val))}">`;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
186
268
|
return `<!DOCTYPE html>
|
|
187
269
|
<html lang="${_escapeHtml(lang)}">
|
|
188
270
|
<head>
|
|
189
271
|
<meta charset="UTF-8">
|
|
190
272
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
191
273
|
<title>${_escapeHtml(title)}</title>
|
|
192
|
-
${
|
|
274
|
+
${headExtra}
|
|
193
275
|
${styleLinks}
|
|
194
276
|
</head>
|
|
195
277
|
<body ${bodyAttrs.replace(/on\w+\s*=/gi, '').replace(/javascript\s*:/gi, '')}>
|
|
@@ -214,11 +296,23 @@ export function createSSRApp() {
|
|
|
214
296
|
|
|
215
297
|
/**
|
|
216
298
|
* Quick one-shot render of a component definition to string.
|
|
217
|
-
* @param {object} definition
|
|
218
|
-
* @param {object} [props]
|
|
299
|
+
* @param {object} definition - component definition
|
|
300
|
+
* @param {object} [props] - props
|
|
219
301
|
* @returns {string}
|
|
220
302
|
*/
|
|
221
303
|
export function renderToString(definition, props = {}) {
|
|
304
|
+
if (!definition || typeof definition !== 'object') {
|
|
305
|
+
throw new ZQueryError(ErrorCode.SSR_COMPONENT, 'renderToString requires a component definition object');
|
|
306
|
+
}
|
|
222
307
|
const instance = new SSRComponent(definition, props);
|
|
223
308
|
return instance.render();
|
|
224
309
|
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Escape HTML entities - exposed for use in SSR templates.
|
|
313
|
+
* @param {string} str
|
|
314
|
+
* @returns {string}
|
|
315
|
+
*/
|
|
316
|
+
export function escapeHtml(str) {
|
|
317
|
+
return _escapeHtml(String(str));
|
|
318
|
+
}
|
package/src/store.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* zQuery Store
|
|
2
|
+
* zQuery Store - Global reactive state management
|
|
3
3
|
*
|
|
4
4
|
* A lightweight Redux/Vuex-inspired store with:
|
|
5
5
|
* - Reactive state via Proxy
|
|
@@ -38,22 +38,22 @@ class Store {
|
|
|
38
38
|
this._history = []; // action log
|
|
39
39
|
this._maxHistory = config.maxHistory || 1000;
|
|
40
40
|
this._debug = config.debug || false;
|
|
41
|
+
this._batching = false;
|
|
42
|
+
this._batchQueue = []; // pending notifications during batch
|
|
43
|
+
this._undoStack = [];
|
|
44
|
+
this._redoStack = [];
|
|
45
|
+
this._maxUndo = config.maxUndo || 50;
|
|
41
46
|
|
|
42
|
-
//
|
|
47
|
+
// Store initial state for reset
|
|
43
48
|
const initial = typeof config.state === 'function' ? config.state() : { ...(config.state || {}) };
|
|
49
|
+
this._initialState = JSON.parse(JSON.stringify(initial));
|
|
44
50
|
|
|
45
51
|
this.state = reactive(initial, (key, value, old) => {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
});
|
|
52
|
-
// Notify wildcard subscribers
|
|
53
|
-
this._wildcards.forEach(fn => {
|
|
54
|
-
try { fn(key, value, old); }
|
|
55
|
-
catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
|
|
56
|
-
});
|
|
52
|
+
if (this._batching) {
|
|
53
|
+
this._batchQueue.push({ key, value, old });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
this._notifySubscribers(key, value, old);
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
// Build getters as computed properties
|
|
@@ -66,10 +66,90 @@ class Store {
|
|
|
66
66
|
}
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
+
/** @private Notify key-specific and wildcard subscribers */
|
|
70
|
+
_notifySubscribers(key, value, old) {
|
|
71
|
+
const subs = this._subscribers.get(key);
|
|
72
|
+
if (subs) subs.forEach(fn => {
|
|
73
|
+
try { fn(key, value, old); }
|
|
74
|
+
catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, `Subscriber for "${key}" threw`, { key }, err); }
|
|
75
|
+
});
|
|
76
|
+
this._wildcards.forEach(fn => {
|
|
77
|
+
try { fn(key, value, old); }
|
|
78
|
+
catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, 'Wildcard subscriber threw', { key }, err); }
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Batch multiple state changes - subscribers fire once at the end
|
|
84
|
+
* with only the latest value per key.
|
|
85
|
+
*/
|
|
86
|
+
batch(fn) {
|
|
87
|
+
this._batching = true;
|
|
88
|
+
this._batchQueue = [];
|
|
89
|
+
try {
|
|
90
|
+
fn(this.state);
|
|
91
|
+
} finally {
|
|
92
|
+
this._batching = false;
|
|
93
|
+
// Deduplicate: keep only the last change per key
|
|
94
|
+
const last = new Map();
|
|
95
|
+
for (const entry of this._batchQueue) {
|
|
96
|
+
last.set(entry.key, entry);
|
|
97
|
+
}
|
|
98
|
+
this._batchQueue = [];
|
|
99
|
+
for (const { key, value, old } of last.values()) {
|
|
100
|
+
this._notifySubscribers(key, value, old);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Save a snapshot for undo. Call before making changes you want to be undoable.
|
|
107
|
+
*/
|
|
108
|
+
checkpoint() {
|
|
109
|
+
const snap = JSON.parse(JSON.stringify(this.state.__raw || this.state));
|
|
110
|
+
this._undoStack.push(snap);
|
|
111
|
+
if (this._undoStack.length > this._maxUndo) {
|
|
112
|
+
this._undoStack.splice(0, this._undoStack.length - this._maxUndo);
|
|
113
|
+
}
|
|
114
|
+
this._redoStack = [];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Undo to the last checkpoint
|
|
119
|
+
* @returns {boolean} true if undo was performed
|
|
120
|
+
*/
|
|
121
|
+
undo() {
|
|
122
|
+
if (this._undoStack.length === 0) return false;
|
|
123
|
+
const current = JSON.parse(JSON.stringify(this.state.__raw || this.state));
|
|
124
|
+
this._redoStack.push(current);
|
|
125
|
+
const prev = this._undoStack.pop();
|
|
126
|
+
this.replaceState(prev);
|
|
127
|
+
return true;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Redo the last undone state change
|
|
132
|
+
* @returns {boolean} true if redo was performed
|
|
133
|
+
*/
|
|
134
|
+
redo() {
|
|
135
|
+
if (this._redoStack.length === 0) return false;
|
|
136
|
+
const current = JSON.parse(JSON.stringify(this.state.__raw || this.state));
|
|
137
|
+
this._undoStack.push(current);
|
|
138
|
+
const next = this._redoStack.pop();
|
|
139
|
+
this.replaceState(next);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Check if undo is available */
|
|
144
|
+
get canUndo() { return this._undoStack.length > 0; }
|
|
145
|
+
|
|
146
|
+
/** Check if redo is available */
|
|
147
|
+
get canRedo() { return this._redoStack.length > 0; }
|
|
148
|
+
|
|
69
149
|
/**
|
|
70
150
|
* Dispatch a named action
|
|
71
|
-
* @param {string} name
|
|
72
|
-
* @param {...any} args
|
|
151
|
+
* @param {string} name - action name
|
|
152
|
+
* @param {...any} args - payload
|
|
73
153
|
*/
|
|
74
154
|
dispatch(name, ...args) {
|
|
75
155
|
const action = this._actions[name];
|
|
@@ -108,13 +188,13 @@ class Store {
|
|
|
108
188
|
|
|
109
189
|
/**
|
|
110
190
|
* Subscribe to changes on a specific state key
|
|
111
|
-
* @param {string|Function} keyOrFn
|
|
112
|
-
* @param {Function} [fn]
|
|
113
|
-
* @returns {Function}
|
|
191
|
+
* @param {string|Function} keyOrFn - state key, or function for all changes
|
|
192
|
+
* @param {Function} [fn] - callback (key, value, oldValue)
|
|
193
|
+
* @returns {Function} - unsubscribe
|
|
114
194
|
*/
|
|
115
195
|
subscribe(keyOrFn, fn) {
|
|
116
196
|
if (typeof keyOrFn === 'function') {
|
|
117
|
-
// Wildcard
|
|
197
|
+
// Wildcard - listen to all changes
|
|
118
198
|
this._wildcards.add(keyOrFn);
|
|
119
199
|
return () => this._wildcards.delete(keyOrFn);
|
|
120
200
|
}
|
|
@@ -160,11 +240,13 @@ class Store {
|
|
|
160
240
|
}
|
|
161
241
|
|
|
162
242
|
/**
|
|
163
|
-
* Reset state to initial values
|
|
243
|
+
* Reset state to initial values. If no argument, resets to the original state.
|
|
164
244
|
*/
|
|
165
245
|
reset(initialState) {
|
|
166
|
-
this.replaceState(initialState);
|
|
246
|
+
this.replaceState(initialState || JSON.parse(JSON.stringify(this._initialState)));
|
|
167
247
|
this._history = [];
|
|
248
|
+
this._undoStack = [];
|
|
249
|
+
this._redoStack = [];
|
|
168
250
|
}
|
|
169
251
|
}
|
|
170
252
|
|
package/src/utils.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* zQuery Utils
|
|
2
|
+
* zQuery Utils - Common utility functions
|
|
3
3
|
*
|
|
4
4
|
* Quality-of-life helpers that every frontend project needs.
|
|
5
5
|
* Attached to $ namespace for convenience.
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
// ---------------------------------------------------------------------------
|
|
11
11
|
|
|
12
12
|
/**
|
|
13
|
-
* Debounce
|
|
13
|
+
* Debounce - delays execution until after `ms` of inactivity
|
|
14
14
|
*/
|
|
15
15
|
export function debounce(fn, ms = 250) {
|
|
16
16
|
let timer;
|
|
@@ -23,7 +23,7 @@ export function debounce(fn, ms = 250) {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Throttle
|
|
26
|
+
* Throttle - limits execution to once per `ms`
|
|
27
27
|
*/
|
|
28
28
|
export function throttle(fn, ms = 250) {
|
|
29
29
|
let last = 0;
|
|
@@ -42,14 +42,14 @@ export function throttle(fn, ms = 250) {
|
|
|
42
42
|
}
|
|
43
43
|
|
|
44
44
|
/**
|
|
45
|
-
* Pipe
|
|
45
|
+
* Pipe - compose functions left-to-right
|
|
46
46
|
*/
|
|
47
47
|
export function pipe(...fns) {
|
|
48
48
|
return (input) => fns.reduce((val, fn) => fn(val), input);
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
/**
|
|
52
|
-
* Once
|
|
52
|
+
* Once - function that only runs once
|
|
53
53
|
*/
|
|
54
54
|
export function once(fn) {
|
|
55
55
|
let called = false, result;
|
|
@@ -60,7 +60,7 @@ export function once(fn) {
|
|
|
60
60
|
}
|
|
61
61
|
|
|
62
62
|
/**
|
|
63
|
-
* Sleep
|
|
63
|
+
* Sleep - promise-based delay
|
|
64
64
|
*/
|
|
65
65
|
export function sleep(ms) {
|
|
66
66
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
@@ -111,8 +111,12 @@ export function trust(htmlStr) {
|
|
|
111
111
|
* Generate UUID v4
|
|
112
112
|
*/
|
|
113
113
|
export function uuid() {
|
|
114
|
-
|
|
115
|
-
|
|
114
|
+
if (crypto?.randomUUID) return crypto.randomUUID();
|
|
115
|
+
// Fallback using crypto.getRandomValues (wider support than randomUUID)
|
|
116
|
+
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
117
|
+
const buf = new Uint8Array(1);
|
|
118
|
+
crypto.getRandomValues(buf);
|
|
119
|
+
const r = buf[0] & 15;
|
|
116
120
|
return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
|
|
117
121
|
});
|
|
118
122
|
}
|
|
@@ -140,13 +144,50 @@ export function kebabCase(str) {
|
|
|
140
144
|
// ---------------------------------------------------------------------------
|
|
141
145
|
|
|
142
146
|
/**
|
|
143
|
-
* Deep clone
|
|
147
|
+
* Deep clone via structuredClone (handles circular refs, Dates, etc.).
|
|
148
|
+
* Falls back to a manual deep clone that preserves Date, RegExp, Map, Set,
|
|
149
|
+
* ArrayBuffer, TypedArrays, undefined values, and circular references.
|
|
144
150
|
*/
|
|
145
151
|
export function deepClone(obj) {
|
|
146
152
|
if (typeof structuredClone === 'function') return structuredClone(obj);
|
|
147
|
-
|
|
153
|
+
|
|
154
|
+
const seen = new Map();
|
|
155
|
+
function clone(val) {
|
|
156
|
+
if (val === null || typeof val !== 'object') return val;
|
|
157
|
+
if (seen.has(val)) return seen.get(val);
|
|
158
|
+
if (val instanceof Date) return new Date(val.getTime());
|
|
159
|
+
if (val instanceof RegExp) return new RegExp(val.source, val.flags);
|
|
160
|
+
if (val instanceof Map) {
|
|
161
|
+
const m = new Map();
|
|
162
|
+
seen.set(val, m);
|
|
163
|
+
val.forEach((v, k) => m.set(clone(k), clone(v)));
|
|
164
|
+
return m;
|
|
165
|
+
}
|
|
166
|
+
if (val instanceof Set) {
|
|
167
|
+
const s = new Set();
|
|
168
|
+
seen.set(val, s);
|
|
169
|
+
val.forEach(v => s.add(clone(v)));
|
|
170
|
+
return s;
|
|
171
|
+
}
|
|
172
|
+
if (ArrayBuffer.isView(val)) return new val.constructor(val.buffer.slice(0));
|
|
173
|
+
if (val instanceof ArrayBuffer) return val.slice(0);
|
|
174
|
+
if (Array.isArray(val)) {
|
|
175
|
+
const arr = [];
|
|
176
|
+
seen.set(val, arr);
|
|
177
|
+
for (let i = 0; i < val.length; i++) arr[i] = clone(val[i]);
|
|
178
|
+
return arr;
|
|
179
|
+
}
|
|
180
|
+
const result = Object.create(Object.getPrototypeOf(val));
|
|
181
|
+
seen.set(val, result);
|
|
182
|
+
for (const key of Object.keys(val)) result[key] = clone(val[key]);
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
return clone(obj);
|
|
148
186
|
}
|
|
149
187
|
|
|
188
|
+
// Keys that must never be written through data-merge or path-set operations
|
|
189
|
+
const _UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
190
|
+
|
|
150
191
|
/**
|
|
151
192
|
* Deep merge objects
|
|
152
193
|
*/
|
|
@@ -156,6 +197,7 @@ export function deepMerge(target, ...sources) {
|
|
|
156
197
|
if (seen.has(src)) return tgt;
|
|
157
198
|
seen.add(src);
|
|
158
199
|
for (const key of Object.keys(src)) {
|
|
200
|
+
if (_UNSAFE_KEYS.has(key)) continue;
|
|
159
201
|
if (src[key] && typeof src[key] === 'object' && !Array.isArray(src[key])) {
|
|
160
202
|
if (!tgt[key] || typeof tgt[key] !== 'object') tgt[key] = {};
|
|
161
203
|
merge(tgt[key], src[key]);
|
|
@@ -362,10 +404,13 @@ export function setPath(obj, path, value) {
|
|
|
362
404
|
let cur = obj;
|
|
363
405
|
for (let i = 0; i < keys.length - 1; i++) {
|
|
364
406
|
const k = keys[i];
|
|
407
|
+
if (_UNSAFE_KEYS.has(k)) return obj;
|
|
365
408
|
if (cur[k] == null || typeof cur[k] !== 'object') cur[k] = {};
|
|
366
409
|
cur = cur[k];
|
|
367
410
|
}
|
|
368
|
-
|
|
411
|
+
const lastKey = keys[keys.length - 1];
|
|
412
|
+
if (_UNSAFE_KEYS.has(lastKey)) return obj;
|
|
413
|
+
cur[lastKey] = value;
|
|
369
414
|
return obj;
|
|
370
415
|
}
|
|
371
416
|
|
|
@@ -416,9 +461,16 @@ export function memoize(fn, keyFnOrOpts) {
|
|
|
416
461
|
|
|
417
462
|
const memoized = (...args) => {
|
|
418
463
|
const key = keyFn ? keyFn(...args) : args[0];
|
|
419
|
-
if (cache.has(key))
|
|
464
|
+
if (cache.has(key)) {
|
|
465
|
+
// LRU: promote to newest by re-inserting
|
|
466
|
+
const value = cache.get(key);
|
|
467
|
+
cache.delete(key);
|
|
468
|
+
cache.set(key, value);
|
|
469
|
+
return value;
|
|
470
|
+
}
|
|
420
471
|
const result = fn(...args);
|
|
421
472
|
cache.set(key, result);
|
|
473
|
+
// LRU eviction: drop the least-recently-used entry
|
|
422
474
|
if (maxSize > 0 && cache.size > maxSize) {
|
|
423
475
|
cache.delete(cache.keys().next().value);
|
|
424
476
|
}
|