@druxt-contrib/node-preview 0.1.0-dev.20261030024907 → 0.1.0-dev.20261109195239
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/CHANGELOG.md +7 -1
- package/README.md +101 -226
- package/dist/index.esm.js +281 -6
- package/dist/index.ssr.js +281 -6
- package/package.json +4 -2
- package/templates/plugin.js +177 -20
package/templates/plugin.js
CHANGED
|
@@ -14,8 +14,57 @@
|
|
|
14
14
|
export default ({ app, store }, inject) => {
|
|
15
15
|
const include = JSON.parse('<%= JSON.stringify(options.include) %>')
|
|
16
16
|
const layouts = JSON.parse('<%= JSON.stringify(options.layouts) %>')
|
|
17
|
-
// Origins the preview may come from, besides this site's own.
|
|
17
|
+
// Origins the preview may come from, besides this site's own. The module
|
|
18
|
+
// only passes origins with plain host names, so none can end the string.
|
|
18
19
|
const origins = JSON.parse('<%= JSON.stringify(options.origins) %>')
|
|
20
|
+
// Druxt's Drupal origin. The page fetches from any other origin without the
|
|
21
|
+
// Druxt client, whose options are for druxt.baseUrl.
|
|
22
|
+
const drupal = JSON.parse('<%= JSON.stringify(options.drupal) %>')
|
|
23
|
+
|
|
24
|
+
// Separates the endpoint from a token in the URL fragment.
|
|
25
|
+
const TOKEN_MARK = ';token='
|
|
26
|
+
// The token, once read and taken out of the address, and its endpoint.
|
|
27
|
+
let kept = null
|
|
28
|
+
// vue-router normalises the URL, which collapses the double slash after the
|
|
29
|
+
// scheme inside the fragment. This repairs it.
|
|
30
|
+
const repair = (endpoint) =>
|
|
31
|
+
String(endpoint).replace(/^(https?):\/(?!\/)/, '$1://')
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Returns the endpoint and the token from a raw URL fragment.
|
|
35
|
+
*
|
|
36
|
+
* It throws on a fragment that doesn't decode, such as "#section%".
|
|
37
|
+
*
|
|
38
|
+
* @param {string} raw - The fragment, without the "#".
|
|
39
|
+
*
|
|
40
|
+
* @returns {string[]} The endpoint, then the token if there is one.
|
|
41
|
+
*/
|
|
42
|
+
const fragment = (raw) => decodeURIComponent(raw).split(TOKEN_MARK)
|
|
43
|
+
|
|
44
|
+
// Where the tab keeps the token for a reload.
|
|
45
|
+
const TOKEN_STORE = 'druxtNodePreview.token'
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Whether a token is a JWT whose expiry has passed.
|
|
49
|
+
*
|
|
50
|
+
* A token without a readable expiry counts as live, and Drupal decides.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} token - The token.
|
|
53
|
+
*
|
|
54
|
+
* @returns {boolean} True when it has expired.
|
|
55
|
+
*/
|
|
56
|
+
const expired = (token) => {
|
|
57
|
+
try {
|
|
58
|
+
const part = String(token)
|
|
59
|
+
.split('.')[1]
|
|
60
|
+
.replace(/-/g, '+')
|
|
61
|
+
.replace(/_/g, '/')
|
|
62
|
+
const { exp } = JSON.parse(window.atob(part))
|
|
63
|
+
return typeof exp === 'number' && exp * 1000 <= Date.now()
|
|
64
|
+
} catch {
|
|
65
|
+
return false
|
|
66
|
+
}
|
|
67
|
+
}
|
|
19
68
|
|
|
20
69
|
// Drupal gives every JSON:API resource a UUID, and a JSON:API Node Preview
|
|
21
70
|
// URL ends in /{uuid}/preview.
|
|
@@ -219,14 +268,14 @@ export default ({ app, store }, inject) => {
|
|
|
219
268
|
*
|
|
220
269
|
* @returns {Error} The error.
|
|
221
270
|
*/
|
|
222
|
-
const failure = (url, status, body) =>
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
(body ? ': ' + String(body).slice(0, 300) : '')
|
|
271
|
+
const failure = (url, status, body) => {
|
|
272
|
+
// An HTML error page says nothing useful in a one-line message.
|
|
273
|
+
const text = String(body || '').trim()
|
|
274
|
+
const shown = text && !text.startsWith('<') ? ': ' + text.slice(0, 300) : ''
|
|
275
|
+
return new Error(
|
|
276
|
+
'Preview request failed with HTTP ' + status + ' for ' + url + shown
|
|
229
277
|
)
|
|
278
|
+
}
|
|
230
279
|
|
|
231
280
|
const plugin = {
|
|
232
281
|
/**
|
|
@@ -253,18 +302,102 @@ export default ({ app, store }, inject) => {
|
|
|
253
302
|
endpoint: () => {
|
|
254
303
|
const hash = (window.location.hash || '').slice(1)
|
|
255
304
|
if (!hash) return null
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
305
|
+
try {
|
|
306
|
+
return repair(fragment(hash)[0])
|
|
307
|
+
} catch {
|
|
308
|
+
// A fragment that doesn't decode, such as "#section%", has no
|
|
309
|
+
// endpoint, so the page says so.
|
|
310
|
+
return null
|
|
311
|
+
}
|
|
312
|
+
},
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Returns the token from the URL fragment, for a frontend on another site.
|
|
316
|
+
*
|
|
317
|
+
* @returns {string|null} The token, or null when the preview reads with
|
|
318
|
+
* the editor's session.
|
|
319
|
+
*/
|
|
320
|
+
token: () => {
|
|
321
|
+
const raw = (window.location.hash || '').slice(1)
|
|
322
|
+
let endpoint
|
|
323
|
+
let token
|
|
324
|
+
try {
|
|
325
|
+
;[endpoint, token] = fragment(raw)
|
|
326
|
+
} catch {
|
|
327
|
+
// A fragment that is not a preview, such as "#section%", has no token.
|
|
328
|
+
return null
|
|
329
|
+
}
|
|
330
|
+
if (token) {
|
|
331
|
+
// An expired token would fail where the session might not, so the
|
|
332
|
+
// page leaves it out. It still leaves the address.
|
|
333
|
+
const live = !expired(token)
|
|
334
|
+
if (live) {
|
|
335
|
+
kept = { endpoint, token }
|
|
336
|
+
// A reload starts a new page from the address without the token.
|
|
337
|
+
// The tab's session storage keeps it for that, out of any logged
|
|
338
|
+
// address.
|
|
339
|
+
try {
|
|
340
|
+
window.sessionStorage.setItem(TOKEN_STORE, JSON.stringify(kept))
|
|
341
|
+
} catch {
|
|
342
|
+
// Storage can be off. The page then reads the token only once.
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
// An error tracker or session recorder on the page can log the
|
|
346
|
+
// address. So the token leaves it, and the page keeps it here.
|
|
347
|
+
// The address keeps the endpoint as it was encoded, so it reads the
|
|
348
|
+
// same when the page loads again.
|
|
349
|
+
const at = raw.indexOf(TOKEN_MARK)
|
|
350
|
+
const address =
|
|
351
|
+
at === -1 ? encodeURIComponent(endpoint) : raw.slice(0, at)
|
|
352
|
+
window.history.replaceState(
|
|
353
|
+
window.history.state,
|
|
354
|
+
'',
|
|
355
|
+
window.location.pathname + window.location.search + '#' + address
|
|
356
|
+
)
|
|
357
|
+
return live ? token : null
|
|
358
|
+
}
|
|
359
|
+
if (!kept) {
|
|
360
|
+
try {
|
|
361
|
+
kept = JSON.parse(window.sessionStorage.getItem(TOKEN_STORE))
|
|
362
|
+
} catch {
|
|
363
|
+
kept = null
|
|
364
|
+
}
|
|
365
|
+
// Use only what this plugin stored: an endpoint and a token.
|
|
366
|
+
if (
|
|
367
|
+
!kept ||
|
|
368
|
+
typeof kept.endpoint !== 'string' ||
|
|
369
|
+
typeof kept.token !== 'string'
|
|
370
|
+
) {
|
|
371
|
+
kept = null
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// A kept token that has expired would fail where the session might
|
|
375
|
+
// not, so the page drops it.
|
|
376
|
+
if (kept && expired(kept.token)) {
|
|
377
|
+
kept = null
|
|
378
|
+
try {
|
|
379
|
+
window.sessionStorage.removeItem(TOKEN_STORE)
|
|
380
|
+
} catch {
|
|
381
|
+
// Storage can be off.
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
return kept && repair(kept.endpoint) === repair(endpoint)
|
|
385
|
+
? kept.token
|
|
386
|
+
: null
|
|
259
387
|
},
|
|
260
388
|
|
|
261
389
|
/**
|
|
262
|
-
* Fetches the preview document with the editor's session
|
|
390
|
+
* Fetches the preview document with the editor's session, or with the
|
|
391
|
+
* fragment's token where the session can't reach this frontend. A token
|
|
392
|
+
* goes without credentials, so CORS on Drupal stays as it is.
|
|
263
393
|
*
|
|
264
394
|
* The request goes through the Druxt client, with credentials. The site's
|
|
265
395
|
* request interceptors apply, and the editor's session cookie goes with
|
|
266
396
|
* it. A path stays a path. The client resolves it against its own base
|
|
267
|
-
* URL, and interceptors that decide by path still see it.
|
|
397
|
+
* URL, and interceptors that decide by path still see it. Only the
|
|
398
|
+
* page's own origin and druxt.baseUrl's use the client, because its
|
|
399
|
+
* options are for druxt.baseUrl. Any other origin, such as a register
|
|
400
|
+
* backend or one in the origins option, gets a plain request.
|
|
268
401
|
*
|
|
269
402
|
* @param {string} endpoint - The JSON:API Node Preview endpoint.
|
|
270
403
|
*
|
|
@@ -273,19 +406,32 @@ export default ({ app, store }, inject) => {
|
|
|
273
406
|
fetch: async (endpoint) => {
|
|
274
407
|
const uuid = checkEndpoint(endpoint)
|
|
275
408
|
const url = withInclude(endpoint)
|
|
276
|
-
const
|
|
277
|
-
const
|
|
409
|
+
const token = plugin.token()
|
|
410
|
+
const headers = {
|
|
411
|
+
Accept: 'application/vnd.api+json',
|
|
412
|
+
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
413
|
+
}
|
|
414
|
+
// The Druxt client's defaults, such as its auth, are for druxt.baseUrl.
|
|
415
|
+
// Any other origin gets a plain request instead.
|
|
416
|
+
const { origin } = new URL(url, window.location.origin)
|
|
417
|
+
const own = origin === window.location.origin || origin === drupal
|
|
418
|
+
const client = own ? (app.$druxt || {}).axios : null
|
|
278
419
|
if (client) {
|
|
279
420
|
let response
|
|
280
421
|
try {
|
|
281
|
-
response = await client.get(url, {
|
|
422
|
+
response = await client.get(url, {
|
|
423
|
+
headers,
|
|
424
|
+
withCredentials: !token,
|
|
425
|
+
})
|
|
282
426
|
} catch (error) {
|
|
283
427
|
const failed = error.response || {}
|
|
284
428
|
if (!failed.status) throw error
|
|
285
429
|
const body =
|
|
286
|
-
|
|
287
|
-
?
|
|
288
|
-
:
|
|
430
|
+
failed.data === undefined || failed.data === null
|
|
431
|
+
? ''
|
|
432
|
+
: typeof failed.data === 'string'
|
|
433
|
+
? failed.data
|
|
434
|
+
: JSON.stringify(failed.data)
|
|
289
435
|
throw failure(url, failed.status, body)
|
|
290
436
|
}
|
|
291
437
|
checkType((response.headers || {})['content-type'])
|
|
@@ -293,7 +439,7 @@ export default ({ app, store }, inject) => {
|
|
|
293
439
|
return response.data
|
|
294
440
|
}
|
|
295
441
|
const response = await window.fetch(url, {
|
|
296
|
-
credentials: 'include',
|
|
442
|
+
credentials: token ? 'omit' : 'include',
|
|
297
443
|
headers,
|
|
298
444
|
})
|
|
299
445
|
if (!response.ok) {
|
|
@@ -341,5 +487,16 @@ export default ({ app, store }, inject) => {
|
|
|
341
487
|
},
|
|
342
488
|
}
|
|
343
489
|
|
|
490
|
+
// Take the token out of the address before anything on the page reads it,
|
|
491
|
+
// such as an analytics page view. The plugin keeps it for the preview. The
|
|
492
|
+
// router's first navigation writes back the address it started with, so
|
|
493
|
+
// take it out again once the router is ready. A script that runs before
|
|
494
|
+
// this plugin, such as one in the page head or an earlier plugin, can still
|
|
495
|
+
// read the token.
|
|
496
|
+
if (typeof window !== 'undefined') {
|
|
497
|
+
plugin.token()
|
|
498
|
+
if (app.router && app.router.onReady) app.router.onReady(plugin.token)
|
|
499
|
+
}
|
|
500
|
+
|
|
344
501
|
inject('druxtNodePreview', plugin)
|
|
345
502
|
}
|