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.
Files changed (99) hide show
  1. package/README.md +55 -31
  2. package/cli/args.js +1 -1
  3. package/cli/commands/build.js +2 -2
  4. package/cli/commands/bundle.js +15 -15
  5. package/cli/commands/create.js +41 -7
  6. package/cli/commands/dev/devtools/index.js +1 -1
  7. package/cli/commands/dev/devtools/js/core.js +14 -14
  8. package/cli/commands/dev/devtools/js/elements.js +4 -4
  9. package/cli/commands/dev/devtools/js/stats.js +1 -1
  10. package/cli/commands/dev/devtools/styles.css +2 -2
  11. package/cli/commands/dev/index.js +2 -2
  12. package/cli/commands/dev/logger.js +1 -1
  13. package/cli/commands/dev/overlay.js +21 -14
  14. package/cli/commands/dev/server.js +5 -5
  15. package/cli/commands/dev/validator.js +7 -7
  16. package/cli/commands/dev/watcher.js +6 -6
  17. package/cli/help.js +4 -2
  18. package/cli/index.js +2 -2
  19. package/cli/scaffold/default/app/app.js +17 -18
  20. package/cli/scaffold/default/app/components/about.js +9 -9
  21. package/cli/scaffold/default/app/components/api-demo.js +6 -6
  22. package/cli/scaffold/default/app/components/contact-card.js +4 -4
  23. package/cli/scaffold/default/app/components/contacts/contacts.css +2 -2
  24. package/cli/scaffold/default/app/components/contacts/contacts.html +3 -3
  25. package/cli/scaffold/default/app/components/contacts/contacts.js +11 -11
  26. package/cli/scaffold/default/app/components/counter.js +8 -8
  27. package/cli/scaffold/default/app/components/home.js +13 -13
  28. package/cli/scaffold/default/app/components/not-found.js +1 -1
  29. package/cli/scaffold/default/app/components/playground/playground.css +1 -1
  30. package/cli/scaffold/default/app/components/playground/playground.html +11 -11
  31. package/cli/scaffold/default/app/components/playground/playground.js +11 -11
  32. package/cli/scaffold/default/app/components/todos.js +8 -8
  33. package/cli/scaffold/default/app/components/toolkit/toolkit.css +1 -1
  34. package/cli/scaffold/default/app/components/toolkit/toolkit.html +4 -4
  35. package/cli/scaffold/default/app/components/toolkit/toolkit.js +7 -7
  36. package/cli/scaffold/default/app/routes.js +1 -1
  37. package/cli/scaffold/default/app/store.js +1 -1
  38. package/cli/scaffold/default/global.css +2 -2
  39. package/cli/scaffold/default/index.html +2 -2
  40. package/cli/scaffold/minimal/app/app.js +6 -7
  41. package/cli/scaffold/minimal/app/components/about.js +5 -5
  42. package/cli/scaffold/minimal/app/components/counter.js +6 -6
  43. package/cli/scaffold/minimal/app/components/home.js +8 -8
  44. package/cli/scaffold/minimal/app/components/not-found.js +1 -1
  45. package/cli/scaffold/minimal/app/routes.js +1 -1
  46. package/cli/scaffold/minimal/app/store.js +1 -1
  47. package/cli/scaffold/minimal/global.css +2 -2
  48. package/cli/scaffold/minimal/index.html +1 -1
  49. package/cli/scaffold/ssr/app/app.js +29 -0
  50. package/cli/scaffold/ssr/app/components/about.js +28 -0
  51. package/cli/scaffold/ssr/app/components/home.js +37 -0
  52. package/cli/scaffold/ssr/app/components/not-found.js +15 -0
  53. package/cli/scaffold/ssr/app/routes.js +6 -0
  54. package/cli/scaffold/ssr/global.css +113 -0
  55. package/cli/scaffold/ssr/index.html +31 -0
  56. package/cli/scaffold/ssr/package.json +8 -0
  57. package/cli/scaffold/ssr/server/index.js +118 -0
  58. package/cli/utils.js +6 -6
  59. package/dist/zquery.dist.zip +0 -0
  60. package/dist/zquery.js +565 -228
  61. package/dist/zquery.min.js +2 -2
  62. package/index.d.ts +25 -12
  63. package/index.js +11 -7
  64. package/package.json +9 -3
  65. package/src/component.js +64 -63
  66. package/src/core.js +15 -15
  67. package/src/diff.js +38 -38
  68. package/src/errors.js +72 -18
  69. package/src/expression.js +15 -17
  70. package/src/http.js +4 -4
  71. package/src/package.json +1 -0
  72. package/src/reactive.js +75 -9
  73. package/src/router.js +104 -24
  74. package/src/ssr.js +133 -39
  75. package/src/store.js +103 -21
  76. package/src/utils.js +64 -12
  77. package/tests/audit.test.js +143 -15
  78. package/tests/cli.test.js +20 -20
  79. package/tests/component.test.js +121 -121
  80. package/tests/core.test.js +56 -56
  81. package/tests/diff.test.js +42 -42
  82. package/tests/errors.test.js +425 -147
  83. package/tests/expression.test.js +58 -53
  84. package/tests/http.test.js +20 -20
  85. package/tests/reactive.test.js +185 -24
  86. package/tests/router.test.js +501 -74
  87. package/tests/ssr.test.js +444 -10
  88. package/tests/store.test.js +264 -23
  89. package/tests/utils.test.js +163 -26
  90. package/types/collection.d.ts +2 -2
  91. package/types/component.d.ts +5 -5
  92. package/types/errors.d.ts +36 -4
  93. package/types/http.d.ts +3 -3
  94. package/types/misc.d.ts +9 -9
  95. package/types/reactive.d.ts +25 -3
  96. package/types/router.d.ts +10 -6
  97. package/types/ssr.d.ts +22 -2
  98. package/types/store.d.ts +40 -5
  99. package/types/utils.d.ts +1 -1
package/src/ssr.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
- * zQuery SSR Server-side rendering to HTML string
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 no DOM required for basic rendering.
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: () => fn.call(this, this.state),
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) definition.init.call(this);
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
- let html = this._def.render.call(this);
77
- html = this._interpolate(html);
78
- return html;
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
- const result = safeEval(expr.trim(), [
87
- this.state,
88
- { props: this.props, computed: this.computed }
89
- ]);
90
- return result != null ? _escapeHtml(String(result)) : '';
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 component registry + renderer
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 registered component name
131
- * @param {object} [props] props to pass
132
- * @param {object} [options] rendering options
133
- * @param {boolean} [options.hydrate=true] add hydration marker
134
- * @returns {Promise<string>} rendered HTML
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) throw new Error(`SSR: Component "${componentName}" not registered`);
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 component name to render
161
- * @param {object} [options.props] props
162
- * @param {string} [options.title] page title
163
- * @param {string[]} [options.styles] CSS file paths
164
- * @param {string[]} [options.scripts] JS file paths
165
- * @param {string} [options.lang] html lang attribute
166
- * @param {string} [options.meta] additional head content
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
- const content = comp ? await this.renderToString(comp, props) : '';
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
- ${meta}
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 component definition
218
- * @param {object} [props] 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 Global reactive state management
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
- // Create reactive state
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
- // Notify key-specific subscribers
47
- const subs = this._subscribers.get(key);
48
- if (subs) subs.forEach(fn => {
49
- try { fn(value, old, key); }
50
- catch (err) { reportError(ErrorCode.STORE_SUBSCRIBE, `Subscriber for "${key}" threw`, { key }, err); }
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 action name
72
- * @param {...any} args payload
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 state key, or function for all changes
112
- * @param {Function} [fn] callback (value, oldValue, key)
113
- * @returns {Function} unsubscribe
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 listen to all changes
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 Common utility functions
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 delays execution until after `ms` of inactivity
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 limits execution to once per `ms`
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 compose functions left-to-right
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 function that only runs 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 promise-based delay
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
- return crypto?.randomUUID?.() || 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
115
- const r = Math.random() * 16 | 0;
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
- return JSON.parse(JSON.stringify(obj));
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
- cur[keys[keys.length - 1]] = value;
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)) return cache.get(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
  }