@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
@@ -0,0 +1,410 @@
1
+ /**
2
+ * source.js — wire the source map into the save pipeline.
3
+ *
4
+ * On by default in edit mode; `exclude=source` turns it off. The mechanism lives in
5
+ * core/source-map.js; this file is the
6
+ * lifecycle around it: fetch the bytes this document was loaded from, pair them to
7
+ * the page, render the save through them, verify, and re-pair when the file moves
8
+ * under us.
9
+ *
10
+ * FOUR MOMENTS
11
+ *
12
+ * boot fetch the source, model it, refuse anything that is not this
13
+ * document, and pair it against a save clone.
14
+ * save render; verify; send the render if it verifies and today's full
15
+ * serialization if it does not, counting the second case.
16
+ * accepted re-model against the bytes the host took, so the next save is
17
+ * measured from what is on disk rather than from what was there at
18
+ * boot.
19
+ * sync-applied re-pair, because a morph replaces live nodes and the map is keyed
20
+ * by node identity.
21
+ *
22
+ * The last two share one deferred, coalesced refresh, so neither sits between a save
23
+ * landing and the page hearing about it, and a burst of frames costs one walk.
24
+ *
25
+ * NOTHING HERE BLOCKS BOOT. The install is async and the loader does not wait for
26
+ * it, because a save before it finishes is a save exactly as it is today. Waiting
27
+ * would trade a guaranteed delay on every page for a better outcome on the rare save
28
+ * that lands in the first few hundred milliseconds.
29
+ *
30
+ * WHEN IT DOES NOTHING
31
+ *
32
+ * Any of these leaves the save pipeline untouched, and each says so once in the
33
+ * console rather than repeatedly at save time:
34
+ *
35
+ * the source fetch fails, redirects, or is not text/html
36
+ * the fetched bytes disagree with the live document outside <html>
37
+ * the render throws
38
+ * the render does not verify
39
+ */
40
+
41
+ import {
42
+ model,
43
+ checkSource,
44
+ locate,
45
+ nodeAt,
46
+ pair,
47
+ render,
48
+ verify
49
+ } from '../core/source-map.js';
50
+ import {
51
+ captureSaveClone,
52
+ originalSnapshotNode,
53
+ setSaveRenderer
54
+ } from '../core/snapshot.js';
55
+ import { onSaveAccepted } from '../core/save-core.js';
56
+
57
+ const state = {
58
+ installed: false,
59
+ refused: null,
60
+ model: null,
61
+ map: null,
62
+ stats: null,
63
+ saves: 0,
64
+ reprints: 0,
65
+ lastReprint: null,
66
+ partialReprints: 0,
67
+ lastPartialReprint: null,
68
+ // What the last render produced, so the save the host accepts can be counted and
69
+ // announced as what it was. Cleared once reported.
70
+ lastOutcome: null,
71
+ lastRenderMs: null,
72
+ lastBytes: null,
73
+ timing: { fetch: null, model: null, pair: null, refresh: null },
74
+ refreshes: 0,
75
+ refreshQueued: false,
76
+ pendingBytes: null,
77
+ };
78
+
79
+ const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
80
+
81
+ /**
82
+ * The bytes this document was loaded from.
83
+ *
84
+ * Every guard here is about one failure: writing somebody else's page into this
85
+ * file. `redirect: 'manual'` and the `redirected` check catch a host that answers a
86
+ * logged-out request with a login page at another URL; the content-type check
87
+ * catches one that answers with JSON or an error page; `cache: 'no-store'` keeps a
88
+ * stale cached copy of an older version of this document out of the model. The
89
+ * shape check in checkSource is the backstop for whatever gets past all three.
90
+ */
91
+ async function fetchSource(url) {
92
+ const res = await fetch(url, { cache: 'no-store', redirect: 'manual', credentials: 'same-origin' });
93
+ if (!res.ok) throw new Error(`source fetch: status ${res.status}`);
94
+ if (res.redirected || res.type === 'opaqueredirect') throw new Error('source fetch: redirected');
95
+ const type = (res.headers.get('content-type') || '').toLowerCase();
96
+ if (!type.startsWith('text/html')) throw new Error(`source fetch: content-type ${type || 'missing'}`);
97
+ return res.text();
98
+ }
99
+
100
+ /**
101
+ * Pair the model against the page.
102
+ *
103
+ * Over a SAVE CLONE, not the live DOM. The clone is in the file's own domain: edit
104
+ * mode deactivated back to the inert attribute forms, [no-save] regions gone, every
105
+ * document transform run. Pairing the live DOM instead would compare
106
+ * `contenteditable="true"` against the file's `inert-contenteditable="true"` and lose
107
+ * the two strongest signature tiers on every activated element and every ancestor of
108
+ * one. The map is keyed through snapshot provenance to the LIVE node, because the
109
+ * clone is rebuilt on every save and its nodes are new objects each time.
110
+ *
111
+ * This capture is an INSPECTION, not a save, and it happens at boot and again on every
112
+ * incoming live-sync frame. So it takes neither of the two things a save capture does
113
+ * on its way past: it does not close the undo batch, which at boot and mid-typing
114
+ * would split the user's undo history at a point they did not make, and it does not
115
+ * run the page's own [onbeforesave] and [onbeforesnapshot] handlers, which are author
116
+ * JavaScript that can do anything and are meant to run once per save. The cost is that
117
+ * a page using those handlers pairs against a tree slightly behind the one it will
118
+ * save, which costs some formatting fidelity on the nodes a handler touches and
119
+ * nothing else, because the render is verified either way.
120
+ */
121
+ function pairAgainstPage(m) {
122
+ const clone = captureSaveClone({ flushUndo: false, authored: false });
123
+ return pair(clone, m, originalSnapshotNode);
124
+ }
125
+
126
+ function install(sourceUrl) {
127
+ const t0 = now();
128
+ return fetchSource(sourceUrl).then((src) => {
129
+ const t1 = now();
130
+ const m = model(src);
131
+ const refused = checkSource(m, document);
132
+ if (refused) throw new Error(`source refused: ${refused}`);
133
+ const t2 = now();
134
+ const { map, stats } = pairAgainstPage(m);
135
+ const t3 = now();
136
+
137
+ state.model = m;
138
+ state.map = map;
139
+ state.stats = stats;
140
+ state.timing = { fetch: t1 - t0, model: t2 - t1, pair: t3 - t2, refresh: null };
141
+ state.installed = true;
142
+ setSaveRenderer(renderSave);
143
+ return summary();
144
+ }).catch((err) => {
145
+ // Not an error the page can do anything about, and not a failure of the save:
146
+ // saves go out exactly as they would without this plugin. Said once.
147
+ state.refused = String(err && err.message ? err.message : err);
148
+ console.info(`clayjs: source map off (${state.refused}); saves use the full serialization`);
149
+ return null;
150
+ });
151
+ }
152
+
153
+ /**
154
+ * Render one save, or hand back today's bytes.
155
+ *
156
+ * Exported so a test can drive the whole path, including the fallback, without a
157
+ * server: `renderSave(clone, today, { breakText: true })` makes every text node
158
+ * reprint, which changes the bytes while leaving the tree identical, and the verifier
159
+ * must still pass it — a tree comparison is the right answer there, and reading that
160
+ * pass as "the verifier is blind" is a mistake this project has already made once.
161
+ * `{ corrupt: 'drop-first-text' }` is the one that must be REJECTED: it changes the
162
+ * tree, which is exactly what the verifier is for.
163
+ *
164
+ * A render that does not verify is not thrown away whole. verify says where it first
165
+ * differs; that element is printed in full and the render tried again, widening to the
166
+ * parent while it still fails. Everything outside the printed elements is still the
167
+ * author's bytes. Only when the widening reaches <html>, or the failure has no node to
168
+ * point at (the prologue, a parse-error count), is today's full serialization sent.
169
+ *
170
+ * Nothing is counted or announced here. A render is not a save: an autosave that
171
+ * found nothing changed, a refused save and a failed request all rendered. The
172
+ * outcome is recorded, and `adopt` reports it when the host accepts these exact bytes.
173
+ */
174
+ export function renderSave(clone, today, opts = {}) {
175
+ if (!state.installed) return today;
176
+ state.lastOutcome = null;
177
+ const print = new Set();
178
+ let firstDiff = null;
179
+ for (let round = 0; round <= MAX_PRINT_ROUNDS; round++) {
180
+ let out;
181
+ try {
182
+ out = render(clone, state.map, state.model, originalSnapshotNode, { ...opts, print });
183
+ } catch (err) {
184
+ return fallback('render threw: ' + (err && err.message ? err.message : err), today);
185
+ }
186
+ const v = verify(out.text, today, document, clone, state.model.parseErrors);
187
+ if (v.ok) {
188
+ state.lastRenderMs = out.ms;
189
+ state.lastBytes = out.text.length;
190
+ state.lastOutcome = print.size
191
+ ? { text: out.text, scope: 'partial', reason: firstDiff, printed: print.size }
192
+ : { text: out.text, scope: null };
193
+ return out.text;
194
+ }
195
+ if (firstDiff === null) firstDiff = v.diff;
196
+ if (!v.at || !widen(clone, nodeAt(clone, v.at), print)) return fallback(firstDiff, today);
197
+ }
198
+ return fallback(firstDiff, today);
199
+ }
200
+
201
+ const MAX_PRINT_ROUNDS = 8;
202
+
203
+ /**
204
+ * Add the next element to print: the one holding `node`, or, when that is already
205
+ * inside a printed element, that element's parent. false when the only element left is
206
+ * the root, which is the full serialization by another name.
207
+ */
208
+ function widen(clone, node, print) {
209
+ let el = node && node.nodeType === 1 ? node : node && node.parentNode;
210
+ for (let a = el; a && a !== clone; a = a.parentNode) {
211
+ if (print.has(a)) { print.delete(a); el = a.parentNode; break; }
212
+ }
213
+ if (!el || el.nodeType !== 1 || el === clone) return false;
214
+ print.add(el);
215
+ return true;
216
+ }
217
+
218
+ /** Send today's full serialization, and remember that this render was a fallback. */
219
+ function fallback(reason, today) {
220
+ state.lastOutcome = { text: today, scope: 'full', reason };
221
+ return today;
222
+ }
223
+
224
+ /**
225
+ * The fallback, and the alarm.
226
+ *
227
+ * Reported for a save the host accepted. The bytes that went out were today's, so the
228
+ * floor of this whole mechanism is the behaviour it replaces. The event and the
229
+ * counter are how a reprint gets fixed
230
+ * in the renderer or in the page, which is the only place it should ever be fixed: a
231
+ * verifier loosened to make this counter look better would pass exactly the writes it
232
+ * exists to stop.
233
+ */
234
+ function reprint(reason) {
235
+ state.reprints++;
236
+ state.lastReprint = reason;
237
+ console.warn('clayjs: source map did not verify, saving the full serialization instead:', reason);
238
+ document.dispatchEvent(new CustomEvent('clay:save-reprinted', { detail: { reason, scope: 'full' } }));
239
+ }
240
+
241
+ /**
242
+ * Part of the document was printed rather than copied. Counted apart from a full
243
+ * reprint because they are different news: this one kept the author's bytes everywhere
244
+ * else, and its rate says how often the renderer and the page disagree about one
245
+ * element, which is where the next renderer fix is.
246
+ */
247
+ function partialReprint(reason, printed) {
248
+ state.partialReprints++;
249
+ state.lastPartialReprint = reason;
250
+ console.info(`clayjs: source map printed ${printed} element(s) in full to match the page:`, reason);
251
+ document.dispatchEvent(new CustomEvent('clay:save-reprinted', { detail: { reason, scope: 'partial', printed } }));
252
+ }
253
+
254
+ /**
255
+ * The host took these bytes, so this is what the file holds now.
256
+ *
257
+ * Deferred, like the re-pair below and for the same reason: re-modelling walks the
258
+ * whole document, and doing it inline would put that walk between the save landing
259
+ * and the page being told about it. Only the most recent accepted bytes matter, so a
260
+ * burst of saves costs one refresh.
261
+ *
262
+ * It is also where a save is counted, because it is the one place that knows a save
263
+ * reached the file.
264
+ */
265
+ function adopt(bytes) {
266
+ if (!state.installed) return;
267
+ report(bytes);
268
+ state.pendingBytes = bytes;
269
+ scheduleRefresh();
270
+ }
271
+
272
+ /**
273
+ * Count and announce an accepted save, if these are the bytes the last render
274
+ * produced. Bytes this module did not render (a caller of `saveHtml` with its own
275
+ * string, or an older render) are not its save to report.
276
+ */
277
+ function report(bytes) {
278
+ const outcome = state.lastOutcome;
279
+ if (!outcome || outcome.text !== bytes) return;
280
+ state.lastOutcome = null;
281
+ state.saves++;
282
+ if (outcome.scope === 'full') reprint(outcome.reason);
283
+ else if (outcome.scope === 'partial') partialReprint(outcome.reason, outcome.printed);
284
+ }
285
+
286
+ /**
287
+ * A morph replaced live nodes, so the map is keyed by objects that are no longer in
288
+ * the page, and it has to be re-paired either way. A DISK frame also carries the bytes
289
+ * now on disk, written by somebody else: those become the model, the same as bytes this
290
+ * tab saved, or the next save would copy the old formatting back over theirs. A peer
291
+ * frame changed nothing on disk, so the model stays.
292
+ */
293
+ function queueRepair(event) {
294
+ if (!state.installed) return;
295
+ const detail = event && event.detail;
296
+ if (detail && detail.source === 'disk' && typeof detail.html === 'string') state.pendingBytes = detail.html;
297
+ scheduleRefresh();
298
+ }
299
+
300
+ /**
301
+ * Re-model if a save was accepted, re-pair either way, once, off the critical path.
302
+ *
303
+ * Coalescing is what makes this safe as well as cheap. Saves are serialized, so the
304
+ * most recent accepted bytes are the file; a refresh that skipped an intermediate
305
+ * save would still land on the right answer, and one that ran them out of order could
306
+ * not.
307
+ */
308
+ function scheduleRefresh() {
309
+ if (state.refreshQueued) return;
310
+ state.refreshQueued = true;
311
+ if (typeof requestIdleCallback === 'function') requestIdleCallback(refreshNow, { timeout: 2000 });
312
+ else setTimeout(refreshNow, 0);
313
+ }
314
+
315
+ /**
316
+ * Run a queued refresh now. A no-op when none is queued, which is also what makes the
317
+ * idle callback of a refresh that `text()` or `locate()` already ran harmless.
318
+ */
319
+ function refreshNow() {
320
+ if (!state.refreshQueued) return;
321
+ state.refreshQueued = false;
322
+ if (!state.installed) return;
323
+ const bytes = state.pendingBytes;
324
+ state.pendingBytes = null;
325
+ const t = now();
326
+ // The two halves fail independently, so they are tried independently. A refused
327
+ // re-model used to skip the re-pair with it, and the re-pair is the half that
328
+ // cannot be skipped: a morph replaced live nodes, so the old map is keyed by
329
+ // objects no longer in the page, and every save after that reprints the whole
330
+ // document until something else queues a refresh. The previous model still
331
+ // describes real bytes, so re-pairing against it is the right answer.
332
+ let m = state.model;
333
+ if (bytes !== null) {
334
+ try {
335
+ const next = model(bytes);
336
+ const refused = checkSource(next, document);
337
+ if (refused) throw new Error(refused);
338
+ m = next;
339
+ } catch (err) {
340
+ console.warn('clayjs: source map kept the previous model, the accepted bytes did not model:', err);
341
+ }
342
+ }
343
+ try {
344
+ const { map, stats } = pairAgainstPage(m);
345
+ state.model = m;
346
+ state.map = map;
347
+ state.stats = stats;
348
+ state.refreshes++;
349
+ state.timing.refresh = now() - t;
350
+ } catch (err) {
351
+ // The map is now stale rather than wrong: it still describes the pairing as of
352
+ // the last successful refresh. Renders off a stale map verify or fall back like
353
+ // any other, so this costs formatting fidelity and nothing else.
354
+ console.warn('clayjs: source map could not re-pair, continuing on the previous map:', err);
355
+ }
356
+ }
357
+
358
+ function summary() {
359
+ const s = state.stats;
360
+ return {
361
+ installed: state.installed,
362
+ refused: state.refused,
363
+ sourceBytes: state.model ? state.model.src.length : null,
364
+ paired: s ? s.paired : 0,
365
+ unresolved: s ? s.unresolved : 0,
366
+ unmatchedLive: s ? s.unmatchedLive.length : 0,
367
+ unmatchedSource: s ? s.unmatchedSource.length : 0,
368
+ attrDiffs: s ? s.attrDiffs.length : 0,
369
+ saves: state.saves,
370
+ reprints: state.reprints,
371
+ lastReprint: state.lastReprint,
372
+ partialReprints: state.partialReprints,
373
+ lastPartialReprint: state.lastPartialReprint,
374
+ lastRenderMs: state.lastRenderMs,
375
+ lastBytes: state.lastBytes,
376
+ refreshes: state.refreshes,
377
+ timing: state.timing,
378
+ };
379
+ }
380
+
381
+ export const source = {
382
+ ready: null,
383
+ stats: summary,
384
+ /** The bytes this module believes are on disk right now. A refresh still queued from a save or a disk frame runs first, so this is never the file before that. */
385
+ text: () => { refreshNow(); return state.model ? state.model.src : null; },
386
+ /**
387
+ * Where a live element is in those bytes: `{ from, to, line, column }`, or null.
388
+ *
389
+ * Offsets and `column` are UTF-16 code units into `text()`, not bytes. Slice `text()` with them
390
+ * and the answer is exact; hand them to something that counts bytes and it is wrong on any
391
+ * document with non-ASCII content, which is the kind of mistake that shows up months later in
392
+ * one customer's file.
393
+ *
394
+ * null whenever this module cannot answer, which includes the whole document when the plugin
395
+ * never installed or refused it. `stats().installed` and `stats().refused` say which, and an
396
+ * agent that cannot tell "this element is new" from "this document has no map" will eventually
397
+ * write into a file it was never modelling.
398
+ */
399
+ locate: (node) => { refreshNow(); return state.installed && node ? locate(node, state.map, state.model) : null; },
400
+ /** What did not pair, for working out why a document reprints more than it should. */
401
+ unpaired: () => (state.stats
402
+ ? { live: state.stats.unmatchedLive.slice(0, 50), source: state.stats.unmatchedSource.slice(0, 50) }
403
+ : null),
404
+ };
405
+
406
+ onSaveAccepted(adopt);
407
+ document.addEventListener('clay:sync-applied', queueRepair);
408
+ source.ready = install(window.location.href);
409
+
410
+ export default source;