@panphora/clayjs 0.2.0 → 0.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 +99 -7
- package/clay-internals.js +19 -0
- package/clay.js +18 -2
- package/package.json +19 -3
- package/sap.js +5 -5
- package/src/attrs/onaftersave.js +12 -1
- package/src/attrs/refetch-on-save.js +4 -4
- package/src/attrs/save-freeze.js +47 -19
- package/src/core/admin-contenteditable.js +2 -2
- package/src/core/admin-inputs.js +2 -2
- package/src/core/admin-onclick.js +2 -2
- package/src/core/admin-resources.js +23 -14
- package/src/core/autosave.js +11 -1
- package/src/core/edit-mode.js +2 -2
- package/src/core/host-attrs.js +46 -0
- package/src/core/is-edit-mode.js +14 -6
- package/src/core/persist.js +8 -1
- package/src/core/save-core.js +199 -290
- package/src/core/save.js +124 -84
- package/src/core/snapshot.js +99 -43
- package/src/core/unsaved-warning.js +8 -2
- package/src/dom/all.js +4 -29
- package/src/dom/form-data.js +1 -1
- package/src/dom/nearest.js +11 -9
- package/src/internals/index.js +64 -0
- package/src/lib/cache-bust.js +10 -0
- package/src/lib/cookie.js +50 -15
- package/src/lib/extension-noise.js +5 -1
- package/src/lib/mutation.js +65 -18
- package/src/lib/region-policy.js +17 -2
- package/src/lib/root-attrs.js +45 -0
- package/src/lib/throttle.js +25 -13
- package/src/loader.js +18 -24
- package/src/options/options.js +1 -3
- package/src/plugins/demo.js +7 -1
- package/src/sync/live-sync.js +121 -89
- package/src/ui/dialogs.js +10 -1
- package/src/ui/index.js +2 -11
- package/src/ui/toast.js +8 -2
- package/src/utils/debounce.js +24 -7
- package/src/vendor/hyper-undo.vendor.js +1 -1
- package/src/vendor/hypercms.vendor.js +29 -16
- package/src/vendor/richclay.vendor.js +29 -22
package/src/core/save-core.js
CHANGED
|
@@ -9,12 +9,11 @@
|
|
|
9
9
|
|
|
10
10
|
import { isEditMode } from "./is-edit-mode.js";
|
|
11
11
|
import { consumeUserDriven, markUserDriven } from "../lib/user-gesture.js";
|
|
12
|
+
import { saveToken, saveTransport, DESKTOP_JSON } from "./host-attrs.js";
|
|
12
13
|
import {
|
|
13
|
-
captureForSave,
|
|
14
|
-
beforeSave,
|
|
15
14
|
getPageContents,
|
|
16
15
|
onSnapshot,
|
|
17
|
-
|
|
16
|
+
addDocumentTransform
|
|
18
17
|
} from "./snapshot.js";
|
|
19
18
|
|
|
20
19
|
// =============================================================================
|
|
@@ -22,7 +21,8 @@ import {
|
|
|
22
21
|
// =============================================================================
|
|
23
22
|
|
|
24
23
|
let saveInProgress = false;
|
|
25
|
-
const
|
|
24
|
+
const SAVE_PATH = '/_/save';
|
|
25
|
+
const SAVE_TIMEOUT_MS = 12000;
|
|
26
26
|
|
|
27
27
|
/**
|
|
28
28
|
* Check if a save is currently in progress.
|
|
@@ -32,214 +32,202 @@ export function isSaveInProgress() {
|
|
|
32
32
|
return saveInProgress;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
-
/**
|
|
36
|
-
* Resolve the save endpoint for the current host.
|
|
37
|
-
*
|
|
38
|
-
* htmlclay (the local Go app for .htmlclay files) authenticates each save with
|
|
39
|
-
* a per-file token injected as the `htmlclaytoken` attribute on <html>, and
|
|
40
|
-
* carries it in the URL path so the same token works for fetch and EventSource.
|
|
41
|
-
* When that attribute is present, save to `/_/save/{token}`; otherwise use the
|
|
42
|
-
* bare `/_/save` (platform and Hyperclay Local, which authenticate by cookie).
|
|
43
|
-
*
|
|
44
|
-
* @returns {string} The endpoint URL for the current save.
|
|
45
|
-
*/
|
|
46
|
-
function getSaveEndpoint() {
|
|
47
|
-
const htmlclayToken = document.documentElement.getAttribute('htmlclaytoken');
|
|
48
|
-
return htmlclayToken ? `${saveEndpoint}/${htmlclayToken}` : saveEndpoint;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
35
|
// =============================================================================
|
|
52
36
|
// RE-EXPORTS FROM SNAPSHOT (for backwards compat)
|
|
53
37
|
// =============================================================================
|
|
54
38
|
|
|
55
|
-
export {
|
|
39
|
+
export { getPageContents, onSnapshot, addDocumentTransform };
|
|
56
40
|
|
|
57
41
|
// =============================================================================
|
|
58
|
-
//
|
|
42
|
+
// THE RESULT
|
|
59
43
|
// =============================================================================
|
|
44
|
+
// One shape from every entry point: {ok, msg, msgType, code, etag}. There used to
|
|
45
|
+
// be three shapes — two callback conventions and a hand-built third — which is how
|
|
46
|
+
// a skipped save came to be read as a successful one.
|
|
47
|
+
//
|
|
48
|
+
// `ok` is the OUTCOME: did these bytes reach the server and get accepted. `msgType`
|
|
49
|
+
// is the SEVERITY, and it belongs to the server, which can answer a perfectly good
|
|
50
|
+
// save with 'warning' (htmlclay does, when an outside process touched the file).
|
|
51
|
+
// Reading the severity as the outcome means treating that warning as a failure.
|
|
52
|
+
|
|
53
|
+
function successResult(data) {
|
|
54
|
+
return {
|
|
55
|
+
ok: true,
|
|
56
|
+
msg: data.msg ?? 'Saved',
|
|
57
|
+
msgType: data.msgType || 'success',
|
|
58
|
+
code: data.code ?? null,
|
|
59
|
+
etag: data.etag ?? null
|
|
60
|
+
};
|
|
61
|
+
}
|
|
60
62
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
63
|
+
function errorResult(err) {
|
|
64
|
+
const timedOut = err.name === 'AbortError';
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
msg: timedOut ? 'Server not responding' : (err.message || 'Save failed'),
|
|
68
|
+
// A timeout is not evidence the write failed. The request may well have landed,
|
|
69
|
+
// so this reports "we do not know" rather than asserting something false.
|
|
70
|
+
msgType: timedOut ? 'unknown' : 'error',
|
|
71
|
+
code: timedOut ? 'timeout' : (err.code ?? null),
|
|
72
|
+
etag: null
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function skippedResult(msg) {
|
|
77
|
+
return { ok: false, msg, msgType: 'skipped', code: null, etag: null };
|
|
70
78
|
}
|
|
71
79
|
|
|
72
80
|
// =============================================================================
|
|
73
|
-
//
|
|
81
|
+
// THE WIRE
|
|
74
82
|
// =============================================================================
|
|
83
|
+
// One request builder and one response decoder. Keeping two copies of the request
|
|
84
|
+
// is what let four separate wire bugs hide at once, each of which had to be found
|
|
85
|
+
// twice.
|
|
75
86
|
|
|
76
87
|
/**
|
|
77
|
-
*
|
|
88
|
+
* Build the save request for this host.
|
|
78
89
|
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
90
|
+
* htmlclay (the local Go app for .htmlclay files) authenticates each save with a
|
|
91
|
+
* per-file token on the root and carries it in the URL path, so the same token
|
|
92
|
+
* works for fetch and EventSource. The platform and Hyperclay Local authenticate
|
|
93
|
+
* by cookie and use the bare endpoint.
|
|
82
94
|
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
95
|
+
* A token save asks for no cookies at all. The token IS the credential, and a
|
|
96
|
+
* credentialed cross-origin request needs Access-Control-Allow-Credentials back;
|
|
97
|
+
* a host that mints per-document tokens must never send that header, because
|
|
98
|
+
* `Origin: null` is forgeable and must not buy ambient authority. Asking anyway
|
|
99
|
+
* gets every sandboxed save blocked before it leaves the browser.
|
|
86
100
|
*
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
* });
|
|
101
|
+
* The endpoint is resolved against the real origin rather than left relative,
|
|
102
|
+
* because a <base href> anywhere in the authored document would otherwise redirect
|
|
103
|
+
* the save — and the per-document token in its path — to an origin the document
|
|
104
|
+
* itself chose.
|
|
92
105
|
*
|
|
93
|
-
* @
|
|
94
|
-
*
|
|
95
|
-
*
|
|
96
|
-
*
|
|
106
|
+
* @param {string} content - HTML to save
|
|
107
|
+
* @param {?string} snapshotHtml - unstripped snapshot, for the desktop envelope
|
|
108
|
+
* @param {boolean} userDriven - Whether a human gesture is behind this save
|
|
109
|
+
* @param {AbortSignal} signal
|
|
110
|
+
* @returns {{url: string, options: Object}}
|
|
97
111
|
*/
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
let currentContents;
|
|
112
|
-
try {
|
|
113
|
-
currentContents = getContentsForSave();
|
|
114
|
-
} catch (err) {
|
|
115
|
-
console.error('savePage: getContentsForSave failed', err);
|
|
116
|
-
const result = { msg: err.message, msgType: "error" };
|
|
117
|
-
callback(result);
|
|
118
|
-
return resolve(result);
|
|
119
|
-
}
|
|
120
|
-
saveInProgress = true;
|
|
121
|
-
|
|
122
|
-
// Test mode: skip network request, return mock success
|
|
123
|
-
if (window.clay?.testMode) {
|
|
124
|
-
setTimeout(() => {
|
|
125
|
-
saveInProgress = false;
|
|
126
|
-
const result = { msg: "Test mode: save skipped", msgType: "success" };
|
|
127
|
-
if (typeof callback === 'function') {
|
|
128
|
-
callback(result);
|
|
129
|
-
}
|
|
130
|
-
resolve(result);
|
|
131
|
-
}, 0);
|
|
132
|
-
return;
|
|
112
|
+
function buildSaveRequest(content, snapshotHtml, userDriven, signal) {
|
|
113
|
+
const token = saveToken();
|
|
114
|
+
const path = token ? `${SAVE_PATH}/${token}` : SAVE_PATH;
|
|
115
|
+
const options = {
|
|
116
|
+
method: 'POST',
|
|
117
|
+
credentials: token ? 'omit' : 'same-origin',
|
|
118
|
+
signal,
|
|
119
|
+
headers: {
|
|
120
|
+
'Document-URL': window.location.href,
|
|
121
|
+
// The older spelling of the same header, which htmlclay reads today.
|
|
122
|
+
'Page-URL': window.location.href,
|
|
123
|
+
'X-Hyperclay-User-Driven': userDriven ? '1' : '0'
|
|
133
124
|
}
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
// The desktop JSON envelope carries the unstripped snapshot alongside the
|
|
128
|
+
// document, so a sync engine can replay what the browser actually had. It goes
|
|
129
|
+
// out only when the served document DECLARES that transport, and it is passed in
|
|
130
|
+
// from the capture that produced `content` — it used to be left on a window
|
|
131
|
+
// global that was cleared only on success, so two captures without an intervening
|
|
132
|
+
// successful save would pair a stale snapshot with fresh content.
|
|
133
|
+
if (saveTransport() === DESKTOP_JSON && snapshotHtml) {
|
|
134
|
+
options.headers['Content-Type'] = 'application/json';
|
|
135
|
+
options.body = JSON.stringify({ content, snapshotHtml, userDriven });
|
|
136
|
+
} else {
|
|
137
|
+
options.body = content;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return { url: new URL(path, window.location.origin).href, options };
|
|
141
|
+
}
|
|
134
142
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
clearTimeout(timeoutId);
|
|
170
|
-
return res.json().then(data => {
|
|
171
|
-
if (!res.ok) {
|
|
172
|
-
throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
|
|
173
|
-
}
|
|
174
|
-
return data;
|
|
175
|
-
});
|
|
176
|
-
})
|
|
177
|
-
.then(data => {
|
|
178
|
-
// Clear the snapshot only once the save actually landed (a failed save
|
|
179
|
-
// keeps it so a retry still carries the provenance snapshot).
|
|
180
|
-
window.__hyperclaySnapshotHtml = null;
|
|
181
|
-
const result = { msg: data.msg, msgType: data.msgType || 'success' };
|
|
182
|
-
if (typeof callback === 'function') {
|
|
183
|
-
callback(result);
|
|
184
|
-
}
|
|
185
|
-
resolve(result);
|
|
186
|
-
})
|
|
187
|
-
.catch(err => {
|
|
188
|
-
clearTimeout(timeoutId);
|
|
189
|
-
console.error('Failed to save page:', err);
|
|
190
|
-
|
|
191
|
-
// The save never landed: re-arm the user-driven bit so the next (retry)
|
|
192
|
-
// save still reports the human gesture instead of reading as background.
|
|
193
|
-
if (userDriven) markUserDriven();
|
|
194
|
-
|
|
195
|
-
const msg = err.name === 'AbortError'
|
|
196
|
-
? 'Server not responding'
|
|
197
|
-
: 'Save failed';
|
|
198
|
-
|
|
199
|
-
const result = { msg, msgType: "error" };
|
|
200
|
-
if (typeof callback === 'function') {
|
|
201
|
-
callback(result);
|
|
143
|
+
/**
|
|
144
|
+
* Send one save and decode one response.
|
|
145
|
+
*
|
|
146
|
+
* Resolves with the server's JSON body: `msg` and `msgType` everywhere, plus
|
|
147
|
+
* `code` and `etag` from a host that follows the spec. Rejects with the raw error,
|
|
148
|
+
* carrying the server's message and `code` when it answered at all.
|
|
149
|
+
*
|
|
150
|
+
* The body is read as text and parsed defensively BEFORE the status is consulted.
|
|
151
|
+
* Calling res.json() first meant a 200 with an empty body reported failure over a
|
|
152
|
+
* save that had landed, and a 502 HTML error page surfaced as "Unexpected token '<'"
|
|
153
|
+
* instead of the status.
|
|
154
|
+
*
|
|
155
|
+
* @param {string} content - HTML to save
|
|
156
|
+
* @param {?string} snapshotHtml
|
|
157
|
+
* @returns {Promise<Object>} The server's response body
|
|
158
|
+
*/
|
|
159
|
+
function sendSave(content, snapshotHtml) {
|
|
160
|
+
const controller = new AbortController();
|
|
161
|
+
const timeoutId = setTimeout(() => controller.abort(), SAVE_TIMEOUT_MS);
|
|
162
|
+
|
|
163
|
+
// Read-and-reset the data-guard provenance bit at the ACTUAL send (past the
|
|
164
|
+
// early returns in the callers), so it's never consumed on a save that never
|
|
165
|
+
// ships.
|
|
166
|
+
const userDriven = consumeUserDriven();
|
|
167
|
+
const { url, options } = buildSaveRequest(content, snapshotHtml, userDriven, controller.signal);
|
|
168
|
+
|
|
169
|
+
return fetch(url, options)
|
|
170
|
+
.then(res => res.text().then(text => {
|
|
171
|
+
let data = {};
|
|
172
|
+
if (text) {
|
|
173
|
+
try {
|
|
174
|
+
data = JSON.parse(text);
|
|
175
|
+
} catch (err) {
|
|
176
|
+
if (res.ok) throw new Error('Server sent a response that was not JSON');
|
|
202
177
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
178
|
+
}
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
const error = new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
|
|
181
|
+
error.code = data.code ?? null;
|
|
182
|
+
error.status = res.status;
|
|
183
|
+
throw error;
|
|
184
|
+
}
|
|
185
|
+
return data;
|
|
186
|
+
}))
|
|
187
|
+
.catch(err => {
|
|
188
|
+
console.error('Failed to save page:', err);
|
|
189
|
+
// The save never landed: re-arm the user-driven bit so the next (retry)
|
|
190
|
+
// save still reports the human gesture instead of reading as background.
|
|
191
|
+
if (userDriven) markUserDriven();
|
|
192
|
+
throw err;
|
|
193
|
+
})
|
|
194
|
+
.finally(() => {
|
|
195
|
+
clearTimeout(timeoutId);
|
|
196
|
+
});
|
|
210
197
|
}
|
|
211
198
|
|
|
199
|
+
// =============================================================================
|
|
200
|
+
// SAVE FUNCTIONS
|
|
201
|
+
// =============================================================================
|
|
202
|
+
|
|
212
203
|
/**
|
|
213
204
|
* Save specific HTML content to the server.
|
|
214
205
|
*
|
|
215
|
-
* Returns a Promise
|
|
216
|
-
* passed to the callback.
|
|
217
|
-
* truthy err. Skipped early-returns resolve with data.msgType: 'skipped'.
|
|
206
|
+
* Returns a Promise resolving with {msg, msgType, code, etag} — the same object
|
|
207
|
+
* passed to the callback. Never rejects.
|
|
218
208
|
*
|
|
219
209
|
* @param {string} html - HTML string to save
|
|
220
|
-
* @param {Function} callback - Called with
|
|
221
|
-
* @
|
|
210
|
+
* @param {Function} callback - Called with the result on completion
|
|
211
|
+
* @param {Object} [options]
|
|
212
|
+
* @param {?string} [options.snapshotHtml] - unstripped snapshot from the same capture
|
|
213
|
+
* @returns {Promise<{msg: string, msgType: string, code: ?string, etag: ?string}>}
|
|
222
214
|
*
|
|
223
215
|
* @example
|
|
224
|
-
*
|
|
225
|
-
*
|
|
226
|
-
* if (err) console.error('Save failed:', err);
|
|
227
|
-
* });
|
|
228
|
-
*
|
|
229
|
-
* @example
|
|
230
|
-
* // Promise form
|
|
231
|
-
* const {err, data} = await saveHtml(myHtml);
|
|
232
|
-
* if (err) console.error('Save failed:', err);
|
|
216
|
+
* const {msg, msgType} = await saveHtml(myHtml);
|
|
217
|
+
* if (msgType === 'error') console.error('Save failed:', msg);
|
|
233
218
|
*/
|
|
234
|
-
export function saveHtml(html, callback = () => {}) {
|
|
219
|
+
export function saveHtml(html, callback = () => {}, { snapshotHtml = null } = {}) {
|
|
235
220
|
return new Promise((resolve) => {
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
return
|
|
221
|
+
const done = (result) => {
|
|
222
|
+
if (typeof callback === 'function') callback(result);
|
|
223
|
+
resolve(result);
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
if (!isEditMode && !window.clay?.testMode) {
|
|
227
|
+
return done(skippedResult('Not in edit mode'));
|
|
228
|
+
}
|
|
229
|
+
if (saveInProgress) {
|
|
230
|
+
return done(skippedResult('Save already in progress'));
|
|
243
231
|
}
|
|
244
232
|
|
|
245
233
|
saveInProgress = true;
|
|
@@ -248,85 +236,19 @@ export function saveHtml(html, callback = () => {}) {
|
|
|
248
236
|
if (window.clay?.testMode) {
|
|
249
237
|
setTimeout(() => {
|
|
250
238
|
saveInProgress = false;
|
|
251
|
-
|
|
252
|
-
if (typeof callback === 'function') {
|
|
253
|
-
callback(null, data);
|
|
254
|
-
}
|
|
255
|
-
resolve({ err: null, data });
|
|
239
|
+
done(successResult({ msg: 'Test mode: save skipped' }));
|
|
256
240
|
}, 0);
|
|
257
241
|
return;
|
|
258
242
|
}
|
|
259
243
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
window.location.hostname === '127.0.0.1';
|
|
267
|
-
|
|
268
|
-
const userDriven = consumeUserDriven();
|
|
269
|
-
|
|
270
|
-
const fetchOptions = {
|
|
271
|
-
method: 'POST',
|
|
272
|
-
credentials: 'include',
|
|
273
|
-
signal: controller.signal,
|
|
274
|
-
headers: { 'Page-URL': window.location.href, 'X-Hyperclay-User-Driven': userDriven ? '1' : '0' }
|
|
275
|
-
};
|
|
276
|
-
|
|
277
|
-
if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
|
|
278
|
-
// Send JSON with both stripped content and full snapshot for platform live sync
|
|
279
|
-
fetchOptions.headers['Content-Type'] = 'application/json';
|
|
280
|
-
fetchOptions.body = JSON.stringify({
|
|
281
|
-
content: html,
|
|
282
|
-
snapshotHtml: window.__hyperclaySnapshotHtml,
|
|
283
|
-
userDriven
|
|
284
|
-
});
|
|
285
|
-
} else {
|
|
286
|
-
// Platform: send plain text as before
|
|
287
|
-
fetchOptions.body = html;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
fetch(getSaveEndpoint(), fetchOptions)
|
|
291
|
-
.then(res => {
|
|
292
|
-
clearTimeout(timeoutId);
|
|
293
|
-
return res.json().then(data => {
|
|
294
|
-
if (!res.ok) {
|
|
295
|
-
throw new Error(data.msg || data.error || `HTTP ${res.status}: ${res.statusText}`);
|
|
296
|
-
}
|
|
297
|
-
return data;
|
|
298
|
-
});
|
|
299
|
-
})
|
|
300
|
-
.then(data => {
|
|
301
|
-
// Clear the snapshot only once the save actually landed (a failed save
|
|
302
|
-
// keeps it so a retry still carries the provenance snapshot).
|
|
303
|
-
window.__hyperclaySnapshotHtml = null;
|
|
304
|
-
if (typeof callback === 'function') {
|
|
305
|
-
callback(null, data);
|
|
306
|
-
}
|
|
307
|
-
resolve({ err: null, data });
|
|
308
|
-
})
|
|
309
|
-
.catch(err => {
|
|
310
|
-
clearTimeout(timeoutId);
|
|
311
|
-
console.error('Failed to save page:', err);
|
|
312
|
-
|
|
313
|
-
// The save never landed: re-arm the user-driven bit so the next (retry)
|
|
314
|
-
// save still reports the human gesture instead of reading as background.
|
|
315
|
-
if (userDriven) markUserDriven();
|
|
316
|
-
|
|
317
|
-
// Normalize timeout errors
|
|
318
|
-
const error = err.name === 'AbortError'
|
|
319
|
-
? new Error('Server not responding')
|
|
320
|
-
: err;
|
|
321
|
-
|
|
322
|
-
if (typeof callback === 'function') {
|
|
323
|
-
callback(error);
|
|
324
|
-
}
|
|
325
|
-
resolve({ err: error, data: null });
|
|
326
|
-
})
|
|
327
|
-
.finally(() => {
|
|
328
|
-
clearTimeout(timeoutId);
|
|
244
|
+
sendSave(html, snapshotHtml)
|
|
245
|
+
.then(successResult, errorResult)
|
|
246
|
+
// Clear the flag BEFORE handing the result back, so a caller that queues a
|
|
247
|
+
// follow-up save can start it immediately rather than being told the lane
|
|
248
|
+
// is busy by the save that just finished.
|
|
249
|
+
.then(result => {
|
|
329
250
|
saveInProgress = false;
|
|
251
|
+
done(result);
|
|
330
252
|
});
|
|
331
253
|
});
|
|
332
254
|
}
|
|
@@ -334,52 +256,39 @@ export function saveHtml(html, callback = () => {}) {
|
|
|
334
256
|
/**
|
|
335
257
|
* Fetch HTML from a URL and save it to replace the current page.
|
|
336
258
|
*
|
|
337
|
-
* Returns a Promise
|
|
338
|
-
* passed to the callback. Promise never rejects.
|
|
339
|
-
*
|
|
340
|
-
* @param {string} url - URL to fetch HTML from
|
|
341
|
-
* @param {Function} callback - Called with (err, data) on completion
|
|
342
|
-
* @returns {Promise<{err: ?Error, data: ?{msg: string, msgType: string}}>}
|
|
259
|
+
* Returns a Promise resolving with {msg, msgType, code, etag}. Never rejects.
|
|
343
260
|
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
346
|
-
* replacePageWith('/templates/blog.html', (err, data) => {
|
|
347
|
-
* if (err) console.error('Failed:', err);
|
|
348
|
-
* else window.location.reload();
|
|
349
|
-
* });
|
|
261
|
+
* The fetch status is checked before the body is used: without that, pointing this
|
|
262
|
+
* at a path that 404s saved the server's error page over the user's document.
|
|
350
263
|
*
|
|
351
|
-
* @
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
* if (!err) window.location.reload();
|
|
264
|
+
* @param {string} url - URL to fetch HTML from
|
|
265
|
+
* @param {Function} callback - Called with the result on completion
|
|
266
|
+
* @returns {Promise<{msg: string, msgType: string, code: ?string, etag: ?string}>}
|
|
355
267
|
*/
|
|
356
268
|
export function replacePageWith(url, callback = () => {}) {
|
|
357
269
|
return new Promise((resolve) => {
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
}
|
|
270
|
+
const done = (result) => {
|
|
271
|
+
if (typeof callback === 'function') callback(result);
|
|
272
|
+
resolve(result);
|
|
273
|
+
};
|
|
274
|
+
|
|
275
|
+
if (!isEditMode) return done(skippedResult('Not in edit mode'));
|
|
276
|
+
if (saveInProgress) return done(skippedResult('Save already in progress'));
|
|
366
277
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
});
|
|
278
|
+
// Resolved against the document's real location, not a <base href> the page
|
|
279
|
+
// may have set.
|
|
280
|
+
fetch(new URL(url, window.location.href).href)
|
|
281
|
+
.then(res => {
|
|
282
|
+
if (!res.ok) {
|
|
283
|
+
throw new Error(`Template fetch failed: HTTP ${res.status} ${res.statusText}`);
|
|
284
|
+
}
|
|
285
|
+
return res.text();
|
|
376
286
|
})
|
|
287
|
+
.then(html => saveHtml(html))
|
|
288
|
+
.then(done)
|
|
377
289
|
.catch(err => {
|
|
378
290
|
console.error('Failed to fetch template:', err);
|
|
379
|
-
|
|
380
|
-
callback(err);
|
|
381
|
-
}
|
|
382
|
-
resolve({ err, data: null });
|
|
291
|
+
done(errorResult(err));
|
|
383
292
|
});
|
|
384
293
|
});
|
|
385
294
|
}
|