@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.
Files changed (43) hide show
  1. package/README.md +99 -7
  2. package/clay-internals.js +19 -0
  3. package/clay.js +18 -2
  4. package/package.json +19 -3
  5. package/sap.js +5 -5
  6. package/src/attrs/onaftersave.js +12 -1
  7. package/src/attrs/refetch-on-save.js +4 -4
  8. package/src/attrs/save-freeze.js +47 -19
  9. package/src/core/admin-contenteditable.js +2 -2
  10. package/src/core/admin-inputs.js +2 -2
  11. package/src/core/admin-onclick.js +2 -2
  12. package/src/core/admin-resources.js +23 -14
  13. package/src/core/autosave.js +11 -1
  14. package/src/core/edit-mode.js +2 -2
  15. package/src/core/host-attrs.js +46 -0
  16. package/src/core/is-edit-mode.js +14 -6
  17. package/src/core/persist.js +8 -1
  18. package/src/core/save-core.js +199 -290
  19. package/src/core/save.js +124 -84
  20. package/src/core/snapshot.js +99 -43
  21. package/src/core/unsaved-warning.js +8 -2
  22. package/src/dom/all.js +4 -29
  23. package/src/dom/form-data.js +1 -1
  24. package/src/dom/nearest.js +11 -9
  25. package/src/internals/index.js +64 -0
  26. package/src/lib/cache-bust.js +10 -0
  27. package/src/lib/cookie.js +50 -15
  28. package/src/lib/extension-noise.js +5 -1
  29. package/src/lib/mutation.js +65 -18
  30. package/src/lib/region-policy.js +17 -2
  31. package/src/lib/root-attrs.js +45 -0
  32. package/src/lib/throttle.js +25 -13
  33. package/src/loader.js +18 -24
  34. package/src/options/options.js +1 -3
  35. package/src/plugins/demo.js +7 -1
  36. package/src/sync/live-sync.js +121 -89
  37. package/src/ui/dialogs.js +10 -1
  38. package/src/ui/index.js +2 -11
  39. package/src/ui/toast.js +8 -2
  40. package/src/utils/debounce.js +24 -7
  41. package/src/vendor/hyper-undo.vendor.js +1 -1
  42. package/src/vendor/hypercms.vendor.js +29 -16
  43. package/src/vendor/richclay.vendor.js +29 -22
@@ -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
- onPrepareForSave
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 saveEndpoint = '/_/save';
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 { beforeSave, getPageContents, onSnapshot, onPrepareForSave };
39
+ export { getPageContents, onSnapshot, addDocumentTransform };
56
40
 
57
41
  // =============================================================================
58
- // INTERNAL: GET PAGE CONTENTS
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
- * Get the current page contents as HTML string for saving.
63
- * Emits snapshot-ready event for live-sync.
64
- *
65
- * @returns {string} HTML string of current page
66
- */
67
- function getContentsForSave() {
68
- // Emit for live-sync when actually saving
69
- return captureForSave({ emitForSync: true });
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
- // SAVE FUNCTIONS
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
- * Save the current page contents to the server.
88
+ * Build the save request for this host.
78
89
  *
79
- * Returns a Promise that resolves with {msg, msgType} — the same object
80
- * passed to the callback. Promise never rejects; errors resolve with
81
- * msgType: 'error', skipped early-returns resolve with msgType: 'skipped'.
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
- * @param {Function} callback - Called with {msg, msgType} on completion
84
- * msgType will be 'success', 'error', or 'skipped'
85
- * @returns {Promise<{msg: string, msgType: string}>}
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
- * @example
88
- * // Callback form (unchanged)
89
- * savePage(({msg, msgType}) => {
90
- * if (msgType === 'error') console.error('Save failed:', msg);
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
- * @example
94
- * // Promise form
95
- * const {msg, msgType} = await savePage();
96
- * if (msgType === 'error') console.error('Save failed:', msg);
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
- export function savePage(callback = () => {}) {
99
- return new Promise((resolve) => {
100
- if (saveInProgress) {
101
- const skipped = { msg: 'Save already in progress', msgType: 'skipped' };
102
- callback(skipped);
103
- return resolve(skipped);
104
- }
105
- if (!isEditMode && !window.clay?.testMode) {
106
- const skipped = { msg: 'Not in edit mode', msgType: 'skipped' };
107
- callback(skipped);
108
- return resolve(skipped);
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
- // Add timeout - abort if server doesn't respond within 12 seconds
136
- const controller = new AbortController();
137
- const timeoutId = setTimeout(() => controller.abort(), 12000);
138
-
139
- // Check if running on Hyperclay Local - send JSON with both versions for platform sync
140
- const isHyperclayLocal = window.location.hostname === 'localhost' ||
141
- window.location.hostname === '127.0.0.1';
142
-
143
- // Read-and-reset the data-guard provenance bit at the ACTUAL send (past the
144
- // early returns above), so it's never consumed on a save that never ships.
145
- const userDriven = consumeUserDriven();
146
-
147
- const fetchOptions = {
148
- method: 'POST',
149
- credentials: 'include',
150
- signal: controller.signal,
151
- headers: { 'Page-URL': window.location.href, 'X-Hyperclay-User-Driven': userDriven ? '1' : '0' }
152
- };
153
-
154
- if (isHyperclayLocal && window.__hyperclaySnapshotHtml) {
155
- // Send JSON with both stripped content and full snapshot for platform live sync
156
- fetchOptions.headers['Content-Type'] = 'application/json';
157
- fetchOptions.body = JSON.stringify({
158
- content: currentContents,
159
- snapshotHtml: window.__hyperclaySnapshotHtml,
160
- userDriven
161
- });
162
- } else {
163
- // Platform: send plain text as before
164
- fetchOptions.body = currentContents;
165
- }
166
-
167
- fetch(getSaveEndpoint(), fetchOptions)
168
- .then(res => {
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
- resolve(result);
204
- })
205
- .finally(() => {
206
- clearTimeout(timeoutId);
207
- saveInProgress = false;
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 that resolves with {err, data} — same arguments
216
- * passed to the callback. Promise never rejects; errors resolve with
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 (err, data) on completion
221
- * @returns {Promise<{err: ?Error, data: ?{msg: string, msgType: string}}>}
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
- * // Callback form (unchanged)
225
- * saveHtml(myHtml, (err, data) => {
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
- if (!isEditMode || saveInProgress) {
237
- const data = {
238
- msg: saveInProgress ? 'Save already in progress' : 'Not in edit mode',
239
- msgType: 'skipped'
240
- };
241
- callback(null, data);
242
- return resolve({ err: null, data });
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
- const data = { msg: "Test mode: save skipped", msgType: "success" };
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
- // Add timeout - abort if server doesn't respond within 12 seconds
261
- const controller = new AbortController();
262
- const timeoutId = setTimeout(() => controller.abort(), 12000);
263
-
264
- // Check if running on Hyperclay Local - send JSON with both versions for platform sync
265
- const isHyperclayLocal = window.location.hostname === 'localhost' ||
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 that resolves with {err, data} — same arguments
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
- * @example
345
- * // Callback form (unchanged)
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
- * @example
352
- * // Promise form
353
- * const {err, data} = await replacePageWith('/templates/blog.html');
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
- if (!isEditMode || saveInProgress) {
359
- const data = {
360
- msg: saveInProgress ? 'Save already in progress' : 'Not in edit mode',
361
- msgType: 'skipped'
362
- };
363
- callback(null, data);
364
- return resolve({ err: null, data });
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
- fetch(url)
368
- .then(res => res.text())
369
- .then(html => {
370
- saveHtml(html, (err, data) => {
371
- if (typeof callback === 'function') {
372
- callback(err, data);
373
- }
374
- resolve({ err: err || null, data: data || null });
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
- if (typeof callback === 'function') {
380
- callback(err);
381
- }
382
- resolve({ err, data: null });
291
+ done(errorResult(err));
383
292
  });
384
293
  });
385
294
  }