@panphora/clayjs 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/dist/clay.standalone.js +6972 -3942
- package/entries/clay-data.js +1 -1
- package/entries/sap.js +6 -4
- 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/autosave.js +2 -0
- package/src/core/is-edit-mode.js +7 -2
- package/src/core/save.js +22 -4
- package/src/core/snapshot.js +46 -3
- package/src/core/source-map.js +246 -38
- package/src/lib/autosave-state.js +17 -0
- package/src/lib/dirty-gate.js +5 -0
- 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 +467 -311
- package/src/sync/section-notice.js +6 -7
- package/src/vendor/control-serialize.vendor.js +13 -6
- package/src/vendor/hyper-morph.vendor.js +4 -9
- package/src/vendor/hypercms.vendor.js +38 -38
- package/src/sync/splice-merge.js +0 -270
package/src/core/save.js
CHANGED
|
@@ -23,7 +23,7 @@ import {
|
|
|
23
23
|
import { captureForComparison, captureForComparisonAndDirty, captureForSaveAndComparison } from "./snapshot.js";
|
|
24
24
|
import { seedEtag } from "./etag.js";
|
|
25
25
|
import { gateCaptureToken, gateClearIfUnchanged } from "../lib/dirty-gate.js";
|
|
26
|
-
import { ROOT_LIBRARY_ATTRS } from "../lib/root-attrs.js";
|
|
26
|
+
import { ROOT_LIBRARY_ATTRS, SAVE_TOKEN_ATTRS, LEGACY_SAVE_TOKEN_ATTRS } from "../lib/root-attrs.js";
|
|
27
27
|
import { logSaveCheck, logBaseline } from "../lib/autosave-debug.js";
|
|
28
28
|
import { initUserGesture, markExplicitSave, clearExplicitSave } from "../lib/user-gesture.js";
|
|
29
29
|
|
|
@@ -43,6 +43,18 @@ addDocumentTransform(clone => {
|
|
|
43
43
|
for (const name of ROOT_LIBRARY_ATTRS) clone.removeAttribute(name);
|
|
44
44
|
});
|
|
45
45
|
|
|
46
|
+
// Keep the host's save token out of the saved bytes, both spellings.
|
|
47
|
+
//
|
|
48
|
+
// It is a credential for this response, never file content: htmlclay strips it from
|
|
49
|
+
// every save body on arrival, so it never reached disk anyway. Sending it made the
|
|
50
|
+
// source map, which models the bytes a save sent, describe a root tag one attribute
|
|
51
|
+
// longer than the file, and every offset after it was off by that much. The save
|
|
52
|
+
// itself is authorized by the URL, which reads the token from the live page. The
|
|
53
|
+
// document id is NOT stripped: htmlclay keeps it on disk on purpose.
|
|
54
|
+
addDocumentTransform(clone => {
|
|
55
|
+
for (const name of [...SAVE_TOKEN_ATTRS, ...LEGACY_SAVE_TOKEN_ATTRS]) clone.removeAttribute(name);
|
|
56
|
+
});
|
|
57
|
+
|
|
46
58
|
// ============================================
|
|
47
59
|
// SAVE STATE MANAGEMENT
|
|
48
60
|
// ============================================
|
|
@@ -246,12 +258,13 @@ function skipped_(msg) {
|
|
|
246
258
|
* where the write may or may not have landed, so it is not treated as success
|
|
247
259
|
* either.
|
|
248
260
|
*/
|
|
249
|
-
function applySaveResult(result, forComparison, forDirty, label, gateToken) {
|
|
261
|
+
function applySaveResult(result, forComparison, forDirty, label, gateToken, forSave = null) {
|
|
250
262
|
if (result.ok) {
|
|
251
263
|
// Both baselines advance from the SAME pre-request capture, never from the
|
|
252
264
|
// live DOM, so an edit made while the request was on the wire stays unsaved.
|
|
253
265
|
lastSavedContents = forComparison;
|
|
254
266
|
lastSavedDirty = forDirty;
|
|
267
|
+
lastSavedBytes = forSave;
|
|
255
268
|
unsavedChanges = false;
|
|
256
269
|
// Generation-checked: clears the scoped-sync dirty gate only if nothing
|
|
257
270
|
// changed while this save was on the wire.
|
|
@@ -301,6 +314,11 @@ export function setUnsavedChanges(val) { unsavedChanges = val; }
|
|
|
301
314
|
export function getLastSavedContents() { return lastSavedContents; }
|
|
302
315
|
export function getLastSavedDirty() { return lastSavedDirty; }
|
|
303
316
|
|
|
317
|
+
// The bytes the last successful save wrote. Live sync merges disk frames
|
|
318
|
+
// against them: they are what the file held when this tab last agreed with it.
|
|
319
|
+
let lastSavedBytes = null;
|
|
320
|
+
export function getLastSavedBytes() { return lastSavedBytes; }
|
|
321
|
+
|
|
304
322
|
/**
|
|
305
323
|
* Install both saved baselines from one post-morph capture.
|
|
306
324
|
*
|
|
@@ -402,7 +420,7 @@ export function savePage(callback = () => {}) {
|
|
|
402
420
|
|
|
403
421
|
// Use saveHtml directly with our pre-captured content (avoids double capture)
|
|
404
422
|
saveHtml(forSave, (result) => {
|
|
405
|
-
applySaveResult(result, forComparison, forDirty, 'updated after save', gateToken);
|
|
423
|
+
applySaveResult(result, forComparison, forDirty, 'updated after save', gateToken, forSave);
|
|
406
424
|
if (typeof callback === 'function') {
|
|
407
425
|
callback(result);
|
|
408
426
|
}
|
|
@@ -455,7 +473,7 @@ export function savePageForce(callback = () => {}) {
|
|
|
455
473
|
setSavingState();
|
|
456
474
|
|
|
457
475
|
saveHtml(forSave, (result) => {
|
|
458
|
-
applySaveResult(result, forComparison, forDirty, 'updated after force save', gateToken);
|
|
476
|
+
applySaveResult(result, forComparison, forDirty, 'updated after force save', gateToken, forSave);
|
|
459
477
|
if (typeof callback === 'function') {
|
|
460
478
|
callback(result);
|
|
461
479
|
}
|
package/src/core/snapshot.js
CHANGED
|
@@ -125,6 +125,49 @@ export function setSaveRenderer(renderer) {
|
|
|
125
125
|
saveRenderer = renderer;
|
|
126
126
|
}
|
|
127
127
|
|
|
128
|
+
const XHTML_NS = 'http://www.w3.org/1999/xhtml';
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* `clone.outerHTML`, except that a <noscript> holding only text is written raw.
|
|
132
|
+
*
|
|
133
|
+
* The page parsed with scripting on, so a <noscript> holds the author's markup as one
|
|
134
|
+
* text node, and the page's own serializer writes it back raw. The clone lives in a
|
|
135
|
+
* document with no window (createContentView), where scripting is off, and Chrome's
|
|
136
|
+
* serializer escapes that text there: `<p>` went to disk as `<p>`, which a reload
|
|
137
|
+
* reads as literal text, and every later save escaped it again. The text is swapped for
|
|
138
|
+
* a marker only while serializing, so the clone holds the same nodes when this returns.
|
|
139
|
+
*/
|
|
140
|
+
function serializeClone(clone) {
|
|
141
|
+
const blocks = [];
|
|
142
|
+
const collect = (node) => {
|
|
143
|
+
for (const child of node.childNodes) {
|
|
144
|
+
if (child.nodeType !== 1) continue;
|
|
145
|
+
if (child.localName === 'noscript' && child.namespaceURI === XHTML_NS) {
|
|
146
|
+
const kids = Array.from(child.childNodes);
|
|
147
|
+
if (kids.length && kids.every((n) => n.nodeType === 3)) blocks.push([child, kids]);
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
collect(child.localName === 'template' && child.content ? child.content : child);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
collect(clone);
|
|
154
|
+
if (!blocks.length) return "<!DOCTYPE html>" + clone.outerHTML;
|
|
155
|
+
const nonce = Math.random().toString(36).slice(2);
|
|
156
|
+
const raw = blocks.map(([el, kids], i) => {
|
|
157
|
+
const marker = `\uE000clay-noscript-${nonce}-${i}\uE000`;
|
|
158
|
+
el.replaceChildren(el.ownerDocument.createTextNode(marker));
|
|
159
|
+
return [marker, kids.map((n) => n.data).join('')];
|
|
160
|
+
});
|
|
161
|
+
let html;
|
|
162
|
+
try {
|
|
163
|
+
html = "<!DOCTYPE html>" + clone.outerHTML;
|
|
164
|
+
} finally {
|
|
165
|
+
for (const [el, kids] of blocks) el.replaceChildren(...kids);
|
|
166
|
+
}
|
|
167
|
+
for (const [marker, text] of raw) html = html.split(marker).join(text);
|
|
168
|
+
return html;
|
|
169
|
+
}
|
|
170
|
+
|
|
128
171
|
/**
|
|
129
172
|
* The bytes a save sends: the prepared clone, through the save renderer if one is
|
|
130
173
|
* installed.
|
|
@@ -135,7 +178,7 @@ export function setSaveRenderer(renderer) {
|
|
|
135
178
|
* cost somebody a save.
|
|
136
179
|
*/
|
|
137
180
|
function serializeSaveClone(clone) {
|
|
138
|
-
const today =
|
|
181
|
+
const today = serializeClone(clone);
|
|
139
182
|
if (!saveRenderer) return today;
|
|
140
183
|
try {
|
|
141
184
|
return saveRenderer(clone, today);
|
|
@@ -409,8 +452,8 @@ export function captureForComparisonAndDirty({ flushUndo = true } = {}) {
|
|
|
409
452
|
* @param {boolean} options.emitForSync - Whether to emit snapshot-ready event (default: true)
|
|
410
453
|
* @returns {{ forSave: string, forComparison: string, forDirty: string }}
|
|
411
454
|
*/
|
|
412
|
-
export function captureForSaveAndComparison({ emitForSync = true } = {}) {
|
|
413
|
-
const clone = captureSnapshot();
|
|
455
|
+
export function captureForSaveAndComparison({ emitForSync = true, flushUndo = true } = {}) {
|
|
456
|
+
const clone = captureSnapshot({ flushUndo });
|
|
414
457
|
|
|
415
458
|
// Emit for live-sync before any stripping.
|
|
416
459
|
//
|
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
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Whether this page autosaves, as decided once at boot by core/autosave.js.
|
|
3
|
+
*
|
|
4
|
+
* Kept in its own module so live sync can ask without importing autosave.js,
|
|
5
|
+
* whose import alone would boot autosave into builds that left it out. An
|
|
6
|
+
* <html autosave> attribute a frame carries later changes nothing here, the
|
|
7
|
+
* same as it changes nothing in autosave.js.
|
|
8
|
+
*/
|
|
9
|
+
let active = false;
|
|
10
|
+
|
|
11
|
+
export function setAutosaveActive(value) {
|
|
12
|
+
active = !!value;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function autosaveActive() {
|
|
16
|
+
return active;
|
|
17
|
+
}
|
package/src/lib/dirty-gate.js
CHANGED
|
@@ -170,6 +170,11 @@ export function gateCaptureToken() {
|
|
|
170
170
|
return { gen: changes, probe };
|
|
171
171
|
}
|
|
172
172
|
|
|
173
|
+
/** A change nothing in the DOM recorded: the page holds text a save has not written. */
|
|
174
|
+
export function gateMarkDirty() {
|
|
175
|
+
changes++;
|
|
176
|
+
}
|
|
177
|
+
|
|
173
178
|
export function gateClearIfUnchanged(token) {
|
|
174
179
|
if (!token) return;
|
|
175
180
|
if (changes === token.gen) {
|
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);
|