@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.
@@ -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
- return { src, doc, outside, root, parseErrors, relocated: root ? firstOutOfOrder(root) : null };
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
- // Normalized, because the node's value is. The parser rewrites every CRLF and
160
- // every lone CR in the input stream to LF before a text node ever sees them,
161
- // so on a CRLF file the raw bytes and the value can never match and this
162
- // comparison silently found no overhang at all. The tail then printed on top
163
- // of the bytes it had been relocated from and the file grew a blank line per
164
- // save. Only the LENGTH taken from here is normalized; the copy path still
165
- // uses the raw bytes, which is why an unedited CRLF file round trips exactly.
166
- const inside = src.slice(from, to).replace(/\r\n?/g, '\n');
167
- if (cl.value.startsWith(inside)) {
168
- const outside = cl.value.slice(inside.length);
169
- if (outside && !/\S/.test(outside)) cl.outsideTail = outside;
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, `&amp;` 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
- return loc;
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.outOfOrder) return loc.tag;
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
- const locOfClone = (n) => { const live = provenance(n); const l = live ? map.get(live) : null; return l && l.gen === m.gen ? l : null; };
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((p) => p.text !== undefined ? p.text : src.slice(p.from, p.to)).join('');
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) return emitElement(n, locOfClone(n));
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
- if (loc && loc.kind === 'text' && loc.value === data && loc.from >= 0 && !opts.breakText) { keep(loc.from, loc.to); return; }
570
- if (loc && loc.kind === 'text' && loc.outsideTail && data.endsWith(loc.outsideTail)) {
571
- data = data.slice(0, data.length - loc.outsideTail.length);
652
+ // The same bytes mean different things in different parents. A text node whose data
653
+ // is `<img>` is written `&lt;img&gt;` 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) continue;
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
- if (!loc) { for (const c of C) emitNode(c, parentTag); return; }
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
- emitNode(c, parentTag);
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
- emitNode(c, parentTag);
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 && !vDiff(A.documentElement, clone, 'html')) {
740
- const w = vParseErrors(rendered, sourceErrors);
741
- return w ? { ok: false, diff: w } : { ok: true, diff: null, oracle: 'clone', todayDiff: diff };
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: it catches the discarded
758
- * class only. Bytes the parser DOES represent, such as a duplicated element, change
759
- * the tree, and the comparison above is what catches those.
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
- const inner = (el) => {
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 `&lt;img&gt;` 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(inner(a), 'text/html').body, P.parseFromString(inner(b), 'text/html').body, path + '>#noscript');
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++) { const d = vDiff(ka[i], kb[i], path + '>' + (ka[i].localName || '#') + '[' + i + ']'); if (d) return d; }
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
  }
@@ -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
- const IMPLIES = { cms: ["quickcrop", "upload"] };
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") {