@panphora/clayjs 1.2.0 → 1.4.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 (42) hide show
  1. package/README.md +7 -3
  2. package/THIRD-PARTY-NOTICES.md +10 -0
  3. package/dist/clay.standalone.js +22539 -14260
  4. package/entries/clay-data.js +1 -1
  5. package/entries/sap.js +1 -1
  6. package/package.json +7 -2
  7. package/packed-contract.json +17 -0
  8. package/src/attrs/save-freeze.js +10 -18
  9. package/src/core/admin-contenteditable.js +8 -6
  10. package/src/core/admin-inputs.js +23 -13
  11. package/src/core/admin-onclick.js +5 -0
  12. package/src/core/is-edit-mode.js +7 -2
  13. package/src/core/persist.js +5 -10
  14. package/src/core/save-core.js +35 -0
  15. package/src/core/save.js +13 -1
  16. package/src/core/snapshot.js +186 -14
  17. package/src/core/source-map.js +1025 -0
  18. package/src/core/unsaved-warning.js +3 -0
  19. package/src/dom/dom-helpers.js +5 -1
  20. package/src/lib/content-dom.js +108 -0
  21. package/src/lib/mutation.js +26 -3
  22. package/src/lib/region-capabilities.js +69 -0
  23. package/src/lib/region-policy.js +18 -13
  24. package/src/loader-logic.js +27 -5
  25. package/src/loader.js +6 -0
  26. package/src/plugins/ai-edit.js +625 -0
  27. package/src/plugins/demo.js +3 -0
  28. package/src/plugins/sortable.js +6 -1
  29. package/src/plugins/source.js +410 -0
  30. package/src/plugins/wire.js +248 -47
  31. package/src/sync/live-sync.js +106 -42
  32. package/src/sync/presence.js +303 -0
  33. package/src/sync/section-notice.js +230 -0
  34. package/src/sync/splice-merge.js +7 -10
  35. package/src/sync/stream.js +190 -0
  36. package/src/vendor/control-serialize.vendor.js +10 -6
  37. package/src/vendor/hyper-morph.vendor.js +2 -2
  38. package/src/vendor/hyper-undo.vendor.js +1 -1
  39. package/src/vendor/hypercms.vendor.js +438 -45
  40. package/src/vendor/parse5.vendor.js +3 -0
  41. package/src/vendor/quickcrop.vendor.js +1 -1
  42. package/src/vendor/richclay.vendor.js +22 -15
package/src/core/save.js CHANGED
@@ -23,7 +23,7 @@ import {
23
23
  import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
24
24
  import { seedEtag } from "./etag.js";
25
25
  import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
26
- import { ROOT_LIBRARY_ATTRS } from "../lib/root-attrs.js";
26
+ import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
27
27
  import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
28
28
  import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
29
29
 
@@ -43,6 +43,18 @@ addDocumentTransform(clone => {
43
43
  for (const name of ROOT_LIBRARY_ATTRS) clone.removeAttribute(name);
44
44
  });
45
45
 
46
+ // Keep the host's save token out of the saved bytes, both spellings.
47
+ //
48
+ // It is a credential for this response, never file content: htmlclay strips it from
49
+ // every save body on arrival, so it never reached disk anyway. Sending it made the
50
+ // source map, which models the bytes a save sent, describe a root tag one attribute
51
+ // longer than the file, and every offset after it was off by that much. The save
52
+ // itself is authorized by the URL, which reads the token from the live page. The
53
+ // document id is NOT stripped: htmlclay keeps it on disk on purpose.
54
+ addDocumentTransform(clone => {
55
+ for (const name of [...SAVE_TOKEN_ATTRS, ...LEGACY_SAVE_TOKEN_ATTRS]) clone.removeAttribute(name);
56
+ });
57
+
46
58
  // ============================================
47
59
  // SAVE STATE MANAGEMENT
48
60
  // ============================================
@@ -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,94 @@ 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
+ const XHTML_NS = 'http://www.w3.org/1999/xhtml';
129
+
130
+ /**
131
+ * `clone.outerHTML`, except that a <noscript> holding only text is written raw.
132
+ *
133
+ * The page parsed with scripting on, so a <noscript> holds the author's markup as one
134
+ * text node, and the page's own serializer writes it back raw. The clone lives in a
135
+ * document with no window (createContentView), where scripting is off, and Chrome's
136
+ * serializer escapes that text there: `<p>` went to disk as `&lt;p&gt;`, which a reload
137
+ * reads as literal text, and every later save escaped it again. The text is swapped for
138
+ * a marker only while serializing, so the clone holds the same nodes when this returns.
139
+ */
140
+ function serializeClone(clone) {
141
+ const blocks = [];
142
+ const collect = (node) => {
143
+ for (const child of node.childNodes) {
144
+ if (child.nodeType !== 1) continue;
145
+ if (child.localName === 'noscript' && child.namespaceURI === XHTML_NS) {
146
+ const kids = Array.from(child.childNodes);
147
+ if (kids.length && kids.every((n) => n.nodeType === 3)) blocks.push([child, kids]);
148
+ continue;
149
+ }
150
+ collect(child.localName === 'template' && child.content ? child.content : child);
151
+ }
152
+ };
153
+ collect(clone);
154
+ if (!blocks.length) return "<!DOCTYPE html>" + clone.outerHTML;
155
+ const nonce = Math.random().toString(36).slice(2);
156
+ const raw = blocks.map(([el, kids], i) => {
157
+ const marker = `\uE000clay-noscript-${nonce}-${i}\uE000`;
158
+ el.replaceChildren(el.ownerDocument.createTextNode(marker));
159
+ return [marker, kids.map((n) => n.data).join('')];
160
+ });
161
+ let html;
162
+ try {
163
+ html = "<!DOCTYPE html>" + clone.outerHTML;
164
+ } finally {
165
+ for (const [el, kids] of blocks) el.replaceChildren(...kids);
166
+ }
167
+ for (const [marker, text] of raw) html = html.split(marker).join(text);
168
+ return html;
169
+ }
170
+
171
+ /**
172
+ * The bytes a save sends: the prepared clone, through the save renderer if one is
173
+ * installed.
174
+ *
175
+ * The try/catch is not defensive noise around a renderer that already catches its
176
+ * own errors. That one catches in order to COUNT, and to fire the event that says a
177
+ * document reprinted; this one is the guarantee that no bug in an optional plugin can
178
+ * cost somebody a save.
179
+ */
180
+ function serializeSaveClone(clone) {
181
+ const today = serializeClone(clone);
182
+ if (!saveRenderer) return today;
183
+ try {
184
+ return saveRenderer(clone, today);
185
+ } catch (err) {
186
+ console.error('clayjs: save renderer failed, sending the full serialization', err);
187
+ return today;
188
+ }
189
+ }
190
+
84
191
  /**
85
192
  * Register a transform that runs over a detached clone when preparing to save.
86
193
  * Use for: stripping admin elements, cleanup.
@@ -116,7 +223,7 @@ function clonePreventingOnclone(node, deep = true) {
116
223
  finally { window.__preventOnclone = prev; }
117
224
  }
118
225
 
119
- export function captureSnapshot({ flushUndo = true } = {}) {
226
+ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
120
227
  // Force-close any pending undo idle batch BEFORE cloning the DOM, so the
121
228
  // snapshot reflects a clean undo boundary. Without this, a save that fires
122
229
  // mid-typing would leave the idle batch open across the save boundary, and
@@ -129,10 +236,27 @@ export function captureSnapshot({ flushUndo = true } = {}) {
129
236
  window.clay.undo.flush();
130
237
  }
131
238
 
132
- const clone = clonePreventingOnclone(document.documentElement);
239
+ const view = createContentView(document.documentElement, { capability: 'snapshot' });
240
+ const clone = view.root;
241
+ // A <template>'s children are not its childNodes, they are its content fragment's,
242
+ // so walking childNodes alone leaves everything inside a template with no provenance.
243
+ // Nothing noticed until the source map asked which live node a clone node came from
244
+ // and got null for every node in a template, which reprinted the whole template on
245
+ // every save. createContentView already maps the fragment itself, so descending into
246
+ // it is all that was missing.
247
+ const remember = (node) => {
248
+ const live = view.original(node);
249
+ if (live) snapshotProvenance.set(node, live);
250
+ const children = node.nodeType === 1 && node.tagName === 'TEMPLATE' && node.content
251
+ ? node.content.childNodes
252
+ : node.childNodes;
253
+ for (const child of children || []) remember(child);
254
+ };
255
+ remember(clone);
133
256
 
134
257
  for (const hook of snapshotHooks) {
135
- hook(clone);
258
+ stripSnapshotRegions(clone);
259
+ hook(clone, { original: originalSnapshotNode });
136
260
  }
137
261
 
138
262
  // Put back any URL clay rewrote at runtime (cache-bust, refetch-on-save) so
@@ -140,11 +264,13 @@ export function captureSnapshot({ flushUndo = true } = {}) {
140
264
  // authored handler sees the same URLs the file will.
141
265
  restoreAuthoredUrls(clone);
142
266
 
143
- runAuthoredHandlers(clone, 'onbeforesnapshot');
267
+ stripSnapshotRegions(clone);
268
+ // authored: false is for a capture that is not a save and not a frame the page will
269
+ // see. The attributes hold page-author JavaScript, so running them is a side effect
270
+ // the author asked for once per save, not once per capture.
271
+ if (authored) runAuthoredHandlers(clone, 'onbeforesnapshot');
144
272
 
145
- for (const el of clone.querySelectorAll(SNAPSHOT_REMOVE_SELECTOR)) {
146
- el.remove();
147
- }
273
+ stripSnapshotRegions(clone);
148
274
 
149
275
  // Browser-extension noise (password-manager menus, Grammarly overlays, and
150
276
  // marker attributes on real inputs) is not page content. Drop it from every
@@ -159,17 +285,21 @@ export function captureSnapshot({ flushUndo = true } = {}) {
159
285
  * Mutates the clone — only call once per snapshot.
160
286
  *
161
287
  * @param {HTMLElement} clone - A snapshot from captureSnapshot()
162
- * @returns {string} Full HTML string ready for server
288
+ * @returns {HTMLElement} The same clone, in the save domain
163
289
  */
164
- function prepareCloneForSave(clone) {
290
+ function prepareCloneForSave(clone, { authored = true } = {}) {
291
+ stripSnapshotRegions(clone);
165
292
  // Run inline [onbeforesave] handlers
166
- runAuthoredHandlers(clone, 'onbeforesave');
293
+ if (authored) runAuthoredHandlers(clone, 'onbeforesave');
167
294
 
168
295
  // Run registered prepare hooks ([freeze]/[save-freeze] innerHTML restore lives here)
169
296
  for (const hook of documentTransforms) {
297
+ stripSnapshotRegions(clone);
170
298
  hook(clone);
171
299
  }
172
300
 
301
+ stripSnapshotRegions(clone);
302
+
173
303
  // Strip [no-save] / legacy [save-remove] LAST (snapshot-algorithm step 7): a
174
304
  // prepare hook (freeze restore) can re-inject [no-save] content into the clone,
175
305
  // so the strip must run after the hooks or that content leaks to disk.
@@ -177,7 +307,29 @@ function prepareCloneForSave(clone) {
177
307
  el.remove();
178
308
  }
179
309
 
180
- return "<!DOCTYPE html>" + clone.outerHTML;
310
+ return clone;
311
+ }
312
+
313
+ /**
314
+ * The save-domain clone, for a caller that needs the tree rather than the bytes.
315
+ *
316
+ * The source map pairs against this rather than against the live DOM, because this is
317
+ * the tree in the file's own domain: edit mode deactivated back to the inert
318
+ * attribute forms, [no-save] gone, every transform run. Every node in it that came
319
+ * from the page can be traced back with originalSnapshotNode.
320
+ *
321
+ * Emits no snapshot-ready event: this capture must never feed the send pipeline.
322
+ *
323
+ * A caller that is only INSPECTING the tree passes `{ flushUndo: false, authored:
324
+ * false }`. Both defaults are right for a save and wrong for anything else: flushing
325
+ * closes the undo batch, so a capture taken mid-typing splits the user's undo history
326
+ * at a point they did not make, and the authored handlers are page JavaScript that is
327
+ * meant to run once per save, not once per capture.
328
+ *
329
+ * @returns {HTMLElement}
330
+ */
331
+ export function captureSaveClone({ flushUndo = true, authored = true } = {}) {
332
+ return prepareCloneForSave(captureSnapshot({ flushUndo, authored }), { authored });
181
333
  }
182
334
 
183
335
  /**
@@ -202,9 +354,12 @@ export function captureForComparison({ flushUndo = true } = {}) {
202
354
 
203
355
  // Run registered prepare hooks
204
356
  for (const hook of documentTransforms) {
357
+ stripSnapshotRegions(clone);
205
358
  hook(clone);
206
359
  }
207
360
 
361
+ stripSnapshotRegions(clone);
362
+
208
363
  return "<!DOCTYPE html>" + clone.outerHTML;
209
364
  }
210
365
 
@@ -232,9 +387,12 @@ export function captureForDirtyCheck({ flushUndo = true } = {}) {
232
387
  }
233
388
 
234
389
  for (const hook of documentTransforms) {
390
+ stripSnapshotRegions(clone);
235
391
  hook(clone);
236
392
  }
237
393
 
394
+ stripSnapshotRegions(clone);
395
+
238
396
  return "<!DOCTYPE html>" + clone.outerHTML;
239
397
  }
240
398
 
@@ -262,8 +420,10 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
262
420
  el.remove();
263
421
  }
264
422
  for (const hook of documentTransforms) {
423
+ stripSnapshotRegions(clone);
265
424
  hook(clone);
266
425
  }
426
+ stripSnapshotRegions(clone);
267
427
  const forComparison = "<!DOCTYPE html>" + clone.outerHTML;
268
428
 
269
429
  let forDirty = forComparison;
@@ -272,8 +432,10 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
272
432
  el.remove();
273
433
  }
274
434
  for (const hook of documentTransforms) {
435
+ stripSnapshotRegions(dirtyClone);
275
436
  hook(dirtyClone);
276
437
  }
438
+ stripSnapshotRegions(dirtyClone);
277
439
  forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
278
440
  }
279
441
 
@@ -324,20 +486,24 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
324
486
  // Save clone: run hooks (freeze restore lives here), THEN strip [no-save]/[save-remove]
325
487
  // LAST (snapshot-algorithm step 7) so freeze-restored [no-save] content can't leak to disk.
326
488
  for (const hook of documentTransforms) {
489
+ stripSnapshotRegions(clone);
327
490
  hook(clone);
328
491
  }
492
+ stripSnapshotRegions(clone);
329
493
  for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
330
494
  el.remove();
331
495
  }
332
- const forSave = "<!DOCTYPE html>" + clone.outerHTML;
496
+ const forSave = serializeSaveClone(clone);
333
497
 
334
498
  // Compare clone: strip every autosave-off region, then run hooks
335
499
  for (const el of compareClone.querySelectorAll(STRIP_FROM_COMPARISON)) {
336
500
  el.remove();
337
501
  }
338
502
  for (const hook of documentTransforms) {
503
+ stripSnapshotRegions(compareClone);
339
504
  hook(compareClone);
340
505
  }
506
+ stripSnapshotRegions(compareClone);
341
507
  const forComparison = "<!DOCTYPE html>" + compareClone.outerHTML;
342
508
 
343
509
  // Dirty clone: same shape as the compare clone, one selector weaker.
@@ -347,8 +513,10 @@ export function captureForSaveAndComparison({ emitForSync = true } = {}) {
347
513
  el.remove();
348
514
  }
349
515
  for (const hook of documentTransforms) {
516
+ stripSnapshotRegions(dirtyClone);
350
517
  hook(dirtyClone);
351
518
  }
519
+ stripSnapshotRegions(dirtyClone);
352
520
  forDirty = "<!DOCTYPE html>" + dirtyClone.outerHTML;
353
521
  }
354
522
 
@@ -400,8 +568,10 @@ export function captureForMerge() {
400
568
  })(compareClone, clone);
401
569
 
402
570
  for (const hook of documentTransforms) {
571
+ stripSnapshotRegions(clone);
403
572
  hook(clone);
404
573
  }
574
+ stripSnapshotRegions(clone);
405
575
  for (const el of clone.querySelectorAll(STRIP_FROM_SAVE)) {
406
576
  el.remove();
407
577
  }
@@ -410,8 +580,10 @@ export function captureForMerge() {
410
580
  el.remove();
411
581
  }
412
582
  for (const hook of documentTransforms) {
583
+ stripSnapshotRegions(compareClone);
413
584
  hook(compareClone);
414
585
  }
586
+ stripSnapshotRegions(compareClone);
415
587
 
416
588
  return { saveClone: clone, compareClone, pairMap };
417
589
  }
@@ -437,7 +609,7 @@ export function captureForSave({ emitForSync = true } = {}) {
437
609
  }));
438
610
  }
439
611
 
440
- return prepareCloneForSave(clone);
612
+ return serializeSaveClone(prepareCloneForSave(clone));
441
613
  }
442
614
 
443
615
  /**