@panphora/clayjs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +57 -0
  3. package/clay.js +21 -0
  4. package/package.json +27 -0
  5. package/src/attrs/onaftersave.js +38 -0
  6. package/src/attrs/refetch-on-save.js +36 -0
  7. package/src/attrs/save-freeze.js +102 -0
  8. package/src/core/admin-attrs.js +22 -0
  9. package/src/core/admin-contenteditable.js +47 -0
  10. package/src/core/admin-inputs.js +48 -0
  11. package/src/core/admin-onclick.js +50 -0
  12. package/src/core/admin-resources.js +49 -0
  13. package/src/core/autosave.js +61 -0
  14. package/src/core/edit-mode.js +38 -0
  15. package/src/core/is-edit-mode.js +30 -0
  16. package/src/core/persist.js +103 -0
  17. package/src/core/save-core.js +385 -0
  18. package/src/core/save.js +475 -0
  19. package/src/core/snapshot.js +282 -0
  20. package/src/core/unsaved-warning.js +38 -0
  21. package/src/lib/autosave-debug.js +223 -0
  22. package/src/lib/cache-bust.js +12 -0
  23. package/src/lib/cookie.js +37 -0
  24. package/src/lib/dom-ready.js +9 -0
  25. package/src/lib/extension-noise.js +63 -0
  26. package/src/lib/load-vendor-script.js +57 -0
  27. package/src/lib/mutation.js +719 -0
  28. package/src/lib/query.js +3 -0
  29. package/src/lib/region-policy.js +220 -0
  30. package/src/lib/throttle.js +41 -0
  31. package/src/lib/user-gesture.js +126 -0
  32. package/src/loader-logic.js +62 -0
  33. package/src/loader.js +123 -0
  34. package/src/plugins/indicator.js +51 -0
  35. package/src/plugins/sortable.js +119 -0
  36. package/src/plugins/undo.js +23 -0
  37. package/src/sync/live-sync.js +752 -0
  38. package/src/vendor/Sortable.vendor.js +2 -0
  39. package/src/vendor/control-serialize.vendor.js +88 -0
  40. package/src/vendor/hyper-morph.vendor.js +22 -0
  41. package/src/vendor/hyper-undo.vendor.js +11 -0
  42. package/src/vendor/hypercms.vendor.js +1751 -0
  43. package/src/vendor/richclay.vendor.js +456 -0
@@ -0,0 +1,103 @@
1
+ import { onSnapshot } from './snapshot.js';
2
+ import { serializeControlToAttributes, finalizeControlForSave } from '../vendor/control-serialize.vendor.js';
3
+
4
+ // Persistent Form Input Values
5
+ //
6
+ // Problem: Browser form values (.value, .checked, .selectedIndex) live in JS
7
+ // memory, not in DOM attributes. When clayjs serializes the page via
8
+ // cloneNode() or outerHTML, those JS-only values are lost. This module syncs
9
+ // them back to the DOM so they survive saves, live-sync, and cloning.
10
+ //
11
+ // The per-control rules (which attribute represents which control's state) live
12
+ // in control-serialize.vendor.js — the single source of truth shared verbatim
13
+ // with sapjs (carrier mirror) so the two implementations never drift. This
14
+ // module owns only the triggers (which elements, on which events) and the
15
+ // snapshot safety net; serializeControlToAttributes / finalizeControlForSave do
16
+ // the actual attribute writes.
17
+ //
18
+ // Strategy: sync to the DOM immediately on every user interaction, not just at
19
+ // snapshot time. This means cloneNode() always gets current values.
20
+ //
21
+ // Why this is safe for each element type:
22
+ //
23
+ // <input type="text"> — setAttribute("value", ...) updates the DOM attribute
24
+ // but does NOT change the displayed text or cursor position. The browser's
25
+ // "dirty value flag" (WHATWG spec) means that once a user has typed into an
26
+ // input, the browser ignores attribute changes for display purposes. The
27
+ // attribute and the live .value property become independent surfaces.
28
+ //
29
+ // <select> — Setting/removing the "selected" attribute on <option> elements
30
+ // has no side effects on display or interaction.
31
+ //
32
+ // <input type="checkbox/radio"> — Setting/removing the "checked" attribute
33
+ // has no side effects.
34
+ //
35
+ // <textarea> — The hard case. Unlike <input>, a textarea stores its default
36
+ // value as child text nodes, not an attribute. Writing textContent while
37
+ // focused destroys cursor position, scroll, and selection. So instead, we
38
+ // write to a harmless "data-value" attribute on every keystroke (completely
39
+ // inert — no cursor, scroll, or reflow). At snapshot time, the onSnapshot
40
+ // hook reads data-value from the cloned textarea, writes it into the
41
+ // clone's textContent, and strips the attribute. The saved HTML is clean.
42
+ //
43
+ // The onSnapshot hook also serves as a safety net for all types, catching any
44
+ // values that weren't synced by the live listeners (e.g., a textarea that was
45
+ // never typed into but had its value set programmatically).
46
+ //
47
+ // Synergy with onclone (custom-attributes/onclone.js):
48
+ // When event-attrs is loaded, its cloneNode() intercept (onclone.js) patches
49
+ // data-value into textContent on cloned textareas automatically.
50
+
51
+ export default function enablePersistentFormInputValues(filterBySelector = "[persist]") {
52
+ const inputSelector = `input${filterBySelector}:not([type="password"]):not([type="hidden"]):not([type="file"])`;
53
+ const textareaSelector = `textarea${filterBySelector}`;
54
+ const selectSelector = `select${filterBySelector}`;
55
+
56
+ // --- Live DOM sync: keep attributes in sync on every user interaction ---
57
+
58
+ // Text-like inputs and textareas serialize on every keystroke; the shared
59
+ // serializer keeps it cursor-safe (value via the dirty flag, textarea via
60
+ // an inert data-value attribute).
61
+ document.addEventListener('input', (e) => {
62
+ const el = e.target;
63
+ if ((el.matches(inputSelector) && el.type !== 'checkbox' && el.type !== 'radio') ||
64
+ el.matches(textareaSelector)) {
65
+ serializeControlToAttributes(el);
66
+ }
67
+ }, true);
68
+
69
+ // Checkboxes, radios, and selects settle on change.
70
+ document.addEventListener('change', (e) => {
71
+ const el = e.target;
72
+ if ((el.matches(inputSelector) && (el.type === 'checkbox' || el.type === 'radio')) ||
73
+ el.matches(selectSelector)) {
74
+ serializeControlToAttributes(el);
75
+ }
76
+ }, true);
77
+
78
+ // --- Snapshot hook: final safety net at serialize time ---
79
+
80
+ // Safety net at serialize time: write each cloned control's attributes from
81
+ // the live element (the true source of truth — catches values set
82
+ // programmatically without firing an input event, and resolves textarea
83
+ // data-value into real textContent). Matched by index per selector; another
84
+ // hook mutating the clone could diverge the lists, hence the per-type loops.
85
+ onSnapshot((doc) => {
86
+ const finalize = (selector) => {
87
+ const live = document.querySelectorAll(selector);
88
+ const cloned = doc.querySelectorAll(selector);
89
+ cloned.forEach((c, i) => { if (live[i]) finalizeControlForSave(c, live[i]); });
90
+ };
91
+ finalize(inputSelector);
92
+ finalize(textareaSelector);
93
+ finalize(selectSelector);
94
+ });
95
+ }
96
+
97
+ // Auto-initialize with default selector
98
+ export function init() {
99
+ enablePersistentFormInputValues("[persist]");
100
+ }
101
+
102
+ // Auto-init when module is imported
103
+ init();
@@ -0,0 +1,385 @@
1
+ /**
2
+ * save-core.js — Network save functionality
3
+ *
4
+ * This module handles sending page contents to the server.
5
+ * It uses snapshot.js for capturing the DOM state.
6
+ *
7
+ * For full save system with state management, use save.js instead.
8
+ */
9
+
10
+ import { isEditMode } from "./is-edit-mode.js";
11
+ import { consumeUserDriven, markUserDriven } from "../lib/user-gesture.js";
12
+ import {
13
+ captureForSave,
14
+ beforeSave,
15
+ getPageContents,
16
+ onSnapshot,
17
+ onPrepareForSave
18
+ } from "./snapshot.js";
19
+
20
+ // =============================================================================
21
+ // STATE
22
+ // =============================================================================
23
+
24
+ let saveInProgress = false;
25
+ const saveEndpoint = '/_/save';
26
+
27
+ /**
28
+ * Check if a save is currently in progress.
29
+ * @returns {boolean}
30
+ */
31
+ export function isSaveInProgress() {
32
+ return saveInProgress;
33
+ }
34
+
35
+ /**
36
+ * Resolve the save endpoint for the current host.
37
+ *
38
+ * htmlclay (the local Go app for .htmlclay files) authenticates each save with
39
+ * a per-file token injected as the `htmlclaytoken` attribute on <html>, and
40
+ * carries it in the URL path so the same token works for fetch and EventSource.
41
+ * When that attribute is present, save to `/_/save/{token}`; otherwise use the
42
+ * bare `/_/save` (platform and Hyperclay Local, which authenticate by cookie).
43
+ *
44
+ * @returns {string} The endpoint URL for the current save.
45
+ */
46
+ function getSaveEndpoint() {
47
+ const htmlclayToken = document.documentElement.getAttribute('htmlclaytoken');
48
+ return htmlclayToken ? `${saveEndpoint}/${htmlclayToken}` : saveEndpoint;
49
+ }
50
+
51
+ // =============================================================================
52
+ // RE-EXPORTS FROM SNAPSHOT (for backwards compat)
53
+ // =============================================================================
54
+
55
+ export { beforeSave, getPageContents, onSnapshot, onPrepareForSave };
56
+
57
+ // =============================================================================
58
+ // INTERNAL: GET PAGE CONTENTS
59
+ // =============================================================================
60
+
61
+ /**
62
+ * Get the current page contents as HTML string for saving.
63
+ * Emits snapshot-ready event for live-sync.
64
+ *
65
+ * @returns {string} HTML string of current page
66
+ */
67
+ function getContentsForSave() {
68
+ // Emit for live-sync when actually saving
69
+ return captureForSave({ emitForSync: true });
70
+ }
71
+
72
+ // =============================================================================
73
+ // SAVE FUNCTIONS
74
+ // =============================================================================
75
+
76
+ /**
77
+ * Save the current page contents to the server.
78
+ *
79
+ * Returns a Promise that resolves with {msg, msgType} — the same object
80
+ * passed to the callback. Promise never rejects; errors resolve with
81
+ * msgType: 'error', skipped early-returns resolve with msgType: 'skipped'.
82
+ *
83
+ * @param {Function} callback - Called with {msg, msgType} on completion
84
+ * msgType will be 'success', 'error', or 'skipped'
85
+ * @returns {Promise<{msg: string, msgType: string}>}
86
+ *
87
+ * @example
88
+ * // Callback form (unchanged)
89
+ * savePage(({msg, msgType}) => {
90
+ * if (msgType === 'error') console.error('Save failed:', msg);
91
+ * });
92
+ *
93
+ * @example
94
+ * // Promise form
95
+ * const {msg, msgType} = await savePage();
96
+ * if (msgType === 'error') console.error('Save failed:', msg);
97
+ */
98
+ export function savePage(callback = () => {}) {
99
+ return new Promise((resolve) => {
100
+ if (saveInProgress) {
101
+ const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
102
+ callback(skipped);
103
+ return resolve(skipped);
104
+ }
105
+ if (!isEditMode && !window.clay?.testMode) {
106
+ const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
107
+ callback(skipped);
108
+ return resolve(skipped);
109
+ }
110
+
111
+ let currentContents;
112
+ try {
113
+ currentContents = getContentsForSave();
114
+ } catch (err) {
115
+ console.error('savePage: getContentsForSave failed', err);
116
+ const result = { msg: err.message, msgType: "error" };
117
+ callback(result);
118
+ return resolve(result);
119
+ }
120
+ saveInProgress = true;
121
+
122
+ // Test mode: skip network request, return mock success
123
+ if (window.clay?.testMode) {
124
+ setTimeout(() => {
125
+ saveInProgress = false;
126
+ const result = { msg: "Test mode: save skipped", msgType: "success" };
127
+ if (typeof callback === 'function') {
128
+ callback(result);
129
+ }
130
+ resolve(result);
131
+ }, 0);
132
+ return;
133
+ }
134
+
135
+ // Add timeout - abort if server doesn't respond within 12 seconds
136
+ const controller = new AbortController();
137
+ const timeoutId = setTimeout(() => controller.abort(), 12000);
138
+
139
+ // Check if running on Hyperclay Local - send JSON with both versions for platform sync
140
+ const isHyperclayLocal = window.location.hostname === 'localhost' ||
141
+ window.location.hostname === '127.0.0.1';
142
+
143
+ // Read-and-reset the data-guard provenance bit at the ACTUAL send (past the
144
+ // early returns above), so it's never consumed on a save that never ships.
145
+ const userDriven = consumeUserDriven();
146
+
147
+ const fetchOptions = {
148
+ method: 'POST',
149
+ credentials: 'include',
150
+ signal: controller.signal,
151
+ headers: { 'Page-URL': window.location.href, 'X-Hyperclay-User-Driven': userDriven ? '1' : '0' }
152
+ };
153
+
154
+ if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
155
+ // Send JSON with both stripped content and full snapshot for platform live sync
156
+ fetchOptions.headers['Content-Type'] = 'application/json';
157
+ fetchOptions.body = JSON.stringify({
158
+ content: currentContents,
159
+ snapshotHtml: window.__hyperclaySnapshotHtml,
160
+ userDriven
161
+ });
162
+ } else {
163
+ // Platform: send plain text as before
164
+ fetchOptions.body = currentContents;
165
+ }
166
+
167
+ fetch(getSaveEndpoint(), fetchOptions)
168
+ .then(res => {
169
+ clearTimeout(timeoutId);
170
+ return res.json().then(data => {
171
+ if (!res.ok) {
172
+ throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
173
+ }
174
+ return data;
175
+ });
176
+ })
177
+ .then(data => {
178
+ // Clear the snapshot only once the save actually landed (a failed save
179
+ // keeps it so a retry still carries the provenance snapshot).
180
+ window.__hyperclaySnapshotHtml = null;
181
+ const result = { msg: data.msg, msgType: data.msgType || 'success' };
182
+ if (typeof callback === 'function') {
183
+ callback(result);
184
+ }
185
+ resolve(result);
186
+ })
187
+ .catch(err => {
188
+ clearTimeout(timeoutId);
189
+ console.error('Failed to save page:', err);
190
+
191
+ // The save never landed: re-arm the user-driven bit so the next (retry)
192
+ // save still reports the human gesture instead of reading as background.
193
+ if (userDriven) markUserDriven();
194
+
195
+ const msg = err.name === 'AbortError'
196
+ ? 'Server not responding'
197
+ : 'Save failed';
198
+
199
+ const result = { msg, msgType: "error" };
200
+ if (typeof callback === 'function') {
201
+ callback(result);
202
+ }
203
+ resolve(result);
204
+ })
205
+ .finally(() => {
206
+ clearTimeout(timeoutId);
207
+ saveInProgress = false;
208
+ });
209
+ });
210
+ }
211
+
212
+ /**
213
+ * Save specific HTML content to the server.
214
+ *
215
+ * Returns a Promise that resolves with {err, data} — same arguments
216
+ * passed to the callback. Promise never rejects; errors resolve with
217
+ * truthy err. Skipped early-returns resolve with data.msgType: 'skipped'.
218
+ *
219
+ * @param {string} html - HTML string to save
220
+ * @param {Function} callback - Called with (err, data) on completion
221
+ * @returns {Promise<{err: ?Error, data: ?{msg: string, msgType: string}}>}
222
+ *
223
+ * @example
224
+ * // Callback form (unchanged)
225
+ * saveHtml(myHtml, (err, data) => {
226
+ * if (err) console.error('Save failed:', err);
227
+ * });
228
+ *
229
+ * @example
230
+ * // Promise form
231
+ * const {err, data} = await saveHtml(myHtml);
232
+ * if (err) console.error('Save failed:', err);
233
+ */
234
+ export function saveHtml(html, callback = () => {}) {
235
+ return new Promise((resolve) => {
236
+ if (!isEditMode || saveInProgress) {
237
+ const data = {
238
+ msg: saveInProgress ? 'Save already in progress' : 'Not in edit mode',
239
+ msgType: 'skipped'
240
+ };
241
+ callback(null, data);
242
+ return resolve({ err: null, data });
243
+ }
244
+
245
+ saveInProgress = true;
246
+
247
+ // Test mode: skip network request, return mock success
248
+ if (window.clay?.testMode) {
249
+ setTimeout(() => {
250
+ saveInProgress = false;
251
+ const data = { msg: "Test mode: save skipped", msgType: "success" };
252
+ if (typeof callback === 'function') {
253
+ callback(null, data);
254
+ }
255
+ resolve({ err: null, data });
256
+ }, 0);
257
+ return;
258
+ }
259
+
260
+ // Add timeout - abort if server doesn't respond within 12 seconds
261
+ const controller = new AbortController();
262
+ const timeoutId = setTimeout(() => controller.abort(), 12000);
263
+
264
+ // Check if running on Hyperclay Local - send JSON with both versions for platform sync
265
+ const isHyperclayLocal = window.location.hostname === 'localhost' ||
266
+ window.location.hostname === '127.0.0.1';
267
+
268
+ const userDriven = consumeUserDriven();
269
+
270
+ const fetchOptions = {
271
+ method: 'POST',
272
+ credentials: 'include',
273
+ signal: controller.signal,
274
+ headers: { 'Page-URL': window.location.href, 'X-Hyperclay-User-Driven': userDriven ? '1' : '0' }
275
+ };
276
+
277
+ if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
278
+ // Send JSON with both stripped content and full snapshot for platform live sync
279
+ fetchOptions.headers['Content-Type'] = 'application/json';
280
+ fetchOptions.body = JSON.stringify({
281
+ content: html,
282
+ snapshotHtml: window.__hyperclaySnapshotHtml,
283
+ userDriven
284
+ });
285
+ } else {
286
+ // Platform: send plain text as before
287
+ fetchOptions.body = html;
288
+ }
289
+
290
+ fetch(getSaveEndpoint(), fetchOptions)
291
+ .then(res => {
292
+ clearTimeout(timeoutId);
293
+ return res.json().then(data => {
294
+ if (!res.ok) {
295
+ throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
296
+ }
297
+ return data;
298
+ });
299
+ })
300
+ .then(data => {
301
+ // Clear the snapshot only once the save actually landed (a failed save
302
+ // keeps it so a retry still carries the provenance snapshot).
303
+ window.__hyperclaySnapshotHtml = null;
304
+ if (typeof callback === 'function') {
305
+ callback(null, data);
306
+ }
307
+ resolve({ err: null, data });
308
+ })
309
+ .catch(err => {
310
+ clearTimeout(timeoutId);
311
+ console.error('Failed to save page:', err);
312
+
313
+ // The save never landed: re-arm the user-driven bit so the next (retry)
314
+ // save still reports the human gesture instead of reading as background.
315
+ if (userDriven) markUserDriven();
316
+
317
+ // Normalize timeout errors
318
+ const error = err.name === 'AbortError'
319
+ ? new Error('Server not responding')
320
+ : err;
321
+
322
+ if (typeof callback === 'function') {
323
+ callback(error);
324
+ }
325
+ resolve({ err: error, data: null });
326
+ })
327
+ .finally(() => {
328
+ clearTimeout(timeoutId);
329
+ saveInProgress = false;
330
+ });
331
+ });
332
+ }
333
+
334
+ /**
335
+ * Fetch HTML from a URL and save it to replace the current page.
336
+ *
337
+ * Returns a Promise that resolves with {err, data} — same arguments
338
+ * passed to the callback. Promise never rejects.
339
+ *
340
+ * @param {string} url - URL to fetch HTML from
341
+ * @param {Function} callback - Called with (err, data) on completion
342
+ * @returns {Promise<{err: ?Error, data: ?{msg: string, msgType: string}}>}
343
+ *
344
+ * @example
345
+ * // Callback form (unchanged)
346
+ * replacePageWith('/templates/blog.html', (err, data) => {
347
+ * if (err) console.error('Failed:', err);
348
+ * else window.location.reload();
349
+ * });
350
+ *
351
+ * @example
352
+ * // Promise form
353
+ * const {err, data} = await replacePageWith('/templates/blog.html');
354
+ * if (!err) window.location.reload();
355
+ */
356
+ export function replacePageWith(url, callback = () => {}) {
357
+ return new Promise((resolve) => {
358
+ if (!isEditMode || saveInProgress) {
359
+ const data = {
360
+ msg: saveInProgress ? 'Save already in progress' : 'Not in edit mode',
361
+ msgType: 'skipped'
362
+ };
363
+ callback(null, data);
364
+ return resolve({ err: null, data });
365
+ }
366
+
367
+ fetch(url)
368
+ .then(res => res.text())
369
+ .then(html => {
370
+ saveHtml(html, (err, data) => {
371
+ if (typeof callback === 'function') {
372
+ callback(err, data);
373
+ }
374
+ resolve({ err: err || null, data: data || null });
375
+ });
376
+ })
377
+ .catch(err => {
378
+ console.error('Failed to fetch template:', err);
379
+ if (typeof callback === 'function') {
380
+ callback(err);
381
+ }
382
+ resolve({ err, data: null });
383
+ });
384
+ });
385
+ }