@panphora/clayjs 1.3.0 → 1.5.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.
@@ -42,6 +42,7 @@ import {
42
42
  model,
43
43
  checkSource,
44
44
  locate,
45
+ nodeAt,
45
46
  pair,
46
47
  render,
47
48
  verify
@@ -62,6 +63,11 @@ const state = {
62
63
  saves: 0,
63
64
  reprints: 0,
64
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,
65
71
  lastRenderMs: null,
66
72
  lastBytes: null,
67
73
  timing: { fetch: null, model: null, pair: null, refresh: null },
@@ -154,38 +160,95 @@ function install(sourceUrl) {
154
160
  * pass as "the verifier is blind" is a mistake this project has already made once.
155
161
  * `{ corrupt: 'drop-first-text' }` is the one that must be REJECTED: it changes the
156
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.
157
173
  */
158
174
  export function renderSave(clone, today, opts = {}) {
159
175
  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);
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; }
166
212
  }
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;
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;
172
222
  }
173
223
 
174
224
  /**
175
225
  * The fallback, and the alarm.
176
226
  *
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
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
179
230
  * in the renderer or in the page, which is the only place it should ever be fixed: a
180
231
  * verifier loosened to make this counter look better would pass exactly the writes it
181
232
  * exists to stop.
182
233
  */
183
- function reprint(reason, today) {
234
+ function reprint(reason) {
184
235
  state.reprints++;
185
236
  state.lastReprint = reason;
186
237
  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;
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 } }));
189
252
  }
190
253
 
191
254
  /**
@@ -196,28 +259,41 @@ function reprint(reason, today) {
196
259
  * and the page being told about it. Only the most recent accepted bytes matter, so a
197
260
  * burst of saves costs one refresh.
198
261
  *
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.
262
+ * It is also where a save is counted, because it is the one place that knows a save
263
+ * reached the file.
206
264
  */
207
265
  function adopt(bytes) {
208
266
  if (!state.installed) return;
267
+ report(bytes);
209
268
  state.pendingBytes = bytes;
210
269
  scheduleRefresh();
211
270
  }
212
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
+
213
286
  /**
214
287
  * 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.
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.
218
292
  */
219
- function queueRepair() {
293
+ function queueRepair(event) {
220
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;
221
297
  scheduleRefresh();
222
298
  }
223
299
 
@@ -232,45 +308,51 @@ function queueRepair() {
232
308
  function scheduleRefresh() {
233
309
  if (state.refreshQueued) return;
234
310
  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
- }
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) {
258
334
  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;
335
+ const next = model(bytes);
336
+ const refused = checkSource(next, document);
337
+ if (refused) throw new Error(refused);
338
+ m = next;
265
339
  } 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);
340
+ console.warn('clayjs: source map kept the previous model, the accepted bytes did not model:', err);
270
341
  }
271
- };
272
- if (typeof requestIdleCallback === 'function') requestIdleCallback(run, { timeout: 2000 });
273
- else setTimeout(run, 0);
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
+ }
274
356
  }
275
357
 
276
358
  function summary() {
@@ -287,6 +369,8 @@ function summary() {
287
369
  saves: state.saves,
288
370
  reprints: state.reprints,
289
371
  lastReprint: state.lastReprint,
372
+ partialReprints: state.partialReprints,
373
+ lastPartialReprint: state.lastPartialReprint,
290
374
  lastRenderMs: state.lastRenderMs,
291
375
  lastBytes: state.lastBytes,
292
376
  refreshes: state.refreshes,
@@ -297,8 +381,8 @@ function summary() {
297
381
  export const source = {
298
382
  ready: null,
299
383
  stats: summary,
300
- /** The bytes this module believes are on disk right now. */
301
- text: () => (state.model ? state.model.src : null),
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; },
302
386
  /**
303
387
  * Where a live element is in those bytes: `{ from, to, line, column }`, or null.
304
388
  *
@@ -312,7 +396,7 @@ export const source = {
312
396
  * agent that cannot tell "this element is new" from "this document has no map" will eventually
313
397
  * write into a file it was never modelling.
314
398
  */
315
- locate: (node) => (state.installed && node ? locate(node, state.map, state.model) : null),
399
+ locate: (node) => { refreshNow(); return state.installed && node ? locate(node, state.map, state.model) : null; },
316
400
  /** What did not pair, for working out why a document reprints more than it should. */
317
401
  unpaired: () => (state.stats
318
402
  ? { live: state.stats.unmatchedLive.slice(0, 50), source: state.stats.unmatchedSource.slice(0, 50) }