@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 +7 -0
- package/LICENSE +21 -0
- package/README.md +311 -2
- package/dist/index.esm.js +117 -0
- package/dist/index.ssr.js +119 -0
- package/package.json +100 -4
- package/templates/DruxtNodePreview.vue +81 -0
- package/templates/DruxtNodePreviewPage.vue +162 -0
- package/templates/plugin.js +345 -0
package/CHANGELOG.md
ADDED
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
|
-
|
|
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
|
-
|
|
8
|
+
# @druxt-contrib/node-preview
|
|
9
|
+
|
|
10
|
+
[](https://www.npmjs.com/package/@druxt-contrib/node-preview)
|
|
11
|
+
[](https://www.npmjs.com/package/@druxt-contrib/node-preview/v/dev)
|
|
12
|
+
[](https://github.com/druxt-contrib/druxt-node-preview/actions/workflows/ci.yml)
|
|
13
|
+
[](https://codecov.io/gh/druxt-contrib/druxt-node-preview)
|
|
14
|
+
[](https://snyk.io/test/github/druxt-contrib/druxt-node-preview?targetFile=package.json)
|
|
15
|
+
[](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
|
+

|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|
+
}
|