@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.
@@ -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;