@panphora/clayjs 0.4.2 → 0.5.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@panphora/clayjs",
3
- "version": "0.4.2",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "clayjs: malleable HTML files. Save lifecycle for self-saving HTML.",
6
6
  "license": "MIT",
@@ -22,9 +22,10 @@ export function enableContentEditableForAdminOnPageLoad () {
22
22
  });
23
23
  }
24
24
 
25
- // Runtime toggle functions
26
- export function enableContentEditable() {
27
- document.querySelectorAll(SELECTOR).forEach(el => {
25
+ // Runtime toggle functions. `root` lets scoped live sync activate a parsed
26
+ // incoming document the same way boot activates the live one.
27
+ export function enableContentEditable(root = document) {
28
+ root.querySelectorAll(SELECTOR).forEach(el => {
28
29
  let val = el.getAttribute("inert-contenteditable");
29
30
  if (!["false", "plaintext-only"].includes(val)) val = "true";
30
31
  el.setAttribute("contenteditable", val);
@@ -24,11 +24,13 @@ export function enableAdminInputsOnPageLoad() {
24
24
  });
25
25
  }
26
26
 
27
- export function enableAdminInputs() {
28
- document.querySelectorAll(SELECTOR_DISABLED).forEach(input => {
27
+ // `root` lets scoped live sync activate a parsed incoming document the same
28
+ // way boot activates the live one.
29
+ export function enableAdminInputs(root = document) {
30
+ root.querySelectorAll(SELECTOR_DISABLED).forEach(input => {
29
31
  input.removeAttribute('disabled');
30
32
  });
31
- document.querySelectorAll(SELECTOR_READONLY).forEach(input => {
33
+ root.querySelectorAll(SELECTOR_READONLY).forEach(input => {
32
34
  input.removeAttribute('readonly');
33
35
  });
34
36
  }
@@ -22,9 +22,10 @@ export function enableOnClickForAdminOnPageLoad () {
22
22
  });
23
23
  }
24
24
 
25
- // Runtime toggle functions
26
- export function enableOnClick() {
27
- document.querySelectorAll(SELECTOR).forEach(el => {
25
+ // Runtime toggle functions. `root` lets scoped live sync activate a parsed
26
+ // incoming document the same way boot activates the live one.
27
+ export function enableOnClick(root = document) {
28
+ root.querySelectorAll(SELECTOR).forEach(el => {
28
29
  const val = el.getAttribute("inert-onclick");
29
30
  if (val) {
30
31
  el.setAttribute("onclick", val);
@@ -37,11 +37,15 @@ export function enableAdminResourcesOnPageLoad () {
37
37
  });
38
38
  }
39
39
 
40
- // Runtime toggle functions
41
- export function enableAdminResources() {
42
- document.querySelectorAll(SELECTOR_INERT).forEach(resource => {
40
+ // Runtime toggle functions. `root` lets scoped live sync activate a parsed
41
+ // incoming document; the clone-swap re-execution trick only applies to the
42
+ // live document — in a detached parse nothing executes, and the morph's own
43
+ // script handling decides execution when the content lands.
44
+ export function enableAdminResources(root = document) {
45
+ const live = (root.ownerDocument || root) === document;
46
+ root.querySelectorAll(SELECTOR_INERT).forEach(resource => {
43
47
  makeActive(resource);
44
- resource.replaceWith(resource.cloneNode(true));
48
+ if (live) resource.replaceWith(resource.cloneNode(true));
45
49
  });
46
50
  }
47
51
 
@@ -4,8 +4,9 @@ import { hasSaveToken } from "./host-attrs.js";
4
4
 
5
5
  // Edit-mode precedence: an explicit ?editmode=true|false URL param wins, then an
6
6
  // opt-in window.clayEditMode global (with the legacy window.__hyperclayEditMode
7
- // still honored as a fallback — htmlclay injects it today), then a save token the
8
- // host put on the root, then the platform's isAdminOfCurrentResource cookie. The
7
+ // still honored as a fallback for older standalone embedders — htmlclay itself
8
+ // uses the injected htmlclaytoken plus the admin cookie, not this global), then
9
+ // a save token the host put on the root, then the isAdminOfCurrentResource cookie. The
9
10
  // global is for standalone uses (demos, htmlclay, any self-saving file) that are
10
11
  // always editable and have no owner cookie; setting it before clayjs loads turns
11
12
  // on the edit-only modules.
package/src/core/save.js CHANGED
@@ -20,6 +20,7 @@ import {
20
20
  isSaveInProgress
21
21
  } from "./save-core.js";
22
22
  import { captureForComparison, captureForSaveAndComparison } from "./snapshot.js";
23
+ import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
23
24
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
24
25
 
25
26
  // Reset savestatus to 'saved' in snapshots (each module cleans up its own attrs)
@@ -129,10 +130,13 @@ function skipped_(msg) {
129
130
  * where the write may or may not have landed, so it is not treated as success
130
131
  * either.
131
132
  */
132
- function applySaveResult(result, forComparison, label) {
133
+ function applySaveResult(result, forComparison, label, gateToken) {
133
134
  if (result.ok) {
134
135
  lastSavedContents = forComparison;
135
136
  unsavedChanges = false;
137
+ // Generation-checked: clears the scoped-sync dirty gate only if nothing
138
+ // changed while this save was on the wire.
139
+ gateClearIfUnchanged(gateToken);
136
140
  // The server's severity rides through untouched: a save can land AND carry a
137
141
  // warning, and the UI module is what decides how to render that.
138
142
  setSaveState('saved', result.msg || 'Saved', result.msgType);
@@ -201,6 +205,7 @@ export function savePage(callback = () => {}) {
201
205
  // forSave strips non-persisted regions ([no-save]/[save-remove])
202
206
  // forComparison additionally strips every autosave-off region
203
207
  let forSave, forComparison, snapshotHtml;
208
+ const gateToken = gateCaptureToken();
204
209
  try {
205
210
  ({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
206
211
  } catch (err) {
@@ -219,6 +224,7 @@ export function savePage(callback = () => {}) {
219
224
 
220
225
  // Skip if content hasn't changed
221
226
  if (!unsavedChanges) {
227
+ gateClearIfUnchanged(gateToken);
222
228
  const skipped = skipped_('No changes to save');
223
229
  callback(skipped);
224
230
  return resolve(skipped);
@@ -229,7 +235,7 @@ export function savePage(callback = () => {}) {
229
235
 
230
236
  // Use saveHtml directly with our pre-captured content (avoids double capture)
231
237
  saveHtml(forSave, (result) => {
232
- applySaveResult(result, forComparison, 'updated after save');
238
+ applySaveResult(result, forComparison, 'updated after save', gateToken);
233
239
  if (typeof callback === 'function') {
234
240
  callback(result);
235
241
  }
@@ -266,6 +272,7 @@ export function savePageForce(callback = () => {}) {
266
272
  }
267
273
 
268
274
  let forSave, forComparison, snapshotHtml;
275
+ const gateToken = gateCaptureToken();
269
276
  try {
270
277
  ({ forSave, forComparison, snapshotHtml } = captureForSaveAndComparison());
271
278
  } catch (err) {
@@ -281,7 +288,7 @@ export function savePageForce(callback = () => {}) {
281
288
  setSavingState();
282
289
 
283
290
  saveHtml(forSave, (result) => {
284
- applySaveResult(result, forComparison, 'updated after force save');
291
+ applySaveResult(result, forComparison, 'updated after force save', gateToken);
285
292
  if (typeof callback === 'function') {
286
293
  callback(result);
287
294
  }
@@ -383,9 +390,13 @@ function initBaselineCapture() {
383
390
  // (if a save happened, lastSavedContents would differ from immediateContents)
384
391
  if (!userEdited && lastSavedContents === immediateContents) {
385
392
  // Store stripped version so comparisons are direct (no parsing needed)
393
+ const gateToken = gateCaptureToken();
386
394
  const contents = captureForComparison();
387
395
  lastSavedContents = contents;
388
396
  baselineContents = contents;
397
+ // Boot churn (modules rewriting attributes at DOM-ready) counted toward
398
+ // the scoped-sync gate; the settled baseline is the proof it wasn't edits.
399
+ gateClearIfUnchanged(gateToken);
389
400
  logBaseline('settled capture', `${contents.length} chars`);
390
401
  } else {
391
402
  logBaseline('settled skipped', userEdited ? 'user edited' : 'save occurred during settle');
@@ -446,6 +457,7 @@ export function savePageThrottled(callback = () => {}) {
446
457
  // matters — otherwise undoing back to the page's original state can never be
447
458
  // persisted, because it matches the baseline forever.
448
459
  // Compare directly - stored versions are already stripped
460
+ const gateToken = gateCaptureToken();
449
461
  const currentForCompare = captureForComparison();
450
462
  const differsFromBaseline = !baselineActive || currentForCompare !== baselineContents;
451
463
  const differsFromLastSave = currentForCompare !== lastSavedContents;
@@ -454,6 +466,7 @@ export function savePageThrottled(callback = () => {}) {
454
466
  logSaveCheck('throttled vs lastSave', !differsFromLastSave);
455
467
 
456
468
  if (!(differsFromBaseline && differsFromLastSave)) {
469
+ if (!differsFromLastSave) gateClearIfUnchanged(gateToken);
457
470
  const skipped = skipped_('No changes to save');
458
471
  callback(skipped);
459
472
  return Promise.resolve(skipped);
@@ -116,13 +116,16 @@ function clonePreventingOnclone(node) {
116
116
  finally { window.__preventOnclone = prev; }
117
117
  }
118
118
 
119
- export function captureSnapshot() {
119
+ export function captureSnapshot({ flushUndo = true } = {}) {
120
120
  // Force-close any pending undo idle batch BEFORE cloning the DOM, so the
121
121
  // snapshot reflects a clean undo boundary. Without this, a save that fires
122
122
  // mid-typing would leave the idle batch open across the save boundary, and
123
123
  // Cmd+Z after save would restore to a state earlier than the last save.
124
124
  // No-op when undo isn't loaded or no batch is pending.
125
- if (typeof window !== 'undefined' && window.clay?.undo?.flush) {
125
+ // flushUndo: false is for captures that are not save boundaries (the scoped
126
+ // live-sync dirty oracle runs per incoming frame, and flushing there would
127
+ // shatter the user's undo batches mid-typing).
128
+ if (flushUndo && typeof window !== 'undefined' && window.clay?.undo?.flush) {
126
129
  window.clay.undo.flush();
127
130
  }
128
131
 
@@ -181,8 +184,8 @@ function prepareCloneForSave(clone) {
181
184
  *
182
185
  * @returns {string} HTML string with all autosave-off regions stripped
183
186
  */
184
- export function captureForComparison() {
185
- const clone = captureSnapshot();
187
+ export function captureForComparison({ flushUndo = true } = {}) {
188
+ const clone = captureSnapshot({ flushUndo });
186
189
 
187
190
  // Run inline [onbeforesave] handlers
188
191
  runAuthoredHandlers(clone, 'onbeforesave');
@@ -260,6 +263,58 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
260
263
  return { forSave, forComparison, snapshotHtml };
261
264
  }
262
265
 
266
+ /**
267
+ * Capture for a protected live-sync merge: the save-domain and comparison-
268
+ * domain clones of ONE snapshot, plus a WeakMap pairing every comparison
269
+ * element to its save-clone twin.
270
+ *
271
+ * The pairing is recorded immediately after the comparison clone is created,
272
+ * while the two trees are still isomorphic; each side's strips then remove
273
+ * nodes independently without disturbing it. The scoped-sync dirty diff runs
274
+ * on the comparison clone (the same domain as lastSavedContents), and dirty
275
+ * roots map through pairMap to save-domain subtrees (the same domain as the
276
+ * file on disk), which still carry the no-trigger-autosave / freeze / no-watch
277
+ * children the comparison strips.
278
+ *
279
+ * Never emits snapshot-ready (this capture must not feed the send pipeline)
280
+ * and never flushes the undo batch (it runs per incoming frame, not per save).
281
+ *
282
+ * @returns {{ saveClone: HTMLElement, compareClone: HTMLElement, pairMap: WeakMap }}
283
+ */
284
+ export function captureForMerge() {
285
+ const clone = captureSnapshot({ flushUndo: false });
286
+
287
+ runAuthoredHandlers(clone, 'onbeforesave');
288
+
289
+ const compareClone = clonePreventingOnclone(clone);
290
+
291
+ const pairMap = new WeakMap();
292
+ (function pair(compareEl, saveEl) {
293
+ pairMap.set(compareEl, saveEl);
294
+ const compareKids = compareEl.children;
295
+ const saveKids = saveEl.children;
296
+ for (let i = 0; i < compareKids.length; i++) {
297
+ pair(compareKids[i], saveKids[i]);
298
+ }
299
+ })(compareClone, clone);
300
+
301
+ for (const hook of documentTransforms) {
302
+ hook(clone);
303
+ }
304
+ for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
305
+ el.remove();
306
+ }
307
+
308
+ for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
309
+ el.remove();
310
+ }
311
+ for (const hook of documentTransforms) {
312
+ hook(compareClone);
313
+ }
314
+
315
+ return { saveClone: clone, compareClone, pairMap };
316
+ }
317
+
263
318
  /**
264
319
  * PHASE 1-4: Full pipeline for saving to server.
265
320
  *
@@ -0,0 +1,152 @@
1
+ /**
2
+ * dirty-gate.js — "might this page hold unsaved edits?" as a cheap counter.
3
+ *
4
+ * Scoped live sync must decide, on every incoming frame, whether to pay for a
5
+ * full capture-and-diff (the oracle) or apply the frame directly. This gate is
6
+ * the cheap side of that decision. It may over-report (a false "dirty" just
7
+ * runs the oracle, which then finds nothing), but it must never under-report:
8
+ * a false "clean" full-morphs over a real unsaved edit.
9
+ *
10
+ * Two feeds, because neither alone sees everything:
11
+ * - the mutation hub (require: 'autosave'), which sees DOM changes but not
12
+ * value-property writes on form controls (typing fires no MutationRecord
13
+ * for .value), and
14
+ * - capture-phase input/change listeners on ALL form controls, not just
15
+ * [persist] ones, plus contenteditable hosts.
16
+ *
17
+ * The one hole left is a PROGRAMMATIC value write on a [persist] control: no
18
+ * event, no MutationRecord. persistProbeDirty() closes it by comparing each
19
+ * [persist] control's live state against its serialized default state, with a
20
+ * per-element cache so a value the oracle or a save has already verified stops
21
+ * costing a probe miss on every frame.
22
+ *
23
+ * Clearing is generation-checked: a save records the counter at capture time
24
+ * and clears only if nothing changed while the request was on the wire, so
25
+ * keystrokes during an in-flight save keep the gate dirty.
26
+ */
27
+
28
+ import Mutation from './mutation.js';
29
+ import { isEditMode } from '../core/is-edit-mode.js';
30
+
31
+ let changes = 0;
32
+ let clearedAt = 0;
33
+ let paused = false;
34
+ let started = false;
35
+
36
+ const PERSIST_CONTROLS = 'input[persist], textarea[persist], select[persist]';
37
+ const probeCache = new WeakMap();
38
+
39
+ function onUserInput(event) {
40
+ // Deliberately NOT gated on `paused`: a morph never dispatches input or
41
+ // change events, so anything arriving here is the user — including typing
42
+ // during a morph's async resource wait, which must keep the page dirty.
43
+ const el = event.target;
44
+ if (!el || el.nodeType !== 1) return;
45
+ if (el.matches('input, textarea, select') || el.isContentEditable) {
46
+ changes++;
47
+ }
48
+ }
49
+
50
+ export function startDirtyGate() {
51
+ if (started) return;
52
+ started = true;
53
+ Mutation.onAnyChange(
54
+ { omitChangeDetails: true, require: 'autosave' },
55
+ () => {
56
+ if (!paused) changes++;
57
+ }
58
+ );
59
+ document.addEventListener('input', onUserInput, true);
60
+ document.addEventListener('change', onUserInput, true);
61
+ }
62
+
63
+ /** The morph-apply window: nothing that happens inside it is a user edit. */
64
+ export function pauseGate() { paused = true; }
65
+ export function resumeGate() { paused = false; }
66
+
67
+ function controlSignature(el) {
68
+ if (el instanceof HTMLSelectElement) {
69
+ return Array.from(el.selectedOptions, (o) => o.value).join('\u0000');
70
+ }
71
+ if (el instanceof HTMLInputElement && (el.type === 'checkbox' || el.type === 'radio')) {
72
+ return el.checked ? '1' : '0';
73
+ }
74
+ return el.value;
75
+ }
76
+
77
+ // True when the control's live state matches its serialized (attribute-level)
78
+ // state — i.e. a snapshot taken right now would carry nothing new for it.
79
+ // persist's own listeners keep attributes current for USER input; only
80
+ // programmatic writes diverge here.
81
+ function serializedStateMatches(el) {
82
+ if (el instanceof HTMLSelectElement) {
83
+ return Array.from(el.options).every((o) => o.selected === o.defaultSelected);
84
+ }
85
+ if (el instanceof HTMLInputElement) {
86
+ if (el.type === 'checkbox' || el.type === 'radio') {
87
+ return el.checked === el.defaultChecked;
88
+ }
89
+ return el.value === el.defaultValue;
90
+ }
91
+ if (el instanceof HTMLTextAreaElement) {
92
+ if (el.hasAttribute('data-value')) {
93
+ return el.value === el.getAttribute('data-value');
94
+ }
95
+ return el.value === el.defaultValue;
96
+ }
97
+ return true;
98
+ }
99
+
100
+ export function persistProbeDirty() {
101
+ for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
102
+ const sig = controlSignature(el);
103
+ if (probeCache.get(el) === sig) continue;
104
+ if (serializedStateMatches(el)) {
105
+ probeCache.set(el, sig);
106
+ continue;
107
+ }
108
+ return true;
109
+ }
110
+ return false;
111
+ }
112
+
113
+ /** Call after the oracle verified the whole page clean against its baseline. */
114
+ export function probeMarkClean() {
115
+ for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
116
+ probeCache.set(el, controlSignature(el));
117
+ }
118
+ }
119
+
120
+ export function pageMaybeDirty() {
121
+ return changes > clearedAt || persistProbeDirty();
122
+ }
123
+
124
+ /**
125
+ * Record the gate state at capture time. The save flow takes a token when it
126
+ * clones the DOM and hands it back on success (or on a verified "no changes"),
127
+ * so a clear can never swallow edits made while the save was in flight.
128
+ */
129
+ export function gateCaptureToken() {
130
+ const probe = [];
131
+ for (const el of document.querySelectorAll(PERSIST_CONTROLS)) {
132
+ probe.push([el, controlSignature(el)]);
133
+ }
134
+ return { gen: changes, probe };
135
+ }
136
+
137
+ export function gateClearIfUnchanged(token) {
138
+ if (!token) return;
139
+ if (changes === token.gen) {
140
+ clearedAt = token.gen;
141
+ }
142
+ // Probe entries clear per element, and only if the element still holds the
143
+ // exact value the capture carried — a programmatic write between capture
144
+ // and completion stays dirty.
145
+ for (const [el, sig] of token.probe) {
146
+ if (controlSignature(el) === sig) probeCache.set(el, sig);
147
+ }
148
+ }
149
+
150
+ if (typeof document !== 'undefined' && isEditMode) {
151
+ startDirtyGate();
152
+ }
@@ -12,7 +12,11 @@
12
12
  // per-tab and makes the host strip it before writing, so it never reaches disk.
13
13
  // But §9 bounds only the save path, and §10 fans a snapshot out to other editors'
14
14
  // browsers, which is the hole this module closes.
15
- export const HOST_TOKEN_ATTRS = ["savetoken", "htmlclaytoken"];
15
+ //
16
+ // htmlclayid is host-injected too (htmlclay's durable file identity, stamped on
17
+ // every serve, absent from disk bytes). It rides here so a morph of raw disk
18
+ // content cannot strip this tab's copy, and a peer's copy is never applied.
19
+ export const HOST_TOKEN_ATTRS = ["savetoken", "htmlclaytoken", "htmlclayid"];
16
20
 
17
21
  // Which save envelope this host's lane takes: a fact about the response, not
18
22
  // about the document.