@panphora/clayjs 1.2.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.
- package/README.md +4 -1
- package/THIRD-PARTY-NOTICES.md +10 -0
- package/dist/clay.standalone.js +21437 -13925
- package/entries/clay-data.js +1 -1
- package/package.json +7 -2
- package/packed-contract.json +17 -0
- package/src/attrs/save-freeze.js +10 -18
- package/src/core/admin-contenteditable.js +5 -6
- package/src/core/persist.js +5 -10
- package/src/core/save-core.js +35 -0
- package/src/core/snapshot.js +143 -14
- package/src/core/source-map.js +817 -0
- package/src/core/unsaved-warning.js +3 -0
- package/src/dom/dom-helpers.js +5 -1
- package/src/lib/content-dom.js +108 -0
- package/src/lib/mutation.js +26 -3
- package/src/lib/region-capabilities.js +69 -0
- package/src/lib/region-policy.js +18 -13
- package/src/loader-logic.js +20 -4
- package/src/loader.js +4 -0
- package/src/plugins/demo.js +3 -0
- package/src/plugins/sortable.js +6 -1
- package/src/plugins/source.js +326 -0
- package/src/plugins/wire.js +248 -47
- package/src/sync/live-sync.js +103 -42
- package/src/sync/presence.js +303 -0
- package/src/sync/section-notice.js +230 -0
- package/src/sync/splice-merge.js +7 -10
- package/src/sync/stream.js +190 -0
- package/src/vendor/hyper-morph.vendor.js +2 -2
- package/src/vendor/hyper-undo.vendor.js +1 -1
- package/src/vendor/hypercms.vendor.js +438 -45
- package/src/vendor/parse5.vendor.js +3 -0
- package/src/vendor/quickcrop.vendor.js +1 -1
- package/src/vendor/richclay.vendor.js +22 -15
|
@@ -0,0 +1,326 @@
|
|
|
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
|
+
pair,
|
|
46
|
+
render,
|
|
47
|
+
verify
|
|
48
|
+
} from '../core/source-map.js';
|
|
49
|
+
import {
|
|
50
|
+
captureSaveClone,
|
|
51
|
+
originalSnapshotNode,
|
|
52
|
+
setSaveRenderer
|
|
53
|
+
} from '../core/snapshot.js';
|
|
54
|
+
import { onSaveAccepted } from '../core/save-core.js';
|
|
55
|
+
|
|
56
|
+
const state = {
|
|
57
|
+
installed: false,
|
|
58
|
+
refused: null,
|
|
59
|
+
model: null,
|
|
60
|
+
map: null,
|
|
61
|
+
stats: null,
|
|
62
|
+
saves: 0,
|
|
63
|
+
reprints: 0,
|
|
64
|
+
lastReprint: null,
|
|
65
|
+
lastRenderMs: null,
|
|
66
|
+
lastBytes: null,
|
|
67
|
+
timing: { fetch: null, model: null, pair: null, refresh: null },
|
|
68
|
+
refreshes: 0,
|
|
69
|
+
refreshQueued: false,
|
|
70
|
+
pendingBytes: null,
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
const now = () => (typeof performance !== 'undefined' ? performance.now() : Date.now());
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The bytes this document was loaded from.
|
|
77
|
+
*
|
|
78
|
+
* Every guard here is about one failure: writing somebody else's page into this
|
|
79
|
+
* file. `redirect: 'manual'` and the `redirected` check catch a host that answers a
|
|
80
|
+
* logged-out request with a login page at another URL; the content-type check
|
|
81
|
+
* catches one that answers with JSON or an error page; `cache: 'no-store'` keeps a
|
|
82
|
+
* stale cached copy of an older version of this document out of the model. The
|
|
83
|
+
* shape check in checkSource is the backstop for whatever gets past all three.
|
|
84
|
+
*/
|
|
85
|
+
async function fetchSource(url) {
|
|
86
|
+
const res = await fetch(url, { cache: 'no-store', redirect: 'manual', credentials: 'same-origin' });
|
|
87
|
+
if (!res.ok) throw new Error(`source fetch: status ${res.status}`);
|
|
88
|
+
if (res.redirected || res.type === 'opaqueredirect') throw new Error('source fetch: redirected');
|
|
89
|
+
const type = (res.headers.get('content-type') || '').toLowerCase();
|
|
90
|
+
if (!type.startsWith('text/html')) throw new Error(`source fetch: content-type ${type || 'missing'}`);
|
|
91
|
+
return res.text();
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Pair the model against the page.
|
|
96
|
+
*
|
|
97
|
+
* Over a SAVE CLONE, not the live DOM. The clone is in the file's own domain: edit
|
|
98
|
+
* mode deactivated back to the inert attribute forms, [no-save] regions gone, every
|
|
99
|
+
* document transform run. Pairing the live DOM instead would compare
|
|
100
|
+
* `contenteditable="true"` against the file's `inert-contenteditable="true"` and lose
|
|
101
|
+
* the two strongest signature tiers on every activated element and every ancestor of
|
|
102
|
+
* one. The map is keyed through snapshot provenance to the LIVE node, because the
|
|
103
|
+
* clone is rebuilt on every save and its nodes are new objects each time.
|
|
104
|
+
*
|
|
105
|
+
* This capture is an INSPECTION, not a save, and it happens at boot and again on every
|
|
106
|
+
* incoming live-sync frame. So it takes neither of the two things a save capture does
|
|
107
|
+
* on its way past: it does not close the undo batch, which at boot and mid-typing
|
|
108
|
+
* would split the user's undo history at a point they did not make, and it does not
|
|
109
|
+
* run the page's own [onbeforesave] and [onbeforesnapshot] handlers, which are author
|
|
110
|
+
* JavaScript that can do anything and are meant to run once per save. The cost is that
|
|
111
|
+
* a page using those handlers pairs against a tree slightly behind the one it will
|
|
112
|
+
* save, which costs some formatting fidelity on the nodes a handler touches and
|
|
113
|
+
* nothing else, because the render is verified either way.
|
|
114
|
+
*/
|
|
115
|
+
function pairAgainstPage(m) {
|
|
116
|
+
const clone = captureSaveClone({ flushUndo: false, authored: false });
|
|
117
|
+
return pair(clone, m, originalSnapshotNode);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function install(sourceUrl) {
|
|
121
|
+
const t0 = now();
|
|
122
|
+
return fetchSource(sourceUrl).then((src) => {
|
|
123
|
+
const t1 = now();
|
|
124
|
+
const m = model(src);
|
|
125
|
+
const refused = checkSource(m, document);
|
|
126
|
+
if (refused) throw new Error(`source refused: ${refused}`);
|
|
127
|
+
const t2 = now();
|
|
128
|
+
const { map, stats } = pairAgainstPage(m);
|
|
129
|
+
const t3 = now();
|
|
130
|
+
|
|
131
|
+
state.model = m;
|
|
132
|
+
state.map = map;
|
|
133
|
+
state.stats = stats;
|
|
134
|
+
state.timing = { fetch: t1 - t0, model: t2 - t1, pair: t3 - t2, refresh: null };
|
|
135
|
+
state.installed = true;
|
|
136
|
+
setSaveRenderer(renderSave);
|
|
137
|
+
return summary();
|
|
138
|
+
}).catch((err) => {
|
|
139
|
+
// Not an error the page can do anything about, and not a failure of the save:
|
|
140
|
+
// saves go out exactly as they would without this plugin. Said once.
|
|
141
|
+
state.refused = String(err && err.message ? err.message : err);
|
|
142
|
+
console.info(`clayjs: source map off (${state.refused}); saves use the full serialization`);
|
|
143
|
+
return null;
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Render one save, or hand back today's bytes.
|
|
149
|
+
*
|
|
150
|
+
* Exported so a test can drive the whole path, including the fallback, without a
|
|
151
|
+
* server: `renderSave(clone, today, { breakText: true })` makes every text node
|
|
152
|
+
* reprint, which changes the bytes while leaving the tree identical, and the verifier
|
|
153
|
+
* must still pass it — a tree comparison is the right answer there, and reading that
|
|
154
|
+
* pass as "the verifier is blind" is a mistake this project has already made once.
|
|
155
|
+
* `{ corrupt: 'drop-first-text' }` is the one that must be REJECTED: it changes the
|
|
156
|
+
* tree, which is exactly what the verifier is for.
|
|
157
|
+
*/
|
|
158
|
+
export function renderSave(clone, today, opts = {}) {
|
|
159
|
+
if (!state.installed) return today;
|
|
160
|
+
state.saves++;
|
|
161
|
+
let out;
|
|
162
|
+
try {
|
|
163
|
+
out = render(clone, state.map, state.model, originalSnapshotNode, opts);
|
|
164
|
+
} catch (err) {
|
|
165
|
+
return reprint('render threw: ' + (err && err.message ? err.message : err), today);
|
|
166
|
+
}
|
|
167
|
+
const v = verify(out.text, today, document, clone, state.model.parseErrors);
|
|
168
|
+
if (!v.ok) return reprint(v.diff, today);
|
|
169
|
+
state.lastRenderMs = out.ms;
|
|
170
|
+
state.lastBytes = out.text.length;
|
|
171
|
+
return out.text;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* The fallback, and the alarm.
|
|
176
|
+
*
|
|
177
|
+
* Both halves matter. The bytes are today's, so the floor of this whole mechanism is
|
|
178
|
+
* the behaviour it replaces. The event and the counter are how a reprint gets fixed
|
|
179
|
+
* in the renderer or in the page, which is the only place it should ever be fixed: a
|
|
180
|
+
* verifier loosened to make this counter look better would pass exactly the writes it
|
|
181
|
+
* exists to stop.
|
|
182
|
+
*/
|
|
183
|
+
function reprint(reason, today) {
|
|
184
|
+
state.reprints++;
|
|
185
|
+
state.lastReprint = reason;
|
|
186
|
+
console.warn('clayjs: source map did not verify, saving the full serialization instead:', reason);
|
|
187
|
+
document.dispatchEvent(new CustomEvent('clay:save-reprinted', { detail: { reason } }));
|
|
188
|
+
return today;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* The host took these bytes, so this is what the file holds now.
|
|
193
|
+
*
|
|
194
|
+
* Deferred, like the re-pair below and for the same reason: re-modelling walks the
|
|
195
|
+
* whole document, and doing it inline would put that walk between the save landing
|
|
196
|
+
* and the page being told about it. Only the most recent accepted bytes matter, so a
|
|
197
|
+
* burst of saves costs one refresh.
|
|
198
|
+
*
|
|
199
|
+
* One caveat, worth knowing rather than working around: a host that rewrites on the
|
|
200
|
+
* way in leaves this model describing something slightly different from disk.
|
|
201
|
+
* htmlclay strips the save token from the root tag, which was never meant to reach
|
|
202
|
+
* disk, so the model carries one attribute the file does not. It is self-correcting
|
|
203
|
+
* rather than cumulative — the next render copies the token's bytes back out of the
|
|
204
|
+
* model and the host strips them again — so the file stays put and only the model's
|
|
205
|
+
* idea of the root tag is one attribute long.
|
|
206
|
+
*/
|
|
207
|
+
function adopt(bytes) {
|
|
208
|
+
if (!state.installed) return;
|
|
209
|
+
state.pendingBytes = bytes;
|
|
210
|
+
scheduleRefresh();
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* A morph replaced live nodes, so the map is keyed by objects that are no longer in
|
|
215
|
+
* the page. Re-pair against the same model: for a peer frame the bytes on disk did
|
|
216
|
+
* not change, and for a disk frame they changed to something this tab cannot see, so
|
|
217
|
+
* the model is the best available answer either way.
|
|
218
|
+
*/
|
|
219
|
+
function queueRepair() {
|
|
220
|
+
if (!state.installed) return;
|
|
221
|
+
scheduleRefresh();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Re-model if a save was accepted, re-pair either way, once, off the critical path.
|
|
226
|
+
*
|
|
227
|
+
* Coalescing is what makes this safe as well as cheap. Saves are serialized, so the
|
|
228
|
+
* most recent accepted bytes are the file; a refresh that skipped an intermediate
|
|
229
|
+
* save would still land on the right answer, and one that ran them out of order could
|
|
230
|
+
* not.
|
|
231
|
+
*/
|
|
232
|
+
function scheduleRefresh() {
|
|
233
|
+
if (state.refreshQueued) return;
|
|
234
|
+
state.refreshQueued = true;
|
|
235
|
+
const run = () => {
|
|
236
|
+
state.refreshQueued = false;
|
|
237
|
+
if (!state.installed) return;
|
|
238
|
+
const bytes = state.pendingBytes;
|
|
239
|
+
state.pendingBytes = null;
|
|
240
|
+
const t = now();
|
|
241
|
+
// The two halves fail independently, so they are tried independently. A refused
|
|
242
|
+
// re-model used to skip the re-pair with it, and the re-pair is the half that
|
|
243
|
+
// cannot be skipped: a morph replaced live nodes, so the old map is keyed by
|
|
244
|
+
// objects no longer in the page, and every save after that reprints the whole
|
|
245
|
+
// document until something else queues a refresh. The previous model still
|
|
246
|
+
// describes real bytes, so re-pairing against it is the right answer.
|
|
247
|
+
let m = state.model;
|
|
248
|
+
if (bytes !== null) {
|
|
249
|
+
try {
|
|
250
|
+
const next = model(bytes);
|
|
251
|
+
const refused = checkSource(next, document);
|
|
252
|
+
if (refused) throw new Error(refused);
|
|
253
|
+
m = next;
|
|
254
|
+
} catch (err) {
|
|
255
|
+
console.warn('clayjs: source map kept the previous model, the accepted bytes did not model:', err);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
try {
|
|
259
|
+
const { map, stats } = pairAgainstPage(m);
|
|
260
|
+
state.model = m;
|
|
261
|
+
state.map = map;
|
|
262
|
+
state.stats = stats;
|
|
263
|
+
state.refreshes++;
|
|
264
|
+
state.timing.refresh = now() - t;
|
|
265
|
+
} catch (err) {
|
|
266
|
+
// The map is now stale rather than wrong: it still describes the pairing as of
|
|
267
|
+
// the last successful refresh. Renders off a stale map verify or fall back like
|
|
268
|
+
// any other, so this costs formatting fidelity and nothing else.
|
|
269
|
+
console.warn('clayjs: source map could not re-pair, continuing on the previous map:', err);
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 2000 });
|
|
273
|
+
else setTimeout(run, 0);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function summary() {
|
|
277
|
+
const s = state.stats;
|
|
278
|
+
return {
|
|
279
|
+
installed: state.installed,
|
|
280
|
+
refused: state.refused,
|
|
281
|
+
sourceBytes: state.model ? state.model.src.length : null,
|
|
282
|
+
paired: s ? s.paired : 0,
|
|
283
|
+
unresolved: s ? s.unresolved : 0,
|
|
284
|
+
unmatchedLive: s ? s.unmatchedLive.length : 0,
|
|
285
|
+
unmatchedSource: s ? s.unmatchedSource.length : 0,
|
|
286
|
+
attrDiffs: s ? s.attrDiffs.length : 0,
|
|
287
|
+
saves: state.saves,
|
|
288
|
+
reprints: state.reprints,
|
|
289
|
+
lastReprint: state.lastReprint,
|
|
290
|
+
lastRenderMs: state.lastRenderMs,
|
|
291
|
+
lastBytes: state.lastBytes,
|
|
292
|
+
refreshes: state.refreshes,
|
|
293
|
+
timing: state.timing,
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export const source = {
|
|
298
|
+
ready: null,
|
|
299
|
+
stats: summary,
|
|
300
|
+
/** The bytes this module believes are on disk right now. */
|
|
301
|
+
text: () => (state.model ? state.model.src : null),
|
|
302
|
+
/**
|
|
303
|
+
* Where a live element is in those bytes: `{ from, to, line, column }`, or null.
|
|
304
|
+
*
|
|
305
|
+
* Offsets and `column` are UTF-16 code units into `text()`, not bytes. Slice `text()` with them
|
|
306
|
+
* and the answer is exact; hand them to something that counts bytes and it is wrong on any
|
|
307
|
+
* document with non-ASCII content, which is the kind of mistake that shows up months later in
|
|
308
|
+
* one customer's file.
|
|
309
|
+
*
|
|
310
|
+
* null whenever this module cannot answer, which includes the whole document when the plugin
|
|
311
|
+
* never installed or refused it. `stats().installed` and `stats().refused` say which, and an
|
|
312
|
+
* agent that cannot tell "this element is new" from "this document has no map" will eventually
|
|
313
|
+
* write into a file it was never modelling.
|
|
314
|
+
*/
|
|
315
|
+
locate: (node) => (state.installed && node ? locate(node, state.map, state.model) : null),
|
|
316
|
+
/** What did not pair, for working out why a document reprints more than it should. */
|
|
317
|
+
unpaired: () => (state.stats
|
|
318
|
+
? { live: state.stats.unmatchedLive.slice(0, 50), source: state.stats.unmatchedSource.slice(0, 50) }
|
|
319
|
+
: null),
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
onSaveAccepted(adopt);
|
|
323
|
+
document.addEventListener('clay:sync-applied', queueRepair);
|
|
324
|
+
source.ready = install(window.location.href);
|
|
325
|
+
|
|
326
|
+
export default source;
|