@druxt-contrib/node-preview 0.0.0-stage → 0.1.0-dev.20261030024907

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 ADDED
@@ -0,0 +1,7 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0-dev.20261030024907
4
+
5
+ ### Minor Changes
6
+
7
+ - 6b39690: Add the DruxtNodePreview module. It renders an unsaved Drupal node in a Druxt site, in Consumer Node Preview's Frontend tab, from the JSON:API Node Preview document.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 DruxtJS
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,312 @@
1
- # Temporary Holding Version
1
+ <!-- vale off -->
2
+ <!-- The alt text describes what the banner shows: the @druxt-contrib/node-preview mark, name and description. The name-colon-description form trips ColonUsage. -->
3
+ <a href="https://druxtjs.org">
4
+ <img src="https://raw.githubusercontent.com/druxt-contrib/druxt-node-preview/0.x/.github/banner.svg" alt="@druxt-contrib/node-preview: Node Preview module for Druxt">
5
+ </a>
6
+ <!-- vale on -->
2
7
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
8
+ # @druxt-contrib/node-preview
9
+
10
+ [![npm](https://img.shields.io/npm/v/@druxt-contrib/node-preview)](https://www.npmjs.com/package/@druxt-contrib/node-preview)
11
+ [![npm@dev](https://img.shields.io/npm/v/@druxt-contrib/node-preview/dev)](https://www.npmjs.com/package/@druxt-contrib/node-preview/v/dev)
12
+ [![CI](https://github.com/druxt-contrib/druxt-node-preview/actions/workflows/ci.yml/badge.svg?branch=0.x)](https://github.com/druxt-contrib/druxt-node-preview/actions/workflows/ci.yml)
13
+ [![codecov](https://codecov.io/gh/druxt-contrib/druxt-node-preview/branch/0.x/graph/badge.svg)](https://codecov.io/gh/druxt-contrib/druxt-node-preview)
14
+ [![Known Vulnerabilities](https://snyk.io/test/github/druxt-contrib/druxt-node-preview/badge.svg?targetFile=package.json)](https://snyk.io/test/github/druxt-contrib/druxt-node-preview?targetFile=package.json)
15
+ [![Discord](https://img.shields.io/discord/753866667590680587?label=discord&logo=discord&logoColor=white)](https://discord.druxtjs.org)
16
+
17
+ > Node Preview module for Druxt
18
+
19
+ Shows an unsaved Drupal node in your [Druxt](https://druxtjs.org) frontend.
20
+ The editor presses Preview in Drupal and sees the node as the live site will
21
+ show it, paragraphs included.
22
+
23
+ ![The Frontend tab of a Drupal preview, showing an unsaved node rendered by the Druxt frontend](https://raw.githubusercontent.com/druxt-contrib/druxt-node-preview/0.x/.github/preview.png)
24
+
25
+ It works with two Drupal modules:
26
+
27
+ - [JSON:API Node Preview](https://www.drupal.org/project/jsonapi_node_preview)
28
+ serves the unsaved node over JSON:API.
29
+ - [Consumer Node Preview](https://www.drupal.org/project/consumer_node_preview)
30
+ adds Frontend, Drupal and JSON:API tabs to Drupal's preview. The Frontend tab
31
+ shows this module's page.
32
+
33
+ ## Links
34
+
35
+ - [DruxtJS](https://druxtjs.org)
36
+ - [Community Discord server](https://discord.druxtjs.org)
37
+
38
+ ## Requirements
39
+
40
+ The frontend needs:
41
+
42
+ - Druxt 0.25 and Druxt Entity 0.29 or later, through `druxt-site` 0.15.
43
+ - Node 16 for the build. Druxt's schema build fails on newer Node.
44
+
45
+ Drupal needs:
46
+
47
+ - Consumer Node Preview and JSON:API Node Preview.
48
+ - The fixes from <https://www.drupal.org/i/3367724> for JSON:API Node Preview.
49
+ Version 1.0.0-beta5 and earlier show stale or missing paragraphs without
50
+ them. The [example's
51
+ `composer.json`](https://github.com/druxt-contrib/druxt-node-preview/blob/0.x/example/drupal/composer.json)
52
+ applies them with composer-patches.
53
+
54
+ Both need one parent domain, such as `www.example.com` for the frontend and
55
+ `cms.example.com` for Drupal. The editor's Drupal session cookie does not
56
+ reach a frontend on another domain.
57
+
58
+ ## Install
59
+
60
+ ```sh
61
+ npm install druxt-site @druxt-contrib/node-preview
62
+ ```
63
+
64
+ ```js
65
+ // nuxt.config.js
66
+ export default {
67
+ modules: ['@druxt-contrib/node-preview', 'druxt-site'],
68
+ druxt: {
69
+ baseUrl: 'https://cms.example.com',
70
+ nodePreview: {
71
+ include: { 'node--page': ['field_content.field_media'] },
72
+ origins: ['https://cms.example.com'],
73
+ layouts: ['default', 'preview'],
74
+ },
75
+ },
76
+ }
77
+ ```
78
+
79
+ A site without `druxt-site` lists `druxt-entity` in `modules` instead.
80
+
81
+ ## Options
82
+
83
+ All options are optional.
84
+
85
+ | Option | Type | Default | Description |
86
+ | --------------------------- | ------------------- | ----------------------- | -------------------------------------------------------------------------- |
87
+ | `druxt.nodePreview.path` | `string` | `'/druxt/node/preview'` | The preview page's path. |
88
+ | `druxt.nodePreview.include` | `array` or `object` | `[]` | Relationships to add to the preview request. |
89
+ | `druxt.nodePreview.origins` | `array` | `[]` | More origins the preview may come from. Each is a full URL. |
90
+ | `druxt.nodePreview.layouts` | `array` | `[]` | Nuxt layouts the preview may use. See [Preview layouts](#preview-layouts). |
91
+
92
+ **include.** Consumer Node Preview already includes a node's paragraphs. Add
93
+ what the paragraphs reference, such as `field_content.field_media`. JSON:API
94
+ refuses a path that the previewed type does not have. Key a path by type when
95
+ only some bundles have it, as in the example above.
96
+
97
+ **origins.** Any link can put an endpoint in the page's URL. So the page only
98
+ fetches from this site, from `druxt.baseUrl`, and from `origins`. The editor's
99
+ session cookie goes with each request to those origins.
100
+
101
+ A value the module cannot use shows a warning in the build output, and the
102
+ module ignores it. The page's route name is `druxt-node-preview`.
103
+
104
+ ## Drupal setup
105
+
106
+ 1. Install Consumer Node Preview and JSON:API Node Preview, with the fixes
107
+ from [Requirements](#requirements).
108
+ 2. Go to **Administration > Configuration > Web services > Consumers** and
109
+ edit the frontend's consumer. Under **Preview targets**, add a label and
110
+ this URL template, with your frontend's host:
111
+
112
+ ```text
113
+ https://www.example.com/druxt/node/preview?vm=[view_mode]&layout=[layout]#[jsonapi_node_preview]
114
+ ```
115
+
116
+ Drupal fills in the parts in square brackets when the editor presses
117
+ Preview. Keep them as they are.
118
+
119
+ 3. Optional: under **Preview layouts**, add one layout name in each box. Put
120
+ `default` first, because the preview opens in the first one. See
121
+ [Preview layouts](#preview-layouts).
122
+ 4. In the site's `services.yml`, such as `sites/default/services.yml`, allow
123
+ the frontend's origin with credentials. Name the origin. Do not use `'*'`.
124
+
125
+ ```yaml
126
+ parameters:
127
+ cors.config:
128
+ enabled: true
129
+ allowedHeaders: ['*']
130
+ allowedMethods: ['GET']
131
+ allowedOrigins: ['https://www.example.com']
132
+ supportsCredentials: true
133
+ ```
134
+
135
+ Then rebuild the caches with `drush cr`.
136
+
137
+ 5. Let the frontend show in Drupal's frame. Remove any `X-Frame-Options`
138
+ header from the frontend's responses. If the frontend sends a
139
+ `frame-ancestors` rule, add Drupal's origin to it. Name only Drupal's
140
+ origin, so other sites cannot frame the preview.
141
+
142
+ To check it, edit a node, change something and press Preview. The Frontend
143
+ tab shows the change.
144
+
145
+ [Consumer Node Preview's
146
+ README](https://www.drupal.org/project/consumer_node_preview) lists the other
147
+ URL tokens, and how to set targets in `settings.php`.
148
+
149
+ ## How it works
150
+
151
+ 1. The editor presses Preview in Drupal. The Frontend tab opens this module's
152
+ page in a frame.
153
+ 2. The page reads the preview's JSON:API address from the part of its URL
154
+ after the `#`. Browsers do not send that part to the frontend's server.
155
+ 3. The page fetches the preview with the editor's Drupal session. It puts the
156
+ document in the Druxt store.
157
+ 4. `DruxtEntity` renders the node from the store, inside a `DruxtNodePreview`
158
+ wrapper. The unsaved content goes through the same components as the live
159
+ site.
160
+
161
+ ## Preview layouts
162
+
163
+ The page uses the layout that `?layout=` names, but only if `layouts` lists
164
+ it. If not, the page uses the default layout. A preview URL cannot pick any
165
+ other layout.
166
+
167
+ Each listed layout must be a file in the site's `layouts/` folder that renders
168
+ `<Nuxt />`. A bare layout suits content that sits inside other pages. It has
169
+ the site's type and spacing, but no header or footer.
170
+
171
+ A site that picks layouts somewhere else can call
172
+ `$druxtNodePreview.layout(name)`. It makes the same check.
173
+
174
+ ## Theming the preview
175
+
176
+ A live page often adds a title or a container around the node. The preview
177
+ gets the same from a wrapper component, picked like any Druxt wrapper. For a
178
+ `node--doc_page` previewed in `full`, the page uses the first of these that
179
+ exists:
180
+
181
+ 1. `DruxtNodePreviewDocPageFull`
182
+ 2. `DruxtNodePreviewDocPage`
183
+ 3. `DruxtNodePreviewDefault`
184
+
185
+ If none of them exists, the node renders in Druxt's plain wrapper.
186
+
187
+ A wrapper gets any of these props that it declares:
188
+
189
+ - `document`: the whole JSON:API preview document.
190
+ - `entity`: the previewed resource, with `attributes` such as the title.
191
+ - `mode`, `type` and `uuid`.
192
+
193
+ Its default slot renders the node with `DruxtEntity`:
194
+
195
+ ```vue
196
+ <!-- components/druxt/node-preview/DocPageFull.vue -->
197
+ <template>
198
+ <article class="prose">
199
+ <h1>{{ entity.attributes.title }}</h1>
200
+ <slot />
201
+ </article>
202
+ </template>
203
+
204
+ <script>
205
+ export default {
206
+ // The props it does not declare would otherwise land on <article>.
207
+ inheritAttrs: false,
208
+ props: {
209
+ entity: { type: Object, required: true },
210
+ },
211
+ }
212
+ </script>
213
+ ```
214
+
215
+ A wrapper named by bundle alone, such as `DruxtNodePreviewDocPage`, applies to
216
+ every view mode. Put page parts such as a title in the `<Bundle>Full` wrapper,
217
+ as above, or check the `mode` prop.
218
+
219
+ ## Components that fetch their own data
220
+
221
+ A component that reads the Druxt store shows the unsaved data. A component
222
+ that fetches its data again, such as paragraphs at a revision, shows the saved
223
+ content instead. Skip that fetch while a preview is active:
224
+
225
+ ```js
226
+ if ((this.$store.state.druxtNodePreview || {}).active) return
227
+ ```
228
+
229
+ When the editor leaves the preview, the module removes the preview data from
230
+ the store. The next page loads the saved content.
231
+
232
+ The module stores the data under the route's `langcode` meta. `DruxtEntity`
233
+ reads it there when it has no `langcode` prop.
234
+
235
+ ## The preview request
236
+
237
+ The page fetches through the Druxt client, `$druxt.axios`, with credentials.
238
+ The site's request interceptors apply, such as one that adds a workspace
239
+ header. Without the Druxt client, the page uses `window.fetch`.
240
+
241
+ A preview address that is a path stays a path. The Druxt client resolves it
242
+ against its own base URL, and interceptors that match by path still match it.
243
+ This suits a frontend that reaches Drupal through a proxy on its own origin.
244
+ The editor must then sign in through that proxy too, because the session
245
+ cookie belongs to the host the editor signed in on. Druxt's
246
+ `proxy: { api: true }` forwards the API but not Drupal's sign-in pages, so it
247
+ is not enough on its own.
248
+
249
+ Without a proxy, the request goes to Drupal's origin. Drupal then needs the
250
+ CORS settings from [Drupal setup](#drupal-setup). The session cookie is
251
+ `SameSite=Lax`, so the frontend and Drupal need one parent domain.
252
+
253
+ The page only fetches a node preview, from an address that ends in
254
+ `/{uuid}/preview`. The response must be `application/vnd.api+json`, and each
255
+ resource in it must have a JSON:API type and a UUID. If a check fails, the
256
+ page shows an error and puts nothing in the store.
257
+
258
+ ## Server rendering and static sites
259
+
260
+ The page only works in the browser. A server render shows "Loading
261
+ preview..." and the browser fills it in. A statically generated page has the
262
+ default layout, so the page switches to the listed layout in the browser.
263
+
264
+ ## Troubleshooting
265
+
266
+ The page shows "Preview unavailable" and a message.
267
+
268
+ - **HTTP 404.** Drupal has no preview of this node for the editor. Check, in
269
+ this order:
270
+ 1. The editor pressed Preview, and can view the node.
271
+ 2. The frontend and Drupal share a parent domain. On your own computer,
272
+ use one host name for both: `localhost` and `127.0.0.1` count as
273
+ different.
274
+ 3. The CORS settings in `services.yml` name the frontend's origin.
275
+ - **"The preview endpoint is not on this site or its Drupal site".** Add the
276
+ preview's origin to the `origins` option.
277
+ - **"The URL fragment has no preview endpoint".** The preview target's URL
278
+ template has no `#[jsonapi_node_preview]` at the end.
279
+ - **HTTP 400.** An `include` path that the previewed type does not have. Key
280
+ that path by type.
281
+
282
+ If `DruxtEntity` reports a missing schema:
283
+
284
+ 1. Check that the content type has a display for the previewed view mode, on
285
+ its **Manage display** tab.
286
+ 2. Make sure Drupal is up, then restart the frontend. Nuxt builds the schema
287
+ when it starts, and `.nuxt` can hold an old one.
288
+
289
+ The [end to end troubleshooting
290
+ notes](https://github.com/druxt-contrib/druxt-node-preview/blob/0.x/test/e2e/TROUBLESHOOTING.md)
291
+ cover the test stack.
292
+
293
+ ## Writing another frontend
294
+
295
+ [Consumer Node Preview's
296
+ README](https://www.drupal.org/project/consumer_node_preview) shows how a
297
+ frontend reads the preview address from the URL, including the `https:/`
298
+ repair that some routers need. Before it renders anything, a frontend makes
299
+ the same checks as this module. See [The preview request](#the-preview-request).
300
+
301
+ This module reads `[view_mode]` and `[layout]` from the `vm` and `layout`
302
+ query parameters. They change only how the preview looks. The JSON:API
303
+ document is the same for each.
304
+
305
+ ## Example
306
+
307
+ See the [example](https://github.com/druxt-contrib/druxt-node-preview/tree/0.x/example)
308
+ for a paired Drupal and Nuxt setup.
309
+
310
+ ## Licence
311
+
312
+ MIT.
@@ -0,0 +1,117 @@
1
+ import { resolve } from 'path';
2
+
3
+ const toOrigin = (url) => {
4
+ try {
5
+ const { origin, protocol } = new URL(url);
6
+ return ["http:", "https:"].includes(protocol) ? origin : null;
7
+ } catch (e) {
8
+ return null;
9
+ }
10
+ };
11
+ const warn = (message) => console.warn("[druxt-node-preview] " + message);
12
+ const toInclude = (value) => {
13
+ const paths = (list, where) => {
14
+ if (!Array.isArray(list)) {
15
+ warn(
16
+ `Ignored the include option${where}. Use a list of paths, such as ['field_content.field_media'].`
17
+ );
18
+ return [];
19
+ }
20
+ return list.filter((path) => {
21
+ if (typeof path === "string" && /^[A-Za-z0-9_.-]+$/.test(path))
22
+ return true;
23
+ warn(
24
+ `Ignored the include path ${JSON.stringify(path)}${where}. Use field names joined by dots, such as "field_content.field_media".`
25
+ );
26
+ return false;
27
+ });
28
+ };
29
+ if (value && typeof value === "object" && !Array.isArray(value)) {
30
+ return Object.fromEntries(
31
+ Object.entries(value).filter(([type]) => {
32
+ if (/^[A-Za-z0-9_]+--[A-Za-z0-9_]+$/.test(type)) return true;
33
+ warn(
34
+ `Ignored the include key "${type}". Key paths by JSON:API type, such as "node--page".`
35
+ );
36
+ return false;
37
+ }).map(([type, list]) => [type, paths(list, ` for ${type}`)])
38
+ );
39
+ }
40
+ return paths(value, "");
41
+ };
42
+ const toLayouts = (value) => {
43
+ if (!Array.isArray(value)) {
44
+ warn(
45
+ "Ignored the layouts option. Use a list of layout names, such as ['preview']."
46
+ );
47
+ return [];
48
+ }
49
+ return value.filter((name) => {
50
+ if (typeof name === "string" && /^[A-Za-z0-9_-]+$/.test(name)) return true;
51
+ warn(
52
+ `Ignored the layout ${JSON.stringify(name)}. Use the name of a file in layouts/, such as "preview". A layout in a subfolder cannot be listed.`
53
+ );
54
+ return false;
55
+ });
56
+ };
57
+ const NuxtModule = function(moduleOptions = {}) {
58
+ const druxt = (this.options || {}).druxt || {};
59
+ const options = {
60
+ path: "/druxt/node/preview",
61
+ include: [],
62
+ origins: [],
63
+ layouts: [],
64
+ ...druxt.nodePreview || {},
65
+ ...moduleOptions
66
+ };
67
+ this.options.store = true;
68
+ this.addTemplate({
69
+ src: resolve(__dirname, "../templates/DruxtNodePreview.vue"),
70
+ fileName: "components/druxt-node-preview.vue"
71
+ });
72
+ const { dst } = this.addTemplate({
73
+ src: resolve(__dirname, "../templates/DruxtNodePreviewPage.vue"),
74
+ fileName: "components/druxt-node-preview-page.vue"
75
+ });
76
+ this.extendRoutes((routes, resolveRoute) => {
77
+ routes.unshift({
78
+ name: "druxt-node-preview",
79
+ path: options.path,
80
+ component: resolveRoute(this.options.buildDir, dst)
81
+ });
82
+ });
83
+ if (!Array.isArray(options.origins)) {
84
+ warn(
85
+ "Ignored the origins option. Use a list of full http(s) URLs, such as ['https://cms.example.com']."
86
+ );
87
+ }
88
+ const listed = (Array.isArray(options.origins) ? options.origins : []).filter(
89
+ (url) => {
90
+ if (toOrigin(url)) return true;
91
+ warn(
92
+ `Ignored the origin "${url}". Use a full http(s) URL, such as "https://cms.example.com".`
93
+ );
94
+ return false;
95
+ }
96
+ );
97
+ const pluginOptions = {
98
+ include: toInclude(options.include),
99
+ origins: [],
100
+ layouts: toLayouts(options.layouts)
101
+ };
102
+ const setOrigins = () => {
103
+ const baseUrl = ((this.options || {}).druxt || {}).baseUrl;
104
+ pluginOptions.origins = [
105
+ ...new Set([baseUrl, ...listed].map(toOrigin).filter(Boolean))
106
+ ];
107
+ };
108
+ setOrigins();
109
+ if (this.nuxt) this.nuxt.hook("modules:done", setOrigins);
110
+ this.addPlugin({
111
+ src: resolve(__dirname, "../templates/plugin.js"),
112
+ fileName: "druxt-node-preview.js",
113
+ options: pluginOptions
114
+ });
115
+ };
116
+
117
+ export { NuxtModule as default };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+
5
+ const toOrigin = (url) => {
6
+ try {
7
+ const { origin, protocol } = new URL(url);
8
+ return ["http:", "https:"].includes(protocol) ? origin : null;
9
+ } catch (e) {
10
+ return null;
11
+ }
12
+ };
13
+ const warn = (message) => console.warn("[druxt-node-preview] " + message);
14
+ const toInclude = (value) => {
15
+ const paths = (list, where) => {
16
+ if (!Array.isArray(list)) {
17
+ warn(
18
+ `Ignored the include option${where}. Use a list of paths, such as ['field_content.field_media'].`
19
+ );
20
+ return [];
21
+ }
22
+ return list.filter((path) => {
23
+ if (typeof path === "string" && /^[A-Za-z0-9_.-]+$/.test(path))
24
+ return true;
25
+ warn(
26
+ `Ignored the include path ${JSON.stringify(path)}${where}. Use field names joined by dots, such as "field_content.field_media".`
27
+ );
28
+ return false;
29
+ });
30
+ };
31
+ if (value && typeof value === "object" && !Array.isArray(value)) {
32
+ return Object.fromEntries(
33
+ Object.entries(value).filter(([type]) => {
34
+ if (/^[A-Za-z0-9_]+--[A-Za-z0-9_]+$/.test(type)) return true;
35
+ warn(
36
+ `Ignored the include key "${type}". Key paths by JSON:API type, such as "node--page".`
37
+ );
38
+ return false;
39
+ }).map(([type, list]) => [type, paths(list, ` for ${type}`)])
40
+ );
41
+ }
42
+ return paths(value, "");
43
+ };
44
+ const toLayouts = (value) => {
45
+ if (!Array.isArray(value)) {
46
+ warn(
47
+ "Ignored the layouts option. Use a list of layout names, such as ['preview']."
48
+ );
49
+ return [];
50
+ }
51
+ return value.filter((name) => {
52
+ if (typeof name === "string" && /^[A-Za-z0-9_-]+$/.test(name)) return true;
53
+ warn(
54
+ `Ignored the layout ${JSON.stringify(name)}. Use the name of a file in layouts/, such as "preview". A layout in a subfolder cannot be listed.`
55
+ );
56
+ return false;
57
+ });
58
+ };
59
+ const NuxtModule = function(moduleOptions = {}) {
60
+ const druxt = (this.options || {}).druxt || {};
61
+ const options = {
62
+ path: "/druxt/node/preview",
63
+ include: [],
64
+ origins: [],
65
+ layouts: [],
66
+ ...druxt.nodePreview || {},
67
+ ...moduleOptions
68
+ };
69
+ this.options.store = true;
70
+ this.addTemplate({
71
+ src: path.resolve(__dirname, "../templates/DruxtNodePreview.vue"),
72
+ fileName: "components/druxt-node-preview.vue"
73
+ });
74
+ const { dst } = this.addTemplate({
75
+ src: path.resolve(__dirname, "../templates/DruxtNodePreviewPage.vue"),
76
+ fileName: "components/druxt-node-preview-page.vue"
77
+ });
78
+ this.extendRoutes((routes, resolveRoute) => {
79
+ routes.unshift({
80
+ name: "druxt-node-preview",
81
+ path: options.path,
82
+ component: resolveRoute(this.options.buildDir, dst)
83
+ });
84
+ });
85
+ if (!Array.isArray(options.origins)) {
86
+ warn(
87
+ "Ignored the origins option. Use a list of full http(s) URLs, such as ['https://cms.example.com']."
88
+ );
89
+ }
90
+ const listed = (Array.isArray(options.origins) ? options.origins : []).filter(
91
+ (url) => {
92
+ if (toOrigin(url)) return true;
93
+ warn(
94
+ `Ignored the origin "${url}". Use a full http(s) URL, such as "https://cms.example.com".`
95
+ );
96
+ return false;
97
+ }
98
+ );
99
+ const pluginOptions = {
100
+ include: toInclude(options.include),
101
+ origins: [],
102
+ layouts: toLayouts(options.layouts)
103
+ };
104
+ const setOrigins = () => {
105
+ const baseUrl = ((this.options || {}).druxt || {}).baseUrl;
106
+ pluginOptions.origins = [
107
+ ...new Set([baseUrl, ...listed].map(toOrigin).filter(Boolean))
108
+ ];
109
+ };
110
+ setOrigins();
111
+ if (this.nuxt) this.nuxt.hook("modules:done", setOrigins);
112
+ this.addPlugin({
113
+ src: path.resolve(__dirname, "../templates/plugin.js"),
114
+ fileName: "druxt-node-preview.js",
115
+ options: pluginOptions
116
+ });
117
+ };
118
+
119
+ module.exports = NuxtModule;
package/package.json CHANGED
@@ -1,6 +1,102 @@
1
1
  {
2
2
  "name": "@druxt-contrib/node-preview",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0-dev.20261030024907",
4
+ "description": "Renders Drupal node previews in a Druxt frontend, fed by JSON:API Node Preview and Consumer Node Preview.",
5
+ "keywords": [
6
+ "decoupled",
7
+ "drupal",
8
+ "druxt",
9
+ "jsonapi",
10
+ "json:api",
11
+ "module",
12
+ "node preview",
13
+ "nuxt",
14
+ "preview"
15
+ ],
16
+ "homepage": "https://github.com/druxt-contrib/druxt-node-preview#readme",
17
+ "bugs": "https://github.com/druxt-contrib/druxt-node-preview/issues",
18
+ "repository": "github:druxt-contrib/druxt-node-preview",
19
+ "license": "MIT",
20
+ "exports": {
21
+ ".": {
22
+ "require": "./dist/index.ssr.js",
23
+ "import": "./dist/index.ssr.js"
24
+ },
25
+ "./package.json": "./package.json"
26
+ },
27
+ "main": "dist/index.ssr.js",
28
+ "module": "dist/index.esm.js",
29
+ "files": [
30
+ "dist",
31
+ "templates",
32
+ "CHANGELOG.md"
33
+ ],
34
+ "scripts": {
35
+ "build": "siroc build",
36
+ "build:watch": "siroc build --watch",
37
+ "changeset": "changeset",
38
+ "hooks:install": "git config core.hooksPath .githooks",
39
+ "lint": "npm run lint:js && npm run lint:format && npm run lint:md && npm run lint:cspell && npm run lint:private && npm run lint:attribution && npm run lint:knip",
40
+ "lint:attribution": "bash .gitlab/scripts/check-attribution.sh",
41
+ "lint:audit": "npm audit --omit=dev --audit-level=high",
42
+ "lint:commit": "commitlint",
43
+ "lint:cspell": "cspell lint --no-progress --no-must-find-files --gitignore --dot \"**/*.{js,mjs,vue,json,jsonc,md,yml,yaml}\"",
44
+ "lint:format": "prettier --check \"**/*.{js,mjs,vue,json,jsonc,md,yml,yaml}\"",
45
+ "lint:format:fix": "prettier --write \"**/*.{js,mjs,vue,json,jsonc,md,yml,yaml}\"",
46
+ "lint:js": "eslint --ext .js,.mjs,.vue src templates test scripts",
47
+ "lint:knip": "knip --include dependencies,unlisted --no-config-hints",
48
+ "lint:md": "markdownlint-cli2 \"**/*.md\"",
49
+ "lint:private": "node scripts/lint-private-refs.mjs",
50
+ "lint:prose": "PROSE_LINT_GLOB='!{.changeset,example/drupal}/**' bash .gitlab/scripts/lint-prose.sh --all",
51
+ "lint:prose:install": "bash .gitlab/scripts/install-vale.sh .vale/bin .vale/styles",
52
+ "prepack": "npm run build",
53
+ "prepare": "node scripts/postinstall.mjs",
54
+ "test": "jest",
55
+ "test:e2e": "playwright test",
56
+ "test:e2e:stack": "test/e2e/run.sh",
57
+ "test:watch": "jest --watch"
58
+ },
59
+ "devDependencies": {
60
+ "@babel/core": "^7.26.0",
61
+ "@babel/preset-env": "^7.26.0",
62
+ "@changesets/cli": "2.25.2",
63
+ "@commitlint/cli": "17.8.1",
64
+ "@commitlint/config-conventional": "17.8.1",
65
+ "@playwright/test": "^1.49.0",
66
+ "babel-core": "7.0.0-bridge.0",
67
+ "cspell": "7.3.9",
68
+ "druxt": "0.25.0",
69
+ "druxt-entity": "0.29.0",
70
+ "esbuild-jest": "0.5.0",
71
+ "eslint": "8.28.0",
72
+ "eslint-config-prettier": "8.10.0",
73
+ "eslint-plugin-nuxt": "3.2.0",
74
+ "eslint-plugin-vue": "8.7.1",
75
+ "jest": "27.5.1",
76
+ "jest-environment-jsdom": "27.5.1",
77
+ "jest-junit": "^15.0.0",
78
+ "knip": "2.43.0",
79
+ "markdownlint-cli2": "0.10.0",
80
+ "prettier": "3.3.3",
81
+ "siroc": "0.16.0",
82
+ "vue": "2.7.14",
83
+ "vue-jest": "3.0.7",
84
+ "vue-template-compiler": "2.7.14"
85
+ },
86
+ "peerDependencies": {
87
+ "druxt": ">=0.25.0 <1",
88
+ "druxt-entity": ">=0.29.0 <1",
89
+ "vue": "^2.7.14"
90
+ },
91
+ "packageManager": "npm@8.19.4",
92
+ "engines": {
93
+ "node": ">=16.20.1"
94
+ },
95
+ "publishConfig": {
96
+ "access": "public"
97
+ },
98
+ "jest-junit": {
99
+ "outputDirectory": "reports/junit",
100
+ "outputName": "junit.xml"
101
+ }
102
+ }
@@ -0,0 +1,81 @@
1
+ <script>
2
+ import DruxtModule from 'druxt/dist/components/DruxtModule.vue'
3
+
4
+ /**
5
+ * Renders a seeded node preview through a themeable wrapper.
6
+ *
7
+ * The wrapper is picked by the node's bundle and view mode, like every Druxt
8
+ * module, so a site can wrap the preview in the same page chrome as the live
9
+ * route. For a `node--page` previewed in `full`, the first of these that
10
+ * exists is used:
11
+ *
12
+ * - `DruxtNodePreviewPageFull`
13
+ * - `DruxtNodePreviewPage`
14
+ * - `DruxtNodePreviewDefault`
15
+ *
16
+ * A wrapper receives whichever of these props it declares: `document` (the
17
+ * whole JSON:API preview document), `entity` (its main resource), `mode`,
18
+ * `type` and `uuid`. Its default slot renders the node with `DruxtEntity`.
19
+ */
20
+ export default {
21
+ name: 'DruxtNodePreview',
22
+
23
+ extends: DruxtModule,
24
+
25
+ props: {
26
+ /**
27
+ * The JSON:API preview document, already seeded into the store.
28
+ *
29
+ * @type {object}
30
+ */
31
+ document: {
32
+ type: Object,
33
+ required: true,
34
+ },
35
+
36
+ /**
37
+ * The view mode being previewed.
38
+ *
39
+ * @type {string}
40
+ */
41
+ mode: {
42
+ type: String,
43
+ default: 'full',
44
+ },
45
+ },
46
+
47
+ computed: {
48
+ /** The previewed resource. */
49
+ entity: ({ document }) => (document || {}).data || {},
50
+
51
+ /** The bundle, from a JSON:API type like `node--page`. */
52
+ bundle: ({ entity }) => (entity.type || '').split('--').slice(1).join('--'),
53
+ },
54
+
55
+ druxt: {
56
+ componentOptions: ({ bundle, mode }) => [[bundle, mode], ['default']],
57
+
58
+ propsData: ({ document, entity, mode }) => ({
59
+ document,
60
+ entity,
61
+ mode,
62
+ type: entity.type,
63
+ uuid: entity.id,
64
+ }),
65
+
66
+ slots(h) {
67
+ return {
68
+ default: () =>
69
+ h('DruxtEntity', {
70
+ key: this.entity.id,
71
+ props: {
72
+ mode: this.mode,
73
+ type: this.entity.type,
74
+ uuid: this.entity.id,
75
+ },
76
+ }),
77
+ }
78
+ },
79
+ },
80
+ }
81
+ </script>
@@ -0,0 +1,162 @@
1
+ <template>
2
+ <div
3
+ class="druxt-node-preview"
4
+ :data-view-mode="viewMode"
5
+ :data-entity-type="entity && entity.type"
6
+ :data-entity-uuid="entity && entity.id"
7
+ >
8
+ <div v-if="error" class="druxt-node-preview-error">
9
+ <h1>Preview unavailable</h1>
10
+ <p>{{ error }}</p>
11
+ </div>
12
+
13
+ <DruxtNodePreview
14
+ v-else-if="document"
15
+ :key="entity.id"
16
+ :document="document"
17
+ :mode="viewMode"
18
+ />
19
+
20
+ <p v-else>Loading preview...</p>
21
+ </div>
22
+ </template>
23
+
24
+ <script>
25
+ import DruxtNodePreview from './druxt-node-preview.vue'
26
+
27
+ /**
28
+ * Renders a Drupal node preview.
29
+ *
30
+ * This page only works in the browser. The preview endpoint arrives in the
31
+ * URL fragment, which never reaches the server, and the fetch uses the
32
+ * editor's Drupal session cookie. A server render shows "Loading preview...".
33
+ */
34
+ export default {
35
+ name: 'DruxtNodePreviewPage',
36
+
37
+ components: { DruxtNodePreview },
38
+
39
+ /**
40
+ * The layout named by `?layout=`, when the `layouts` option lists it.
41
+ *
42
+ * @param {object} context - The Nuxt context.
43
+ *
44
+ * @returns {string|undefined} The layout, or undefined for the default.
45
+ */
46
+ layout: ({ $druxtNodePreview, route }) =>
47
+ $druxtNodePreview.layout(route.query.layout),
48
+
49
+ data: () => ({
50
+ document: null,
51
+ entity: null,
52
+ error: null,
53
+ }),
54
+
55
+ computed: {
56
+ // The view mode comes from the query, which any link can set, so only a
57
+ // machine name is used.
58
+ viewMode() {
59
+ const { vm } = this.$route.query
60
+ return typeof vm === 'string' && /^[A-Za-z0-9_]+$/.test(vm) ? vm : 'full'
61
+ },
62
+ },
63
+
64
+ async mounted() {
65
+ // A statically generated site renders this page once, with no query, and
66
+ // Nuxt keeps that layout in the browser. So the listed layout is set here
67
+ // as well, where the query is known.
68
+ const layout = this.$druxtNodePreview.layout(this.$route.query.layout)
69
+ if (layout && this.$nuxt && this.$nuxt.layoutName !== layout) {
70
+ const previous = this.$nuxt.layoutName
71
+ // A layout chunk can fail to download. Then the preview shows in the
72
+ // layout the page already has. Webpack names that a ChunkLoadError, or
73
+ // says "Loading chunk" in the message. Any other error is a fault in
74
+ // the layout, so it goes to the console too.
75
+ const report = (error) => {
76
+ if (
77
+ !/chunk/i.test(`${error && error.name} ${error && error.message}`)
78
+ ) {
79
+ console.error('DruxtNodePreview could not load the layout:', error)
80
+ }
81
+ }
82
+ const state = this.$nuxt.nuxt || {}
83
+ const before = state.err
84
+ try {
85
+ await this.$nuxt.loadLayout(layout)
86
+ if (state.err && state.err !== before) {
87
+ // Nuxt 2 does not reject a failed layout. It calls error() and
88
+ // resolves, and its error page would replace the preview. So the
89
+ // error is cleared. Nuxt's error handler can still change the
90
+ // layout, so the page waits for it before the check below.
91
+ report(state.err)
92
+ this.$nuxt.error(null)
93
+ await new Promise((done) => setTimeout(done))
94
+ } else {
95
+ this.$nuxt.setLayout(layout)
96
+ }
97
+ } catch (error) {
98
+ report(error)
99
+ }
100
+ // A new layout mounts a new page, which loads the preview. If this one
101
+ // loaded it too, the two would seed the same document twice. Nuxt falls
102
+ // back to the default layout for one the site does not have, and then
103
+ // nothing new mounts, so this page loads the preview itself.
104
+ if (this.$nuxt.layoutName !== previous) return
105
+ }
106
+
107
+ return this.load()
108
+ },
109
+
110
+ // A site can keep pages alive. Leaving the preview flushes the seeded
111
+ // resources, so a page that comes back loads its preview again. Vue also
112
+ // calls activated after the first mount, and that call loads nothing.
113
+ activated() {
114
+ if (!this.left) return
115
+ this.left = false
116
+ return this.load()
117
+ },
118
+
119
+ deactivated() {
120
+ this.left = true
121
+ },
122
+
123
+ beforeDestroy() {
124
+ this.gone = true
125
+ },
126
+
127
+ methods: {
128
+ /**
129
+ * Fetches the preview and seeds the Druxt store with it.
130
+ */
131
+ async load() {
132
+ this.run = (this.run || 0) + 1
133
+ const run = this.run
134
+ // A reload starts clean. The old preview's resources are flushed, and
135
+ // an old error would hide a preview that now loads.
136
+ this.document = null
137
+ this.entity = null
138
+ this.error = null
139
+ try {
140
+ const endpoint = this.$druxtNodePreview.endpoint()
141
+ if (!endpoint) {
142
+ this.error = 'The URL fragment has no preview endpoint.'
143
+ return
144
+ }
145
+ const document = await this.$druxtNodePreview.fetch(endpoint)
146
+ // The editor can open another preview before this fetch ends. A page
147
+ // that is gone, kept alive or loading again seeds nothing, so this
148
+ // document cannot replace a newer one.
149
+ if (this.gone || this.left || run !== this.run) return
150
+ // seed() returns null once the editor is leaving. This page can still
151
+ // be mounted then, and a document without an entity would not render.
152
+ const entity = this.$druxtNodePreview.seed(document)
153
+ if (!entity) return
154
+ this.entity = entity
155
+ this.document = document
156
+ } catch (error) {
157
+ if (run === this.run) this.error = error.message
158
+ }
159
+ },
160
+ },
161
+ }
162
+ </script>
@@ -0,0 +1,345 @@
1
+ /**
2
+ * The $druxtNodePreview plugin.
3
+ *
4
+ * Fetches a JSON:API Node Preview document with the editor's Drupal session,
5
+ * and seeds the Druxt store with it so DruxtEntity renders the unsaved data.
6
+ *
7
+ * It also registers the `druxtNodePreview` store module. Its `active` state is
8
+ * true while a preview is seeded, so a site component that refetches its data
9
+ * (for example paragraphs at a revision) can skip that and read the store.
10
+ *
11
+ * Leaving the preview route flushes the seeded resources from the Druxt store
12
+ * and clears `active`, so the next page renders the saved content.
13
+ */
14
+ export default ({ app, store }, inject) => {
15
+ const include = JSON.parse('<%= JSON.stringify(options.include) %>')
16
+ const layouts = JSON.parse('<%= JSON.stringify(options.layouts) %>')
17
+ // Origins the preview may come from, besides this site's own.
18
+ const origins = JSON.parse('<%= JSON.stringify(options.origins) %>')
19
+
20
+ // Drupal gives every JSON:API resource a UUID, and a JSON:API Node Preview
21
+ // URL ends in /{uuid}/preview.
22
+ const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
23
+ const PREVIEW_PATH =
24
+ /\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\/preview$/
25
+
26
+ /**
27
+ * Returns the node's UUID from a preview endpoint, or throws.
28
+ *
29
+ * The endpoint comes from the URL fragment, which any link can set. So it
30
+ * must be on this site or an allowed origin, and it must be a node preview.
31
+ * Without these checks, a crafted link could make the page fetch other JSON
32
+ * with the editor's credentials, such as an uploaded file, and render it.
33
+ *
34
+ * @param {string} endpoint - The endpoint, a URL or a path.
35
+ *
36
+ * @returns {string} The previewed node's UUID.
37
+ */
38
+ const checkEndpoint = (endpoint) => {
39
+ const { origin, pathname } = new URL(endpoint, window.location.origin)
40
+ if (origin !== window.location.origin && !origins.includes(origin)) {
41
+ throw new Error(
42
+ 'The preview endpoint is not on this site or its Drupal site: ' + origin
43
+ )
44
+ }
45
+ const match = pathname.match(PREVIEW_PATH)
46
+ if (!match) {
47
+ throw new Error('The preview endpoint is not a node preview: ' + pathname)
48
+ }
49
+ return match[1]
50
+ }
51
+
52
+ /**
53
+ * Throws unless a response's content type is JSON:API.
54
+ *
55
+ * @param {*} type - The Content-Type header.
56
+ */
57
+ const checkType = (type) => {
58
+ if (!String(type || '').includes('application/vnd.api+json')) {
59
+ throw new Error('The preview response is not a JSON:API document.')
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Throws unless the document is safe to put in the Druxt store.
65
+ *
66
+ * Every resource needs a JSON:API type and a UUID. The store keys resources
67
+ * by type and id, so a name such as `__proto__` would write to the object
68
+ * prototype.
69
+ *
70
+ * @param {*} doc - The response.
71
+ * @param {string} [uuid] - The UUID the main resource must have.
72
+ */
73
+ const checkDocument = (doc, uuid) => {
74
+ const safe = (key) =>
75
+ typeof key === 'string' &&
76
+ /^[A-Za-z0-9_-]+$/.test(key) &&
77
+ !(key in Object.prototype)
78
+ const included = (doc || {}).included
79
+ const resources =
80
+ doc &&
81
+ typeof doc === 'object' &&
82
+ doc.data &&
83
+ typeof doc.data === 'object' &&
84
+ (included === undefined || Array.isArray(included))
85
+ ? [doc.data, ...(included || [])]
86
+ : []
87
+ if (
88
+ !resources.length ||
89
+ !resources.every(
90
+ (resource) => resource && safe(resource.type) && UUID.test(resource.id)
91
+ )
92
+ ) {
93
+ throw new Error('The preview response is not a node preview document.')
94
+ }
95
+ if (uuid && doc.data.id !== uuid) {
96
+ throw new Error('The preview response is not a preview of ' + uuid + '.')
97
+ }
98
+ }
99
+
100
+ /**
101
+ * Returns whether a route is the preview route.
102
+ *
103
+ * nuxt-i18n adds a language suffix to route names, such as
104
+ * `druxt-node-preview___en`.
105
+ *
106
+ * @param {object} route - A route.
107
+ *
108
+ * @returns {boolean} True for the preview route.
109
+ */
110
+ const isPreview = (route) =>
111
+ /^druxt-node-preview(___[A-Za-z0-9_-]+)?$/.test((route || {}).name || '')
112
+
113
+ // The type and id of every resource the preview put in the Druxt store.
114
+ const seeded = []
115
+
116
+ if (!store.hasModule('druxtNodePreview')) {
117
+ store.registerModule(
118
+ 'druxtNodePreview',
119
+ {
120
+ namespaced: true,
121
+ state: () => ({ active: false, uuid: null }),
122
+ mutations: {
123
+ start(state, uuid) {
124
+ state.active = true
125
+ state.uuid = uuid
126
+ },
127
+ stop(state) {
128
+ state.active = false
129
+ state.uuid = null
130
+ },
131
+ },
132
+ },
133
+ { preserveState: !!(store.state || {}).druxtNodePreview }
134
+ )
135
+ }
136
+
137
+ // True from when the editor leaves the preview route until a preview route
138
+ // opens again. vue-router changes currentRoute only after the next route's
139
+ // code loads, so a fetch that ends meanwhile still sees the preview route.
140
+ // When that code fails to download, Nuxt reloads the page, which resets
141
+ // this. A site guard that cancels the leave with next(false) leaves it set
142
+ // until the next navigation, because vue-router has no hook for that.
143
+ let left = false
144
+
145
+ // Before the next route loads, so none of its components read a seeded
146
+ // resource. Druxt then fetches the saved one.
147
+ if (app.router) {
148
+ app.router.beforeEach((to, from, next) => {
149
+ if (isPreview(from) && !isPreview(to)) {
150
+ left = true
151
+ if (seeded.length) {
152
+ for (const { type, id } of seeded.splice(0)) {
153
+ store.commit('druxt/flushResource', { type, id })
154
+ }
155
+ store.commit('druxtNodePreview/stop')
156
+ }
157
+ } else if (isPreview(to)) {
158
+ left = false
159
+ }
160
+ next()
161
+ })
162
+ }
163
+
164
+ /**
165
+ * Adds the configured includes to the include the URL already has.
166
+ *
167
+ * Consumer Node Preview already includes a node's paragraphs. A site adds
168
+ * what those paragraphs reference, such as their media.
169
+ *
170
+ * @param {string} url - The endpoint.
171
+ *
172
+ * @returns {string} The URL to request.
173
+ */
174
+ const withInclude = (url) => {
175
+ const configured = includeFor(url)
176
+ if (!configured.length) return url
177
+ const [base, query = ''] = url.split('?')
178
+ const params = query
179
+ .split('&')
180
+ .filter((param) => param && !param.startsWith('include='))
181
+ const current = (
182
+ query.split('&').find((param) => param.startsWith('include=')) ||
183
+ 'include='
184
+ )
185
+ .slice('include='.length)
186
+ .split(',')
187
+ .filter(Boolean)
188
+ const merged = [...new Set([...current, ...configured])]
189
+ return base + '?' + [...params, 'include=' + merged.join(',')].join('&')
190
+ }
191
+
192
+ /**
193
+ * Returns the configured include paths for an endpoint.
194
+ *
195
+ * The option is a list for every previewed type, or an object of lists
196
+ * keyed by JSON:API type, such as `node--page`. JSON:API rejects a path
197
+ * that the type does not have, so a path only some bundles have goes in
198
+ * the object form.
199
+ *
200
+ * @param {string} url - The endpoint, from Consumer Node Preview:
201
+ * `.../{entity type}/{bundle}/{uuid}/preview`.
202
+ *
203
+ * @returns {string[]} The include paths.
204
+ */
205
+ function includeFor(url) {
206
+ if (Array.isArray(include)) return include
207
+ const match = url
208
+ .split('?')[0]
209
+ .match(/\/([^/]+)\/([^/]+)\/[0-9a-f-]{36}\/preview$/)
210
+ return (match && include[match[1] + '--' + match[2]]) || []
211
+ }
212
+
213
+ /**
214
+ * Builds an error that carries the status and the start of the body.
215
+ *
216
+ * @param {string} url - The requested URL.
217
+ * @param {number} status - The HTTP status.
218
+ * @param {string} body - The response body.
219
+ *
220
+ * @returns {Error} The error.
221
+ */
222
+ const failure = (url, status, body) =>
223
+ new Error(
224
+ 'Preview request failed with HTTP ' +
225
+ status +
226
+ ' for ' +
227
+ url +
228
+ (body ? ': ' + String(body).slice(0, 300) : '')
229
+ )
230
+
231
+ const plugin = {
232
+ /**
233
+ * Returns the Nuxt layout for a requested layout name.
234
+ *
235
+ * Only a name in the `layouts` option is used, so a preview URL cannot
236
+ * put the page in any other layout of the site.
237
+ *
238
+ * @param {*} name - The requested name, usually from `?layout=`.
239
+ *
240
+ * @returns {string|undefined} The layout, or undefined for the default.
241
+ */
242
+ layout: (name) =>
243
+ typeof name === 'string' && layouts.includes(name) ? name : undefined,
244
+
245
+ /**
246
+ * Returns the preview endpoint from the URL fragment.
247
+ *
248
+ * The fragment never reaches any server, so the preview URL stays out of
249
+ * request logs. It is only readable in the browser.
250
+ *
251
+ * @returns {string|null} The endpoint, or null when there is none.
252
+ */
253
+ endpoint: () => {
254
+ const hash = (window.location.hash || '').slice(1)
255
+ if (!hash) return null
256
+ // vue-router normalises the URL, which collapses the double slash after
257
+ // the scheme inside the fragment. Repair it.
258
+ return decodeURIComponent(hash).replace(/^(https?):\/(?!\/)/, '$1://')
259
+ },
260
+
261
+ /**
262
+ * Fetches the preview document with the editor's session.
263
+ *
264
+ * The request goes through the Druxt client, with credentials. The site's
265
+ * request interceptors apply, and the editor's session cookie goes with
266
+ * 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.
268
+ *
269
+ * @param {string} endpoint - The JSON:API Node Preview endpoint.
270
+ *
271
+ * @returns {object} The JSON:API document.
272
+ */
273
+ fetch: async (endpoint) => {
274
+ const uuid = checkEndpoint(endpoint)
275
+ const url = withInclude(endpoint)
276
+ const headers = { Accept: 'application/vnd.api+json' }
277
+ const client = (app.$druxt || {}).axios
278
+ if (client) {
279
+ let response
280
+ try {
281
+ response = await client.get(url, { headers, withCredentials: true })
282
+ } catch (error) {
283
+ const failed = error.response || {}
284
+ if (!failed.status) throw error
285
+ const body =
286
+ typeof failed.data === 'string'
287
+ ? failed.data
288
+ : JSON.stringify(failed.data || '')
289
+ throw failure(url, failed.status, body)
290
+ }
291
+ checkType((response.headers || {})['content-type'])
292
+ checkDocument(response.data, uuid)
293
+ return response.data
294
+ }
295
+ const response = await window.fetch(url, {
296
+ credentials: 'include',
297
+ headers,
298
+ })
299
+ if (!response.ok) {
300
+ throw failure(
301
+ url,
302
+ response.status,
303
+ await response.text().catch(() => '')
304
+ )
305
+ }
306
+ checkType(response.headers && response.headers.get('content-type'))
307
+ const doc = await response.json()
308
+ checkDocument(doc, uuid)
309
+ return doc
310
+ },
311
+
312
+ /**
313
+ * Seeds the Druxt store with the preview document.
314
+ *
315
+ * The main resource and every included resource are stored, so a
316
+ * DruxtEntity render finds the unsaved preview data instead of fetching
317
+ * the saved data from the API.
318
+ *
319
+ * @param {object} doc - The JSON:API document.
320
+ *
321
+ * @returns {object|null} The type and id of the previewed resource, or null
322
+ * when the editor has left the preview.
323
+ */
324
+ seed: (doc) => {
325
+ // A fetch can end after the editor has left the preview. Then nothing
326
+ // is seeded, so nothing unsaved reaches the next page.
327
+ if (left) return null
328
+ if (app.router && !isPreview(app.router.currentRoute)) return null
329
+ checkDocument(doc)
330
+ // DruxtEntity reads a resource under its language: its langcode prop,
331
+ // else the route's langcode meta. The preview's DruxtEntity has no
332
+ // langcode prop, so the resources go under the route's.
333
+ const prefix = (((app.router || {}).currentRoute || {}).meta || {})
334
+ .langcode
335
+ for (const data of [doc.data, ...(doc.included || [])]) {
336
+ store.commit('druxt/addResource', { prefix, resource: { data } })
337
+ seeded.push({ type: data.type, id: data.id })
338
+ }
339
+ store.commit('druxtNodePreview/start', doc.data.id)
340
+ return { type: doc.data.type, id: doc.data.id }
341
+ },
342
+ }
343
+
344
+ inject('druxtNodePreview', plugin)
345
+ }