payload-live-preview 1.0.2 → 1.0.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +150 -84
- package/dist/adapters/astro/RichText.astro +8 -4
- package/dist/adapters/astro/index.d.ts +84 -9
- package/dist/adapters/astro/index.js +1 -363
- package/dist/adapters/astro/index.js.map +1 -1
- package/dist/adapters/astro/middleware-entry.d.ts +1 -1
- package/dist/adapters/astro/middleware-entry.js +1 -296
- package/dist/adapters/astro/middleware-entry.js.map +1 -1
- package/dist/adapters/nextjs/index.d.ts +24 -10
- package/dist/adapters/nextjs/index.js +1 -305
- package/dist/adapters/nextjs/index.js.map +1 -1
- package/dist/adapters/nuxt/index.d.ts +20 -10
- package/dist/adapters/nuxt/index.js +1 -293
- package/dist/adapters/nuxt/index.js.map +1 -1
- package/dist/adapters/sveltekit/index.d.ts +22 -6
- package/dist/adapters/sveltekit/index.js +1 -272
- package/dist/adapters/sveltekit/index.js.map +1 -1
- package/dist/codegen-astro.js +1 -609
- package/dist/codegen-astro.js.map +1 -1
- package/dist/codegen-cli.js +1 -665
- package/dist/codegen-cli.js.map +1 -1
- package/dist/codegen.cjs +1 -550
- package/dist/codegen.cjs.map +1 -1
- package/dist/codegen.js +1 -546
- package/dist/codegen.js.map +1 -1
- package/dist/core.cjs +1 -3719
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +1210 -7
- package/dist/core.d.ts +1210 -7
- package/dist/core.js +1 -3692
- package/dist/core.js.map +1 -1
- package/dist/index.cjs +1 -4118
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1359 -34
- package/dist/index.d.ts +1359 -34
- package/dist/index.js +1 -4075
- package/dist/index.js.map +1 -1
- package/dist/{middleware-BwIlf0jW.d.cts → middleware-CkSeLZ-9.d.ts} +35 -19
- package/dist/payload.cjs +1 -30
- package/dist/payload.cjs.map +1 -1
- package/dist/payload.d.cts +4 -3
- package/dist/payload.d.ts +4 -3
- package/dist/payload.js +1 -28
- package/dist/payload.js.map +1 -1
- package/package.json +55 -16
- package/dist/adapters/astro/index.cjs +0 -370
- package/dist/adapters/astro/index.cjs.map +0 -1
- package/dist/adapters/astro/index.d.cts +0 -105
- package/dist/adapters/astro/middleware-entry.cjs +0 -303
- package/dist/adapters/astro/middleware-entry.cjs.map +0 -1
- package/dist/adapters/astro/middleware-entry.d.cts +0 -15
- package/dist/adapters/nextjs/index.cjs +0 -309
- package/dist/adapters/nextjs/index.cjs.map +0 -1
- package/dist/adapters/nextjs/index.d.cts +0 -75
- package/dist/adapters/nuxt/index.cjs +0 -299
- package/dist/adapters/nuxt/index.cjs.map +0 -1
- package/dist/adapters/nuxt/index.d.cts +0 -126
- package/dist/adapters/sveltekit/index.cjs +0 -275
- package/dist/adapters/sveltekit/index.cjs.map +0 -1
- package/dist/adapters/sveltekit/index.d.cts +0 -80
- package/dist/bind-BSgxoQhN.d.cts +0 -1137
- package/dist/bind-BSgxoQhN.d.ts +0 -1137
- package/dist/codegen-astro.cjs +0 -612
- package/dist/codegen-astro.cjs.map +0 -1
- package/dist/codegen-astro.d.cts +0 -54
- package/dist/codegen-cli.cjs +0 -669
- package/dist/codegen-cli.cjs.map +0 -1
- package/dist/codegen-cli.d.cts +0 -19
- package/dist/middleware-BwIlf0jW.d.ts +0 -168
- package/dist/preview-request-DAvxm18T.d.cts +0 -68
- package/dist/preview-request-DAvxm18T.d.ts +0 -68
package/README.md
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
> **Live preview for Payload CMS in Astro** — and any other server-rendered or static frontend (SvelteKit, Nuxt, Next.js, plain HTML).
|
|
10
10
|
|
|
11
|
-
**The missing piece for Astro + Payload.** The official live-preview packages are React/Vue hooks — great
|
|
11
|
+
**The missing piece for Astro + Payload.** The official live-preview packages are React/Vue hooks — great when a hydrated component tree owns the rendering, but they cannot directly re-render server-produced Astro markup. This package makes the CMS's real-time preview work where there is no client framework to re-render: annotate your `.astro` markup with `data-payload-field`, add one line to `astro.config.mjs`, and edits stream into the preview iframe as the editor types. No rebuild, no page reload, no React.
|
|
12
12
|
|
|
13
13
|
Under the hood it's framework-agnostic — the same runtime drives SvelteKit, Nuxt, Next.js (static/SSR) and plain HTML — but Astro is the first-class, end-to-end-tested path.
|
|
14
14
|
|
|
@@ -16,27 +16,27 @@ Under the hood it's framework-agnostic — the same runtime drives SvelteKit, Nu
|
|
|
16
16
|
|
|
17
17
|
## Highlights
|
|
18
18
|
|
|
19
|
-
|
|
|
20
|
-
|
|
21
|
-
| **Single source of truth**
|
|
22
|
-
| **Payload 3.x native**
|
|
23
|
-
| **Complete Lexical renderer** | 16 node types incl. `upload`, `relationship`, `block`, `autolink`, `tab`, indent, RTL — plus automatic rich-text detection, so `data-payload-field` alone is enough.
|
|
24
|
-
| **
|
|
25
|
-
| **Strict security**
|
|
26
|
-
| **Per-instance**
|
|
27
|
-
| **Typed DSL + codegen**
|
|
28
|
-
| **First-class adapters**
|
|
19
|
+
| | |
|
|
20
|
+
| ----------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
21
|
+
| **Single source of truth** | One TypeScript runtime compiled to a self-contained IIFE (~64 KB raw, ~20 KB gzipped) at build time — no parallel inline/class implementations to drift. |
|
|
22
|
+
| **Payload 3.x native** | Optional REST data merging (`serverURL`) re-fetches populated documents exactly like the official client, so relationship and upload fields render as content, not as bare IDs. |
|
|
23
|
+
| **Complete Lexical renderer** | 16 node types incl. `upload`, `relationship`, `block`, `autolink`, `tab`, indent, RTL — plus automatic rich-text detection, so `data-payload-field` alone is enough. |
|
|
24
|
+
| **Intent-gated delivery** | Server adapters can limit runtime delivery to requests carrying preview-intent signals (`?preview=true`, `Sec-Fetch-Dest: iframe`, admin referer). Those client-controlled signals are an optimisation, never authorization. |
|
|
25
|
+
| **Strict security** | Escape-by-default sanitizer with curated whitelist, URL and `srcset` validation, prototype-pollution guards, policed attribute writes, CSP helpers with union-merge. |
|
|
26
|
+
| **Per-instance** | Event emitters, plugins, field-renderer layers, connections, and structural-diff memory live on each client. Multiple programmatic clients coexist, and each `destroy()` tears down only its own resources. Stateful built-in renderers are recreated per client; explicit Lexical-node, block, and built-in-renderer registrations are module/realm-wide configuration (built-in renderer registrations affect future client snapshots). The separate inline global API clears its `window.__livePreview` handle when destroyed. The built-in highlight plugin and accessibility announcer use narrow, DOM-keyed, reference-counted `WeakMap` leases solely to coordinate shared style, class, and live-region ownership across clients. |
|
|
27
|
+
| **Typed DSL + codegen** | `pll-codegen` emits TypeScript interfaces from your `payload.config.ts`; `bind<T>()` gives compile-time-checked field bindings. |
|
|
28
|
+
| **First-class adapters** | Astro (integration + middleware), Next.js, SvelteKit, Nuxt — all share the same core. |
|
|
29
29
|
|
|
30
30
|
## Compatibility
|
|
31
31
|
|
|
32
|
-
|
|
|
33
|
-
|
|
34
|
-
| Scalar field updates
|
|
35
|
-
| Rich text (Lexical)
|
|
36
|
-
| Relationship / upload population
|
|
37
|
-
| Schema-driven field typing (`fieldSchemaJSON`) | ✅
|
|
32
|
+
| | Payload 2.x | Payload 3.x |
|
|
33
|
+
| ---------------------------------------------- | ----------------------------- | --------------------------------------------------------------------- |
|
|
34
|
+
| Scalar field updates | ✅ | ✅ |
|
|
35
|
+
| Rich text (Lexical) | ✅ | ✅ |
|
|
36
|
+
| Relationship / upload population | ✅ (admin merges client-side) | ✅ with `serverURL` (REST merge) |
|
|
37
|
+
| Schema-driven field typing (`fieldSchemaJSON`) | ✅ | — (3.x removed it; DOM heuristics + Lexical auto-detection take over) |
|
|
38
38
|
|
|
39
|
-
Astro **4 – 7**
|
|
39
|
+
Astro **4 – 7** is the supported peer range; the current real-app browser fixture exercises Astro 7, not every supported Astro major. Node ≥ 20.19. Protocol compatibility is covered by captured-message integration tests plus a weekly `@payloadcms/live-preview` latest/canary drift check.
|
|
40
40
|
|
|
41
41
|
**When to use the official packages instead:** for a client-rendered React or Vue app, [`@payloadcms/live-preview-react`](https://payloadcms.com/docs/live-preview/client) / `-vue` re-render your real component tree and are maintained in lockstep with Payload — that is the better tool there. This package exists for everything the official hooks cannot cover: Astro, static/SSR pages, SvelteKit/Nuxt server-rendered markup, plain HTML — anywhere there is no client framework to re-render the page.
|
|
42
42
|
|
|
@@ -78,7 +78,7 @@ export default buildConfig({
|
|
|
78
78
|
});
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
-
The helper appends `?preview=true` automatically so the adapters
|
|
81
|
+
The helper appends `?preview=true` automatically so the adapters detect preview intent. That query parameter is client-controlled and does not authorize draft access or response changes. A hand-written `url: ({ data, locale, collectionConfig, globalConfig }) => string` callback works exactly the same — see the [official docs](https://payloadcms.com/docs/live-preview/overview) for the full contract.
|
|
82
82
|
|
|
83
83
|
## Quick start
|
|
84
84
|
|
|
@@ -110,7 +110,7 @@ Annotate the elements you want bound:
|
|
|
110
110
|
|
|
111
111
|
That's it — the inline script auto-detects the iframe context and starts updating. Rich text is detected automatically from the value shape; `data-payload-richtext` is only needed to force it.
|
|
112
112
|
|
|
113
|
-
For rich text, the `<RichText />` component
|
|
113
|
+
For rich text, the `<RichText />` component uses the same built-in Lexical serializer as live patches, reducing SSR/preview drift while providing the binding and empty-anchor pattern. Exact markup parity still requires equivalent sanitizer availability/configuration and the same custom node/block registrations in both JavaScript realms; server-side registrations are not copied into the prebuilt inline runtime:
|
|
114
114
|
|
|
115
115
|
```astro
|
|
116
116
|
---
|
|
@@ -119,7 +119,7 @@ import RichText from 'payload-live-preview/astro/RichText.astro';
|
|
|
119
119
|
<RichText value={page.body} field="body" class="prose" />
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
-
**Injection modes.** The default (`mode: 'inline'`) bakes the runtime into every page at build time — right for `output: 'static'`, where no middleware runs at request time. For SSR projects (`output: 'server'`),
|
|
122
|
+
**Injection modes.** The default (`mode: 'inline'`) bakes the runtime into every page at build time — right for `output: 'static'`, where no middleware runs at request time. For SSR projects (`output: 'server'`), request-time injection can limit the bytes to requests carrying preview intent:
|
|
123
123
|
|
|
124
124
|
```ts
|
|
125
125
|
livePreview({
|
|
@@ -129,18 +129,38 @@ livePreview({
|
|
|
129
129
|
}),
|
|
130
130
|
```
|
|
131
131
|
|
|
132
|
-
This auto-registers the preview middleware: it detects
|
|
132
|
+
This auto-registers the preview middleware: it detects `?preview=true` / `?draft=true`, `Sec-Fetch-Dest: iframe`, or an admin referer, injects the script, and merges `frame-ancestors`. These are all client-controlled **intent signals**. Restricting to `previewSignals: ['query']` reduces accidental activation but still does not authenticate a user. `allowedOrigins` protects the browser's inbound `postMessage` channel; it does not authorize the HTTP request. `shouldInject` is only a route/content filter and does not suppress CSP handling.
|
|
133
133
|
|
|
134
|
-
If
|
|
134
|
+
If draft data or response changes are privileged, use one application-owned server authorization for all of them: draft selection, forwarded credentials, `private, no-store` cache policy, CSP changes, and runtime injection. The package deliberately does not infer that decision from intent. For example, invoke the manual Astro middleware only after your session verifier succeeds:
|
|
135
135
|
|
|
136
136
|
```ts
|
|
137
|
-
|
|
137
|
+
// src/middleware.ts
|
|
138
|
+
import { createLivePreviewMiddleware, isPreviewRequest } from 'payload-live-preview/astro';
|
|
139
|
+
import { verifyAppPreviewSession } from './lib/server/preview-auth'; // your server code
|
|
138
140
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
141
|
+
const previewMiddleware = createLivePreviewMiddleware({
|
|
142
|
+
allowedOrigins: [import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN],
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
export const onRequest = async (context, next) => {
|
|
146
|
+
const hasPreviewIntent = isPreviewRequest(context.request, {
|
|
147
|
+
adminOrigins: [import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN],
|
|
148
|
+
});
|
|
149
|
+
const authorization = hasPreviewIntent ? await verifyAppPreviewSession(context.request) : null;
|
|
150
|
+
|
|
151
|
+
if (authorization === null) return next();
|
|
152
|
+
|
|
153
|
+
// Reuse this request-scoped decision in the page's draft fetch.
|
|
154
|
+
context.locals.previewAuthorization = authorization;
|
|
155
|
+
const response = await previewMiddleware(context, next);
|
|
156
|
+
const headers = new Headers(response.headers);
|
|
157
|
+
headers.set('cache-control', 'private, no-store');
|
|
158
|
+
return new Response(response.body, {
|
|
159
|
+
status: response.status,
|
|
160
|
+
statusText: response.statusText,
|
|
161
|
+
headers,
|
|
162
|
+
});
|
|
163
|
+
};
|
|
144
164
|
```
|
|
145
165
|
|
|
146
166
|
### Next.js (App Router)
|
|
@@ -171,7 +191,7 @@ export default function RootLayout({ children }: { children: React.ReactNode })
|
|
|
171
191
|
```
|
|
172
192
|
|
|
173
193
|
```ts
|
|
174
|
-
// middleware.ts — frame-ancestors on preview
|
|
194
|
+
// middleware.ts — frame-ancestors on requests carrying preview intent
|
|
175
195
|
import { NextResponse, type NextRequest } from 'next/server';
|
|
176
196
|
import { createLivePreviewMiddleware } from 'payload-live-preview/nextjs';
|
|
177
197
|
|
|
@@ -185,6 +205,8 @@ export async function middleware(request: NextRequest) {
|
|
|
185
205
|
}
|
|
186
206
|
```
|
|
187
207
|
|
|
208
|
+
A static build cannot make a request-time authorization decision, so the inline runtime bytes above are public and shipped to every page; `allowedOrigins` still constrains which browser messages are accepted. If runtime delivery or CSP changes must be private, use a dynamic preview route/layout and invoke both only after your server-side session or short-lived signed authorization succeeds. Never use a query/iframe/referer signal to unlock draft data.
|
|
209
|
+
|
|
188
210
|
### SvelteKit
|
|
189
211
|
|
|
190
212
|
```ts
|
|
@@ -196,6 +218,8 @@ export const handle = livePreviewHandle({
|
|
|
196
218
|
});
|
|
197
219
|
```
|
|
198
220
|
|
|
221
|
+
This shorthand performs intent-gated delivery, not authentication. For protected preview responses, wrap it and invoke it only after an application-owned server verifier succeeds; reuse that authorization for draft data and cache policy.
|
|
222
|
+
|
|
199
223
|
### Nuxt 3
|
|
200
224
|
|
|
201
225
|
```ts
|
|
@@ -210,7 +234,7 @@ export default defineNitroPlugin(
|
|
|
210
234
|
);
|
|
211
235
|
```
|
|
212
236
|
|
|
213
|
-
The plugin hooks `render:html`, injects the script into preview
|
|
237
|
+
The plugin hooks `render:html`, injects the script into responses carrying preview intent, and merges the CSP header. It does not authenticate those requests and exposes no authorization hook. If those response changes are protected, use an application-owned `render:html` hook and call `renderLivePreviewScript()` / `buildLivePreviewCsp()` only after the same authorization that controls draft data and caching.
|
|
214
238
|
|
|
215
239
|
> Same caveat as Next.js: DOM patches apply to the server-rendered markup. A hydrated Vue island that re-renders the bound nodes will overwrite them — bind fields in server-rendered regions, or use the official `@payloadcms/live-preview-vue` composable inside client components.
|
|
216
240
|
|
|
@@ -235,39 +259,48 @@ Requirements: the preview page must be able to reach the Payload API with the ed
|
|
|
235
259
|
|
|
236
260
|
## Draft documents on first load
|
|
237
261
|
|
|
238
|
-
Live preview patches the DOM **after** the page has loaded — the initial server render is your job. If you use Payload drafts, fetch draft content
|
|
262
|
+
Live preview patches the DOM **after** the page has loaded — the initial server render is your job. If you use Payload drafts, fetch draft content only after authorizing the preview request; otherwise editors see stale published content until their first keystroke. `fetchPreviewDocument` / `fetchPreviewGlobal` build the REST query but deliberately do not authenticate it. Their 1.x `draft` default remains `true` for compatibility, so set it explicitly from your verified decision:
|
|
239
263
|
|
|
240
264
|
```ts
|
|
241
|
-
// Astro example — in your page/loader code
|
|
242
|
-
|
|
265
|
+
// Astro example — in your page/loader code. The middleware example
|
|
266
|
+
// above stored this application-owned, request-scoped authorization.
|
|
267
|
+
import { fetchPreviewDocument } from 'payload-live-preview';
|
|
243
268
|
|
|
269
|
+
const authorization = Astro.locals.previewAuthorization ?? null;
|
|
244
270
|
const page = await fetchPreviewDocument<Page>({
|
|
245
271
|
serverURL: import.meta.env.PAYLOAD_URL,
|
|
246
272
|
collection: 'pages',
|
|
247
273
|
where: { slug: { equals: Astro.params.slug } },
|
|
248
|
-
draft:
|
|
274
|
+
draft: authorization !== null, // published for unauthorised/normal traffic
|
|
249
275
|
depth: 1, // keep equal to mergeDepth!
|
|
250
|
-
|
|
276
|
+
...(authorization === null ? {} : { headers: authorization.payloadHeaders }),
|
|
251
277
|
});
|
|
252
278
|
```
|
|
253
279
|
|
|
280
|
+
`isPreviewRequest()` is intentionally retained as the 1.x compatibility name, but it detects intent only. Query parameters, iframe navigation, and referrers are not credentials. Do not attach a long-lived API key or service token based on its boolean result; the verifier should forward only the minimum request-scoped session material or validate a short-lived, scoped signature.
|
|
281
|
+
|
|
254
282
|
## Data-attribute reference
|
|
255
283
|
|
|
256
|
-
| Attribute
|
|
257
|
-
|
|
258
|
-
| `data-payload-field`
|
|
259
|
-
| `data-payload-type`
|
|
260
|
-
| `data-payload-attribute`
|
|
261
|
-
| `data-payload-href`
|
|
262
|
-
| `data-payload-src`
|
|
263
|
-
| `data-payload-alt`
|
|
264
|
-
| `data-payload-richtext`
|
|
265
|
-
| `data-payload-html`
|
|
266
|
-
| `data-payload-array`
|
|
267
|
-
| `data-payload-array-template`
|
|
268
|
-
| `data-payload-array-separator` | Separator for primitive arrays
|
|
269
|
-
| `data-payload-structural`
|
|
270
|
-
| `data-payload-locale`
|
|
284
|
+
| Attribute | Purpose | Example |
|
|
285
|
+
| ------------------------------ | --------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
286
|
+
| `data-payload-field` | Bind element to a Payload field path | `data-payload-field="hero.title"` |
|
|
287
|
+
| `data-payload-type` | Force a specific renderer | `data-payload-type="image"` |
|
|
288
|
+
| `data-payload-attribute` | Write the value into an attribute instead of content (unsafe attributes and URLs are refused) | `data-payload-attribute="datetime"` |
|
|
289
|
+
| `data-payload-href` | Read `href` from a sibling field | `data-payload-href="ctaUrl"` |
|
|
290
|
+
| `data-payload-src` | Read `src` from a sibling field | `data-payload-src="hero.url"` |
|
|
291
|
+
| `data-payload-alt` | Read `alt` from a sibling field | `data-payload-alt="hero.alt"` |
|
|
292
|
+
| `data-payload-richtext` | Force Lexical rendering (usually auto-detected) | `data-payload-richtext` |
|
|
293
|
+
| `data-payload-html` | Render value as sanitised HTML | `data-payload-html` |
|
|
294
|
+
| `data-payload-array` | Treat value as an array | `data-payload-array` |
|
|
295
|
+
| `data-payload-array-template` | HTML template per array item | `data-payload-array-template="<li>{{title}}</li>"` |
|
|
296
|
+
| `data-payload-array-separator` | Separator for primitive arrays | `data-payload-array-separator=" · "` |
|
|
297
|
+
| `data-payload-structural` | Use diff-based structural updates | `data-payload-structural` |
|
|
298
|
+
| `data-payload-locale` | Override locale for this element | `data-payload-locale="de-AT"` |
|
|
299
|
+
|
|
300
|
+
Binding metadata is live: changing any of these attributes (including inferred
|
|
301
|
+
type markers and an input's native `type`) rebuilds the affected cache snapshot
|
|
302
|
+
after the mutation debounce. Locale-specific bindings resolve their own suffixed
|
|
303
|
+
field value, and stale work prepared for a previous element locale is discarded.
|
|
271
304
|
|
|
272
305
|
**Empty-field gotcha:** the runtime can only patch elements that exist. If your template renders a binding only when the field is non-empty, editing a previously-empty field has nowhere to land. Render the anchor unconditionally:
|
|
273
306
|
|
|
@@ -330,51 +363,84 @@ import type { Homepage } from '../lib/bind-types';
|
|
|
330
363
|
const client = new LivePreviewClient({ allowedOrigins: [ADMIN] });
|
|
331
364
|
|
|
332
365
|
client.events.on('connect', (e) => console.log('connected to', e.origin));
|
|
333
|
-
client.events.on('beforeUpdate', (e) => {
|
|
366
|
+
client.events.on('beforeUpdate', (e) => {
|
|
367
|
+
if (frozen) e.cancel();
|
|
368
|
+
});
|
|
334
369
|
client.events.on('documentSave', () => location.reload());
|
|
335
370
|
```
|
|
336
371
|
|
|
337
372
|
Events: `init` · `connect` · `disconnect` · `beforeUpdate` · `afterUpdate` · `elementUpdate` · `documentSave` · `cacheRefresh` · `error` · `destroy`.
|
|
338
373
|
|
|
374
|
+
Plugin resources are registration-scoped. `client.unuse(name)` revokes that registration's event listeners, transforms, renderer layers, and `registerCleanup()` callbacks without touching another plugin or the built-in renderer; registering it again starts with a fresh scope. Resources remain staged until `init()` succeeds, and a failed `init()` rolls them back without exposing a partial registration. Renderers form a per-type stack (last active registration wins, removal reveals the previous layer). Synchronous transforms see the merged field value and run in registration order while each revision-bound binding entry is prepared, immediately before that entry is scheduled. The transformed value is fixed in the entry and later passes through the normal attribute or renderer dispatch. A thrown error or Promise/thenable result emits a lifecycle `error` and stores the original merged value as fallback, which still passes through the normal security checks.
|
|
375
|
+
|
|
339
376
|
Built-in plugins:
|
|
340
377
|
|
|
341
|
-
| Plugin
|
|
342
|
-
|
|
343
|
-
| `highlightPlugin`
|
|
344
|
-
| `debugPlugin`
|
|
345
|
-
| `createAnalyticsPlugin()`
|
|
378
|
+
| Plugin | Effect |
|
|
379
|
+
| ---------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
380
|
+
| `highlightPlugin` | Flashes an outline on updated elements (respects reduced-motion). |
|
|
381
|
+
| `debugPlugin` | Logs every lifecycle event to the console. |
|
|
382
|
+
| `createAnalyticsPlugin()` | Collects update statistics, exposed via `getStats()`. |
|
|
346
383
|
| `documentSavePlugin({ strategy })` | Reacts to admin saves: `'silent'` · `'reload'` (scroll-preserving) · `'revalidate'` · `'fetch'`. |
|
|
347
384
|
|
|
348
385
|
## Configuration reference
|
|
349
386
|
|
|
350
|
-
Options accepted by `generateInlineScript`,
|
|
351
|
-
|
|
352
|
-
| Option
|
|
353
|
-
|
|
354
|
-
| `allowedOrigins` | `[]`
|
|
355
|
-
| `serverURL`
|
|
356
|
-
| `apiRoute`
|
|
357
|
-
| `mergeDepth`
|
|
358
|
-
| `debug`
|
|
359
|
-
| `debounceMs`
|
|
360
|
-
| `heartbeatMs`
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
|
366
|
-
|
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
387
|
+
Options accepted by `generateInlineScript`, every adapter, and `LivePreviewClient`:
|
|
388
|
+
|
|
389
|
+
| Option | Default | Meaning |
|
|
390
|
+
| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
391
|
+
| `allowedOrigins` | `[]` | Allowed admin origins for inbound browser messages (recommended in production; not HTTP authorization). |
|
|
392
|
+
| `serverURL` | — | Payload origin for REST data merging (Payload 3.x population). |
|
|
393
|
+
| `apiRoute` | `/api` | REST route prefix used with `serverURL`. |
|
|
394
|
+
| `mergeDepth` | `1` | Population depth used with `serverURL`. |
|
|
395
|
+
| `debug` | `false` for inline/adapters; dev-mode detection for `LivePreviewClient` | Verbose console logging. |
|
|
396
|
+
| `debounceMs` | `50` | Debounce window for incoming updates. |
|
|
397
|
+
| `heartbeatMs` | `0` (off) | Idle timeout. Leave off — Payload sends no keepalive. |
|
|
398
|
+
|
|
399
|
+
Additional runtime options accepted by `generateInlineScript` and `LivePreviewClient`
|
|
400
|
+
(not by the adapter option objects):
|
|
401
|
+
|
|
402
|
+
| Option | Default | Meaning |
|
|
403
|
+
| -------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
404
|
+
| `enableA11y` | `true` | Shared `aria-live` region announcing connections, applied updates, and heartbeat-timeout disconnects while mounted; destroy releases it synchronously without promising a final audible announcement. |
|
|
405
|
+
| `disableVisibilityGate` | `false` | Apply updates to off-screen elements immediately. |
|
|
406
|
+
| `visibilityGateThreshold` | `50` | Cache size above which off-screen updates are queued. |
|
|
407
|
+
| `intersectionRootMargin` | `'200px'` | Pre-render margin for the visibility gate. |
|
|
408
|
+
| `disableReferrerDetection` | `false` | Opt out of `document.referrer` origin auto-detection. |
|
|
409
|
+
| `disableLocalhostMatching` | `false` | Opt out of dev-mode localhost origin matching. |
|
|
410
|
+
|
|
411
|
+
`LivePreviewClient` additionally accepts the programmatic-only `mergeFetch`,
|
|
412
|
+
`a11yLocale`, `root`, `autoStart`, and `validateToken` options. `validateToken` is a
|
|
413
|
+
package extension: an unmodified Payload admin does not send the required token.
|
|
414
|
+
|
|
415
|
+
Adapter options vary by framework. Shared adapter options include `inject`
|
|
416
|
+
(`'preview-only'` default / `'always'`), `previewQueryParams`, `previewSignals`
|
|
417
|
+
(choose which client-controlled intent signals count), `autoInject`, `manageCsp`
|
|
418
|
+
(`'frame-ancestors'` default / `'full'` / `false`), `strictDynamic`,
|
|
419
|
+
`frameAncestorsExtra`, and `scriptSrcExtra`. Astro, Next.js, and SvelteKit also expose
|
|
420
|
+
`shouldInject` as a script route/content filter only; it is not authorization and
|
|
421
|
+
does not suppress CSP handling. Astro additionally accepts `mode` (`'inline'` /
|
|
422
|
+
`'middleware'`). The Astro, Next.js, and Nuxt manual render helpers accept a
|
|
423
|
+
per-request `nonce`; `generateInlineScript()` itself returns a script body, so pass
|
|
424
|
+
the nonce to `wrapWithScriptTag()` when embedding it manually.
|
|
425
|
+
|
|
426
|
+
Bundle-size note: `import … from 'payload-live-preview/core'` is a lighter entry without the built-in plugin constructors, inline generator/runtime source, or framework adapters. It still includes the built-in field renderers used by `LivePreviewClient`, including Lexical rendering. Hot-path timings live in [docs/benchmarks.md](docs/benchmarks.md).
|
|
427
|
+
|
|
428
|
+
The three real-app browser fixtures in `examples/` cover Astro 7, Next.js 16, and SvelteKit 2 in Chromium, Firefox, and WebKit. The Nuxt adapter currently has unit/integration coverage, not an equivalent real-app browser fixture. The Astro 4–7 peer range is likewise broader than the single Astro-major browser fixture.
|
|
429
|
+
|
|
430
|
+
**How the protocol coverage is layered** (so you know exactly what's proven):
|
|
431
|
+
|
|
432
|
+
1. **Full running-Payload E2E** (`tests/real-payload/`, `npm run test:e2e:real-payload`) boots an **actual Payload 3.x admin** — `examples/payload-backend`, a self-contained SQLite Payload + Next.js server, seeded and auto-logged-in — opens its **real** Live Preview panel, types into real form fields, and asserts the cross-origin Astro preview iframe (our injected runtime) patches the DOM. No mock, no fixture, no stub: `real admin → real form → real postMessage → real iframe → runtime → DOM`, driven by Payload's own admin code.
|
|
433
|
+
2. **Browser E2E** (`tests/e2e/`) drives a real browser + real iframe across Chromium, Firefox and WebKit: `postMessage → runtime → DOM`. Its `/admin` page _emulates_ the Payload admin, so it can exercise edge cases (XSS, origin spoofing, every field type) faster than booting a full server.
|
|
434
|
+
3. **Real-message contract test** (`tests/integration/real-payload-protocol.test.ts`) feeds a message **captured verbatim from a running Payload 3.85 admin** through the real `MessageBus` + runtime — the envelope quirks included: `collectionSlug` absent on a global, `externallyUpdatedRelationship: null`, `_status`/`id` alongside real fields.
|
|
435
|
+
4. **Weekly protocol-watch** (`.github/workflows/protocol-watch.yml`) **executes** the real `@payloadcms/live-preview@latest` **and `@canary`** (Payload 4.0 pre-releases) and asserts their actual behaviour — the `ready` handshake, event discriminators, and `mergeData` REST request — still matches our runtime's invariants.
|
|
436
|
+
|
|
437
|
+
Together these span the whole spectrum: tier 1 proves the real thing works end to end, tier 2 exhausts edge cases quickly, tier 3 pins the exact wire shape Payload emits, and tier 4 catches drift the moment Payload ships it.
|
|
373
438
|
|
|
374
439
|
## Security model
|
|
375
440
|
|
|
376
|
-
- **
|
|
377
|
-
- **
|
|
441
|
+
- **Preview intent is not authorization** — `isPreviewRequest()` checks client-controlled query, iframe-destination, and referer signals. `allowedOrigins` governs browser `postMessage` senders, and `shouldInject` is a route/content filter; neither authenticates the HTTP request. Verify an application-owned server session or short-lived scoped signature, then use that one result to control draft reads and credentials, `private, no-store` caching, CSP changes, and runtime injection.
|
|
442
|
+
- **Origin validation** — every incoming `postMessage` is checked against explicit `allowedOrigins` plus (in dev) a localhost pattern. `document.referrer` is a **zero-config fallback only**: the moment you configure explicit origins, the referrer is ignored — a foreign site framing your page can never widen a pinned allow-list. After the first accepted data-bearing update the detector locks to that exact origin. ⚠️ In referrer-fallback mode any site that frames the page becomes a trusted sender — the inline bootstrap logs a warning; set explicit `allowedOrigins` and serve a `frame-ancestors` CSP in production. Adapters add that policy by default on intent-matched responses; invoke them behind application authorization when the policy change is privileged.
|
|
443
|
+
- **HTML sanitisation** — Browser/live Lexical and HTML-field writes run through a DOM sanitiser with a curated tag/attribute whitelist (media tags allowed; `<script>`, `<form>`, `<iframe>`, `<svg>`, event handlers, `style` rejected; `srcset` candidates URL-validated). SSR `lexicalToHtml()` uses the same backstop when a DOM is supplied with `setSanitizerDocument()`; without one, built-in nodes remain escape-by-default but custom node/block renderers must sanitize their own HTML.
|
|
378
444
|
- **URL validation** — every URL that lands in `href`/`src`/`srcset`/`poster` must be `http(s)` / `mailto:` / `tel:` / relative; `javascript:`, `data:`, `vbscript:`, `file:`, `blob:` are rejected (case-insensitive, whitespace-tolerant). External links — including protocol-relative ones — get `rel="noopener noreferrer"`.
|
|
379
445
|
- **Policed attribute writes** — `data-payload-attribute` refuses event handlers, `style`, `srcdoc`, `formaction`, `id`/`name` (DOM clobbering) and validates URL attributes.
|
|
380
446
|
- **CSP-friendly** — adapters merge `frame-ancestors` for the admin origins without clobbering your existing policy; opt-in `manageCsp: 'full'` manages a per-request nonce'd `script-src` (`'strict-dynamic'` opt-in — it disables `'self'`/host sources in CSP 3).
|
|
@@ -384,10 +450,10 @@ Full details in [docs/security.md](docs/security.md). Report vulnerabilities per
|
|
|
384
450
|
|
|
385
451
|
## Troubleshooting
|
|
386
452
|
|
|
387
|
-
- **Nothing updates** — open the browser console inside the preview iframe (
|
|
453
|
+
- **Nothing updates** — open the browser console inside the preview iframe (`debug: true` adds verbose diagnostics). The most common causes: the admin origin is not in `allowedOrigins`; the page is not actually loaded in an iframe; the binding element does not exist (see the empty-field gotcha above — orphan-update warnings are always enabled and deduplicated per field).
|
|
388
454
|
- **Relationship fields show IDs** — set `serverURL` (Payload 3.x sends unpopulated form values).
|
|
389
455
|
- **`Referrer-Policy: no-referrer`** on the admin breaks zero-config origin detection — set `allowedOrigins` explicitly.
|
|
390
|
-
- **Preview iframe refuses to load** — your host sets `X-Frame-Options` or a restrictive `frame-ancestors`. The adapters' CSP management overrides `frame-ancestors` on
|
|
456
|
+
- **Preview iframe refuses to load** — your host sets `X-Frame-Options` or a restrictive `frame-ancestors`. The adapters' CSP management overrides `frame-ancestors` on intent-matched responses, but `X-Frame-Options: DENY` from a proxy must be removed for authorized preview responses.
|
|
391
457
|
|
|
392
458
|
## License
|
|
393
459
|
|
|
@@ -2,9 +2,11 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Server-rendered Lexical rich text with a live-preview binding.
|
|
4
4
|
*
|
|
5
|
-
* Renders the field through the
|
|
6
|
-
* uses for live updates
|
|
7
|
-
*
|
|
5
|
+
* Renders the field through the same built-in Lexical serializer the
|
|
6
|
+
* runtime uses for live updates. This reduces SSR/preview drift; exact
|
|
7
|
+
* markup parity still requires equivalent sanitizer availability and
|
|
8
|
+
* the same custom renderer registrations in both JavaScript realms.
|
|
9
|
+
* Also solves the two classic integration papercuts:
|
|
8
10
|
*
|
|
9
11
|
* - **Empty-anchor pattern**: the wrapper renders even when the
|
|
10
12
|
* value is empty, so the runtime has a node to patch when the
|
|
@@ -23,7 +25,9 @@
|
|
|
23
25
|
*
|
|
24
26
|
* Custom block renderers registered via `registerBlockRenderer` (from
|
|
25
27
|
* the main entry) apply here as well — register them in a module
|
|
26
|
-
* imported by your page before this component renders.
|
|
28
|
+
* imported by your page before this component renders. Registrations
|
|
29
|
+
* are module/realm-local and do not automatically configure the
|
|
30
|
+
* prebuilt inline runtime.
|
|
27
31
|
*/
|
|
28
32
|
// Self-referencing import so the shipped copy in dist/ resolves
|
|
29
33
|
// through the package exports map in consumer projects.
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { L as LivePreviewAstroOptions } from '../../middleware-
|
|
2
|
-
export { a as LivePreviewMiddleware, N as NONCE_LOCALS_KEY, c as createLivePreviewMiddleware } from '../../middleware-
|
|
3
|
-
export { P as PreviewRequestLike, a as PreviewRequestOptions, i as isPreviewRequest } from '../../preview-request-DAvxm18T.js';
|
|
1
|
+
import { L as LivePreviewAstroOptions } from '../../middleware-CkSeLZ-9.js';
|
|
2
|
+
export { a as LivePreviewMiddleware, N as NONCE_LOCALS_KEY, c as createLivePreviewMiddleware } from '../../middleware-CkSeLZ-9.js';
|
|
4
3
|
|
|
5
4
|
/**
|
|
6
5
|
* Astro integration for Payload Live Preview.
|
|
@@ -24,9 +23,11 @@ export { P as PreviewRequestLike, a as PreviewRequestOptions, i as isPreviewRequ
|
|
|
24
23
|
* time. The runtime stays inert outside the admin iframe.
|
|
25
24
|
* - **`mode: 'middleware'`** — registers the preview middleware via
|
|
26
25
|
* `addMiddleware()`: the runtime is injected at request time into
|
|
27
|
-
* preview
|
|
28
|
-
*
|
|
29
|
-
*
|
|
26
|
+
* requests carrying preview intent, and `frame-ancestors` CSP is
|
|
27
|
+
* managed. This is a delivery gate, not authentication. For
|
|
28
|
+
* authorization-gated response changes, register
|
|
29
|
+
* `createLivePreviewMiddleware()` manually after the application's
|
|
30
|
+
* server-side verifier. (`shouldInject` is not supported in this
|
|
30
31
|
* mode: options travel through a serialized virtual module.)
|
|
31
32
|
*
|
|
32
33
|
* @module @adapters/astro/integration
|
|
@@ -58,8 +59,8 @@ interface AstroIntegrationLike {
|
|
|
58
59
|
}
|
|
59
60
|
/**
|
|
60
61
|
* Build the Astro integration. The injected script auto-detects the
|
|
61
|
-
* preview context
|
|
62
|
-
*
|
|
62
|
+
* browser preview context and stays inert on ordinary top-level pages.
|
|
63
|
+
* Browser-context detection does not authorize HTTP response changes.
|
|
63
64
|
*/
|
|
64
65
|
declare function livePreview(options?: LivePreviewAstroOptions): AstroIntegrationLike;
|
|
65
66
|
|
|
@@ -102,4 +103,78 @@ interface RenderScriptOptions extends LivePreviewAstroOptions {
|
|
|
102
103
|
*/
|
|
103
104
|
declare function renderLivePreviewScript(options?: RenderScriptOptions): string;
|
|
104
105
|
|
|
105
|
-
|
|
106
|
+
/**
|
|
107
|
+
* Server-side preview-intent detection.
|
|
108
|
+
*
|
|
109
|
+
* The live-preview runtime only ever activates inside the Payload
|
|
110
|
+
* admin's iframe, but the *server* still has to decide whether a
|
|
111
|
+
* request expresses preview intent before doing optional preview
|
|
112
|
+
* work. Adapters use this predicate as a delivery optimisation, and
|
|
113
|
+
* consumers with hand-rolled middleware can import it directly.
|
|
114
|
+
*
|
|
115
|
+
* IMPORTANT: every signal inspected here is controlled or triggerable
|
|
116
|
+
* by the client. A positive result is never proof of identity,
|
|
117
|
+
* authentication, or permission to read drafts. Before forwarding
|
|
118
|
+
* credentials, fetching draft data, bypassing caches, changing CSP,
|
|
119
|
+
* or injecting the runtime, verify the request with an application-
|
|
120
|
+
* owned server session or a short-lived signed authorization. Use the
|
|
121
|
+
* same verified decision for all of those effects.
|
|
122
|
+
*
|
|
123
|
+
* Preview intent is present when any of these hold:
|
|
124
|
+
*
|
|
125
|
+
* 1. A preview query parameter is present (`?preview=true`,
|
|
126
|
+
* `?draft=true`, `?livePreview=true` — configurable).
|
|
127
|
+
* 2. `Sec-Fetch-Dest: iframe` — the document is being loaded as an
|
|
128
|
+
* iframe, which is exactly how the Payload admin embeds it.
|
|
129
|
+
* (Sent by all evergreen browsers; absence never *excludes* a
|
|
130
|
+
* request, presence includes it.)
|
|
131
|
+
* 3. The `Referer` points at one of the given admin origins.
|
|
132
|
+
*
|
|
133
|
+
* @module @adapters/shared/preview-request
|
|
134
|
+
*/
|
|
135
|
+
/** The individual signals the predicate can consider. */
|
|
136
|
+
type PreviewSignal = 'query' | 'fetch-dest' | 'referer';
|
|
137
|
+
interface PreviewRequestOptions {
|
|
138
|
+
/**
|
|
139
|
+
* Which signals count as preview intent. Default: all three.
|
|
140
|
+
* Restricting this to `['query']` reduces accidental activation, but
|
|
141
|
+
* an explicit `?preview=true` is still client-controlled and must not
|
|
142
|
+
* authorize response changes or privileged data access by itself.
|
|
143
|
+
*/
|
|
144
|
+
readonly signals?: readonly PreviewSignal[];
|
|
145
|
+
/**
|
|
146
|
+
* Query parameters (with values `true` or `1`) that signal preview
|
|
147
|
+
* intent. Default: `['preview', 'draft', 'livePreview']`.
|
|
148
|
+
*/
|
|
149
|
+
readonly queryParams?: readonly string[];
|
|
150
|
+
/**
|
|
151
|
+
* Treat `Sec-Fetch-Dest: iframe` as a preview-intent signal. Default `true`.
|
|
152
|
+
* Legacy alias for excluding `'fetch-dest'` from `signals`.
|
|
153
|
+
*/
|
|
154
|
+
readonly checkFetchDest?: boolean;
|
|
155
|
+
/**
|
|
156
|
+
* Admin origins whose `Referer` signals preview intent, e.g.
|
|
157
|
+
* `['https://cms.example.com']`. Referer matching is not HTTP-request
|
|
158
|
+
* authentication.
|
|
159
|
+
*/
|
|
160
|
+
readonly adminOrigins?: readonly string[];
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* The minimal request surface the predicate needs. The standard
|
|
164
|
+
* `Request` satisfies it structurally, and server frameworks without
|
|
165
|
+
* fetch-style requests (e.g. Nitro/H3) can supply a tiny shim.
|
|
166
|
+
*/
|
|
167
|
+
interface PreviewRequestLike {
|
|
168
|
+
readonly url: string;
|
|
169
|
+
readonly headers: {
|
|
170
|
+
get(name: string): string | null;
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* Detect whether `request` carries live-preview intent. See the module
|
|
175
|
+
* docblock for the exact signals and the required authorization
|
|
176
|
+
* boundary. This compatibility name does not imply authentication.
|
|
177
|
+
*/
|
|
178
|
+
declare function isPreviewRequest(request: PreviewRequestLike, options?: PreviewRequestOptions): boolean;
|
|
179
|
+
|
|
180
|
+
export { type AstroIntegrationLike, LivePreviewAstroOptions, type PreviewRequestLike, type PreviewRequestOptions, type RenderScriptOptions, isPreviewRequest, livePreview, renderLivePreviewScript };
|