@panphora/clayjs 1.1.0 → 1.3.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/README.md +4 -1
  2. package/THIRD-PARTY-NOTICES.md +10 -0
  3. package/dist/clay.standalone.js +21751 -13942
  4. package/entries/clay-data.js +1 -1
  5. package/package.json +7 -2
  6. package/packed-contract.json +17 -0
  7. package/src/attrs/save-freeze.js +10 -18
  8. package/src/core/admin-contenteditable.js +5 -6
  9. package/src/core/etag.js +17 -4
  10. package/src/core/host-attrs.js +52 -2
  11. package/src/core/is-edit-mode.js +14 -5
  12. package/src/core/persist.js +5 -10
  13. package/src/core/save-conflict-notice.js +22 -27
  14. package/src/core/save-core.js +326 -26
  15. package/src/core/save.js +19 -4
  16. package/src/core/snapshot.js +148 -15
  17. package/src/core/source-map.js +817 -0
  18. package/src/core/stale-host-notice.js +95 -0
  19. package/src/core/unsaved-warning.js +3 -0
  20. package/src/dom/dom-helpers.js +5 -1
  21. package/src/lib/content-dom.js +108 -0
  22. package/src/lib/hostile-css.js +49 -0
  23. package/src/lib/mutation.js +26 -3
  24. package/src/lib/region-capabilities.js +69 -0
  25. package/src/lib/region-policy.js +18 -13
  26. package/src/lib/root-attrs.js +52 -13
  27. package/src/loader-logic.js +25 -4
  28. package/src/loader.js +4 -0
  29. package/src/plugins/demo.js +3 -0
  30. package/src/plugins/sortable.js +6 -1
  31. package/src/plugins/source.js +326 -0
  32. package/src/plugins/wire.js +248 -47
  33. package/src/sync/live-sync.js +275 -63
  34. package/src/sync/presence.js +303 -0
  35. package/src/sync/section-notice.js +230 -0
  36. package/src/sync/splice-merge.js +7 -10
  37. package/src/sync/stream.js +190 -0
  38. package/src/vendor/hyper-morph.vendor.js +2 -2
  39. package/src/vendor/hyper-undo.vendor.js +1 -1
  40. package/src/vendor/hypercms.vendor.js +438 -45
  41. package/src/vendor/parse5.vendor.js +3 -0
  42. package/src/vendor/quickcrop.vendor.js +1 -1
  43. package/src/vendor/richclay.vendor.js +22 -15
@@ -30,7 +30,9 @@
30
30
  * ┌─────────────────────────┐
31
31
  * │ 4. SERIALIZE │
32
32
  * │ "<!DOCTYPE html>" │
33
- * │ + outerHTML │
33
+ * │ + outerHTML, unless a │
34
+ * │ save renderer is set │
35
+ * │ (setSaveRenderer) │
34
36
  * │ │
35
37
  * │ → sent to server │
36
38
  * └─────────────────────────┘
@@ -40,6 +42,7 @@ import { stripExtensionNoise } from '../lib/extension-noise.js';
40
42
  import { restoreAuthoredUrls } from '../lib/authored-url.js';
41
43
  import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, STRIP_FROM_DIRTY_CHECK, NO_TRIGGER_AUTOSAVE_SELECTOR, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
42
44
  import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
45
+ import { createContentView } from '../lib/content-dom.js';
43
46
 
44
47
  // =============================================================================
45
48
  // HOOK REGISTRIES
@@ -47,6 +50,20 @@ import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
47
50
 
48
51
  const snapshotHooks = []; // Phase 2: Always run (form sync)
49
52
  const documentTransforms = []; // Phase 3a: Save and change check (strip admin)
53
+ const snapshotProvenance = new WeakMap();
54
+
55
+ // Phase 4, and there is only ever one. Everything else in this file is a list
56
+ // because many things want a turn; serializing the document is one decision, so a
57
+ // second renderer would be two answers to "what bytes does this save send".
58
+ let saveRenderer = null;
59
+
60
+ export function originalSnapshotNode(cloneNode) {
61
+ return snapshotProvenance.get(cloneNode) || null;
62
+ }
63
+
64
+ function stripSnapshotRegions(clone) {
65
+ for (const el of clone.querySelectorAll(SNAPSHOT_REMOVE_SELECTOR)) el.remove();
66
+ }
50
67
 
51
68
  /**
52
69
  * Run every authored handler of one kind over a clone, and never let one of them
@@ -63,6 +80,8 @@ const documentTransforms = []; // Phase 3a: Save and change check (strip admin)
63
80
  */
64
81
  function runAuthoredHandlers(clone, attr) {
65
82
  for (const el of clone.querySelectorAll(`[${attr}]`)) {
83
+ stripSnapshotRegions(clone);
84
+ if (el !== clone && !clone.contains(el)) continue;
66
85
  try {
67
86
  new Function(el.getAttribute(attr)).call(el);
68
87
  } catch (err) {
@@ -81,6 +100,51 @@ export function onSnapshot(callback) {
81
100
  snapshotHooks.push(callback);
82
101
  }
83
102
 
103
+ /**
104
+ * Replace the save-domain serializer.
105
+ *
106
+ * Given the prepared save clone and the bytes `clone.outerHTML` would have sent, it
107
+ * returns the bytes to send instead. The source map (plugins/source.js) is the only
108
+ * caller: it renders the file's own bytes back out with only the changed regions
109
+ * reprinted, so a save stops rewriting 88% of an authored document's lines.
110
+ *
111
+ * It runs on the save path with a person's document in its hands, so the contract is
112
+ * narrow. It must return a complete document. It must not touch the clone, which is
113
+ * also the source of both comparison baselines. And it must be able to answer with
114
+ * `today` unchanged, because that is what it has to do whenever it is not certain:
115
+ * the caller here treats a throw the same way, so the floor of any renderer is the
116
+ * serialization it replaced.
117
+ *
118
+ * Comparison and dirty-check captures deliberately do NOT go through it. Those
119
+ * strings are only ever compared against each other, so they gain nothing from
120
+ * source fidelity and would pay a full render on every keystroke's dirty check.
121
+ *
122
+ * @param {?Function} renderer - (clone, today) => string, or null to restore outerHTML
123
+ */
124
+ export function setSaveRenderer(renderer) {
125
+ saveRenderer = renderer;
126
+ }
127
+
128
+ /**
129
+ * The bytes a save sends: the prepared clone, through the save renderer if one is
130
+ * installed.
131
+ *
132
+ * The try/catch is not defensive noise around a renderer that already catches its
133
+ * own errors. That one catches in order to COUNT, and to fire the event that says a
134
+ * document reprinted; this one is the guarantee that no bug in an optional plugin can
135
+ * cost somebody a save.
136
+ */
137
+ function serializeSaveClone(clone) {
138
+ const today = "<!DOCTYPE html>" + clone.outerHTML;
139
+ if (!saveRenderer) return today;
140
+ try {
141
+ return saveRenderer(clone, today);
142
+ } catch (err) {
143
+ console.error('clayjs: save renderer failed, sending the full serialization', err);
144
+ return today;
145
+ }
146
+ }
147
+
84
148
  /**
85
149
  * Register a transform that runs over a detached clone when preparing to save.
86
150
  * Use for: stripping admin elements, cleanup.
@@ -116,7 +180,7 @@ function clonePreventingOnclone(node, deep = true) {
116
180
  finally { window.__preventOnclone = prev; }
117
181
  }
118
182
 
119
- export function captureSnapshot({ flushUndo = true } = {}) {
183
+ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
120
184
  // Force-close any pending undo idle batch BEFORE cloning the DOM, so the
121
185
  // snapshot reflects a clean undo boundary. Without this, a save that fires
122
186
  // mid-typing would leave the idle batch open across the save boundary, and
@@ -129,10 +193,27 @@ export function captureSnapshot({ flushUndo = true } = {}) {
129
193
  window.clay.undo.flush();
130
194
  }
131
195
 
132
- const clone = clonePreventingOnclone(document.documentElement);
196
+ const view = createContentView(document.documentElement, { capability: 'snapshot' });
197
+ const clone = view.root;
198
+ // A <template>'s children are not its childNodes, they are its content fragment's,
199
+ // so walking childNodes alone leaves everything inside a template with no provenance.
200
+ // Nothing noticed until the source map asked which live node a clone node came from
201
+ // and got null for every node in a template, which reprinted the whole template on
202
+ // every save. createContentView already maps the fragment itself, so descending into
203
+ // it is all that was missing.
204
+ const remember = (node) => {
205
+ const live = view.original(node);
206
+ if (live) snapshotProvenance.set(node, live);
207
+ const children = node.nodeType === 1 && node.tagName === 'TEMPLATE' && node.content
208
+ ? node.content.childNodes
209
+ : node.childNodes;
210
+ for (const child of children || []) remember(child);
211
+ };
212
+ remember(clone);
133
213
 
134
214
  for (const hook of snapshotHooks) {
135
- hook(clone);
215
+ stripSnapshotRegions(clone);
216
+ hook(clone, { original: originalSnapshotNode });
136
217
  }
137
218
 
138
219
  // Put back any URL clay rewrote at runtime (cache-bust, refetch-on-save) so
@@ -140,11 +221,13 @@ export function captureSnapshot({ flushUndo = true } = {}) {
140
221
  // authored handler sees the same URLs the file will.
141
222
  restoreAuthoredUrls(clone);
142
223
 
143
- runAuthoredHandlers(clone, 'onbeforesnapshot');
224
+ stripSnapshotRegions(clone);
225
+ // authored: false is for a capture that is not a save and not a frame the page will
226
+ // see. The attributes hold page-author JavaScript, so running them is a side effect
227
+ // the author asked for once per save, not once per capture.
228
+ if (authored) runAuthoredHandlers(clone, 'onbeforesnapshot');
144
229
 
145
- for (const el of clone.querySelectorAll(SNAPSHOT_REMOVE_SELECTOR)) {
146
- el.remove();
147
- }
230
+ stripSnapshotRegions(clone);
148
231
 
149
232
  // Browser-extension noise (password-manager menus, Grammarly overlays, and
150
233
  // marker attributes on real inputs) is not page content. Drop it from every
@@ -159,17 +242,21 @@ export function captureSnapshot({ flushUndo = true } = {}) {
159
242
  * Mutates the clone — only call once per snapshot.
160
243
  *
161
244
  * @param {HTMLElement} clone - A snapshot from captureSnapshot()
162
- * @returns {string} Full HTML string ready for server
245
+ * @returns {HTMLElement} The same clone, in the save domain
163
246
  */
164
- function prepareCloneForSave(clone) {
247
+ function prepareCloneForSave(clone, { authored = true } = {}) {
248
+ stripSnapshotRegions(clone);
165
249
  // Run inline [onbeforesave] handlers
166
- runAuthoredHandlers(clone, 'onbeforesave');
250
+ if (authored) runAuthoredHandlers(clone, 'onbeforesave');
167
251
 
168
252
  // Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
169
253
  for (const hook of documentTransforms) {
254
+ stripSnapshotRegions(clone);
170
255
  hook(clone);
171
256
  }
172
257
 
258
+ stripSnapshotRegions(clone);
259
+
173
260
  // Strip [no-save] / legacy [save-remove] LAST (snapshot-algorithm step 7): a
174
261
  // prepare hook (freeze restore) can re-inject [no-save] content into the clone,
175
262
  // so the strip must run after the hooks or that content leaks to disk.
@@ -177,7 +264,29 @@ function prepareCloneForSave(clone) {
177
264
  el.remove();
178
265
  }
179
266
 
180
- return "<!DOCTYPE html>" + clone.outerHTML;
267
+ return clone;
268
+ }
269
+
270
+ /**
271
+ * The save-domain clone, for a caller that needs the tree rather than the bytes.
272
+ *
273
+ * The source map pairs against this rather than against the live DOM, because this is
274
+ * the tree in the file's own domain: edit mode deactivated back to the inert
275
+ * attribute forms, [no-save] gone, every transform run. Every node in it that came
276
+ * from the page can be traced back with originalSnapshotNode.
277
+ *
278
+ * Emits no snapshot-ready event: this capture must never feed the send pipeline.
279
+ *
280
+ * A caller that is only INSPECTING the tree passes `{ flushUndo: false, authored:
281
+ * false }`. Both defaults are right for a save and wrong for anything else: flushing
282
+ * closes the undo batch, so a capture taken mid-typing splits the user's undo history
283
+ * at a point they did not make, and the authored handlers are page JavaScript that is
284
+ * meant to run once per save, not once per capture.
285
+ *
286
+ * @returns {HTMLElement}
287
+ */
288
+ export function captureSaveClone({ flushUndo = true, authored = true } = {}) {
289
+ return prepareCloneForSave(captureSnapshot({ flushUndo, authored }), { authored });
181
290
  }
182
291
 
183
292
  /**
@@ -202,9 +311,12 @@ export function captureForComparison({ flushUndo = true } = {}) {
202
311
 
203
312
  // Run registered prepare hooks
204
313
  for (const hook of documentTransforms) {
314
+ stripSnapshotRegions(clone);
205
315
  hook(clone);
206
316
  }
207
317
 
318
+ stripSnapshotRegions(clone);
319
+
208
320
  return "<!DOCTYPE html>" + clone.outerHTML;
209
321
  }
210
322
 
@@ -232,9 +344,12 @@ export function captureForDirtyCheck({ flushUndo = true } = {}) {
232
344
  }
233
345
 
234
346
  for (const hook of documentTransforms) {
347
+ stripSnapshotRegions(clone);
235
348
  hook(clone);
236
349
  }
237
350
 
351
+ stripSnapshotRegions(clone);
352
+
238
353
  return "<!DOCTYPE html>" + clone.outerHTML;
239
354
  }
240
355
 
@@ -262,8 +377,10 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
262
377
  el.remove();
263
378
  }
264
379
  for (const hook of documentTransforms) {
380
+ stripSnapshotRegions(clone);
265
381
  hook(clone);
266
382
  }
383
+ stripSnapshotRegions(clone);
267
384
  const forComparison = "<!DOCTYPE html>" + clone.outerHTML;
268
385
 
269
386
  let forDirty = forComparison;
@@ -272,8 +389,10 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
272
389
  el.remove();
273
390
  }
274
391
  for (const hook of documentTransforms) {
392
+ stripSnapshotRegions(dirtyClone);
275
393
  hook(dirtyClone);
276
394
  }
395
+ stripSnapshotRegions(dirtyClone);
277
396
  forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
278
397
  }
279
398
 
@@ -324,20 +443,24 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
324
443
  // Save clone: run hooks (freeze restore lives here), THEN strip [no-save]/[save-remove]
325
444
  // LAST (snapshot-algorithm step 7) so freeze-restored [no-save] content can't leak to disk.
326
445
  for (const hook of documentTransforms) {
446
+ stripSnapshotRegions(clone);
327
447
  hook(clone);
328
448
  }
449
+ stripSnapshotRegions(clone);
329
450
  for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
330
451
  el.remove();
331
452
  }
332
- const forSave = "<!DOCTYPE html>" + clone.outerHTML;
453
+ const forSave = serializeSaveClone(clone);
333
454
 
334
455
  // Compare clone: strip every autosave-off region, then run hooks
335
456
  for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
336
457
  el.remove();
337
458
  }
338
459
  for (const hook of documentTransforms) {
460
+ stripSnapshotRegions(compareClone);
339
461
  hook(compareClone);
340
462
  }
463
+ stripSnapshotRegions(compareClone);
341
464
  const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
342
465
 
343
466
  // Dirty clone: same shape as the compare clone, one selector weaker.
@@ -347,8 +470,10 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
347
470
  el.remove();
348
471
  }
349
472
  for (const hook of documentTransforms) {
473
+ stripSnapshotRegions(dirtyClone);
350
474
  hook(dirtyClone);
351
475
  }
476
+ stripSnapshotRegions(dirtyClone);
352
477
  forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
353
478
  }
354
479
 
@@ -400,8 +525,10 @@ export function captureForMerge() {
400
525
  })(compareClone, clone);
401
526
 
402
527
  for (const hook of documentTransforms) {
528
+ stripSnapshotRegions(clone);
403
529
  hook(clone);
404
530
  }
531
+ stripSnapshotRegions(clone);
405
532
  for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
406
533
  el.remove();
407
534
  }
@@ -410,8 +537,10 @@ export function captureForMerge() {
410
537
  el.remove();
411
538
  }
412
539
  for (const hook of documentTransforms) {
540
+ stripSnapshotRegions(compareClone);
413
541
  hook(compareClone);
414
542
  }
543
+ stripSnapshotRegions(compareClone);
415
544
 
416
545
  return { saveClone: clone, compareClone, pairMap };
417
546
  }
@@ -437,7 +566,7 @@ export function captureForSave({ emitForSync = true } = {}) {
437
566
  }));
438
567
  }
439
568
 
440
- return prepareCloneForSave(clone);
569
+ return serializeSaveClone(prepareCloneForSave(clone));
441
570
  }
442
571
 
443
572
  /**
@@ -477,7 +606,11 @@ export function serializeForSync(clone) {
477
606
  // truncate the broadcast.
478
607
  const shell = bareRoot.outerHTML;
479
608
  const endTag = `</${bareRoot.localName}>`;
480
- return shell.slice(0, shell.length - endTag.length) + clone.innerHTML + endTag;
609
+ // The doctype, so this artifact is a complete document like every other one this
610
+ // module produces. Spec section 2 asks for that, and it costs nothing on the wire:
611
+ // a receiver parses the string and morphs documentElement against documentElement,
612
+ // so the prologue is consumed by the parser and never reaches the morph.
613
+ return "<!DOCTYPE html>" + shell.slice(0, shell.length - endTag.length) + clone.innerHTML + endTag;
481
614
  }
482
615
 
483
616
  /**