hyperclayjs 1.26.0 → 1.26.2

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 CHANGED
@@ -62,10 +62,10 @@ import 'hyperclayjs/presets/standard.js';
62
62
  | edit-mode-helpers | 6.8KB | Admin-only functionality: [viewmode:disabled], [editmode:resource], [editmode:onclick] |
63
63
  | option-visibility | 7.1KB | Dynamic show/hide based on ancestor state with option:attribute="value" |
64
64
  | persist | 6.4KB | Persist input/select/textarea values to the DOM with [persist] attribute |
65
- | save-core | 8.9KB | Basic save function only - hyperclay.savePage() |
66
- | save-system | 13.4KB | CMD+S, [trigger-save] button, savestatus attribute |
65
+ | save-core | 11.5KB | Basic save function only - hyperclay.savePage() |
66
+ | save-system | 15.4KB | CMD+S, [trigger-save] button, savestatus attribute |
67
67
  | save-toast | 0.9KB | Toast notifications for save events |
68
- | snapshot | 10.8KB | Source of truth for page state - captures DOM snapshots for save and sync |
68
+ | snapshot | 11KB | Source of truth for page state - captures DOM snapshots for save and sync |
69
69
  | unsaved-warning | 1.3KB | Warn before leaving page with unsaved changes |
70
70
 
71
71
  ### Custom Attributes (HTML enhancements)
@@ -96,10 +96,10 @@ import 'hyperclayjs/presets/standard.js';
96
96
  |--------|------|-------------|
97
97
  | cache-bust | 0.6KB | Cache-bust href/src attributes |
98
98
  | cookie | 1.4KB | Cookie management (often auto-included) |
99
- | debounce | 0.4KB | Function debouncing |
99
+ | debounce | 0.7KB | Function debouncing |
100
100
  | mutation | 13.8KB | DOM mutation observation (often auto-included) |
101
101
  | nearest | 3.4KB | Find nearest elements (often auto-included) |
102
- | throttle | 0.8KB | Function throttling |
102
+ | throttle | 1.3KB | Function throttling |
103
103
 
104
104
  ### DOM Utilities (DOM manipulation helpers)
105
105
 
@@ -134,17 +134,17 @@ import 'hyperclayjs/presets/standard.js';
134
134
 
135
135
  ## Presets
136
136
 
137
- ### Minimal (~57KB)
137
+ ### Minimal (~61.8KB)
138
138
  Essential features for basic editing
139
139
 
140
140
  **Modules:** `save-core`, `snapshot`, `save-system`, `edit-mode-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
141
141
 
142
- ### Standard (~83.9KB)
142
+ ### Standard (~88.7KB)
143
143
  Standard feature set for most use cases
144
144
 
145
145
  **Modules:** `save-core`, `snapshot`, `save-system`, `unsaved-warning`, `edit-mode-helpers`, `persist`, `option-visibility`, `event-attrs`, `dom-helpers`, `toast`, `save-toast`, `export-to-window`, `view-mode-excludes-edit-modules`
146
146
 
147
- ### Everything (~225.8KB)
147
+ ### Everything (~231.4KB)
148
148
  All available features
149
149
 
150
150
  Includes all available modules across all categories.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hyperclayjs",
3
- "version": "1.26.0",
3
+ "version": "1.26.2",
4
4
  "description": "Modular JavaScript library for building interactive malleable HTML files with Hyperclay",
5
5
  "type": "module",
6
6
  "main": "src/hyperclay.js",
@@ -121,129 +121,160 @@ export function getLastSavedContents() { return lastSavedContents; }
121
121
  export function setLastSavedContents(val) { lastSavedContents = val; }
122
122
 
123
123
  /**
124
- * Save the current page with change detection and state management
124
+ * Save the current page with change detection and state management.
125
+ *
126
+ * Returns a Promise that resolves with {msg, msgType} — the same object
127
+ * passed to the callback. Promise never rejects; errors resolve with
128
+ * msgType: 'error', skipped early-returns resolve with msgType: 'skipped'.
125
129
  *
126
130
  * @param {Function} callback - Optional callback for custom handling
131
+ * @returns {Promise<{msg: string, msgType: string}>}
127
132
  */
128
133
  export function savePage(callback = () => {}) {
129
- if (!isEditMode && !window.hyperclay?.testMode) {
130
- return;
131
- }
134
+ return new Promise((resolve) => {
135
+ if (!isEditMode && !window.hyperclay?.testMode) {
136
+ const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
137
+ callback(skipped);
138
+ return resolve(skipped);
139
+ }
132
140
 
133
- // Don't start a new save if one is already in progress
134
- if (isSaveInProgress()) {
135
- return;
136
- }
141
+ // Don't start a new save if one is already in progress
142
+ if (isSaveInProgress()) {
143
+ const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
144
+ callback(skipped);
145
+ return resolve(skipped);
146
+ }
137
147
 
138
- // Check if offline - set DOM state immediately for UI feedback
139
- // but still try the fetch (navigator.onLine can be wrong)
140
- const wasOffline = !navigator.onLine;
141
- if (wasOffline) {
142
- setOfflineStateQuiet();
143
- }
148
+ // Check if offline - set DOM state immediately for UI feedback
149
+ // but still try the fetch (navigator.onLine can be wrong)
150
+ const wasOffline = !navigator.onLine;
151
+ if (wasOffline) {
152
+ setOfflineStateQuiet();
153
+ }
144
154
 
145
- // Single capture: clone once, get both versions
146
- // forComparison has [save-remove] and [save-ignore] stripped
147
- // forSave has only [save-remove] stripped
148
- let forSave, forComparison;
149
- try {
150
- ({ forSave, forComparison } = captureForSaveAndComparison());
151
- } catch (err) {
152
- console.error('savePage: captureForSaveAndComparison failed', err);
153
- setSaveState('error', err.message);
154
- if (typeof callback === 'function') {
155
- callback({ msg: err.message, msgType: 'error' });
155
+ // Single capture: clone once, get both versions
156
+ // forComparison has [save-remove] and [save-ignore] stripped
157
+ // forSave has only [save-remove] stripped
158
+ let forSave, forComparison;
159
+ try {
160
+ ({ forSave, forComparison } = captureForSaveAndComparison());
161
+ } catch (err) {
162
+ console.error('savePage: captureForSaveAndComparison failed', err);
163
+ setSaveState('error', err.message);
164
+ const result = { msg: err.message, msgType: 'error' };
165
+ if (typeof callback === 'function') {
166
+ callback(result);
167
+ }
168
+ return resolve(result);
156
169
  }
157
- return;
158
- }
159
170
 
160
- // Compare directly - lastSavedContents is already stripped
161
- unsavedChanges = (forComparison !== lastSavedContents);
162
- logSaveCheck('savePage dirty check', !unsavedChanges);
171
+ // Compare directly - lastSavedContents is already stripped
172
+ unsavedChanges = (forComparison !== lastSavedContents);
173
+ logSaveCheck('savePage dirty check', !unsavedChanges);
163
174
 
164
- // Skip if content hasn't changed
165
- if (!unsavedChanges) {
166
- return;
167
- }
175
+ // Skip if content hasn't changed
176
+ if (!unsavedChanges) {
177
+ const skipped = { msg: 'No changes to save', msgType: 'skipped' };
178
+ callback(skipped);
179
+ return resolve(skipped);
180
+ }
168
181
 
169
- // Start debounced 'saving' state (only shows if save takes >500ms)
170
- setSavingState();
171
-
172
- // Use saveHtml directly with our pre-captured content (avoids double capture)
173
- saveHtml(forSave, (err, data) => {
174
- if (!err) {
175
- // SUCCESS - store stripped version for future comparisons
176
- lastSavedContents = forComparison;
177
- unsavedChanges = false;
178
- setSaveState('saved', data?.msg || 'Saved');
179
- logBaseline('updated after save', `${lastSavedContents.length} chars`);
180
- } else {
181
- // FAILED - determine if it's offline or server error
182
- if (!navigator.onLine) {
183
- setSaveState('offline', err.message);
182
+ // Start debounced 'saving' state (only shows if save takes >500ms)
183
+ setSavingState();
184
+
185
+ // Use saveHtml directly with our pre-captured content (avoids double capture)
186
+ saveHtml(forSave, (err, data) => {
187
+ if (!err) {
188
+ // SUCCESS - store stripped version for future comparisons
189
+ lastSavedContents = forComparison;
190
+ unsavedChanges = false;
191
+ setSaveState('saved', data?.msg || 'Saved');
192
+ logBaseline('updated after save', `${lastSavedContents.length} chars`);
184
193
  } else {
185
- setSaveState('error', err.message);
194
+ // FAILED - determine if it's offline or server error
195
+ if (!navigator.onLine) {
196
+ setSaveState('offline', err.message);
197
+ } else {
198
+ setSaveState('error', err.message);
199
+ }
186
200
  }
187
- }
188
201
 
189
- // Call user callback if provided (preserve server's msgType)
190
- if (typeof callback === 'function') {
191
- callback({
202
+ // Call user callback if provided (preserve server's msgType)
203
+ const result = {
192
204
  msg: err?.message || data?.msg,
193
205
  msgType: err ? 'error' : (data?.msgType || 'success')
194
- });
195
- }
206
+ };
207
+ if (typeof callback === 'function') {
208
+ callback(result);
209
+ }
210
+ resolve(result);
211
+ });
196
212
  });
197
213
  }
198
214
 
215
+ /**
216
+ * Force-save the current page (skips dirty check).
217
+ *
218
+ * @param {Function} callback - Optional callback for custom handling
219
+ * @returns {Promise<{msg: string, msgType: string}>}
220
+ */
199
221
  export function savePageForce(callback = () => {}) {
200
- if (!isEditMode && !window.hyperclay?.testMode) {
201
- return;
202
- }
222
+ return new Promise((resolve) => {
223
+ if (!isEditMode && !window.hyperclay?.testMode) {
224
+ const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
225
+ callback(skipped);
226
+ return resolve(skipped);
227
+ }
203
228
 
204
- if (isSaveInProgress()) {
205
- return;
206
- }
229
+ if (isSaveInProgress()) {
230
+ const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
231
+ callback(skipped);
232
+ return resolve(skipped);
233
+ }
207
234
 
208
- const wasOffline = !navigator.onLine;
209
- if (wasOffline) {
210
- setOfflineStateQuiet();
211
- }
235
+ const wasOffline = !navigator.onLine;
236
+ if (wasOffline) {
237
+ setOfflineStateQuiet();
238
+ }
212
239
 
213
- let forSave, forComparison;
214
- try {
215
- ({ forSave, forComparison } = captureForSaveAndComparison());
216
- } catch (err) {
217
- console.error('savePageForce: captureForSaveAndComparison failed', err);
218
- setSaveState('error', err.message);
219
- if (typeof callback === 'function') {
220
- callback({ msg: err.message, msgType: 'error' });
240
+ let forSave, forComparison;
241
+ try {
242
+ ({ forSave, forComparison } = captureForSaveAndComparison());
243
+ } catch (err) {
244
+ console.error('savePageForce: captureForSaveAndComparison failed', err);
245
+ setSaveState('error', err.message);
246
+ const result = { msg: err.message, msgType: 'error' };
247
+ if (typeof callback === 'function') {
248
+ callback(result);
249
+ }
250
+ return resolve(result);
221
251
  }
222
- return;
223
- }
224
252
 
225
- setSavingState();
253
+ setSavingState();
226
254
 
227
- saveHtml(forSave, (err, data) => {
228
- if (!err) {
229
- lastSavedContents = forComparison;
230
- unsavedChanges = false;
231
- setSaveState('saved', data?.msg || 'Saved');
232
- logBaseline('updated after force save', `${lastSavedContents.length} chars`);
233
- } else {
234
- if (!navigator.onLine) {
235
- setSaveState('offline', err.message);
255
+ saveHtml(forSave, (err, data) => {
256
+ if (!err) {
257
+ lastSavedContents = forComparison;
258
+ unsavedChanges = false;
259
+ setSaveState('saved', data?.msg || 'Saved');
260
+ logBaseline('updated after force save', `${lastSavedContents.length} chars`);
236
261
  } else {
237
- setSaveState('error', err.message);
262
+ if (!navigator.onLine) {
263
+ setSaveState('offline', err.message);
264
+ } else {
265
+ setSaveState('error', err.message);
266
+ }
238
267
  }
239
- }
240
268
 
241
- if (typeof callback === 'function') {
242
- callback({
269
+ const result = {
243
270
  msg: err?.message || data?.msg,
244
271
  msgType: err ? 'error' : (data?.msgType || 'success')
245
- });
246
- }
272
+ };
273
+ if (typeof callback === 'function') {
274
+ callback(result);
275
+ }
276
+ resolve(result);
277
+ });
247
278
  });
248
279
  }
249
280
 
@@ -364,13 +395,21 @@ if (document.readyState === 'loading') {
364
395
  }
365
396
 
366
397
  /**
367
- * Save the page with throttling, for use with auto-save
368
- * Checks both baseline and last saved content to prevent saves from initial setup
398
+ * Save the page with throttling, for use with auto-save.
399
+ * Checks both baseline and last saved content to prevent saves from initial setup.
400
+ *
401
+ * Returns a Promise resolving with {msg, msgType}. Within-throttle-window calls
402
+ * piggyback on the trailing-edge save and resolve with its result.
369
403
  *
370
404
  * @param {Function} callback - Optional callback
405
+ * @returns {Promise<{msg: string, msgType: string}>}
371
406
  */
372
407
  export function savePageThrottled(callback = () => {}) {
373
- if (!isEditMode) return;
408
+ if (!isEditMode) {
409
+ const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
410
+ callback(skipped);
411
+ return Promise.resolve(skipped);
412
+ }
374
413
 
375
414
  // For autosave: check both that content changed from baseline AND from last save
376
415
  // This prevents saves from initial setup mutations
@@ -382,10 +421,14 @@ export function savePageThrottled(callback = () => {}) {
382
421
  logSaveCheck('throttled vs baseline', !differsFromBaseline);
383
422
  logSaveCheck('throttled vs lastSave', !differsFromLastSave);
384
423
 
385
- if (differsFromBaseline && differsFromLastSave) {
386
- unsavedChanges = true;
387
- throttledSave(callback);
424
+ if (!(differsFromBaseline && differsFromLastSave)) {
425
+ const skipped = { msg: 'No changes to save', msgType: 'skipped' };
426
+ callback(skipped);
427
+ return Promise.resolve(skipped);
388
428
  }
429
+
430
+ unsavedChanges = true;
431
+ return throttledSave(callback);
389
432
  }
390
433
 
391
434
  /**
@@ -66,240 +66,294 @@ function getContentsForSave() {
66
66
  /**
67
67
  * Save the current page contents to the server.
68
68
  *
69
+ * Returns a Promise that resolves with {msg, msgType} — the same object
70
+ * passed to the callback. Promise never rejects; errors resolve with
71
+ * msgType: 'error', skipped early-returns resolve with msgType: 'skipped'.
72
+ *
69
73
  * @param {Function} callback - Called with {msg, msgType} on completion
70
- * msgType will be 'success' or 'error'
74
+ * msgType will be 'success', 'error', or 'skipped'
75
+ * @returns {Promise<{msg: string, msgType: string}>}
71
76
  *
72
77
  * @example
78
+ * // Callback form (unchanged)
73
79
  * savePage(({msg, msgType}) => {
74
- * if (msgType === 'error') {
75
- * console.error('Save failed:', msg);
76
- * } else {
77
- * console.log('Saved:', msg);
78
- * }
80
+ * if (msgType === 'error') console.error('Save failed:', msg);
79
81
  * });
82
+ *
83
+ * @example
84
+ * // Promise form
85
+ * const {msg, msgType} = await savePage();
86
+ * if (msgType === 'error') console.error('Save failed:', msg);
80
87
  */
81
88
  export function savePage(callback = () => {}) {
82
- if (saveInProgress) {
83
- return;
84
- }
85
- if (!isEditMode && !window.hyperclay?.testMode) {
86
- return;
87
- }
88
-
89
- let currentContents;
90
- try {
91
- currentContents = getContentsForSave();
92
- } catch (err) {
93
- console.error('savePage: getContentsForSave failed', err);
94
- callback({ msg: err.message, msgType: "error" });
95
- return;
96
- }
97
- saveInProgress = true;
98
-
99
- // Test mode: skip network request, return mock success
100
- if (window.hyperclay?.testMode) {
101
- setTimeout(() => {
102
- saveInProgress = false;
103
- if (typeof callback === 'function') {
104
- callback({ msg: "Test mode: save skipped", msgType: "success" });
105
- }
106
- }, 0);
107
- return;
108
- }
89
+ return new Promise((resolve) => {
90
+ if (saveInProgress) {
91
+ const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
92
+ callback(skipped);
93
+ return resolve(skipped);
94
+ }
95
+ if (!isEditMode && !window.hyperclay?.testMode) {
96
+ const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
97
+ callback(skipped);
98
+ return resolve(skipped);
99
+ }
100
+
101
+ let currentContents;
102
+ try {
103
+ currentContents = getContentsForSave();
104
+ } catch (err) {
105
+ console.error('savePage: getContentsForSave failed', err);
106
+ const result = { msg: err.message, msgType: "error" };
107
+ callback(result);
108
+ return resolve(result);
109
+ }
110
+ saveInProgress = true;
111
+
112
+ // Test mode: skip network request, return mock success
113
+ if (window.hyperclay?.testMode) {
114
+ setTimeout(() => {
115
+ saveInProgress = false;
116
+ const result = { msg: "Test mode: save skipped", msgType: "success" };
117
+ if (typeof callback === 'function') {
118
+ callback(result);
119
+ }
120
+ resolve(result);
121
+ }, 0);
122
+ return;
123
+ }
124
+
125
+ // Add timeout - abort if server doesn't respond within 12 seconds
126
+ const controller = new AbortController();
127
+ const timeoutId = setTimeout(() => controller.abort(), 12000);
128
+
129
+ // Check if running on Hyperclay Local - send JSON with both versions for platform sync
130
+ const isHyperclayLocal = window.location.hostname === 'localhost' ||
131
+ window.location.hostname === '127.0.0.1';
132
+
133
+ const fetchOptions = {
134
+ method: 'POST',
135
+ credentials: 'include',
136
+ signal: controller.signal,
137
+ headers: { 'Page-URL': window.location.href }
138
+ };
139
+
140
+ if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
141
+ // Send JSON with both stripped content and full snapshot for platform live sync
142
+ fetchOptions.headers['Content-Type'] = 'application/json';
143
+ fetchOptions.body = JSON.stringify({
144
+ content: currentContents,
145
+ snapshotHtml: window.__hyperclaySnapshotHtml
146
+ });
147
+ // Clear after use to avoid stale data
148
+ window.__hyperclaySnapshotHtml = null;
149
+ } else {
150
+ // Platform: send plain text as before
151
+ fetchOptions.body = currentContents;
152
+ }
153
+
154
+ fetch(saveEndpoint, fetchOptions)
155
+ .then(res => {
156
+ clearTimeout(timeoutId);
157
+ return res.json().then(data => {
158
+ if (!res.ok) {
159
+ throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
160
+ }
161
+ return data;
162
+ });
163
+ })
164
+ .then(data => {
165
+ const result = { msg: data.msg, msgType: data.msgType || 'success' };
166
+ if (typeof callback === 'function') {
167
+ callback(result);
168
+ }
169
+ resolve(result);
170
+ })
171
+ .catch(err => {
172
+ clearTimeout(timeoutId);
173
+ console.error('Failed to save page:', err);
109
174
 
110
- // Add timeout - abort if server doesn't respond within 12 seconds
111
- const controller = new AbortController();
112
- const timeoutId = setTimeout(() => controller.abort(), 12000);
113
-
114
- // Check if running on Hyperclay Local - send JSON with both versions for platform sync
115
- const isHyperclayLocal = window.location.hostname === 'localhost' ||
116
- window.location.hostname === '127.0.0.1';
117
-
118
- const fetchOptions = {
119
- method: 'POST',
120
- credentials: 'include',
121
- signal: controller.signal,
122
- headers: { 'Page-URL': window.location.href }
123
- };
124
-
125
- if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
126
- // Send JSON with both stripped content and full snapshot for platform live sync
127
- fetchOptions.headers['Content-Type'] = 'application/json';
128
- fetchOptions.body = JSON.stringify({
129
- content: currentContents,
130
- snapshotHtml: window.__hyperclaySnapshotHtml
131
- });
132
- // Clear after use to avoid stale data
133
- window.__hyperclaySnapshotHtml = null;
134
- } else {
135
- // Platform: send plain text as before
136
- fetchOptions.body = currentContents;
137
- }
175
+ const msg = err.name === 'AbortError'
176
+ ? 'Server not responding'
177
+ : 'Save failed';
138
178
 
139
- fetch(saveEndpoint, fetchOptions)
140
- .then(res => {
141
- clearTimeout(timeoutId);
142
- return res.json().then(data => {
143
- if (!res.ok) {
144
- throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
179
+ const result = { msg, msgType: "error" };
180
+ if (typeof callback === 'function') {
181
+ callback(result);
145
182
  }
146
- return data;
183
+ resolve(result);
184
+ })
185
+ .finally(() => {
186
+ clearTimeout(timeoutId);
187
+ saveInProgress = false;
147
188
  });
148
- })
149
- .then(data => {
150
- if (typeof callback === 'function') {
151
- callback({ msg: data.msg, msgType: data.msgType || 'success' });
152
- }
153
- })
154
- .catch(err => {
155
- clearTimeout(timeoutId);
156
- console.error('Failed to save page:', err);
157
-
158
- const msg = err.name === 'AbortError'
159
- ? 'Server not responding'
160
- : 'Save failed';
161
-
162
- if (typeof callback === 'function') {
163
- callback({ msg, msgType: "error" });
164
- }
165
- })
166
- .finally(() => {
167
- clearTimeout(timeoutId);
168
- saveInProgress = false;
169
- });
189
+ });
170
190
  }
171
191
 
172
192
  /**
173
193
  * Save specific HTML content to the server.
174
194
  *
195
+ * Returns a Promise that resolves with {err, data} — same arguments
196
+ * passed to the callback. Promise never rejects; errors resolve with
197
+ * truthy err. Skipped early-returns resolve with data.msgType: 'skipped'.
198
+ *
175
199
  * @param {string} html - HTML string to save
176
200
  * @param {Function} callback - Called with (err, data) on completion
201
+ * @returns {Promise<{err: ?Error, data: ?{msg: string, msgType: string}}>}
177
202
  *
178
203
  * @example
204
+ * // Callback form (unchanged)
179
205
  * saveHtml(myHtml, (err, data) => {
180
- * if (err) {
181
- * console.error('Save failed:', err);
182
- * } else {
183
- * console.log('Saved:', data);
184
- * }
206
+ * if (err) console.error('Save failed:', err);
185
207
  * });
208
+ *
209
+ * @example
210
+ * // Promise form
211
+ * const {err, data} = await saveHtml(myHtml);
212
+ * if (err) console.error('Save failed:', err);
186
213
  */
187
214
  export function saveHtml(html, callback = () => {}) {
188
- if (!isEditMode || saveInProgress) {
189
- return;
190
- }
191
-
192
- saveInProgress = true;
193
-
194
- // Test mode: skip network request, return mock success
195
- if (window.hyperclay?.testMode) {
196
- setTimeout(() => {
197
- saveInProgress = false;
198
- if (typeof callback === 'function') {
199
- callback(null, { msg: "Test mode: save skipped", msgType: "success" });
200
- }
201
- }, 0);
202
- return;
203
- }
215
+ return new Promise((resolve) => {
216
+ if (!isEditMode || saveInProgress) {
217
+ const data = {
218
+ msg: saveInProgress ? 'Save already in progress' : 'Not in edit mode',
219
+ msgType: 'skipped'
220
+ };
221
+ callback(null, data);
222
+ return resolve({ err: null, data });
223
+ }
224
+
225
+ saveInProgress = true;
226
+
227
+ // Test mode: skip network request, return mock success
228
+ if (window.hyperclay?.testMode) {
229
+ setTimeout(() => {
230
+ saveInProgress = false;
231
+ const data = { msg: "Test mode: save skipped", msgType: "success" };
232
+ if (typeof callback === 'function') {
233
+ callback(null, data);
234
+ }
235
+ resolve({ err: null, data });
236
+ }, 0);
237
+ return;
238
+ }
239
+
240
+ // Add timeout - abort if server doesn't respond within 12 seconds
241
+ const controller = new AbortController();
242
+ const timeoutId = setTimeout(() => controller.abort(), 12000);
243
+
244
+ // Check if running on Hyperclay Local - send JSON with both versions for platform sync
245
+ const isHyperclayLocal = window.location.hostname === 'localhost' ||
246
+ window.location.hostname === '127.0.0.1';
247
+
248
+ const fetchOptions = {
249
+ method: 'POST',
250
+ credentials: 'include',
251
+ signal: controller.signal,
252
+ headers: { 'Page-URL': window.location.href }
253
+ };
254
+
255
+ if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
256
+ // Send JSON with both stripped content and full snapshot for platform live sync
257
+ fetchOptions.headers['Content-Type'] = 'application/json';
258
+ fetchOptions.body = JSON.stringify({
259
+ content: html,
260
+ snapshotHtml: window.__hyperclaySnapshotHtml
261
+ });
262
+ // Clear after use to avoid stale data
263
+ window.__hyperclaySnapshotHtml = null;
264
+ } else {
265
+ // Platform: send plain text as before
266
+ fetchOptions.body = html;
267
+ }
268
+
269
+ fetch(saveEndpoint, fetchOptions)
270
+ .then(res => {
271
+ clearTimeout(timeoutId);
272
+ return res.json().then(data => {
273
+ if (!res.ok) {
274
+ throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
275
+ }
276
+ return data;
277
+ });
278
+ })
279
+ .then(data => {
280
+ if (typeof callback === 'function') {
281
+ callback(null, data);
282
+ }
283
+ resolve({ err: null, data });
284
+ })
285
+ .catch(err => {
286
+ clearTimeout(timeoutId);
287
+ console.error('Failed to save page:', err);
204
288
 
205
- // Add timeout - abort if server doesn't respond within 12 seconds
206
- const controller = new AbortController();
207
- const timeoutId = setTimeout(() => controller.abort(), 12000);
208
-
209
- // Check if running on Hyperclay Local - send JSON with both versions for platform sync
210
- const isHyperclayLocal = window.location.hostname === 'localhost' ||
211
- window.location.hostname === '127.0.0.1';
212
-
213
- const fetchOptions = {
214
- method: 'POST',
215
- credentials: 'include',
216
- signal: controller.signal,
217
- headers: { 'Page-URL': window.location.href }
218
- };
219
-
220
- if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
221
- // Send JSON with both stripped content and full snapshot for platform live sync
222
- fetchOptions.headers['Content-Type'] = 'application/json';
223
- fetchOptions.body = JSON.stringify({
224
- content: html,
225
- snapshotHtml: window.__hyperclaySnapshotHtml
226
- });
227
- // Clear after use to avoid stale data
228
- window.__hyperclaySnapshotHtml = null;
229
- } else {
230
- // Platform: send plain text as before
231
- fetchOptions.body = html;
232
- }
289
+ // Normalize timeout errors
290
+ const error = err.name === 'AbortError'
291
+ ? new Error('Server not responding')
292
+ : err;
233
293
 
234
- fetch(saveEndpoint, fetchOptions)
235
- .then(res => {
236
- clearTimeout(timeoutId);
237
- return res.json().then(data => {
238
- if (!res.ok) {
239
- throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
294
+ if (typeof callback === 'function') {
295
+ callback(error);
240
296
  }
241
- return data;
297
+ resolve({ err: error, data: null });
298
+ })
299
+ .finally(() => {
300
+ clearTimeout(timeoutId);
301
+ saveInProgress = false;
242
302
  });
243
- })
244
- .then(data => {
245
- if (typeof callback === 'function') {
246
- callback(null, data);
247
- }
248
- })
249
- .catch(err => {
250
- clearTimeout(timeoutId);
251
- console.error('Failed to save page:', err);
252
-
253
- // Normalize timeout errors
254
- const error = err.name === 'AbortError'
255
- ? new Error('Server not responding')
256
- : err;
257
-
258
- if (typeof callback === 'function') {
259
- callback(error);
260
- }
261
- })
262
- .finally(() => {
263
- clearTimeout(timeoutId);
264
- saveInProgress = false;
265
- });
303
+ });
266
304
  }
267
305
 
268
306
  /**
269
307
  * Fetch HTML from a URL and save it to replace the current page.
270
308
  *
309
+ * Returns a Promise that resolves with {err, data} — same arguments
310
+ * passed to the callback. Promise never rejects.
311
+ *
271
312
  * @param {string} url - URL to fetch HTML from
272
313
  * @param {Function} callback - Called with (err, data) on completion
314
+ * @returns {Promise<{err: ?Error, data: ?{msg: string, msgType: string}}>}
273
315
  *
274
316
  * @example
317
+ * // Callback form (unchanged)
275
318
  * replacePageWith('/templates/blog.html', (err, data) => {
276
- * if (err) {
277
- * console.error('Failed:', err);
278
- * } else {
279
- * window.location.reload();
280
- * }
319
+ * if (err) console.error('Failed:', err);
320
+ * else window.location.reload();
281
321
  * });
322
+ *
323
+ * @example
324
+ * // Promise form
325
+ * const {err, data} = await replacePageWith('/templates/blog.html');
326
+ * if (!err) window.location.reload();
282
327
  */
283
328
  export function replacePageWith(url, callback = () => {}) {
284
- if (!isEditMode || saveInProgress) {
285
- return;
286
- }
287
-
288
- fetch(url)
289
- .then(res => res.text())
290
- .then(html => {
291
- saveHtml(html, (err, data) => {
329
+ return new Promise((resolve) => {
330
+ if (!isEditMode || saveInProgress) {
331
+ const data = {
332
+ msg: saveInProgress ? 'Save already in progress' : 'Not in edit mode',
333
+ msgType: 'skipped'
334
+ };
335
+ callback(null, data);
336
+ return resolve({ err: null, data });
337
+ }
338
+
339
+ fetch(url)
340
+ .then(res => res.text())
341
+ .then(html => {
342
+ saveHtml(html, (err, data) => {
343
+ if (typeof callback === 'function') {
344
+ callback(err, data);
345
+ }
346
+ resolve({ err: err || null, data: data || null });
347
+ });
348
+ })
349
+ .catch(err => {
350
+ console.error('Failed to fetch template:', err);
292
351
  if (typeof callback === 'function') {
293
- callback(err, data);
352
+ callback(err);
294
353
  }
354
+ resolve({ err, data: null });
295
355
  });
296
- })
297
- .catch(err => {
298
- console.error('Failed to fetch template:', err);
299
- if (typeof callback === 'function') {
300
- callback(err);
301
- }
302
- });
356
+ });
303
357
  }
304
358
 
305
359
  // =============================================================================
@@ -78,8 +78,15 @@ export const beforeSave = onPrepareForSave;
78
78
  *
79
79
  * @returns {HTMLElement} Cloned document element with snapshot hooks applied
80
80
  */
81
+ function clonePreventingOnclone(node) {
82
+ const prev = window.__preventOnclone;
83
+ window.__preventOnclone = true;
84
+ try { return node.cloneNode(true); }
85
+ finally { window.__preventOnclone = prev; }
86
+ }
87
+
81
88
  export function captureSnapshot() {
82
- const clone = document.documentElement.cloneNode(true);
89
+ const clone = clonePreventingOnclone(document.documentElement);
83
90
 
84
91
  for (const hook of snapshotHooks) {
85
92
  hook(clone);
@@ -196,7 +203,7 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
196
203
  }
197
204
 
198
205
  // Clone for comparison before stripping (cheaper than cloning live DOM)
199
- const compareClone = clone.cloneNode(true);
206
+ const compareClone = clonePreventingOnclone(clone);
200
207
 
201
208
  // Save clone: strip [save-remove], then run hooks
202
209
  for (const el of clone.querySelectorAll('[save-remove]')) {
@@ -13,8 +13,10 @@ function init() {
13
13
  const clonedNode = originalCloneNode.call(this, deep);
14
14
 
15
15
  if (clonedNode.nodeType === Node.ELEMENT_NODE) {
16
- processOnclone(clonedNode);
17
- clonedNode.querySelectorAll('[onclone]').forEach(processOnclone);
16
+ if (!window.__preventOnclone) {
17
+ processOnclone(clonedNode);
18
+ clonedNode.querySelectorAll('[onclone]').forEach(processOnclone);
19
+ }
18
20
 
19
21
  // Patch textareas: the persist module writes live values to data-value
20
22
  // on every keystroke (because writing textContent on a focused textarea
package/src/hyperclay.js CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * DO NOT EDIT THIS FILE DIRECTLY — it is generated from build/hyperclay.template.js
3
3
  *
4
- * HyperclayJS v1.26.0 - Minimal Browser-Native Loader
4
+ * HyperclayJS v1.26.2 - Minimal Browser-Native Loader
5
5
  *
6
6
  * Modules auto-init when imported (no separate init call needed).
7
7
  * Include `export-to-window` feature to export to window.hyperclay.
@@ -1,13 +1,22 @@
1
1
  // debounce.js
2
2
  function debounce(callback, delay) {
3
3
  let timeoutId;
4
+ let pendingResolvers = [];
4
5
 
5
6
  return function (...args) {
7
+ const ctx = this;
6
8
  clearTimeout(timeoutId);
7
9
 
8
- timeoutId = setTimeout(() => {
9
- callback.apply(this, args);
10
- }, delay);
10
+ return new Promise((resolve) => {
11
+ pendingResolvers.push(resolve);
12
+
13
+ timeoutId = setTimeout(() => {
14
+ const resolvers = pendingResolvers;
15
+ pendingResolvers = [];
16
+ Promise.resolve(callback.apply(ctx, args))
17
+ .then(value => { for (const r of resolvers) r(value); });
18
+ }, delay);
19
+ });
11
20
  };
12
21
  }
13
22
 
@@ -1,22 +1,40 @@
1
1
  function throttle(callback, delay, executeFirst = true) {
2
2
  let lastCall = executeFirst ? 0 : Date.now();
3
3
  let timeoutId = null;
4
+ let pendingResolvers = [];
4
5
 
5
6
  return function (...args) {
7
+ const ctx = this;
6
8
  const now = Date.now();
7
9
  const remaining = delay - (now - lastCall);
8
10
 
9
- if (remaining <= 0) {
10
- clearTimeout(timeoutId);
11
- lastCall = now;
12
- return callback.apply(this, args);
13
- } else if (!timeoutId) {
14
- timeoutId = setTimeout(() => {
15
- lastCall = Date.now();
11
+ return new Promise((resolve) => {
12
+ if (remaining <= 0) {
13
+ clearTimeout(timeoutId);
16
14
  timeoutId = null;
17
- callback.apply(this, args);
18
- }, remaining);
19
- }
15
+ lastCall = now;
16
+
17
+ const resolvers = pendingResolvers.concat(resolve);
18
+ pendingResolvers = [];
19
+
20
+ Promise.resolve(callback.apply(ctx, args))
21
+ .then(value => { for (const r of resolvers) r(value); });
22
+ } else {
23
+ pendingResolvers.push(resolve);
24
+
25
+ if (!timeoutId) {
26
+ timeoutId = setTimeout(() => {
27
+ lastCall = Date.now();
28
+ timeoutId = null;
29
+ const resolvers = pendingResolvers;
30
+ pendingResolvers = [];
31
+
32
+ Promise.resolve(callback.apply(ctx, args))
33
+ .then(value => { for (const r of resolvers) r(value); });
34
+ }, remaining);
35
+ }
36
+ }
37
+ });
20
38
  };
21
39
  }
22
40