@panphora/clayjs 1.3.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.
- package/README.md +3 -2
- package/dist/clay.standalone.js +1595 -828
- package/entries/sap.js +1 -1
- package/package.json +1 -1
- package/src/core/admin-contenteditable.js +4 -1
- package/src/core/admin-inputs.js +23 -13
- package/src/core/admin-onclick.js +5 -0
- package/src/core/is-edit-mode.js +7 -2
- package/src/core/save.js +13 -1
- package/src/core/snapshot.js +44 -1
- package/src/core/source-map.js +246 -38
- package/src/loader-logic.js +8 -2
- package/src/loader.js +2 -0
- package/src/plugins/ai-edit.js +625 -0
- package/src/plugins/source.js +150 -66
- package/src/sync/live-sync.js +3 -0
- package/src/vendor/control-serialize.vendor.js +10 -6
- package/src/vendor/hyper-morph.vendor.js +2 -2
package/src/core/source-map.js
CHANGED
|
@@ -40,6 +40,8 @@
|
|
|
40
40
|
import { parse } from '../vendor/parse5.vendor.js';
|
|
41
41
|
|
|
42
42
|
const RAW_TEXT = new Set(['script', 'style', 'xmp', 'iframe', 'noembed', 'noframes', 'plaintext', 'noscript']);
|
|
43
|
+
const RCDATA = new Set(['textarea', 'title']);
|
|
44
|
+
const textContext = (tag) => (RAW_TEXT.has(tag) ? 'raw' : RCDATA.has(tag) ? 'rcdata' : 'normal');
|
|
43
45
|
const VOID = new Set(['area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'param', 'source', 'track', 'wbr']);
|
|
44
46
|
const XHTML = 'http://www.w3.org/1999/xhtml';
|
|
45
47
|
|
|
@@ -66,7 +68,13 @@ export function model(src) {
|
|
|
66
68
|
? { kind: 'doctype', name: n.name || '', publicId: n.publicId || '', systemId: n.systemId || '' }
|
|
67
69
|
: { kind: n.nodeName === '#comment' ? 'comment' : n.nodeName, value: n.data });
|
|
68
70
|
const root = html ? locOf(html, null, src) : null;
|
|
69
|
-
|
|
71
|
+
if (root) settle(root, src);
|
|
72
|
+
return {
|
|
73
|
+
src, doc, outside, root, parseErrors,
|
|
74
|
+
relocated: root ? firstOutOfOrder(root) : null,
|
|
75
|
+
movedIn: root ? firstOutOfOrder(root, 'movedIn') : null,
|
|
76
|
+
tailOwner: root ? tailOwner(root) : null,
|
|
77
|
+
};
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
function locOf(n, parent, src) {
|
|
@@ -81,6 +89,7 @@ function locOf(n, parent, src) {
|
|
|
81
89
|
loc.openTo = loc.located ? l.startTag.endOffset : -1;
|
|
82
90
|
loc.closeFrom = l && l.endTag ? l.endTag.startOffset : (loc.located ? loc.to : -1);
|
|
83
91
|
loc.closeTo = l && l.endTag ? l.endTag.endOffset : (loc.located ? loc.to : -1);
|
|
92
|
+
loc.endTagged = !!(l && l.endTag);
|
|
84
93
|
// Nothing can end before its own start tag does, but parse5 reports exactly that for
|
|
85
94
|
// an element the parser inserts and immediately pops. A <form> written directly
|
|
86
95
|
// inside a <table> is the case that ships: the form is inserted, the form pointer is
|
|
@@ -124,7 +133,28 @@ function locOf(n, parent, src) {
|
|
|
124
133
|
loc.closeFrom = loc.closeTo = last;
|
|
125
134
|
loc.from = first; loc.to = last;
|
|
126
135
|
}
|
|
136
|
+
return loc;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Clamp every child's ranges into its parent's, top-down.
|
|
141
|
+
*
|
|
142
|
+
* Top-down because a parent's close range can itself be corrected here, and its
|
|
143
|
+
* children have to be clamped against the corrected one. An element the parser closed
|
|
144
|
+
* implicitly (a missing </div>, an omitted </p> at </body>) has no end tag, and parse5
|
|
145
|
+
* gives it the end of the file as its end offset. Its close gap then spanned its
|
|
146
|
+
* ancestors' end tags, which it copied, and they copied them again: the file grew by
|
|
147
|
+
* `</body></html>` on every save, and the extra tags reparse to nothing, so verify
|
|
148
|
+
* passed each time.
|
|
149
|
+
*/
|
|
150
|
+
function settle(loc, src, limit = Infinity) {
|
|
151
|
+
if (loc.kind !== 'element') return;
|
|
127
152
|
for (const cl of loc.children) {
|
|
153
|
+
// Content written after an end tag that the parser carried back inside the element:
|
|
154
|
+
// anything after </body> or </html> other than whitespace lands in <body>. Its
|
|
155
|
+
// bytes sit past the close that every copy of the file emits verbatim, so a render
|
|
156
|
+
// writes them twice. Refused at install, like foster parenting, for the same reason.
|
|
157
|
+
if (cl.from >= limit && !(cl.kind === 'text' && !/\S/.test(cl.value))) loc.movedIn = true;
|
|
128
158
|
// Clamp every child into its parent's content range, at BOTH ends.
|
|
129
159
|
//
|
|
130
160
|
// The spec (and parse5, and every browser) appends whitespace that follows an end
|
|
@@ -155,25 +185,29 @@ function locOf(n, parent, src) {
|
|
|
155
185
|
// different places into one text node.
|
|
156
186
|
//
|
|
157
187
|
// Whitespace only, which is all a parser relocates.
|
|
158
|
-
if (cl.kind === 'text') {
|
|
159
|
-
//
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
//
|
|
164
|
-
//
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
188
|
+
if (cl.kind === 'text' && !RAW_TEXT.has(loc.tag) && !RCDATA.has(loc.tag)) {
|
|
189
|
+
// Read from the bytes PAST the clamped end, with the end tags and comments the
|
|
190
|
+
// span crosses taken out, never by matching the bytes inside against the
|
|
191
|
+
// node's value: those can hold a character reference, `&` in the file and
|
|
192
|
+
// `&` in the value, so a prefix match found no overhang at all and an edit to
|
|
193
|
+
// the text printed the relocated newlines on top of the ones still copied
|
|
194
|
+
// after the end tags. Normalized, because the node's value is: the parser
|
|
195
|
+
// rewrites every CRLF and lone CR to LF before a text node sees them. Only the
|
|
196
|
+
// tail's text is normalized; the copy path still uses the raw bytes, which is
|
|
197
|
+
// why an unedited CRLF file round trips exactly. Never inside a raw-text or
|
|
198
|
+
// RCDATA element, whose "tags" are text: an unclosed <textarea> at the end of a
|
|
199
|
+
// file holds `</html>\n` as characters, not as an end tag and a relocation.
|
|
200
|
+
const outside = src.slice(to, cl.to).replace(/<!--[\s\S]*?-->|<[^>]*>/g, '').replace(/\r\n?/g, '\n');
|
|
201
|
+
if (outside && !/\S/.test(outside) && cl.value.endsWith(outside)) cl.outsideTail = outside;
|
|
171
202
|
}
|
|
172
203
|
cl.clamped = true;
|
|
173
204
|
cl.from = from;
|
|
174
205
|
cl.to = to;
|
|
175
206
|
}
|
|
176
207
|
}
|
|
208
|
+
if (cl.kind === 'element' && cl.located && !cl.endTagged && cl.closeFrom > cl.to) {
|
|
209
|
+
cl.closeFrom = cl.closeTo = cl.to;
|
|
210
|
+
}
|
|
177
211
|
}
|
|
178
212
|
// Every copy below walks the source forward, so the children have to be in source
|
|
179
213
|
// order. Foster parenting is where they are not: content written inside a <table>
|
|
@@ -189,14 +223,25 @@ function locOf(n, parent, src) {
|
|
|
189
223
|
if (cl.from < floor) { loc.outOfOrder = true; break; }
|
|
190
224
|
floor = cl.to;
|
|
191
225
|
}
|
|
192
|
-
|
|
226
|
+
const inner = loc.located && loc.endTagged ? Math.min(limit, loc.closeTo) : limit;
|
|
227
|
+
for (const cl of loc.children) settle(cl, src, inner);
|
|
193
228
|
}
|
|
194
229
|
|
|
195
|
-
function firstOutOfOrder(loc) {
|
|
196
|
-
if (loc
|
|
230
|
+
function firstOutOfOrder(loc, flag = 'outOfOrder') {
|
|
231
|
+
if (loc[flag]) return loc.tag;
|
|
197
232
|
for (const c of loc.children) {
|
|
198
233
|
if (c.kind !== 'element') continue;
|
|
199
|
-
const found = firstOutOfOrder(c);
|
|
234
|
+
const found = firstOutOfOrder(c, flag);
|
|
235
|
+
if (found) return found;
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The one text node holding whitespace the parser carried in from past </body>, if any. */
|
|
241
|
+
function tailOwner(loc) {
|
|
242
|
+
if (loc.outsideTail) return loc;
|
|
243
|
+
for (const c of loc.children || []) {
|
|
244
|
+
const found = tailOwner(c);
|
|
200
245
|
if (found) return found;
|
|
201
246
|
}
|
|
202
247
|
return null;
|
|
@@ -222,6 +267,7 @@ export function checkSource(m, liveDocument) {
|
|
|
222
267
|
// bracket with.
|
|
223
268
|
if (m.root.openFrom < 0) return 'source has no content inside <html>';
|
|
224
269
|
if (m.relocated) return 'the parser moved content out of <' + m.relocated + '>, so source order and tree order disagree';
|
|
270
|
+
if (m.movedIn) return 'the parser moved content written after the end of <' + m.movedIn + '> back inside it, so source order and tree order disagree';
|
|
225
271
|
const want = outsideOf(liveDocument);
|
|
226
272
|
const got = m.outside.map(outsideKey);
|
|
227
273
|
if (want.length !== got.length) return 'outside <html>: ' + JSON.stringify(got) + ' vs live ' + JSON.stringify(want);
|
|
@@ -543,19 +589,56 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
543
589
|
// wrong byte and looks like a successful render.
|
|
544
590
|
const keep = (from, to) => { if (from >= 0 && to > from) pieces.push({ from, to }); };
|
|
545
591
|
const text = (t) => { if (t) pieces.push({ text: t }); };
|
|
546
|
-
|
|
592
|
+
// Elements in `opts.print`, and everything inside them, are printed rather than
|
|
593
|
+
// copied: the answer to a region whose copy did not verify.
|
|
594
|
+
let printing = 0;
|
|
595
|
+
const locOfClone = (n) => { if (printing) return null; const live = provenance(n); const l = live ? map.get(live) : null; return l && l.gen === m.gen ? l : null; };
|
|
596
|
+
// Whitespace written after </body> or </html> is not where its bytes are. The parser
|
|
597
|
+
// appends it to the last text node in <body> (the model's `tailOwner`), and every
|
|
598
|
+
// copy of the file writes it back after the end tags, which reparses to the same
|
|
599
|
+
// node. That only holds while the owner is still the last thing in <body>. Once
|
|
600
|
+
// something follows it (an appended element, a removed or printed owner), the same
|
|
601
|
+
// bytes reparse as a new text node at the end of <body>, so the render has to put the
|
|
602
|
+
// tail back on the owner and write nothing but tags and comments after </body>.
|
|
603
|
+
let tailMark = null; // where the owner's copied text ends, and the tail it left out
|
|
604
|
+
let afterBody = -1; // the first piece after body's end
|
|
605
|
+
let tailClean = false;
|
|
547
606
|
const rootLoc = m.root;
|
|
548
607
|
keep(0, rootLoc.openFrom); // the authored doctype and anything before <html>, verbatim
|
|
549
608
|
emitElement(clone, rootLoc);
|
|
550
609
|
keep(rootLoc.closeTo, src.length); // the trailing bytes, verbatim
|
|
610
|
+
if (m.tailOwner && afterBody >= 0 && !tailClean) {
|
|
611
|
+
for (let i = afterBody; i < pieces.length; i++) pieces[i] = { text: withoutWhitespace(piece(pieces[i])) };
|
|
612
|
+
if (tailMark && tailMark.tail) pieces.splice(tailMark.at, 0, { text: tailMark.tail });
|
|
613
|
+
}
|
|
551
614
|
|
|
552
|
-
const out = pieces.map(
|
|
615
|
+
const out = pieces.map(piece).join('');
|
|
553
616
|
return { text: out, ms: now() - t0 };
|
|
554
617
|
|
|
618
|
+
function piece(p) { return p.text !== undefined ? p.text : src.slice(p.from, p.to); }
|
|
619
|
+
function withoutWhitespace(s) {
|
|
620
|
+
return s.split(/(<!--[\s\S]*?-->|<[^>]*>)/).map((seg, i) => (i % 2 ? seg : seg.replace(/[\t\n\f\r ]+/g, ''))).join('');
|
|
621
|
+
}
|
|
622
|
+
// Called once <body>'s end tag, if any, has been emitted. The owner is still last when
|
|
623
|
+
// nothing but end tags was emitted after its text.
|
|
624
|
+
function endOfBody() {
|
|
625
|
+
afterBody = pieces.length;
|
|
626
|
+
tailClean = !!tailMark && tailMark.whole
|
|
627
|
+
&& /^(?:<\/[^>]*>)*$/.test(pieces.slice(tailMark.at).map(piece).join(''));
|
|
628
|
+
}
|
|
629
|
+
|
|
555
630
|
function emitNode(n, parentTag) {
|
|
556
631
|
if (n.nodeType === 3) return emitText(n, parentTag);
|
|
557
632
|
if (n.nodeType === 8) return emitComment(n);
|
|
558
|
-
if (n.nodeType === 1)
|
|
633
|
+
if (n.nodeType === 1) {
|
|
634
|
+
if (opts.print && opts.print.has(n)) {
|
|
635
|
+
printing++;
|
|
636
|
+
try { emitElement(n, null); } finally { printing--; }
|
|
637
|
+
} else {
|
|
638
|
+
emitElement(n, locOfClone(n));
|
|
639
|
+
}
|
|
640
|
+
if (n.localName === 'body' && n.parentNode === clone) endOfBody();
|
|
641
|
+
}
|
|
559
642
|
}
|
|
560
643
|
function emitComment(n) {
|
|
561
644
|
const loc = locOfClone(n);
|
|
@@ -566,11 +649,28 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
566
649
|
const loc = locOfClone(n);
|
|
567
650
|
let data = n.data;
|
|
568
651
|
if (opts.corrupt === 'drop-first-text' && !opts.corrupted && data.trim()) { opts.corrupted = true; data = data.slice(1); }
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
652
|
+
// The same bytes mean different things in different parents. A text node whose data
|
|
653
|
+
// is `<img>` is written `<img>` in a <div>, and raw in a <noscript>, whose
|
|
654
|
+
// content a scripting browser reads as raw text; copied into one from the other it
|
|
655
|
+
// says something else. A text node moved across that boundary is printed for its
|
|
656
|
+
// new parent, never copied from its old one.
|
|
657
|
+
const sameContext = loc && loc.parent && textContext(loc.parent.tag) === textContext(parentTag);
|
|
658
|
+
if (loc && loc.kind === 'text' && loc.value === data && loc.from >= 0 && !opts.breakText && sameContext) {
|
|
659
|
+
keep(loc.from, loc.to);
|
|
660
|
+
// Test switch: copy one named text node's bytes twice, a corruption only the
|
|
661
|
+
// copy path makes, so printing the element around it is the fix.
|
|
662
|
+
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 };
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
let owner = null;
|
|
667
|
+
if (loc && loc.kind === 'text' && loc.outsideTail) {
|
|
668
|
+
const whole = data.endsWith(loc.outsideTail);
|
|
669
|
+
if (whole) data = data.slice(0, data.length - loc.outsideTail.length);
|
|
670
|
+
owner = { tail: whole ? loc.outsideTail : '', whole };
|
|
572
671
|
}
|
|
573
672
|
text(RAW_TEXT.has(parentTag) ? data : escText(data));
|
|
673
|
+
if (owner) tailMark = { at: pieces.length, ...owner };
|
|
574
674
|
}
|
|
575
675
|
function emitElement(n, loc) {
|
|
576
676
|
const tag = n.localName;
|
|
@@ -625,7 +725,13 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
625
725
|
const seen = new Set();
|
|
626
726
|
let cursor = loc.openFrom;
|
|
627
727
|
for (const a of loc.attrs) {
|
|
628
|
-
if (a.from < 0)
|
|
728
|
+
if (a.from < 0) {
|
|
729
|
+
// No bytes in this tag: parse5 merged it in from a second <body> or <html> tag,
|
|
730
|
+
// where its bytes still are. Appending it here as well wrote it twice.
|
|
731
|
+
const cur = live.get(a.key);
|
|
732
|
+
if (cur && cur.value === a.value) seen.add(a.key);
|
|
733
|
+
continue;
|
|
734
|
+
}
|
|
629
735
|
const cur = live.get(a.key);
|
|
630
736
|
seen.add(a.key);
|
|
631
737
|
if (!cur) { keep(cursor, gapStart(cursor, a.from)); cursor = a.to; continue; }
|
|
@@ -668,27 +774,94 @@ export function render(clone, map, m, provenance, opts = {}) {
|
|
|
668
774
|
// this parent and form the longest in-order run stay in place and copy their bytes;
|
|
669
775
|
// every other clone child is emitted where the clone has it (printed, or copied out of
|
|
670
776
|
// place as a move); every source child the clone no longer has is dropped.
|
|
777
|
+
// An element the author left open (`<li>a`, a `<div>` missing its `</div>`) is closed
|
|
778
|
+
// by whatever the parser met next in the file. Copied without an end tag, it stays
|
|
779
|
+
// closed only while that same node still follows it. Anything else emitted after it
|
|
780
|
+
// (an appended element, a text node, a moved sibling) would parse INSIDE it, so it
|
|
781
|
+
// gets the end tag the file never had, written just before that next node.
|
|
782
|
+
function copiedOpen(c) {
|
|
783
|
+
if (c.nodeType !== 1 || printing || (opts.print && opts.print.has(c))) return null;
|
|
784
|
+
const l = locOfClone(c);
|
|
785
|
+
return l && l.kind === 'element' && l.located && !l.endTagged && !VOID.has(l.tag) && !opts.breakTags ? l : null;
|
|
786
|
+
}
|
|
787
|
+
// The end tags a copied-open element needs, innermost first: its own, and those of the
|
|
788
|
+
// open elements it ends with, since one end tag does not close them all. `</b>` inside
|
|
789
|
+
// `<b><b>` closes only the inner one.
|
|
790
|
+
function openChain(c) {
|
|
791
|
+
const l = copiedOpen(c);
|
|
792
|
+
if (!l) return null;
|
|
793
|
+
const kids = liveKids(c);
|
|
794
|
+
const inner = kids.length ? openChain(kids[kids.length - 1]) : null;
|
|
795
|
+
return (inner || []).concat(l.tag);
|
|
796
|
+
}
|
|
671
797
|
function emitChildren(el, loc, parentTag) {
|
|
672
798
|
const C = liveKids(el);
|
|
673
|
-
|
|
799
|
+
let open = null; // { tags, j }: the last child's end tags if it was copied open, and its source index
|
|
800
|
+
const closeOpen = (i, j) => {
|
|
801
|
+
if (open && !(j >= 0 && open.j >= 0 && j === open.j + 1)) text(open.tags.map((t) => '</' + t + '>').join(''));
|
|
802
|
+
open = null;
|
|
803
|
+
};
|
|
804
|
+
if (!loc) {
|
|
805
|
+
for (const c of C) { closeOpen(-1, -1); emitNode(c, parentTag); const tags = openChain(c); if (tags) open = { tags, j: -1 }; }
|
|
806
|
+
return;
|
|
807
|
+
}
|
|
674
808
|
const S = loc.children;
|
|
675
809
|
const sIndex = new Map(S.map((s, j) => [s, j]));
|
|
676
810
|
const owned = C.map((c) => { const l = locOfClone(c); return l && l.parent === loc && l.from >= 0 ? sIndex.get(l) : -1; });
|
|
811
|
+
// A child with no identity at all is usually not new. A [freeze] restore, a
|
|
812
|
+
// [persist] textarea, an innerHTML rebuild after pairing: each hands the save clone
|
|
813
|
+
// fresh nodes for content the file already holds. Matched here, by exact subtree
|
|
814
|
+
// signature against this parent's source children nothing else claimed, in order,
|
|
815
|
+
// and copied whole. Exact means the bytes say what the node says; verify still checks.
|
|
816
|
+
const whole = new Set();
|
|
817
|
+
if (!opts.breakText && !opts.breakTags) {
|
|
818
|
+
const taken = new Set(owned);
|
|
819
|
+
const free = new Map();
|
|
820
|
+
S.forEach((s, j) => {
|
|
821
|
+
if (taken.has(j) || s.from < 0 || !s.keys) return;
|
|
822
|
+
if (!free.has(s.keys.deep)) free.set(s.keys.deep, []);
|
|
823
|
+
free.get(s.keys.deep).push(j);
|
|
824
|
+
});
|
|
825
|
+
if (free.size) C.forEach((c, i) => {
|
|
826
|
+
if (owned[i] >= 0 || locOfClone(c)) return;
|
|
827
|
+
const q = free.get(keysOfLive(c, new Map()).deep);
|
|
828
|
+
if (q && q.length) { owned[i] = q.shift(); whole.add(i); }
|
|
829
|
+
});
|
|
830
|
+
}
|
|
831
|
+
const emitChild = (c, i) => {
|
|
832
|
+
if (!whole.has(i)) return emitNode(c, parentTag);
|
|
833
|
+
const s = S[owned[i]];
|
|
834
|
+
keep(s.from, s.to);
|
|
835
|
+
if (s.outsideTail) tailMark = { at: pieces.length, tail: s.outsideTail, whole: true };
|
|
836
|
+
};
|
|
837
|
+
const openOf = (c, i) => {
|
|
838
|
+
if (!whole.has(i)) return openChain(c);
|
|
839
|
+
const s = S[owned[i]];
|
|
840
|
+
return s.kind === 'element' && s.located && !s.endTagged && !VOID.has(s.tag) ? [s.tag] : null;
|
|
841
|
+
};
|
|
677
842
|
const inPlace = new Set(lis(owned));
|
|
678
843
|
let cursor = loc.openTo;
|
|
679
844
|
let lastS = -1;
|
|
680
845
|
const dropTo = (j) => { for (let k = lastS + 1; k < j; k++) { const sk = S[k]; if (sk.from < 0) continue; keep(cursor, sk.from); cursor = Math.max(cursor, sk.to); } };
|
|
681
846
|
for (let i = 0; i < C.length; i++) {
|
|
682
847
|
const c = C[i];
|
|
848
|
+
const tags = openOf(c, i);
|
|
683
849
|
if (inPlace.has(i)) {
|
|
684
850
|
const j = owned[i], s = S[j];
|
|
851
|
+
closeOpen(i, j);
|
|
685
852
|
dropTo(j);
|
|
686
853
|
keep(cursor, s.from);
|
|
687
|
-
|
|
854
|
+
emitChild(c, i);
|
|
688
855
|
cursor = Math.max(cursor, s.to); lastS = j;
|
|
856
|
+
open = tags ? { tags, j } : null;
|
|
689
857
|
continue;
|
|
690
858
|
}
|
|
691
|
-
|
|
859
|
+
// An original follower the parser implied rather than read (a <p> made by a stray
|
|
860
|
+
// </p>) has no bytes of its own, so it is not "in place", but it still follows.
|
|
861
|
+
const lc = locOfClone(c);
|
|
862
|
+
closeOpen(i, lc && lc.parent === loc ? sIndex.get(lc) : -1);
|
|
863
|
+
emitChild(c, i);
|
|
864
|
+
open = tags ? { tags, j: -1 } : null;
|
|
692
865
|
}
|
|
693
866
|
dropTo(S.length);
|
|
694
867
|
keep(cursor, loc.closeFrom);
|
|
@@ -736,13 +909,32 @@ export function verify(rendered, today, liveDocument, clone = null, sourceErrors
|
|
|
736
909
|
const diff = vDiff(A.documentElement, B.documentElement, 'html');
|
|
737
910
|
const worse = diff ? null : vParseErrors(rendered, sourceErrors);
|
|
738
911
|
if (!diff) return worse ? { ok: false, diff: worse } : { ok: true, diff: null, oracle: 'today' };
|
|
739
|
-
if (clone
|
|
740
|
-
|
|
741
|
-
|
|
912
|
+
if (clone) {
|
|
913
|
+
// `at` is the index path, in the clone, of the node where the render first differs,
|
|
914
|
+
// so a caller can print that element and try again instead of discarding the whole
|
|
915
|
+
// render. It is valid in the clone because every earlier sibling at every level on
|
|
916
|
+
// the way down compared equal.
|
|
917
|
+
const at = [];
|
|
918
|
+
if (!vDiff(A.documentElement, clone, 'html', at)) {
|
|
919
|
+
const w = vParseErrors(rendered, sourceErrors);
|
|
920
|
+
return w ? { ok: false, diff: w } : { ok: true, diff: null, oracle: 'clone', todayDiff: diff };
|
|
921
|
+
}
|
|
922
|
+
return { ok: false, diff, at };
|
|
742
923
|
}
|
|
743
924
|
return { ok: false, diff };
|
|
744
925
|
}
|
|
745
926
|
|
|
927
|
+
/** The node at an index path from verify's `at`, walking the same children verify walked. */
|
|
928
|
+
export function nodeAt(root, at) {
|
|
929
|
+
let n = root;
|
|
930
|
+
for (const i of at) {
|
|
931
|
+
const kids = vKids(n);
|
|
932
|
+
if (i >= kids.length) return null;
|
|
933
|
+
n = kids[i];
|
|
934
|
+
}
|
|
935
|
+
return n;
|
|
936
|
+
}
|
|
937
|
+
|
|
746
938
|
/**
|
|
747
939
|
* The check the tree comparison cannot make.
|
|
748
940
|
*
|
|
@@ -754,9 +946,13 @@ export function verify(rendered, today, liveDocument, clone = null, sourceErrors
|
|
|
754
946
|
* full serialization cannot produce one, because the DOM it comes from cannot hold a
|
|
755
947
|
* duplicate attribute in the first place.
|
|
756
948
|
*
|
|
757
|
-
* This is narrower than it sounds and worth saying plainly
|
|
758
|
-
*
|
|
759
|
-
*
|
|
949
|
+
* This is narrower than it sounds and worth saying plainly. It sees what parse5
|
|
950
|
+
* reports, which is tokenizer errors such as `duplicate-attribute`. Input the tree
|
|
951
|
+
* builder discards is not reported at all: a stray end tag, a second `<body>` tag's
|
|
952
|
+
* attributes, a stray doctype. A render that wrote one of those would reparse to the
|
|
953
|
+
* same tree with no new error, and nothing here would see it. Bytes the parser DOES
|
|
954
|
+
* represent, such as a duplicated element, change the tree, and the comparison above
|
|
955
|
+
* is what catches those.
|
|
760
956
|
*/
|
|
761
957
|
function vParseErrors(rendered, sourceErrors) {
|
|
762
958
|
if (!sourceErrors) return null;
|
|
@@ -793,18 +989,25 @@ function vOutside(doc) {
|
|
|
793
989
|
*/
|
|
794
990
|
function vNoscript(a, b, path) {
|
|
795
991
|
const P = new DOMParser();
|
|
796
|
-
|
|
992
|
+
// Read each side the way it was parsed, which verify fixes, not the node. `a` is always
|
|
993
|
+
// the render reparsed by DOMParser, with scripting off, so its <noscript> text is TEXT,
|
|
994
|
+
// and reading its `.data` as markup would turn `<img>` into an <img> and pass a
|
|
995
|
+
// render that wrote escaped text where the page has an image. `b` always comes from the
|
|
996
|
+
// page, parsed with scripting on, so a <noscript> holding only text holds its markup
|
|
997
|
+
// raw. Asking the node's own document gets `b` wrong: Chrome builds the save clone in a
|
|
998
|
+
// document with no window.
|
|
999
|
+
const raw = (el) => {
|
|
797
1000
|
const kids = Array.from(el.childNodes);
|
|
798
1001
|
return kids.length && kids.every((n) => n.nodeType === 3) ? kids.map((n) => n.data).join('') : el.innerHTML;
|
|
799
1002
|
};
|
|
800
|
-
return vDiff(P.parseFromString(
|
|
1003
|
+
return vDiff(P.parseFromString(a.innerHTML, 'text/html').body, P.parseFromString(raw(b), 'text/html').body, path + '>#noscript');
|
|
801
1004
|
}
|
|
802
1005
|
function vKids(el) {
|
|
803
1006
|
const list = el.localName === 'template' && el.content ? el.content.childNodes : el.childNodes;
|
|
804
1007
|
return Array.from(list).filter((n) => n.nodeType === 1 || n.nodeType === 3 || n.nodeType === 8);
|
|
805
1008
|
}
|
|
806
1009
|
const vAttrs = (el) => Array.from(el.attributes, (a) => (a.namespaceURI || '') + ' ' + a.name + '=' + JSON.stringify(a.value)).sort().join('; ');
|
|
807
|
-
function vDiff(a, b, path) {
|
|
1010
|
+
function vDiff(a, b, path, at = []) {
|
|
808
1011
|
if (a.nodeType !== b.nodeType) return path + ': node type ' + a.nodeType + ' vs ' + b.nodeType;
|
|
809
1012
|
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));
|
|
810
1013
|
if (a.localName !== b.localName || a.namespaceURI !== b.namespaceURI) return path + ': tag ' + a.localName + ' vs ' + b.localName;
|
|
@@ -812,6 +1015,11 @@ function vDiff(a, b, path) {
|
|
|
812
1015
|
if (a.localName === 'noscript' && a.namespaceURI === XHTML) return vNoscript(a, b, path);
|
|
813
1016
|
const ka = vKids(a), kb = vKids(b);
|
|
814
1017
|
if (ka.length !== kb.length) return path + ': ' + ka.length + ' vs ' + kb.length + ' children';
|
|
815
|
-
for (let i = 0; i < ka.length; i++) {
|
|
1018
|
+
for (let i = 0; i < ka.length; i++) {
|
|
1019
|
+
at.push(i);
|
|
1020
|
+
const d = vDiff(ka[i], kb[i], path + '>' + (ka[i].localName || '#') + '[' + i + ']', at);
|
|
1021
|
+
if (d) return d;
|
|
1022
|
+
at.pop();
|
|
1023
|
+
}
|
|
816
1024
|
return null;
|
|
817
1025
|
}
|
package/src/loader-logic.js
CHANGED
|
@@ -36,6 +36,9 @@ export const PLUGIN_PATHS = {
|
|
|
36
36
|
// superset of the cms's, so the plugin is present exactly when it can be used.
|
|
37
37
|
upload: { path: "plugins/upload.js", editOnly: true, default: false },
|
|
38
38
|
wire: { path: "plugins/wire.js", editOnly: false, default: false },
|
|
39
|
+
// Edit mode only: the AI comment box is an editing gesture, and the plugin is
|
|
40
|
+
// dormant anyway on any host that does not list a ready `ai-edit` helper.
|
|
41
|
+
"ai-edit": { path: "plugins/ai-edit.js", editOnly: true, default: false },
|
|
39
42
|
demo: { path: "plugins/demo.js", editOnly: false, default: false },
|
|
40
43
|
// Saves the file's own bytes back instead of a fresh serialization of the DOM.
|
|
41
44
|
// editOnly because a page that cannot save has nothing to preserve. On by default
|
|
@@ -84,13 +87,14 @@ export const MODULES = {
|
|
|
84
87
|
"vendor/quickcrop.vendor.js": () => import("./vendor/quickcrop.vendor.js"),
|
|
85
88
|
"plugins/upload.js": () => import("./plugins/upload.js"),
|
|
86
89
|
"plugins/wire.js": () => import("./plugins/wire.js"),
|
|
90
|
+
"plugins/ai-edit.js": () => import("./plugins/ai-edit.js"),
|
|
87
91
|
"plugins/demo.js": () => import("./plugins/demo.js"),
|
|
88
92
|
"plugins/source.js": () => import("./plugins/source.js"),
|
|
89
93
|
};
|
|
90
94
|
|
|
91
95
|
// `source` is last on purpose: its install captures a save clone, so it wants every
|
|
92
96
|
// plugin that registers a document transform to have registered it first.
|
|
93
|
-
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "cms", "sync", "wire", "demo", "source"];
|
|
97
|
+
const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop", "upload", "cms", "sync", "wire", "ai-edit", "demo", "source"];
|
|
94
98
|
|
|
95
99
|
// A plugin that cannot do its whole job alone. hypercms reads the cropper through
|
|
96
100
|
// a capability lookup (`clay.quickcrop`) and silently uploads the raw file when it
|
|
@@ -108,7 +112,9 @@ const PLUGIN_ORDER = ["richclay", "indicator", "sortable", "undo", "quickcrop",
|
|
|
108
112
|
// This is the only line in the capability that changes how an already-published
|
|
109
113
|
// page behaves, which is why it shipped alone, one release after the plugin it
|
|
110
114
|
// enables. Reverting it is reverting this line.
|
|
111
|
-
|
|
115
|
+
// ai-edit reads `clay.wire.helpers()` and sends through `clay.wire.send`, so a page
|
|
116
|
+
// that asks for it has to have the wire. The wire loads first in the order above.
|
|
117
|
+
const IMPLIES = { cms: ["quickcrop", "upload"], "ai-edit": ["wire"] };
|
|
112
118
|
|
|
113
119
|
function parseCsv(params, key, enabled, apply) {
|
|
114
120
|
const raw = params.get(key);
|
package/src/loader.js
CHANGED
|
@@ -114,6 +114,8 @@ function attachPluginMember(path, mod) {
|
|
|
114
114
|
clay.upload = mod.upload || mod.default;
|
|
115
115
|
} else if (path === "plugins/wire.js") {
|
|
116
116
|
clay.wire = mod.wire || mod.default;
|
|
117
|
+
} else if (path === "plugins/ai-edit.js") {
|
|
118
|
+
clay.aiEdit = mod.aiEdit || mod.default;
|
|
117
119
|
} else if (path === "plugins/demo.js") {
|
|
118
120
|
clay.demo = mod.demo;
|
|
119
121
|
} else if (path === "vendor/richclay.vendor.js") {
|