@panphora/clayjs 1.11.1 → 1.11.2
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/dist/clay.standalone.js +768 -547
- package/package.json +2 -1
- package/src/attrs/save-freeze.js +6 -3
- package/src/core/persist.js +11 -1
- package/src/core/save.js +63 -8
- package/src/core/snapshot.js +8 -16
- package/src/core/source-map.js +61 -19
- package/src/lib/content-dom.js +58 -28
- package/src/lib/ephemeral.js +88 -0
- package/src/lib/extension-noise.js +11 -4
- package/src/lib/mutation.js +4 -1
- package/src/lib/root-attrs.js +24 -1
- package/src/loader.js +2 -0
- package/src/plugins/undo.js +17 -2
- package/src/sync/live-sync.js +8 -6
- package/src/tailwind.js +12 -0
- package/src/vendor/hyper-morph.vendor.js +3 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@panphora/clayjs",
|
|
3
|
-
"version": "1.11.
|
|
3
|
+
"version": "1.11.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "clayjs: malleable documents. Save lifecycle for self-saving HTML.",
|
|
6
6
|
"license": "MIT-0",
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"./src/*": "./src/*",
|
|
33
33
|
"./entries/*": "./entries/*",
|
|
34
34
|
"./clay.standalone.js": "./dist/clay.standalone.js",
|
|
35
|
+
"./tailwind": "./src/tailwind.js",
|
|
35
36
|
"./dist/*": "./dist/*",
|
|
36
37
|
"./*": "./entries/*"
|
|
37
38
|
},
|
package/src/attrs/save-freeze.js
CHANGED
|
@@ -20,6 +20,7 @@ import { onSnapshot, addDocumentTransform } from "../core/snapshot.js";
|
|
|
20
20
|
import { isEditMode } from "../core/is-edit-mode.js";
|
|
21
21
|
import { FREEZE_SELECTOR } from "../lib/region-policy.js";
|
|
22
22
|
import { ROUTE_ACTIVE_ATTR } from "../lib/root-attrs.js";
|
|
23
|
+
import { stripEphemeral } from "../lib/ephemeral.js";
|
|
23
24
|
import Mutation from "../lib/mutation.js";
|
|
24
25
|
|
|
25
26
|
const originals = new WeakMap();
|
|
@@ -30,12 +31,14 @@ function log(...args) {
|
|
|
30
31
|
if (saveFreeze.debug) console.log('[save-freeze]', ...args);
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
// The captured original is what the file keeps, so it must hold no router stamp
|
|
34
|
-
//
|
|
34
|
+
// The captured original is what the file keeps, so it must hold no router stamp
|
|
35
|
+
// and no ephemeral state: either can be set on a child before this capture runs.
|
|
35
36
|
function authoredInnerHTML(el) {
|
|
36
|
-
|
|
37
|
+
const html = el.innerHTML;
|
|
38
|
+
if (!el.querySelector(`[${ROUTE_ACTIVE_ATTR}]`) && !html.includes("ephemeral") && !html.includes("<dialog")) return html;
|
|
37
39
|
const copy = el.cloneNode(true);
|
|
38
40
|
for (const s of copy.querySelectorAll(`[${ROUTE_ACTIVE_ATTR}]`)) s.removeAttribute(ROUTE_ACTIVE_ATTR);
|
|
41
|
+
for (const child of copy.children) stripEphemeral(child);
|
|
39
42
|
return copy.innerHTML;
|
|
40
43
|
}
|
|
41
44
|
|
package/src/core/persist.js
CHANGED
|
@@ -96,7 +96,17 @@ export default function enablePersistentFormInputValues(filterBySelector = PERSI
|
|
|
96
96
|
};
|
|
97
97
|
finalize(inputSelector);
|
|
98
98
|
finalize(textareaSelector);
|
|
99
|
-
|
|
99
|
+
// A select's options pair through provenance, not by index: an excluded option
|
|
100
|
+
// (editor-ui, no-snapshot) is already gone from the copy, so its options are a
|
|
101
|
+
// subset of the live ones.
|
|
102
|
+
for (const control of doc.querySelectorAll(selectSelector)) {
|
|
103
|
+
if (!provenance.original(control)) continue;
|
|
104
|
+
for (const option of control.options) {
|
|
105
|
+
const live = provenance.original(option);
|
|
106
|
+
if (!(live && live.selected)) option.removeAttribute('selected');
|
|
107
|
+
else if (!option.hasAttribute('selected')) option.setAttribute('selected', '');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
100
110
|
});
|
|
101
111
|
}
|
|
102
112
|
|
package/src/core/save.js
CHANGED
|
@@ -21,7 +21,8 @@ import {
|
|
|
21
21
|
saveFateIsUnknown,
|
|
22
22
|
withKeepalive
|
|
23
23
|
} from "./save-core.js";
|
|
24
|
-
import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
|
|
24
|
+
import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison, captureForDirtyCheck } from "./snapshot.js";
|
|
25
|
+
import { NO_TRIGGER_AUTOSAVE_SELECTOR } from "../lib/region-policy.js";
|
|
25
26
|
import { seedEtag, lastSeenEtag } from "./etag.js";
|
|
26
27
|
import { gateCaptureToken, gateClearIfUnchanged, pageMaybeDirty } from "../lib/dirty-gate.js";
|
|
27
28
|
import { hasUnsavedState } from "../lib/unsaved-state.js";
|
|
@@ -189,6 +190,12 @@ let lastSavedContents = '';
|
|
|
189
190
|
let lastSavedDirty = '';
|
|
190
191
|
// A save was requested while one was on the wire; run one more when it settles.
|
|
191
192
|
let pendingSave = false;
|
|
193
|
+
// An autosave arrived while one of our saves was on the wire. When that save lands
|
|
194
|
+
// it runs the autosave check again, rules and all, rather than an explicit save.
|
|
195
|
+
let pendingAutosave = false;
|
|
196
|
+
// True while a savePage/savePageForce request is on the wire. Only those drain the
|
|
197
|
+
// queue when they land, so an autosave never queues behind a raw saveHtml call.
|
|
198
|
+
let ownSaveOnWire = false;
|
|
192
199
|
|
|
193
200
|
// ============================================
|
|
194
201
|
// AUTOSAVE SUSPENSION
|
|
@@ -236,6 +243,9 @@ export function autosaveIsSuspended() {
|
|
|
236
243
|
// save asked for meanwhile is remembered and runs on release.
|
|
237
244
|
let previewHold = 0;
|
|
238
245
|
let previewMissed = false;
|
|
246
|
+
// An autosave skipped by the hold. Replayed as an autosave, so it keeps autosave
|
|
247
|
+
// rules (a batching-region edit still waits for an explicit save).
|
|
248
|
+
let previewAutosaveMissed = false;
|
|
239
249
|
const PREVIEW_HOLD_REASON = 'Waiting for the AI edit to be kept or reverted';
|
|
240
250
|
const holdReasons = new Map();
|
|
241
251
|
|
|
@@ -252,8 +262,12 @@ export function releaseAllSaves({ replay = true, reason = PREVIEW_HOLD_REASON }
|
|
|
252
262
|
previewHold--;
|
|
253
263
|
if (previewHold > 0) return;
|
|
254
264
|
const missed = previewMissed;
|
|
265
|
+
const autosaveMissedInHold = previewAutosaveMissed;
|
|
255
266
|
previewMissed = false;
|
|
256
|
-
|
|
267
|
+
previewAutosaveMissed = false;
|
|
268
|
+
if (!replay) return;
|
|
269
|
+
if (missed) savePage();
|
|
270
|
+
else if (autosaveMissedInHold) savePageThrottled();
|
|
257
271
|
}
|
|
258
272
|
|
|
259
273
|
export function savesHeld() {
|
|
@@ -407,10 +421,16 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
|
|
|
407
421
|
// dropped, so the newer bytes still go out on the next save; the page is still
|
|
408
422
|
// dirty, so an edit or a close warning will produce one.
|
|
409
423
|
function drainPendingSave() {
|
|
410
|
-
if (!pendingSave) return;
|
|
424
|
+
if (!pendingSave && !pendingAutosave) return;
|
|
411
425
|
if (saveFateIsUnknown()) return;
|
|
412
|
-
pendingSave
|
|
413
|
-
|
|
426
|
+
if (pendingSave) {
|
|
427
|
+
pendingSave = false;
|
|
428
|
+
pendingAutosave = false;
|
|
429
|
+
savePage();
|
|
430
|
+
return;
|
|
431
|
+
}
|
|
432
|
+
pendingAutosave = false;
|
|
433
|
+
savePageThrottled();
|
|
414
434
|
}
|
|
415
435
|
|
|
416
436
|
// State accessors for autosave module
|
|
@@ -526,7 +546,9 @@ export function savePage(callback = () => {}) {
|
|
|
526
546
|
setSavingState();
|
|
527
547
|
|
|
528
548
|
// Use saveHtml directly with our pre-captured content (avoids double capture)
|
|
549
|
+
ownSaveOnWire = true;
|
|
529
550
|
saveHtml(forSave, (result) => {
|
|
551
|
+
ownSaveOnWire = false;
|
|
530
552
|
applySaveResult(result, forComparison, forDirty, 'updated after save', gateToken, forSave);
|
|
531
553
|
if (typeof callback === 'function') {
|
|
532
554
|
callback(result);
|
|
@@ -581,7 +603,9 @@ export function savePageForce(callback = () => {}) {
|
|
|
581
603
|
|
|
582
604
|
setSavingState();
|
|
583
605
|
|
|
606
|
+
ownSaveOnWire = true;
|
|
584
607
|
saveHtml(forSave, (result) => {
|
|
608
|
+
ownSaveOnWire = false;
|
|
585
609
|
applySaveResult(result, forComparison, forDirty, 'updated after force save', gateToken, forSave);
|
|
586
610
|
if (typeof callback === 'function') {
|
|
587
611
|
callback(result);
|
|
@@ -878,7 +902,7 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
878
902
|
}
|
|
879
903
|
|
|
880
904
|
if (previewHold > 0) {
|
|
881
|
-
|
|
905
|
+
previewAutosaveMissed = true;
|
|
882
906
|
const skipped = skipped_(saveHoldReason());
|
|
883
907
|
callback(skipped);
|
|
884
908
|
return Promise.resolve(skipped);
|
|
@@ -894,6 +918,17 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
894
918
|
return Promise.resolve(skipped);
|
|
895
919
|
}
|
|
896
920
|
|
|
921
|
+
// One of our saves is on the wire: queue this autosave instead of copying the
|
|
922
|
+
// page to compare. When that save lands, drainPendingSave runs this check again.
|
|
923
|
+
// Not while the page is settling, where every candidate save must pass the
|
|
924
|
+
// baseline veto below.
|
|
925
|
+
if (!baselineActive && ownSaveOnWire) {
|
|
926
|
+
pendingAutosave = true;
|
|
927
|
+
const skipped = skipped_('Save already in progress');
|
|
928
|
+
callback(skipped);
|
|
929
|
+
return Promise.resolve(skipped);
|
|
930
|
+
}
|
|
931
|
+
|
|
897
932
|
// For autosave: while the page is still settling, content must differ from BOTH
|
|
898
933
|
// the load-time baseline and the last save, so module setup churn cannot trigger
|
|
899
934
|
// a save. Once settled, the baseline veto is disarmed and only the last save
|
|
@@ -901,7 +936,21 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
901
936
|
// persisted, because it matches the baseline forever.
|
|
902
937
|
// Compare directly - stored versions are already stripped
|
|
903
938
|
const gateToken = gateCaptureToken();
|
|
904
|
-
|
|
939
|
+
let currentForCompare;
|
|
940
|
+
let dirtyMatchesLastSave;
|
|
941
|
+
try {
|
|
942
|
+
currentForCompare = captureForComparison();
|
|
943
|
+
// This comparison leaves out batching (no-trigger-autosave) regions, which the
|
|
944
|
+
// gate counts, so on a page that has one the dirty domain must match too.
|
|
945
|
+
dirtyMatchesLastSave = () => !document.querySelector(NO_TRIGGER_AUTOSAVE_SELECTOR)
|
|
946
|
+
|| (pageMaybeDirty() && captureForDirtyCheck({ flushUndo: false }) === lastSavedDirty);
|
|
947
|
+
} catch (err) {
|
|
948
|
+
console.error('savePageThrottled: captureForComparison failed', err);
|
|
949
|
+
setSaveState('error', err.message);
|
|
950
|
+
const result = { msg: err.message, msgType: 'error', code: null, etag: null };
|
|
951
|
+
callback(result);
|
|
952
|
+
return Promise.resolve(result);
|
|
953
|
+
}
|
|
905
954
|
const differsFromBaseline = !baselineActive || currentForCompare !== baselineContents;
|
|
906
955
|
const differsFromLastSave = currentForCompare !== lastSavedContents;
|
|
907
956
|
|
|
@@ -909,7 +958,13 @@ export function savePageThrottled(callback = () => {}) {
|
|
|
909
958
|
logSaveCheck('throttled vs lastSave', !differsFromLastSave);
|
|
910
959
|
|
|
911
960
|
if (!(differsFromBaseline && differsFromLastSave)) {
|
|
912
|
-
|
|
961
|
+
let clear = false;
|
|
962
|
+
try {
|
|
963
|
+
clear = !differsFromLastSave && dirtyMatchesLastSave();
|
|
964
|
+
} catch (err) {
|
|
965
|
+
console.error('savePageThrottled: captureForDirtyCheck failed', err);
|
|
966
|
+
}
|
|
967
|
+
if (clear) gateClearIfUnchanged(gateToken);
|
|
913
968
|
const skipped = skipped_('No changes to save');
|
|
914
969
|
callback(skipped);
|
|
915
970
|
return Promise.resolve(skipped);
|
package/src/core/snapshot.js
CHANGED
|
@@ -42,6 +42,7 @@ import { stripExtensionNoise } from '../lib/extension-noise.js';
|
|
|
42
42
|
import { restoreAuthoredUrls } from '../lib/authored-url.js';
|
|
43
43
|
import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, STRIP_FROM_DIRTY_CHECK, NO_TRIGGER_AUTOSAVE_SELECTOR, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
|
|
44
44
|
import { TAB_LOCAL_ROOT_ATTRS, stripRouteAttrs } from '../lib/root-attrs.js';
|
|
45
|
+
import { stripEphemeral } from '../lib/ephemeral.js';
|
|
45
46
|
import { createContentView } from '../lib/content-dom.js';
|
|
46
47
|
|
|
47
48
|
// =============================================================================
|
|
@@ -236,23 +237,11 @@ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
|
|
|
236
237
|
window.clay.undo.flush();
|
|
237
238
|
}
|
|
238
239
|
|
|
239
|
-
|
|
240
|
+
// The view records each clone node's live original straight into the provenance
|
|
241
|
+
// map as it clones, including the nodes inside a <template>'s content fragment,
|
|
242
|
+
// so the source map can ask which live node any clone node came from.
|
|
243
|
+
const view = createContentView(document.documentElement, { capability: 'snapshot', provenance: snapshotProvenance });
|
|
240
244
|
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);
|
|
256
245
|
|
|
257
246
|
for (const hook of snapshotHooks) {
|
|
258
247
|
stripSnapshotRegions(clone);
|
|
@@ -280,6 +269,9 @@ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
|
|
|
280
269
|
// Router state says where this tab is, not what the document holds.
|
|
281
270
|
stripRouteAttrs(clone);
|
|
282
271
|
|
|
272
|
+
// So does ephemeral state: a selection, an open dialog, a marked attribute.
|
|
273
|
+
stripEphemeral(clone);
|
|
274
|
+
|
|
283
275
|
return clone;
|
|
284
276
|
}
|
|
285
277
|
|
package/src/core/source-map.js
CHANGED
|
@@ -587,7 +587,16 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
587
587
|
// `from >= 0` is not defensive padding: `src.slice(-1, n)` is the LAST BYTE of the
|
|
588
588
|
// source, so an unset offset reaching here does not produce nothing, it produces one
|
|
589
589
|
// wrong byte and looks like a successful render.
|
|
590
|
-
|
|
590
|
+
// Adjacent copied ranges merge into one piece, except across a mark that records
|
|
591
|
+
// a piece index (tailMark, afterBody): a piece sealed under a mark is never extended,
|
|
592
|
+
// so the next range starts a new piece at the index the mark points to.
|
|
593
|
+
let sealedAt = 0;
|
|
594
|
+
const keep = (from, to) => {
|
|
595
|
+
if (!(from >= 0 && to > from)) return;
|
|
596
|
+
const last = pieces.length > sealedAt ? pieces[pieces.length - 1] : null;
|
|
597
|
+
if (last && last.text === undefined && last.to === from) last.to = to;
|
|
598
|
+
else pieces.push({ from, to });
|
|
599
|
+
};
|
|
591
600
|
const text = (t) => { if (t) pieces.push({ text: t }); };
|
|
592
601
|
// Elements in `opts.print`, and everything inside them, are printed rather than
|
|
593
602
|
// copied: the answer to a region whose copy did not verify.
|
|
@@ -622,7 +631,7 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
622
631
|
// Called once <body>'s end tag, if any, has been emitted. The owner is still last when
|
|
623
632
|
// nothing but end tags was emitted after its text.
|
|
624
633
|
function endOfBody() {
|
|
625
|
-
afterBody = pieces.length;
|
|
634
|
+
afterBody = pieces.length; sealedAt = pieces.length;
|
|
626
635
|
tailClean = !!tailMark && tailMark.whole
|
|
627
636
|
&& /^(?:<\/[^>]*>)*$/.test(pieces.slice(tailMark.at).map(piece).join(''));
|
|
628
637
|
}
|
|
@@ -660,7 +669,7 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
660
669
|
// Test switch: copy one named text node's bytes twice, a corruption only the
|
|
661
670
|
// copy path makes, so printing the element around it is the fix.
|
|
662
671
|
if (opts.doubleCopied !== undefined && data === opts.doubleCopied) keep(loc.from, loc.to);
|
|
663
|
-
if (loc.outsideTail) tailMark = { at: pieces.length, tail: loc.outsideTail, whole: true };
|
|
672
|
+
if (loc.outsideTail) { tailMark = { at: pieces.length, tail: loc.outsideTail, whole: true }; sealedAt = pieces.length; }
|
|
664
673
|
return;
|
|
665
674
|
}
|
|
666
675
|
let owner = null;
|
|
@@ -670,7 +679,7 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
670
679
|
owner = { tail: whole ? loc.outsideTail : '', whole };
|
|
671
680
|
}
|
|
672
681
|
text(RAW_TEXT.has(parentTag) ? data : escText(data));
|
|
673
|
-
if (owner) tailMark = { at: pieces.length, ...owner };
|
|
682
|
+
if (owner) { tailMark = { at: pieces.length, ...owner }; sealedAt = pieces.length; }
|
|
674
683
|
}
|
|
675
684
|
function emitElement(n, loc) {
|
|
676
685
|
const tag = n.localName;
|
|
@@ -719,7 +728,20 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
719
728
|
// clone still has with the same value copies its bytes; a changed one keeps the
|
|
720
729
|
// author's quoting; a removed one takes its leading whitespace with it; a new one is
|
|
721
730
|
// appended just before the `>`.
|
|
731
|
+
// The open tag copies as one range when the clone's attributes are the source's, in
|
|
732
|
+
// the source's order, with the same values: the attribute walk below would copy the
|
|
733
|
+
// same bytes piece by piece.
|
|
734
|
+
function sameAttrs(n, loc) {
|
|
735
|
+
const attrs = n.attributes;
|
|
736
|
+
if (attrs.length !== loc.attrs.length) return false;
|
|
737
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
738
|
+
const a = loc.attrs[i];
|
|
739
|
+
if (a.from < 0 || attrs[i].value !== a.value || attrs[i].name.toLowerCase() !== a.key) return false;
|
|
740
|
+
}
|
|
741
|
+
return true;
|
|
742
|
+
}
|
|
722
743
|
function emitOpenTag(n, loc) {
|
|
744
|
+
if (sameAttrs(n, loc)) { keep(loc.openFrom, loc.openTo); return; }
|
|
723
745
|
const live = new Map();
|
|
724
746
|
for (const a of n.attributes) live.set(a.name.toLowerCase(), a);
|
|
725
747
|
const seen = new Set();
|
|
@@ -832,7 +854,7 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
832
854
|
if (!whole.has(i)) return emitNode(c, parentTag);
|
|
833
855
|
const s = S[owned[i]];
|
|
834
856
|
keep(s.from, s.to);
|
|
835
|
-
if (s.outsideTail) tailMark = { at: pieces.length, tail: s.outsideTail, whole: true };
|
|
857
|
+
if (s.outsideTail) { tailMark = { at: pieces.length, tail: s.outsideTail, whole: true }; sealedAt = pieces.length; }
|
|
836
858
|
};
|
|
837
859
|
const openOf = (c, i) => {
|
|
838
860
|
if (!whole.has(i)) return openChain(c);
|
|
@@ -903,24 +925,31 @@ function lis(vals) {
|
|
|
903
925
|
export function verify(rendered, today, liveDocument, clone = null, sourceErrors = null) {
|
|
904
926
|
const P = new DOMParser();
|
|
905
927
|
const A = P.parseFromString(rendered, 'text/html');
|
|
906
|
-
const B = P.parseFromString(today, 'text/html');
|
|
907
928
|
const outsideA = vOutside(A), outsideLive = vOutside(liveDocument);
|
|
908
929
|
if (outsideA.join('\n') !== outsideLive.join('\n')) return { ok: false, diff: 'document: outside <html> ' + JSON.stringify(outsideA) + ' vs live ' + JSON.stringify(outsideLive) };
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
930
|
+
// The clone oracle first: it is the stronger claim and needs no second parse. The
|
|
931
|
+
// accept set is unchanged (equal to the clone, or equal to today's bytes reparsed);
|
|
932
|
+
// only the order the two are tried in differs, and the oracle named is still the
|
|
933
|
+
// one that passed.
|
|
934
|
+
let at = null;
|
|
935
|
+
let cloneDiff = null;
|
|
912
936
|
if (clone) {
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
// the way down compared equal.
|
|
917
|
-
const at = [];
|
|
918
|
-
if (!vDiff(A.documentElement, clone, 'html', at)) {
|
|
937
|
+
at = [];
|
|
938
|
+
cloneDiff = vDiff(A.documentElement, clone, 'html', at);
|
|
939
|
+
if (!cloneDiff) {
|
|
919
940
|
const w = vParseErrors(rendered, sourceErrors);
|
|
920
|
-
return w ? { ok: false, diff: w } : { ok: true, diff: null, oracle: 'clone'
|
|
941
|
+
return w ? { ok: false, diff: w } : { ok: true, diff: null, oracle: 'clone' };
|
|
921
942
|
}
|
|
922
|
-
return { ok: false, diff, at };
|
|
923
943
|
}
|
|
944
|
+
const B = P.parseFromString(today, 'text/html');
|
|
945
|
+
const diff = vDiff(A.documentElement, B.documentElement, 'html');
|
|
946
|
+
const worse = diff ? null : vParseErrors(rendered, sourceErrors);
|
|
947
|
+
if (!diff) return worse ? { ok: false, diff: worse } : { ok: true, diff: null, oracle: 'today' };
|
|
948
|
+
// `at` is the index path, in the clone, of the node where the render first differs,
|
|
949
|
+
// so a caller can print that element and try again instead of discarding the whole
|
|
950
|
+
// render. It is valid in the clone because every earlier sibling at every level on
|
|
951
|
+
// the way down compared equal.
|
|
952
|
+
if (clone) return { ok: false, diff, at };
|
|
924
953
|
return { ok: false, diff };
|
|
925
954
|
}
|
|
926
955
|
|
|
@@ -1004,14 +1033,27 @@ function vNoscript(a, b, path) {
|
|
|
1004
1033
|
}
|
|
1005
1034
|
function vKids(el) {
|
|
1006
1035
|
const list = el.localName === 'template' && el.content ? el.content.childNodes : el.childNodes;
|
|
1007
|
-
|
|
1036
|
+
const out = [];
|
|
1037
|
+
for (let i = 0; i < list.length; i++) { const n = list[i]; if (n.nodeType === 1 || n.nodeType === 3 || n.nodeType === 8) out.push(n); }
|
|
1038
|
+
return out;
|
|
1008
1039
|
}
|
|
1009
1040
|
const vAttrs = (el) => Array.from(el.attributes, (a) => (a.namespaceURI || '') + ' ' + a.name + '=' + JSON.stringify(a.value)).sort().join('; ');
|
|
1041
|
+
// Equal attribute lists in the same order need no sorted strings; any other case is
|
|
1042
|
+
// decided by the strings, as before.
|
|
1043
|
+
function sameAttrsInOrder(a, b) {
|
|
1044
|
+
const A = a.attributes, B = b.attributes;
|
|
1045
|
+
if (A.length !== B.length) return false;
|
|
1046
|
+
for (let i = 0; i < A.length; i++) {
|
|
1047
|
+
const x = A[i], y = B[i];
|
|
1048
|
+
if (x.name !== y.name || x.value !== y.value || x.namespaceURI !== y.namespaceURI) return false;
|
|
1049
|
+
}
|
|
1050
|
+
return true;
|
|
1051
|
+
}
|
|
1010
1052
|
function vDiff(a, b, path, at = []) {
|
|
1011
1053
|
if (a.nodeType !== b.nodeType) return path + ': node type ' + a.nodeType + ' vs ' + b.nodeType;
|
|
1012
1054
|
if (a.nodeType === 3 || a.nodeType === 8) return a.data === b.data ? null : path + ': text ' + JSON.stringify(a.data.slice(0, 60)) + ' vs ' + JSON.stringify(b.data.slice(0, 60));
|
|
1013
1055
|
if (a.localName !== b.localName || a.namespaceURI !== b.namespaceURI) return path + ': tag ' + a.localName + ' vs ' + b.localName;
|
|
1014
|
-
if (vAttrs(a) !== vAttrs(b)) return path + ': attrs ' + vAttrs(a) + ' vs ' + vAttrs(b);
|
|
1056
|
+
if (!sameAttrsInOrder(a, b) && vAttrs(a) !== vAttrs(b)) return path + ': attrs ' + vAttrs(a) + ' vs ' + vAttrs(b);
|
|
1015
1057
|
if (a.localName === 'noscript' && a.namespaceURI === XHTML) return vNoscript(a, b, path);
|
|
1016
1058
|
const ka = vKids(a), kb = vKids(b);
|
|
1017
1059
|
if (ka.length !== kb.length) return path + ': ' + ka.length + ' vs ' + kb.length + ' children';
|
package/src/lib/content-dom.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { capabilitySelector, hasCapability } from './region-capabilities.js'
|
|
1
|
+
import { capabilitySelector, hasCapability, CAPABILITIES, expandsTo, hasPolicyToken } from './region-capabilities.js'
|
|
2
2
|
|
|
3
3
|
const UNSUPPORTED_SELECTOR = /:(?:focus(?:-within|-visible)?|hover|active|visited|defined)\b/i
|
|
4
4
|
|
|
@@ -7,10 +7,9 @@ function copyControlState(source, copy) {
|
|
|
7
7
|
const type = (source.getAttribute?.('type') || '').toLowerCase()
|
|
8
8
|
if ('value' in source && 'value' in copy && source.tagName !== 'OPTION' && type !== 'checkbox' && type !== 'radio' && type !== 'file') copy.value = source.value
|
|
9
9
|
if (source.tagName === 'INPUT' && (type === 'checkbox' || type === 'radio')) copy.checked = source.checked
|
|
10
|
+
// Each surviving option carries its own selection, so a <select> whose excluded
|
|
11
|
+
// options were removed from the copy keeps the rest in step with the page.
|
|
10
12
|
if (source.tagName === 'OPTION') copy.selected = source.selected
|
|
11
|
-
if (source.tagName === 'SELECT') {
|
|
12
|
-
for (let i = 0; i < source.options.length; i++) copy.options[i].selected = source.options[i].selected
|
|
13
|
-
}
|
|
14
13
|
if ('indeterminate' in source && 'indeterminate' in copy) copy.indeterminate = source.indeterminate
|
|
15
14
|
}
|
|
16
15
|
|
|
@@ -25,46 +24,77 @@ function matchesWithin(node, selector, boundary, inherit) {
|
|
|
25
24
|
return false
|
|
26
25
|
}
|
|
27
26
|
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
27
|
+
// One native deep clone, then a lockstep walk of the two isomorphic trees that
|
|
28
|
+
// records provenance, collects controls, and marks excluded subtrees for removal.
|
|
29
|
+
// The browser's clone is far cheaper than importing node by node. The root answers
|
|
30
|
+
// for its ancestors once; below it the walk is top-down, so with inheritance each
|
|
31
|
+
// element answers only for its own markers: an excluded ancestor is never entered.
|
|
32
|
+
function excluded(source, capability, capabilityMatch, exclude, boundary, inherit) {
|
|
33
|
+
return (inherit
|
|
34
|
+
? hasCapability(source, capability)
|
|
35
|
+
: matchesWithin(source, capabilityMatch, boundary, false)) ||
|
|
36
|
+
!!(exclude && (inherit && source !== boundary ? source.matches(exclude) : matchesWithin(source, exclude, boundary, inherit)))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// The test closestWithCapability makes at each level, for this element alone.
|
|
40
|
+
function hasOwnCapability(element, capability) {
|
|
41
|
+
const definition = CAPABILITIES[capability]
|
|
42
|
+
return definition.tokens.some(token => expandsTo(element, token)) ||
|
|
43
|
+
definition.bundles.some(bundle => hasPolicyToken(element, bundle))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const CONTROL_TAGS = new Set(['INPUT', 'TEXTAREA', 'SELECT', 'OPTION'])
|
|
47
|
+
|
|
48
|
+
function pairTree(source, copy, maps, drop) {
|
|
49
|
+
if (source.nodeType === 1 && drop(source)) { maps.dropped.push(copy); return }
|
|
36
50
|
maps.cloneToLive.set(copy, source)
|
|
37
51
|
maps.liveToClone.set(source, copy)
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
if (
|
|
41
|
-
maps.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
copyChildren.
|
|
48
|
-
|
|
52
|
+
let sourceChildren = source
|
|
53
|
+
let copyChildren = copy
|
|
54
|
+
if (source.nodeType === 1) {
|
|
55
|
+
if (CONTROL_TAGS.has(source.tagName)) maps.controls.push(source, copy)
|
|
56
|
+
if (source.tagName === 'TEMPLATE') {
|
|
57
|
+
// The deep clone also copied the template's own child nodes, which are not its
|
|
58
|
+
// content and were never part of the view.
|
|
59
|
+
while (copy.firstChild) copy.removeChild(copy.firstChild)
|
|
60
|
+
sourceChildren = source.content
|
|
61
|
+
copyChildren = copy.content
|
|
62
|
+
maps.cloneToLive.set(copyChildren, sourceChildren)
|
|
63
|
+
maps.liveToClone.set(sourceChildren, copyChildren)
|
|
49
64
|
}
|
|
65
|
+
} else if (source.nodeType !== 11) return
|
|
66
|
+
let c = copyChildren.firstChild
|
|
67
|
+
for (let s = sourceChildren.firstChild; s && c; s = s.nextSibling) {
|
|
68
|
+
pairTree(s, c, maps, drop)
|
|
69
|
+
c = c.nextSibling
|
|
50
70
|
}
|
|
51
|
-
if (source.nodeType === 1) copyControlState(source, copy)
|
|
52
|
-
return copy
|
|
53
71
|
}
|
|
54
72
|
|
|
55
|
-
export function createContentView(context, { capability = 'data', exclude = null, inherit = true } = {}) {
|
|
73
|
+
export function createContentView(context, { capability = 'data', exclude = null, inherit = true, provenance = null } = {}) {
|
|
56
74
|
if (!context) throw new TypeError('createContentView requires a DOM context')
|
|
57
75
|
const sourceDocument = context.nodeType === 9 ? context : context.ownerDocument
|
|
58
76
|
if (!sourceDocument?.implementation?.createHTMLDocument) {
|
|
59
77
|
throw new TypeError('createContentView requires an HTML DOM implementation')
|
|
60
78
|
}
|
|
61
79
|
const inertDocument = sourceDocument.implementation.createHTMLDocument('')
|
|
62
|
-
const cloneToLive = new WeakMap()
|
|
80
|
+
const cloneToLive = provenance || new WeakMap()
|
|
63
81
|
const liveToClone = new WeakMap()
|
|
64
82
|
const sourceRoot = context.nodeType === 9 ? context.documentElement : context
|
|
65
83
|
if (exclude) inertDocument.documentElement.matches(exclude)
|
|
66
84
|
const capabilityMatch = capabilitySelector(capability)
|
|
67
|
-
const
|
|
85
|
+
const drop = inherit
|
|
86
|
+
? (el) => hasOwnCapability(el, capability) || !!(exclude && el.matches(exclude))
|
|
87
|
+
: (el) => excluded(el, capability, capabilityMatch, exclude, sourceRoot, false)
|
|
88
|
+
let root = null
|
|
89
|
+
if (sourceRoot.nodeType !== 1 || !excluded(sourceRoot, capability, capabilityMatch, exclude, sourceRoot, inherit)) {
|
|
90
|
+
root = inertDocument.importNode(sourceRoot, true)
|
|
91
|
+
const maps = { cloneToLive, liveToClone, dropped: [], controls: [] }
|
|
92
|
+
pairTree(sourceRoot, root, maps, drop)
|
|
93
|
+
for (const node of maps.dropped) node.remove()
|
|
94
|
+
// Control state is copied once the copy holds only what the view keeps, so a
|
|
95
|
+
// <select> sees exactly the options the old node-by-node import gave it.
|
|
96
|
+
for (let i = 0; i < maps.controls.length; i += 2) copyControlState(maps.controls[i], maps.controls[i + 1])
|
|
97
|
+
}
|
|
68
98
|
if (context.nodeType === 9 && root) {
|
|
69
99
|
inertDocument.replaceChild(root, inertDocument.documentElement)
|
|
70
100
|
liveToClone.set(context, inertDocument)
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { isPolicyAttribute } from "./region-capabilities.js";
|
|
2
|
+
|
|
3
|
+
// Tab-local state on saved elements. The ephemeral attribute holds words a
|
|
4
|
+
// script sets for this tab (selected, dragging), and an ephemeral:NAME marker
|
|
5
|
+
// makes the element's own NAME attribute tab-local too. A dialog's open is
|
|
6
|
+
// tab-local with no marker: showModal() sets it, and a dialog saved open is
|
|
7
|
+
// always a bug. Like route-active, none of it is saved, synced, undone or
|
|
8
|
+
// counted as a change. The marker itself is ordinary saved content.
|
|
9
|
+
|
|
10
|
+
export const EPHEMERAL_ATTR = "ephemeral";
|
|
11
|
+
const MARKER = "ephemeral:";
|
|
12
|
+
// Identity, region attributes (a marker must not switch a region off) and form
|
|
13
|
+
// state (persist owns those) can never be ephemeral.
|
|
14
|
+
const NEVER = new Set([
|
|
15
|
+
"class", "id", "data-id", "value", "checked", "selected",
|
|
16
|
+
"save-remove", "save-ignore", "save-freeze", "mutations-ignore", "snapshot-remove",
|
|
17
|
+
]);
|
|
18
|
+
const never = (name) => NEVER.has(name) || isPolicyAttribute(name);
|
|
19
|
+
|
|
20
|
+
export function isEphemeralAttr(name, el) {
|
|
21
|
+
if (name === EPHEMERAL_ATTR) return true;
|
|
22
|
+
if (!name || !el || el.nodeType !== 1 || never(name) || name.startsWith(MARKER)) return false;
|
|
23
|
+
if (name === "open" && el.localName === "dialog") return true;
|
|
24
|
+
return el.hasAttribute(MARKER + name);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** True when `el` carries an ephemeral:NAME marker for `name`. */
|
|
28
|
+
export function isMarkedAttr(name, el) {
|
|
29
|
+
return !!el && el.nodeType === 1 && el.hasAttribute(MARKER + name);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function stripElement(el) {
|
|
33
|
+
if (el.localName === "dialog" && el.hasAttribute("open")) el.removeAttribute("open");
|
|
34
|
+
if (el.hasAttribute(EPHEMERAL_ATTR)) el.removeAttribute(EPHEMERAL_ATTR);
|
|
35
|
+
const attrs = el.attributes;
|
|
36
|
+
let names = null;
|
|
37
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
38
|
+
const name = attrs[i].name;
|
|
39
|
+
if (!name.startsWith(MARKER)) continue;
|
|
40
|
+
const target = name.slice(MARKER.length);
|
|
41
|
+
if (target && !never(target)) (names || (names = [])).push(target);
|
|
42
|
+
}
|
|
43
|
+
if (names) for (const name of names) el.removeAttribute(name);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Remove ephemeral state from a snapshot clone, in place: the root, every descendant, and template contents. */
|
|
47
|
+
export function stripEphemeral(root) {
|
|
48
|
+
if (!root || root.nodeType !== 1) return;
|
|
49
|
+
stripElement(root);
|
|
50
|
+
for (const el of root.querySelectorAll("*")) {
|
|
51
|
+
stripElement(el);
|
|
52
|
+
if (el.localName === "template" && el.content) {
|
|
53
|
+
for (const child of el.content.children) stripEphemeral(child);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (root.localName === "template" && root.content) {
|
|
57
|
+
for (const child of root.content.children) stripEphemeral(child);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function words(el) {
|
|
62
|
+
return (el.getAttribute(EPHEMERAL_ATTR) || "").split(/\s+/).filter(Boolean);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function write(el, list) {
|
|
66
|
+
if (list.length) el.setAttribute(EPHEMERAL_ATTR, list.join(" "));
|
|
67
|
+
else el.removeAttribute(EPHEMERAL_ATTR);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export const ephemeral = {
|
|
71
|
+
has(el, word) {
|
|
72
|
+
return words(el).includes(word);
|
|
73
|
+
},
|
|
74
|
+
add(el, word) {
|
|
75
|
+
const list = words(el);
|
|
76
|
+
if (!list.includes(word)) write(el, [...list, word]);
|
|
77
|
+
},
|
|
78
|
+
remove(el, word) {
|
|
79
|
+
const list = words(el);
|
|
80
|
+
if (list.includes(word)) write(el, list.filter((w) => w !== word));
|
|
81
|
+
},
|
|
82
|
+
toggle(el, word, force) {
|
|
83
|
+
const on = force === undefined ? !ephemeral.has(el, word) : !!force;
|
|
84
|
+
if (on) ephemeral.add(el, word);
|
|
85
|
+
else ephemeral.remove(el, word);
|
|
86
|
+
return on;
|
|
87
|
+
},
|
|
88
|
+
};
|
|
@@ -55,12 +55,19 @@ export function stripExtensionNoise(root) {
|
|
|
55
55
|
// querySelectorAll('*') never returns the root itself, so an extension stamp on
|
|
56
56
|
// <html> used to ride into every saved file. The root is the one element an
|
|
57
57
|
// extension can mark on a page with no other match.
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
// The attribute list is only copied when a marker is found on it.
|
|
59
|
+
const strip = (el) => {
|
|
60
|
+
const attrs = el.attributes
|
|
61
|
+
if (!attrs) return
|
|
62
|
+
let found = null
|
|
63
|
+
for (let i = 0; i < attrs.length; i++) {
|
|
64
|
+
const name = attrs[i].name
|
|
65
|
+
if (EXTENSION_ATTR_PATTERN.test(name.toLowerCase())) (found || (found = [])).push(name)
|
|
62
66
|
}
|
|
67
|
+
if (found) for (const name of found) el.removeAttribute(name)
|
|
63
68
|
}
|
|
69
|
+
strip(root)
|
|
70
|
+
for (const el of root.querySelectorAll('*')) strip(el)
|
|
64
71
|
} catch (e) {
|
|
65
72
|
if (typeof console !== 'undefined') console.warn('[clay] stripExtensionNoise skipped:', e)
|
|
66
73
|
}
|