payload-live-preview 2.0.0-beta.0 → 2.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +60 -775
- package/dist/adapters/astro/PreviewBoundary.astro +2 -2
- package/dist/adapters/astro/index.d.ts +28 -154
- package/dist/adapters/astro/index.js +1 -1
- package/dist/adapters/astro/index.js.map +1 -1
- package/dist/adapters/astro/middleware-entry.d.ts +3 -2
- package/dist/adapters/astro/middleware-entry.js +1 -1
- package/dist/adapters/astro/middleware-entry.js.map +1 -1
- package/dist/adapters/nextjs/index.d.ts +247 -6
- package/dist/adapters/nextjs/index.js +1 -1
- package/dist/adapters/nextjs/index.js.map +1 -1
- package/dist/adapters/nuxt/index.d.ts +58 -3
- package/dist/adapters/nuxt/index.js +1 -1
- package/dist/adapters/nuxt/index.js.map +1 -1
- package/dist/adapters/nuxt/module.d.ts +82 -0
- package/dist/adapters/nuxt/module.js +2 -0
- package/dist/adapters/nuxt/module.js.map +1 -0
- package/dist/adapters/react/index.d.ts +110 -0
- package/dist/adapters/react/index.js +3 -0
- package/dist/adapters/react/index.js.map +1 -0
- package/dist/adapters/sveltekit/index.d.ts +65 -7
- package/dist/adapters/sveltekit/index.js +1 -1
- package/dist/adapters/sveltekit/index.js.map +1 -1
- package/dist/adapters/vue/index.d.ts +56 -0
- package/dist/adapters/vue/index.js +2 -0
- package/dist/adapters/vue/index.js.map +1 -0
- package/dist/annotate.d.ts +161 -0
- package/dist/annotate.js +2 -0
- package/dist/annotate.js.map +1 -0
- package/dist/client.cjs +1 -1
- package/dist/client.cjs.map +1 -1
- package/dist/client.d.cts +182 -9
- package/dist/client.d.ts +182 -9
- package/dist/client.js +1 -1
- package/dist/client.js.map +1 -1
- package/dist/codegen-astro.js +1 -1
- package/dist/codegen-astro.js.map +1 -1
- package/dist/codegen-cli.js +1 -1
- package/dist/codegen-cli.js.map +1 -1
- package/dist/codegen.cjs +1 -1
- package/dist/codegen.cjs.map +1 -1
- package/dist/codegen.d.cts +129 -4
- package/dist/codegen.d.ts +129 -4
- package/dist/codegen.js +1 -1
- package/dist/codegen.js.map +1 -1
- package/dist/core.cjs +1 -1
- package/dist/core.cjs.map +1 -1
- package/dist/core.d.cts +264 -73
- package/dist/core.d.ts +264 -73
- package/dist/core.js +1 -1
- package/dist/core.js.map +1 -1
- package/dist/doctor-cli.d.ts +1 -1
- package/dist/doctor-cli.js +1 -1
- package/dist/doctor-cli.js.map +1 -1
- package/dist/doctor.d.ts +7 -6
- package/dist/doctor.js +1 -1
- package/dist/doctor.js.map +1 -1
- package/dist/document-session-BfBfLVpg.d.ts +53 -0
- package/dist/fragment-endpoint-3Mlz7GDz.d.ts +189 -0
- package/dist/fragment.cjs +1 -1
- package/dist/fragment.cjs.map +1 -1
- package/dist/fragment.d.cts +64 -18
- package/dist/fragment.d.ts +64 -18
- package/dist/fragment.js +1 -1
- package/dist/fragment.js.map +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +422 -83
- package/dist/index.d.ts +422 -83
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/lean.cjs +2 -0
- package/dist/lean.cjs.map +1 -0
- package/dist/lean.d.cts +44 -0
- package/dist/lean.d.ts +44 -0
- package/dist/lean.js +2 -0
- package/dist/lean.js.map +1 -0
- package/dist/lexical.cjs +1 -1
- package/dist/lexical.cjs.map +1 -1
- package/dist/lexical.d.cts +19 -1
- package/dist/lexical.d.ts +19 -1
- package/dist/lexical.js +1 -1
- package/dist/lexical.js.map +1 -1
- package/dist/locals-DBXDMxOA.d.ts +45 -0
- package/dist/{middleware-CEPsY1P7.d.ts → middleware-Bx9I7PXW.d.ts} +14 -14
- package/dist/migrate.d.ts +4 -3
- package/dist/migrate.js +1 -1
- package/dist/migrate.js.map +1 -1
- package/dist/{options-CEzzfHJu.d.ts → options-BW1eqXvc.d.ts} +90 -2
- package/dist/plugins.cjs +1 -1
- package/dist/plugins.cjs.map +1 -1
- package/dist/plugins.d.cts +111 -6
- package/dist/plugins.d.ts +111 -6
- package/dist/plugins.js +1 -1
- package/dist/plugins.js.map +1 -1
- package/dist/{probe-CYtvj_Lm.d.ts → probe-CELeUciC.d.ts} +26 -4
- package/dist/server.cjs +1 -1
- package/dist/server.cjs.map +1 -1
- package/dist/server.d.cts +67 -13
- package/dist/server.d.ts +67 -13
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/structural.cjs +2 -2
- package/dist/structural.cjs.map +1 -1
- package/dist/structural.d.cts +47 -4
- package/dist/structural.d.ts +47 -4
- package/dist/structural.js +2 -2
- package/dist/structural.js.map +1 -1
- package/package.json +77 -9
package/README.md
CHANGED
|
@@ -8,24 +8,19 @@
|
|
|
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
|
|
11
|
+
**The missing piece for Astro + Payload.** The official live-preview packages are React and Vue hooks: they re-render a hydrated component tree, so they cannot touch server-produced Astro markup. This package makes the admin's real-time preview work where no client framework owns the page. Add one line to `astro.config.mjs`, mark what should update — one attribute per component on a server-rendered page, per field on a static one — and edits stream into the preview iframe as the editor types. No rebuild, no reload, no React.
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
The runtime is framework-agnostic: one script drives Astro, SvelteKit, Nuxt, Next.js and plain HTML. Astro is the first-class, end-to-end-tested path.
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
**New here? Start with the [Astro guide](docs/astro.md), or pick your framework in the [documentation index](docs/README.md).**
|
|
16
16
|
|
|
17
17
|
## Highlights
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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. |
|
|
19
|
+
- **One runtime, every frontend.** One TypeScript runtime compiled to a self-contained inline script of about 34 KB gzip — 26 KB with the lean artifact ([docs/options.md](docs/options.md)); the adapters for Astro, Next.js, SvelteKit and Nuxt only decide when to deliver it.
|
|
20
|
+
- **Payload 3.x native.** `serverURL` re-fetches the populated document, like the official client, so relationship and upload fields render as content rather than as IDs — but only when an edit needs one: typing into a text field costs no request, and a burst on a relationship costs two rather than one per keystroke.
|
|
21
|
+
- **Complete Lexical renderer.** 16 node types including `upload`, `relationship`, `block`, `autolink`, tabs, indent and RTL, with automatic rich-text detection: `data-payload-field` alone is enough.
|
|
22
|
+
- **Authorization before anything privileged.** Draft reads, runtime injection, CSP changes and binding attributes follow one verified decision per request; the client-controlled intent signals never unlock anything.
|
|
23
|
+
- **Strict by default.** Escape-by-default sanitizer, URL and `srcset` validation, policed attribute writes, prototype-pollution guards, per-instance clients, typed bindings with `pll-codegen`.
|
|
29
24
|
|
|
30
25
|
## Compatibility
|
|
31
26
|
|
|
@@ -40,22 +35,26 @@ Under the hood it's framework-agnostic — the same runtime drives SvelteKit, Nu
|
|
|
40
35
|
|
|
41
36
|
| Framework | Supported | Tested in CI on every push (version, browsers) |
|
|
42
37
|
| --------- | --------------------- | --------------------------------------------------------------------------------- |
|
|
43
|
-
| Astro | >=4.0.0 <8.0.0 | 7.2
|
|
44
|
-
| Next.js | App Router, 15 and 16 | 16.3.
|
|
38
|
+
| Astro | >=4.0.0 <8.0.0 | 7.3.2 (chromium, firefox, webkit); 6.x (chromium); 5.x (chromium); 4.x (chromium) |
|
|
39
|
+
| Next.js | App Router, 15 and 16 | 16.3.4 (chromium, firefox, webkit) |
|
|
45
40
|
| SvelteKit | 2.x | 2.70.2 (chromium, firefox, webkit) |
|
|
46
41
|
| Nuxt | 3.x | 3.21.11 (chromium, firefox, webkit) |
|
|
47
42
|
|
|
48
43
|
Node >=20.19.0; the unit and integration suites run on Node 20, 22, 24, 26. Every version in the table is what the fixture lockfile or the matrix job installs, checked by `npm run compat:check`.
|
|
49
44
|
|
|
45
|
+
Vite 5 through 8: that is what the supported framework majors install (Astro 7 → 8, Astro 6 → 7, Astro 5 → 6, Astro 4 → 5, SvelteKit 2 → 5/6/7/8, Nuxt 3 → 7), measured 2026-09-06. `npm run compat:check` keeps the devDependency and the fixture lockfiles inside that span; `npm run compat:refresh` re-reads it from the registry.
|
|
46
|
+
|
|
50
47
|
- Payload 2.x: captured-message integration tests and fieldSchemaJSON typing.
|
|
51
48
|
- Payload 3.85.0: wire corpus captured from a real admin, replayed in tests/integration/wire-corpus.test.ts.
|
|
52
|
-
- Payload 3.88.0:
|
|
49
|
+
- Payload 3.88.0: wire corpus captured from a real admin, replayed in tests/integration/wire-corpus.test.ts.
|
|
50
|
+
- Payload 3.89.0: real admin E2E (examples/payload-backend) on every push, plus a wire corpus captured from it.
|
|
51
|
+
- Payload 4.0.0-canary.33: wire corpus captured from a real Payload 4 admin in a one-off upgrade round, replayed in tests/integration/wire-corpus.test.ts; the fixture itself stays on 3.x.
|
|
53
52
|
- Payload latest: weekly protocol watch executes @payloadcms/live-preview@latest against the corpus.
|
|
54
53
|
- Payload 4.0 pre-releases: weekly protocol watch against @payloadcms/live-preview@canary, early warning only.
|
|
55
54
|
|
|
56
55
|
<!-- compat-matrix:end -->
|
|
57
56
|
|
|
58
|
-
**
|
|
57
|
+
**A client-rendered app wants a hook.** `payload-live-preview/react` and `payload-live-preview/vue` are ones: `useLivePreviewDocument()` returns the merged document and re-renders your tree, with this package's merge underneath — one request at a time, the newer answer winning, an HTTP error refused instead of becoming the document, and a cache per call ([docs/react.md](docs/react.md), [docs/vue.md](docs/vue.md)). The official [`@payloadcms/live-preview-react`](https://payloadcms.com/docs/live-preview/client) / `-vue` hooks ship in lockstep with Payload and remain the safe default if that matters more to you; running either alongside the DOM runtime on one page: [docs/interop.md](docs/interop.md).
|
|
59
58
|
|
|
60
59
|
## Install
|
|
61
60
|
|
|
@@ -63,28 +62,11 @@ Node >=20.19.0; the unit and integration suites run on Node 20, 22, 24, 26. Ever
|
|
|
63
62
|
npm install payload-live-preview
|
|
64
63
|
```
|
|
65
64
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
The root import carries everything. The focused entries below ship the same
|
|
69
|
-
code as smaller, self-contained bundles — each with ESM and CommonJS builds,
|
|
70
|
-
its own type declarations, size budget and API report:
|
|
71
|
-
|
|
72
|
-
| Entry | Contents |
|
|
73
|
-
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
74
|
-
| `payload-live-preview` | Everything: client, inline script generator, renderers, plugins, adapters' shared helpers. |
|
|
75
|
-
| `payload-live-preview/core` | The client and runtime without the built-in plugin constructors, generator or adapters. |
|
|
76
|
-
| `payload-live-preview/client` | `LivePreviewClient` and `initLivePreview()` alone. |
|
|
77
|
-
| `payload-live-preview/structural` | The structural array renderer, the keyed morph (ADR 0008) and the `data-payload-depends` helpers. |
|
|
78
|
-
| `payload-live-preview/lexical` | `lexicalToHtml()`, `lexicalToPlainText()`, node and block renderer registries. |
|
|
79
|
-
| `payload-live-preview/plugins` | `PluginManager`, plugin types and the built-in plugins. |
|
|
80
|
-
| `payload-live-preview/fragment` | `createFragmentStrategy()`: the browser half of server-rendered fragment boundaries (ADR 0011). |
|
|
81
|
-
| `payload-live-preview/server` | `definePreview()` and `authorizePreviewRequest()` for server code. |
|
|
82
|
-
| `payload-live-preview/{astro,nextjs,sveltekit,nuxt}` | One framework adapter each. |
|
|
83
|
-
| `payload-live-preview/codegen` | Type generation from a Payload config. |
|
|
65
|
+
Three entries cover most projects. The root `payload-live-preview` carries the client, the inline script generator, the renderers and the plugins. `payload-live-preview/astro`, `/nextjs`, `/sveltekit` and `/nuxt` hold one framework adapter each. `payload-live-preview/server` is the privileged surface for server code: `definePreview()`, `authorizePreviewRequest()`, `issuePreviewToken()` and `createPreviewBindings()`. The focused entries (`core`, `client`, `lexical`, `structural`, `plugins`, `fragment`, `payload`, `codegen`, `doctor`, `migrate`) are listed in [docs/options.md](docs/options.md).
|
|
84
66
|
|
|
85
67
|
## Configure Payload
|
|
86
68
|
|
|
87
|
-
Enable live preview in `payload.config.ts
|
|
69
|
+
Enable live preview in `payload.config.ts`. The `url` callback maps the edited document to the frontend URL shown in the preview iframe; `buildLivePreviewUrl` replaces the usual lookup-table boilerplate:
|
|
88
70
|
|
|
89
71
|
```ts
|
|
90
72
|
import { buildLivePreviewUrl } from 'payload-live-preview/payload';
|
|
@@ -114,92 +96,9 @@ export default buildConfig({
|
|
|
114
96
|
});
|
|
115
97
|
```
|
|
116
98
|
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
The URL above says `?preview=true`, which is intent, not proof. To let the
|
|
120
|
-
frontend verify the iframe request without a session cookie crossing
|
|
121
|
-
origins, mint a short-lived token on the Payload side and verify it in the
|
|
122
|
-
adapter with the `signed-token` strategy. The token is bound to the site,
|
|
123
|
-
the path, the locale and ten minutes; a leaked one is worth that much.
|
|
124
|
-
|
|
125
|
-
```ts
|
|
126
|
-
// payload.config.ts
|
|
127
|
-
import { buildLivePreviewUrl } from 'payload-live-preview/payload';
|
|
128
|
-
import { issuePreviewToken } from 'payload-live-preview';
|
|
99
|
+
The helper appends `?preview=true`, one of the query parameters (`preview`, `draft`, `livePreview`) the adapters read as preview intent. The parameter is client-controlled: it selects delivery and never authorizes draft access. A hand-written `url: ({ data, locale, collectionConfig, globalConfig }) => string` callback works the same way; the [official docs](https://payloadcms.com/docs/live-preview/overview) have the full contract. A resolver may return `null` for a document without a route, and `fallback: null` declines every unmapped one; Payload then shows no iframe.
|
|
129
100
|
|
|
130
|
-
|
|
131
|
-
baseUrl: process.env.FRONTEND_URL!,
|
|
132
|
-
collections: { posts: ({ data }) => `/blog/${String(data.slug ?? '')}` },
|
|
133
|
-
globals: { homepage: '/' },
|
|
134
|
-
fallback: '/',
|
|
135
|
-
});
|
|
136
|
-
|
|
137
|
-
export default buildConfig({
|
|
138
|
-
admin: {
|
|
139
|
-
livePreview: {
|
|
140
|
-
url: async (args) => {
|
|
141
|
-
const url = new URL(toPreviewUrl(args));
|
|
142
|
-
const locale = typeof args.locale === 'string' ? args.locale : args.locale?.code;
|
|
143
|
-
url.searchParams.set(
|
|
144
|
-
'previewToken',
|
|
145
|
-
await issuePreviewToken(
|
|
146
|
-
{
|
|
147
|
-
audience: url.origin,
|
|
148
|
-
path: url.pathname,
|
|
149
|
-
...(locale ? { locale } : {}),
|
|
150
|
-
ttlMs: 10 * 60_000,
|
|
151
|
-
},
|
|
152
|
-
{ secret: process.env.PREVIEW_TOKEN_SECRET! }, // ≥ 32 bytes, shared with the frontend
|
|
153
|
-
),
|
|
154
|
-
);
|
|
155
|
-
return url.toString();
|
|
156
|
-
},
|
|
157
|
-
// …breakpoints, collections, globals as above
|
|
158
|
-
},
|
|
159
|
-
},
|
|
160
|
-
});
|
|
161
|
-
```
|
|
162
|
-
|
|
163
|
-
```ts
|
|
164
|
-
// frontend middleware — the matching side
|
|
165
|
-
authorizePreview: (request) =>
|
|
166
|
-
authorizePreviewRequest(request, {
|
|
167
|
-
type: 'signed-token',
|
|
168
|
-
secret: import.meta.env.PREVIEW_TOKEN_SECRET,
|
|
169
|
-
audience: import.meta.env.SITE_ORIGIN,
|
|
170
|
-
locale: (request) => new URL(request.url).pathname.split('/')[1],
|
|
171
|
-
}),
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
Keep the query parameter out of access-log formats and error-reporter URLs,
|
|
175
|
-
set `Referrer-Policy: no-referrer` on preview responses, and hand the
|
|
176
|
-
adapter a replay store if a replay inside the ten minutes matters to you —
|
|
177
|
-
[ADR 0006](docs/architecture/0006-authorized-preview-context.md) lists the
|
|
178
|
-
channels and what each binding closes.
|
|
179
|
-
|
|
180
|
-
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.
|
|
181
|
-
|
|
182
|
-
**Documents without a preview target.** Not every document has a reachable
|
|
183
|
-
route: a draft with no slug yet, a collection that is never rendered, a
|
|
184
|
-
document whose id is missing. Payload's `url` callback accepts `null` for
|
|
185
|
-
exactly this — it then shows no preview iframe. Return `null` from a resolver,
|
|
186
|
-
or set `fallback: null` to decline every unmapped document:
|
|
187
|
-
|
|
188
|
-
```ts
|
|
189
|
-
url: buildLivePreviewUrl({
|
|
190
|
-
baseUrl: process.env.FRONTEND_URL,
|
|
191
|
-
collections: {
|
|
192
|
-
// No id yet means no stable route — no iframe beats a wrong one.
|
|
193
|
-
services: ({ data }) => (typeof data.id === 'number' ? `/services/${data.id}` : null),
|
|
194
|
-
},
|
|
195
|
-
globals: { homepage: '/' },
|
|
196
|
-
fallback: null, // anything unmapped resolves to no preview
|
|
197
|
-
}),
|
|
198
|
-
```
|
|
199
|
-
|
|
200
|
-
With string-only resolvers and a string `fallback` the callback keeps its
|
|
201
|
-
1.0 type and always produces a URL. Using `null` anywhere widens the return
|
|
202
|
-
type to `string | null`, so the two forms cannot be confused.
|
|
101
|
+
To verify the iframe request without a session cookie crossing origins, mint a short-lived token inside the `url` callback with `issuePreviewToken()` and check it in the adapter with the `signed-token` strategy. [docs/authorization.md](docs/authorization.md) shows both sides, the `payload-session` and `verifier` strategies, and the initial draft read.
|
|
203
102
|
|
|
204
103
|
## Quick start
|
|
205
104
|
|
|
@@ -223,459 +122,36 @@ export default defineConfig({
|
|
|
223
122
|
});
|
|
224
123
|
```
|
|
225
124
|
|
|
226
|
-
|
|
125
|
+
Then mark what should update. On a server-rendered page that is one attribute per component — the region is rendered again from the unsaved form state, so everything in it stays correct, conditional sections and derived values included:
|
|
227
126
|
|
|
228
127
|
```astro
|
|
229
|
-
<
|
|
230
|
-
<
|
|
231
|
-
<img data-payload-field="hero" alt={alt} src={url} />
|
|
232
|
-
```
|
|
233
|
-
|
|
234
|
-
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.
|
|
235
|
-
|
|
236
|
-
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:
|
|
237
|
-
|
|
238
|
-
```astro
|
|
239
|
-
---
|
|
240
|
-
import RichText from 'payload-live-preview/astro/RichText.astro';
|
|
241
|
-
---
|
|
242
|
-
<RichText value={page.body} field="body" class="prose" />
|
|
243
|
-
```
|
|
244
|
-
|
|
245
|
-
**Injection modes.** Three, and for a statically built site the default is rarely the best one.
|
|
246
|
-
|
|
247
|
-
`mode: 'inline'` (the default) bakes the runtime into every page at build time. Simple, and it works without a server — but every ordinary visitor downloads and parses ~21 KB gzip for a feature only an editor uses.
|
|
248
|
-
|
|
249
|
-
`mode: 'loader'` is the same deal without that cost. The page carries a few hundred bytes that check the preview context and fetch the runtime as a content-hashed, SRI-verified asset **only inside a preview**. Measured on the Astro fixture in this repository:
|
|
250
|
-
|
|
251
|
-
| | `index.html` |
|
|
252
|
-
| ---------------- | --------------- |
|
|
253
|
-
| `mode: 'inline'` | 70 314 bytes |
|
|
254
|
-
| `mode: 'loader'` | **3 151 bytes** |
|
|
255
|
-
|
|
256
|
-
That is per page, so a hundred-page site saves it a hundred times. The asset is published once at `/_payload-live-preview/runtime.<hash>.js`, cached across every page, and — because the configuration stays in the inline bootstrap — byte identical for every site on this version. It therefore cannot carry a deployment secret, and a redeploy that did not change the runtime does not invalidate the cached copy. `astro dev` serves the same path from memory, so development and production behave the same.
|
|
257
|
-
|
|
258
|
-
```ts
|
|
259
|
-
livePreview({
|
|
260
|
-
mode: 'loader',
|
|
261
|
-
allowedOrigins: [import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN],
|
|
262
|
-
}),
|
|
263
|
-
```
|
|
264
|
-
|
|
265
|
-
The trade is one extra request the first time an editor opens a preview.
|
|
266
|
-
|
|
267
|
-
Under a strict CSP, the asset itself is unremarkable: a same-origin script with an `integrity` attribute, needing no `'unsafe-inline'`. The bootstrap does need covering, though — a static build has no request, so there is no per-request nonce to attach and Astro emits it as a plain inline `<script>`. Its content is deterministic for a given package version and configuration, so the practical answer is a `'sha256-…'` source expression rather than `'unsafe-inline'`. `mode: 'middleware'` is the option that can carry a real nonce, because there a request exists to derive one from.
|
|
268
|
-
|
|
269
|
-
For SSR projects (`output: 'server'`), request-time injection can limit the bytes to requests carrying preview intent:
|
|
270
|
-
continue
|
|
271
|
-
|
|
272
|
-
```ts
|
|
273
|
-
livePreview({
|
|
274
|
-
mode: 'middleware',
|
|
275
|
-
allowedOrigins: [import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN],
|
|
276
|
-
serverURL: import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN,
|
|
277
|
-
mergeDepth: 1,
|
|
278
|
-
}),
|
|
279
|
-
```
|
|
280
|
-
|
|
281
|
-
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.
|
|
282
|
-
|
|
283
|
-
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:
|
|
284
|
-
|
|
285
|
-
```ts
|
|
286
|
-
// src/middleware.ts
|
|
287
|
-
import { createLivePreviewMiddleware } from 'payload-live-preview/astro';
|
|
288
|
-
import { authorizePreviewRequest } from 'payload-live-preview';
|
|
289
|
-
|
|
290
|
-
export const onRequest = createLivePreviewMiddleware({
|
|
291
|
-
allowedOrigins: [import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN],
|
|
292
|
-
strict: true,
|
|
293
|
-
// Runs only on requests with preview intent. A refusal leaves the response
|
|
294
|
-
// exactly as rendered: no runtime, no CSP change, no nonce in Astro.locals.
|
|
295
|
-
authorizePreview: (request) =>
|
|
296
|
-
authorizePreviewRequest(request, {
|
|
297
|
-
type: 'payload-session',
|
|
298
|
-
serverURL: import.meta.env.PAYLOAD_URL,
|
|
299
|
-
}),
|
|
300
|
-
});
|
|
301
|
-
```
|
|
302
|
-
|
|
303
|
-
The same verdict gates the page: read `Astro.locals.livePreviewNonce` for your own scripts, and pass the context to `fetchPreviewDocument({ authorization })` and `createPreviewBindings({ authorization })` so draft selection and `data-payload-*` emission follow it. Three strategies exist — `payload-session`, `signed-token` (issued by `issuePreviewToken()` on the Payload side, bound to site, path, locale and a few minutes) and `verifier` (your own function). [ADR 0006](docs/architecture/0006-authorized-preview-context.md) is the threat model.
|
|
304
|
-
|
|
305
|
-
### Next.js (App Router)
|
|
306
|
-
|
|
307
|
-
> **Client-rendered React app?** Use the official [`@payloadcms/live-preview-react`](https://payloadcms.com/docs/live-preview/client) (`useLivePreview`) instead — it re-renders your real component tree, so conditional sections and custom components update with full fidelity, and it ships in lockstep with Payload. This package's DOM patching targets **server-rendered/static markup**; a hydrating React tree can revert its patches on re-render. For React Server Components, Payload's `RefreshRouteOnSave` is the save-triggered equivalent.
|
|
308
|
-
|
|
309
|
-
For statically rendered pages, embed the script in the root layout — Next middleware cannot inject into the HTML body (a `NextResponse.next()` carries no body), so use it for CSP headers only:
|
|
310
|
-
|
|
311
|
-
```tsx
|
|
312
|
-
// app/layout.tsx — script executes because it is part of the SSR HTML
|
|
313
|
-
import { generateInlineScript } from 'payload-live-preview';
|
|
314
|
-
|
|
315
|
-
const previewScript = generateInlineScript({
|
|
316
|
-
allowedOrigins: [process.env.PAYLOAD_ADMIN_ORIGIN!],
|
|
317
|
-
serverURL: process.env.PAYLOAD_ADMIN_ORIGIN!,
|
|
318
|
-
mergeDepth: 1,
|
|
319
|
-
});
|
|
320
|
-
|
|
321
|
-
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
322
|
-
return (
|
|
323
|
-
<html>
|
|
324
|
-
<head>
|
|
325
|
-
<script dangerouslySetInnerHTML={{ __html: previewScript }} />
|
|
326
|
-
</head>
|
|
327
|
-
<body>{children}</body>
|
|
328
|
-
</html>
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
|
-
```
|
|
332
|
-
|
|
333
|
-
```ts
|
|
334
|
-
// middleware.ts — frame-ancestors on requests carrying preview intent
|
|
335
|
-
import { NextResponse, type NextRequest } from 'next/server';
|
|
336
|
-
import { createLivePreviewMiddleware } from 'payload-live-preview/nextjs';
|
|
337
|
-
import { authorizePreviewRequest } from 'payload-live-preview';
|
|
338
|
-
|
|
339
|
-
const livePreview = createLivePreviewMiddleware({
|
|
340
|
-
allowedOrigins: [process.env.PAYLOAD_ADMIN_ORIGIN!],
|
|
341
|
-
autoInject: false,
|
|
342
|
-
// Required under the 2.0 defaults: `strict` is on, and without this hook the
|
|
343
|
-
// adapter refuses rather than gating a response on client-controlled intent.
|
|
344
|
-
authorizePreview: (request) =>
|
|
345
|
-
authorizePreviewRequest(request, {
|
|
346
|
-
type: 'payload-session',
|
|
347
|
-
serverURL: process.env.PAYLOAD_ADMIN_ORIGIN!,
|
|
348
|
-
}),
|
|
349
|
-
});
|
|
350
|
-
|
|
351
|
-
export async function middleware(request: NextRequest) {
|
|
352
|
-
return livePreview(request, NextResponse.next());
|
|
353
|
-
}
|
|
354
|
-
```
|
|
355
|
-
|
|
356
|
-
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.
|
|
357
|
-
|
|
358
|
-
### SvelteKit
|
|
359
|
-
|
|
360
|
-
```ts
|
|
361
|
-
// src/hooks.server.ts
|
|
362
|
-
import { env } from '$env/dynamic/private';
|
|
363
|
-
import { livePreviewHandle } from 'payload-live-preview/sveltekit';
|
|
364
|
-
import { authorizePreviewRequest } from 'payload-live-preview';
|
|
365
|
-
const adminOrigin = env.PAYLOAD_ADMIN_ORIGIN ?? '';
|
|
366
|
-
export const handle = livePreviewHandle({
|
|
367
|
-
allowedOrigins: [adminOrigin],
|
|
368
|
-
serverURL: adminOrigin,
|
|
369
|
-
mergeDepth: 1,
|
|
370
|
-
// Required under the 2.0 defaults; without it the handle refuses and every
|
|
371
|
-
// route, not only the preview, fails.
|
|
372
|
-
authorizePreview: (request) =>
|
|
373
|
-
authorizePreviewRequest(request, { type: 'payload-session', serverURL: adminOrigin }),
|
|
374
|
-
});
|
|
375
|
-
```
|
|
376
|
-
|
|
377
|
-
`$env/dynamic/private` rather than `process.env`: a SvelteKit project carries no
|
|
378
|
-
Node globals in its types unless it adds `@types/node`, so the `process.env`
|
|
379
|
-
form does not type-check in a fresh one.
|
|
380
|
-
|
|
381
|
-
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.
|
|
382
|
-
|
|
383
|
-
### Nuxt
|
|
384
|
-
|
|
385
|
-
```ts
|
|
386
|
-
// server/plugins/live-preview.ts
|
|
387
|
-
import { livePreviewNitroPlugin } from 'payload-live-preview/nuxt';
|
|
388
|
-
import { authorizePreviewRequest } from 'payload-live-preview';
|
|
389
|
-
|
|
390
|
-
export default defineNitroPlugin(
|
|
391
|
-
livePreviewNitroPlugin({
|
|
392
|
-
allowedOrigins: [process.env.NUXT_PUBLIC_PAYLOAD_ADMIN_ORIGIN!],
|
|
393
|
-
serverURL: process.env.NUXT_PUBLIC_PAYLOAD_ADMIN_ORIGIN!,
|
|
394
|
-
mergeDepth: 1,
|
|
395
|
-
// Required under the 2.0 defaults; without it the plugin refuses.
|
|
396
|
-
authorizePreview: (request) =>
|
|
397
|
-
authorizePreviewRequest(request, {
|
|
398
|
-
type: 'payload-session',
|
|
399
|
-
serverURL: process.env.NUXT_PUBLIC_PAYLOAD_ADMIN_ORIGIN!,
|
|
400
|
-
}),
|
|
401
|
-
}),
|
|
402
|
-
);
|
|
403
|
-
```
|
|
404
|
-
|
|
405
|
-
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.
|
|
406
|
-
|
|
407
|
-
> 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.
|
|
408
|
-
|
|
409
|
-
### Plain HTML (advanced)
|
|
410
|
-
|
|
411
|
-
```ts
|
|
412
|
-
import { generateInlineScript, wrapWithScriptTag } from 'payload-live-preview';
|
|
413
|
-
const script = generateInlineScript({
|
|
414
|
-
allowedOrigins: ['https://admin.example.com'],
|
|
415
|
-
serverURL: 'https://admin.example.com',
|
|
416
|
-
mergeDepth: 1,
|
|
417
|
-
});
|
|
418
|
-
// Inject via `<script>${script}</script>` — or wrapWithScriptTag(script, { nonce }).
|
|
419
|
-
```
|
|
420
|
-
|
|
421
|
-
## Payload 3.x: populated relationships (`serverURL`)
|
|
422
|
-
|
|
423
|
-
Payload 3.x posts **raw form values** on every edit — relationship and upload fields arrive as bare IDs. Set `serverURL` (any adapter, the inline generator, or the client) and the runtime re-fetches each update through the Payload REST API (`X-Payload-HTTP-Method-Override: GET`, `credentials: 'include'` — the same strategy as the official `@payloadcms/live-preview` client). The response is the populated document.
|
|
424
|
-
|
|
425
|
-
Requirements: the preview page must be able to reach the Payload API with the editor's credentials (same-site cookies, or CORS with `credentials`). On failure the runtime falls back to rendering the raw values. Tune with `apiRoute` (default `/api`) and `mergeDepth` (default `1`).
|
|
426
|
-
|
|
427
|
-
⚠️ **`mergeDepth` must match the `depth` of your initial page fetch.** If the page was rendered from a `depth=2` query but merges arrive with `depth=1`, nested relationships that were objects on first load degrade to IDs after the first edit — the same footgun the official docs warn about for their `depth` option.
|
|
428
|
-
|
|
429
|
-
## Draft documents on first load
|
|
430
|
-
|
|
431
|
-
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:
|
|
432
|
-
|
|
433
|
-
```ts
|
|
434
|
-
// src/lib/preview.ts — server-only
|
|
435
|
-
import { definePreview } from 'payload-live-preview/server';
|
|
436
|
-
|
|
437
|
-
export const preview = definePreview({
|
|
438
|
-
serverURL: import.meta.env.PAYLOAD_URL,
|
|
439
|
-
depth: 1, // one number for the initial read and the runtime merge
|
|
440
|
-
});
|
|
441
|
-
```
|
|
442
|
-
|
|
443
|
-
```astro
|
|
444
|
-
---
|
|
445
|
-
import { preview } from '../lib/preview';
|
|
446
|
-
|
|
447
|
-
const result = await preview.fetchDocument<Page>({
|
|
448
|
-
collection: 'pages',
|
|
449
|
-
where: { slug: { equals: Astro.params.slug } },
|
|
450
|
-
authorization: Astro.locals.livePreviewAuthorization ?? null, // draft only with a verified context
|
|
451
|
-
signal: Astro.request.signal,
|
|
452
|
-
});
|
|
453
|
-
if (!result.ok) return new Response(null, { status: 503 }); // or log result.reason
|
|
454
|
-
const page = result.data;
|
|
455
|
-
---
|
|
456
|
-
```
|
|
457
|
-
|
|
458
|
-
Spread `preview.runtimeOptions` (`serverURL`, `apiRoute`, `mergeDepth`) into the adapter so the runtime merges at the depth the page was read with. The root-entry `fetchPreviewDocument()` / `fetchPreviewGlobal()` keep their 1.x behaviour (draft by default, `null` on failure) and are deprecated.
|
|
459
|
-
|
|
460
|
-
`hasPreviewIntent()` (and its deprecated alias `isPreviewRequest()`) 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.
|
|
461
|
-
|
|
462
|
-
## Data-attribute reference
|
|
463
|
-
|
|
464
|
-
| Attribute | Purpose | Example |
|
|
465
|
-
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- |
|
|
466
|
-
| `data-payload-field` | Bind element to a Payload field path | `data-payload-field="hero.title"` |
|
|
467
|
-
| `data-payload-type` | Force a specific renderer | `data-payload-type="image"` |
|
|
468
|
-
| `data-payload-attribute` | Write the value into an attribute instead of content (unsafe attributes and URLs are refused) | `data-payload-attribute="datetime"` |
|
|
469
|
-
| `data-payload-href` | Read `href` from a sibling field | `data-payload-href="ctaUrl"` |
|
|
470
|
-
| `data-payload-src` | Read `src` from a sibling field | `data-payload-src="hero.url"` |
|
|
471
|
-
| `data-payload-alt` | Read `alt` from a sibling field | `data-payload-alt="hero.alt"` |
|
|
472
|
-
| `data-payload-richtext` | Force Lexical rendering (usually auto-detected) | `data-payload-richtext` |
|
|
473
|
-
| `data-payload-html` | Render value as sanitised HTML | `data-payload-html` |
|
|
474
|
-
| `data-payload-array` | Treat value as an array | `data-payload-array` |
|
|
475
|
-
| `data-payload-array-template` | HTML template per array item | `data-payload-array-template="<li>{{title}}</li>"` |
|
|
476
|
-
| `data-payload-array-separator` | Separator for primitive arrays | `data-payload-array-separator=" · "` |
|
|
477
|
-
| `data-payload-structural` | Use diff-based structural updates | `data-payload-structural` |
|
|
478
|
-
| `data-payload-locale` | Override locale for this element | `data-payload-locale="de-AT"` |
|
|
479
|
-
| `data-payload-owner` | Document this subtree belongs to (see below) | `data-payload-owner="global:homepage"` |
|
|
480
|
-
| `data-payload-depends` | Fields whose change re-applies this binding under `skipUnchanged`, e.g. `data-payload-depends="price currency"` | `data-payload-depends="price"` |
|
|
481
|
-
| `data-payload-strategy` | Delivery strategy: `patch` (default), `fragment` (server-rendered boundary) or `route` (whole-route refresh); anything else is left alone with `LP0407`. Without it: inside a fragment boundary → fragment, in `<head>` → route, else patch | `data-payload-strategy="route"` |
|
|
482
|
-
| `data-payload-fragment` | A server-rendered boundary; the value is a registry id the endpoint may render ([docs/hybrid.md](docs/hybrid.md)) | `data-payload-fragment="hero"` |
|
|
483
|
-
| `data-payload-fragment-key` | Distinguishes several boundaries of one id on a page | `data-payload-fragment-key="a"` |
|
|
484
|
-
| `data-payload-boundary` | A stable anchor for a possibly-empty field: hidden while empty, shown when filled (`PreviewBoundary.astro` renders it) | `data-payload-boundary hidden` |
|
|
485
|
-
| `data-payload-island` | A hydrated framework root: never patched or morphed into (see docs/renderers.md, islands) | `data-payload-island` |
|
|
486
|
-
| `data-payload-owned` | A subtree the site scripts itself: the morph retains it whole | `data-payload-owned` |
|
|
487
|
-
|
|
488
|
-
The `{{field}}` mustache in `data-payload-array-template` is read by this
|
|
489
|
-
package, not by your framework — but Svelte reads `{...}` and Vue reads
|
|
490
|
-
`{{ ... }}` as their own interpolation, so writing the template inline in a
|
|
491
|
-
`.svelte` or `.vue` file is a compile error or a silently empty value. Bind it
|
|
492
|
-
as a string instead:
|
|
493
|
-
|
|
494
|
-
```svelte
|
|
495
|
-
<script lang="ts">
|
|
496
|
-
const template = '<li><a data-payload-href="url">{{title}}</a></li>';
|
|
497
|
-
</script>
|
|
498
|
-
<ul data-payload-field="posts" data-payload-array-template={template}></ul>
|
|
499
|
-
```
|
|
500
|
-
|
|
501
|
-
```vue
|
|
502
|
-
<script setup lang="ts">
|
|
503
|
-
const template = '<li><a data-payload-href="url">{{title}}</a></li>';
|
|
504
|
-
</script>
|
|
505
|
-
<template>
|
|
506
|
-
<ul data-payload-field="posts" :data-payload-array-template="template"></ul>
|
|
507
|
-
</template>
|
|
508
|
-
```
|
|
509
|
-
|
|
510
|
-
Binding metadata is live: changing any of these attributes (including inferred
|
|
511
|
-
type markers and an input's native `type`) rebuilds the affected cache snapshot
|
|
512
|
-
after the mutation debounce. Locale-specific bindings resolve their own suffixed
|
|
513
|
-
field value, and stale work prepared for a previous element locale is discarded.
|
|
514
|
-
|
|
515
|
-
**Empty-field gotcha:** the runtime can only patch elements that exist. `PreviewBoundary.astro` (`payload-live-preview/astro/PreviewBoundary.astro`) renders a hidden anchor for an empty field and the runtime unhides it when the editor fills the field. 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:
|
|
516
|
-
|
|
517
|
-
```astro
|
|
518
|
-
<div data-payload-field="subtitle">{subtitle ?? ''}</div>
|
|
519
|
-
```
|
|
520
|
-
|
|
521
|
-
### Pages that preview more than one document
|
|
522
|
-
|
|
523
|
-
By default a binding's identity is its field path alone. On a page that renders
|
|
524
|
-
several documents — a page global, shared SEO metadata, and a list of collection
|
|
525
|
-
rows — a field called `title` in any of them matches every `title` on the page,
|
|
526
|
-
so editing one overwrites all of them.
|
|
527
|
-
|
|
528
|
-
`data-payload-owner` names the document a subtree belongs to. It is resolved
|
|
529
|
-
from the nearest marked ancestor (the element itself included), so a shell
|
|
530
|
-
component can own a whole region without repeating the marker, and a nested
|
|
531
|
-
document can override the owner it would inherit:
|
|
532
|
-
|
|
533
|
-
```astro
|
|
534
|
-
<section data-payload-owner="global:homepage">
|
|
535
|
-
<h1 data-payload-field="title">{page.title}</h1>
|
|
536
|
-
|
|
537
|
-
<article data-payload-owner={`collection:services:${service.id}`}>
|
|
538
|
-
<h2 data-payload-field="title">{service.title}</h2>
|
|
539
|
-
</article>
|
|
128
|
+
<section data-payload-fragment="hero" data-payload-depends="title,subtitle,body">
|
|
129
|
+
<Hero {...page} />
|
|
540
130
|
</section>
|
|
541
131
|
```
|
|
542
132
|
|
|
543
|
-
|
|
544
|
-
`collection:<slug>:<id>`. A marker without an id claims every document of that
|
|
545
|
-
collection, which is what a page rendering exactly one of them wants.
|
|
546
|
-
|
|
547
|
-
Enable enforcement with `scopeBindingsByOwner` — off by default so existing 1.x
|
|
548
|
-
pages keep working unchanged:
|
|
549
|
-
|
|
550
|
-
```ts
|
|
551
|
-
new LivePreviewClient({ allowedOrigins: [...], scopeBindingsByOwner: true });
|
|
552
|
-
```
|
|
553
|
-
|
|
554
|
-
Payload already sends the edited document's identity on every message, so
|
|
555
|
-
nothing else needs configuring. While enabled:
|
|
556
|
-
|
|
557
|
-
- an update reaches only bindings owned by the document it names;
|
|
558
|
-
- a binding **without** an owner is never updated — ownership is a deliberate
|
|
559
|
-
claim, not a default that silently matches everything;
|
|
560
|
-
- an exact `collection:<slug>:<id>` marker stays unreachable while the message
|
|
561
|
-
carries no document id, rather than being addressed on a guess;
|
|
562
|
-
- a message naming neither a global nor a collection changes nothing and warns
|
|
563
|
-
once.
|
|
564
|
-
|
|
565
|
-
The same option exists on `generateInlineScript()` for the adapter path.
|
|
566
|
-
|
|
567
|
-
### Keeping binding attributes off public responses
|
|
568
|
-
|
|
569
|
-
Binding attributes are not neutral markup. `data-payload-field` names a CMS
|
|
570
|
-
field, and `data-payload-owner` names a global, a collection and often a
|
|
571
|
-
document id. Emitted unconditionally they publish the shape of your content
|
|
572
|
-
model — and the identity of documents — to every anonymous visitor and
|
|
573
|
-
crawler.
|
|
574
|
-
|
|
575
|
-
The gate is the same decision that already controls draft reads. Apply it once
|
|
576
|
-
per request with `createPreviewBindings()` so no individual call site can
|
|
577
|
-
forget it:
|
|
578
|
-
|
|
579
|
-
```ts
|
|
580
|
-
const preview = createPreviewBindings({
|
|
581
|
-
authorized: authorization !== null, // your verified server-side decision
|
|
582
|
-
owner: `global:${slug}`,
|
|
583
|
-
});
|
|
584
|
-
```
|
|
133
|
+
On a static build there is no server to render it, so the fields are bound individually — and inside a boundary too, for the ones an editor types into while watching, because a patch keeps focus and the caret where a re-render would not:
|
|
585
134
|
|
|
586
135
|
```astro
|
|
587
|
-
<
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
</section>
|
|
591
|
-
```
|
|
592
|
-
|
|
593
|
-
While unauthorized every helper returns an empty attribute set, so the response
|
|
594
|
-
carries no `data-payload-*` at all.
|
|
595
|
-
|
|
596
|
-
**Emit a binding as one unit.** A field travels with its type, locale,
|
|
597
|
-
rich-text marker and owner. Hand-writing a companion next to a gated field
|
|
598
|
-
leaves it behind when the gate closes — the taxonomy leaks anyway, and the
|
|
599
|
-
runtime sees a binding whose field is gone. Pass companions through
|
|
600
|
-
`BindOptions` (`richtext`, `html`, `locale`, `type`, `attribute`) instead of
|
|
601
|
-
writing the attributes yourself.
|
|
602
|
-
|
|
603
|
-
⚠️ **Do not key CSS off `data-payload-*`.** A selector like
|
|
604
|
-
|
|
605
|
-
```css
|
|
606
|
-
section:not(:has([data-payload-field]:not(:empty))) {
|
|
607
|
-
display: none;
|
|
608
|
-
}
|
|
609
|
-
```
|
|
610
|
-
|
|
611
|
-
reads "no filled binding" as "empty section" the moment nobody is logged in,
|
|
612
|
-
so gating emission silently changes your **public** layout. Style on a stable
|
|
613
|
-
marker of your own that has nothing to do with preview state.
|
|
614
|
-
|
|
615
|
-
## Field types
|
|
616
|
-
|
|
617
|
-
`text` · `textarea` · `richText` · `html` · `email` · `number` · `checkbox` · `date` · `select` · `radio` · `relationship` · `upload` · `image` · `url` · `array` · `blocks` · `structural-array`
|
|
618
|
-
|
|
619
|
-
Custom renderers register via the plugin system (see [docs/renderers.md](docs/renderers.md) for what a renderer receives, namespaced custom keys such as `acme:money`, the `resolveRenderer` and `renderRichText` client options, and the plugin ownership contract):
|
|
620
|
-
|
|
621
|
-
```ts
|
|
622
|
-
import { LivePreviewClient } from 'payload-live-preview';
|
|
623
|
-
|
|
624
|
-
const client = new LivePreviewClient({ allowedOrigins: ['https://admin.example.com'] });
|
|
625
|
-
await client.use({
|
|
626
|
-
name: 'currency',
|
|
627
|
-
init: (ctx) => {
|
|
628
|
-
ctx.registerFieldRenderer({
|
|
629
|
-
name: 'text',
|
|
630
|
-
render: (target, value) => {
|
|
631
|
-
target.element.textContent = new Intl.NumberFormat('de-AT', {
|
|
632
|
-
style: 'currency',
|
|
633
|
-
currency: 'EUR',
|
|
634
|
-
}).format(Number(value));
|
|
635
|
-
},
|
|
636
|
-
});
|
|
637
|
-
},
|
|
638
|
-
});
|
|
136
|
+
<h1 data-payload-field="title">{title}</h1>
|
|
137
|
+
<div data-payload-field="body">…server-rendered rich text…</div>
|
|
138
|
+
<img data-payload-field="hero" alt={alt} src={url} />
|
|
639
139
|
```
|
|
640
140
|
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
Patching edits what the page already renders. A `data-payload-fragment`
|
|
644
|
-
boundary is rendered by **your server** from the unsaved form state instead —
|
|
645
|
-
conditional sections, derived values, custom blocks, the component's own
|
|
646
|
-
logic — and morphed in with focus and visitor state intact. The runtime posts
|
|
647
|
-
the fields to a same-origin endpoint you build with `createFragmentEndpoint()`
|
|
648
|
-
(Astro; registry of components, the same authorization strategies as the
|
|
649
|
-
page, no code or paths cross the wire), and patches the boundary's own
|
|
650
|
-
bindings if the server cannot render. Opt in per boundary; a page without
|
|
651
|
-
`fragments` carries no fragment client. [docs/hybrid.md](docs/hybrid.md) has
|
|
652
|
-
the markup, the endpoint, the deployment requirements and the abuse model
|
|
653
|
-
(ADR 0011). A binding in `<head>` or one marked `data-payload-strategy="route"`
|
|
654
|
-
refreshes the whole route once per revision instead, with scroll and focus
|
|
655
|
-
kept and the unsaved state re-applied on the fresh markup.
|
|
656
|
-
|
|
657
|
-
## Typed bindings and codegen
|
|
658
|
-
|
|
659
|
-
Generate interfaces straight from your Payload config:
|
|
141
|
+
Which to reach for, and what each costs: [docs/bindings.md](docs/bindings.md#how-much-markup-this-actually-needs). `pll-codegen annotate` writes the unambiguous bindings for you and reports the rest.
|
|
660
142
|
|
|
661
|
-
|
|
662
|
-
npx pll-codegen --config ../backend/src/payload.config.ts --out src/lib/bind-types.ts
|
|
663
|
-
```
|
|
143
|
+
That is it: the inline script detects the admin's iframe and starts patching. Rich text is detected from the value shape; `data-payload-richtext` only forces it. Payload 3.x posts raw form values, so relationship and upload fields arrive as IDs until `serverURL` with `mergeDepth` re-fetches the populated document ([docs/options.md](docs/options.md)). Injection modes, request-time middleware, authorization and the initial draft read: [docs/astro.md](docs/astro.md).
|
|
664
144
|
|
|
665
|
-
|
|
145
|
+
### Other frameworks
|
|
666
146
|
|
|
667
|
-
|
|
147
|
+
- **Next.js (App Router)** — the script in `app/layout.tsx`, the adapter middleware for headers: [docs/nextjs.md](docs/nextjs.md).
|
|
148
|
+
- **SvelteKit** — `livePreviewHandle()` in `hooks.server.ts`: [docs/sveltekit.md](docs/sveltekit.md).
|
|
149
|
+
- **Nuxt** — a Nitro plugin plus a server handler: [docs/nuxt.md](docs/nuxt.md).
|
|
150
|
+
- **Plain HTML** — `generateInlineScript()` at build time: [docs/html.md](docs/html.md).
|
|
668
151
|
|
|
669
|
-
|
|
670
|
-
---
|
|
671
|
-
import { bind } from 'payload-live-preview';
|
|
672
|
-
import type { Homepage } from '../lib/bind-types';
|
|
673
|
-
---
|
|
674
|
-
<h1 {...bind<Homepage>('heroTitle')}>{data.heroTitle}</h1>
|
|
675
|
-
<img {...bind<Homepage>('heroImage', { attribute: 'src' })} />
|
|
676
|
-
```
|
|
152
|
+
## Patch, fragment, route
|
|
677
153
|
|
|
678
|
-
|
|
154
|
+
A binding is patched in place by default. A `data-payload-fragment` boundary is rendered by your server from the unsaved form state instead — conditional sections, derived values, custom blocks, the component's own logic — and morphed in with focus and visitor state intact; the runtime posts the fields to a same-origin endpoint built with `createFragmentEndpoint()`, which every adapter entry exports — Astro renders through its container API, Next.js through `react-dom/server`, SvelteKit through `svelte/server`, Nuxt through `vue/server-renderer` — and patches the boundary's own bindings when the server cannot render. A binding in `<head>`, or one marked `data-payload-strategy="route"`, refreshes the whole route once per revision with scroll and focus kept. Markup, endpoint, deployment requirements and the abuse model: [docs/hybrid.md](docs/hybrid.md).
|
|
679
155
|
|
|
680
156
|
## Events and plugins
|
|
681
157
|
|
|
@@ -689,232 +165,41 @@ client.events.on('beforeUpdate', (e) => {
|
|
|
689
165
|
client.events.on('documentSave', () => location.reload());
|
|
690
166
|
```
|
|
691
167
|
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
Since 1.1.0 `beforeUpdate`, `afterUpdate` and `elementUpdate` also carry `receivedAt` (when the runtime accepted the message, Unix ms) and `source` (`'patch'` for an admin postMessage; fragment strategies will name themselves here), next to `revision`.
|
|
695
|
-
|
|
696
|
-
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.
|
|
697
|
-
|
|
698
|
-
Built-in plugins:
|
|
699
|
-
|
|
700
|
-
| Plugin | Effect |
|
|
701
|
-
| ---------------------------------- | ------------------------------------------------------------------------------------------------ |
|
|
702
|
-
| `highlightPlugin` | Flashes an outline on updated elements (respects reduced-motion). |
|
|
703
|
-
| `debugPlugin` | Logs every lifecycle event to the console. |
|
|
704
|
-
| `createAnalyticsPlugin()` | Collects update statistics, exposed via `getStats()`. |
|
|
705
|
-
| `documentSavePlugin({ strategy })` | Reacts to admin saves: `'silent'` · `'reload'` (scroll-preserving) · `'revalidate'` · `'fetch'`. |
|
|
706
|
-
|
|
707
|
-
## Configuration reference
|
|
708
|
-
|
|
709
|
-
Options accepted by `generateInlineScript`, every adapter, and `LivePreviewClient`:
|
|
710
|
-
|
|
711
|
-
| Option | Default | Meaning |
|
|
712
|
-
| ---------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- |
|
|
713
|
-
| `allowedOrigins` | `[]` | Allowed admin origins for inbound browser messages (recommended in production; not HTTP authorization). |
|
|
714
|
-
| `serverURL` | — | Payload origin for REST data merging (Payload 3.x population). |
|
|
715
|
-
| `apiRoute` | `/api` | REST route prefix used with `serverURL`. |
|
|
716
|
-
| `mergeDepth` | `1` | Population depth used with `serverURL`. |
|
|
717
|
-
| `debug` | `false` for inline/adapters; dev-mode detection for `LivePreviewClient` | Verbose console logging. |
|
|
718
|
-
| `debounceMs` | `50` | Debounce window for incoming updates. |
|
|
719
|
-
| `heartbeatMs` | `0` (off) | Idle timeout. Leave off — Payload sends no keepalive. |
|
|
720
|
-
|
|
721
|
-
Additional runtime options accepted by `generateInlineScript` and `LivePreviewClient`
|
|
722
|
-
(not by the adapter option objects):
|
|
723
|
-
|
|
724
|
-
| Option | Default | Meaning |
|
|
725
|
-
| -------------------------- | ---------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
726
|
-
| `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. |
|
|
727
|
-
| `disableVisibilityGate` | `false` | Apply updates to off-screen elements immediately. |
|
|
728
|
-
| `visibilityGateThreshold` | `50` | Cache size above which off-screen updates are queued. |
|
|
729
|
-
| `scopeBindingsByOwner` | `false` | Restrict each update to bindings owned by the document it names (`data-payload-owner`). Unowned bindings stop updating. |
|
|
730
|
-
| `skipUnchanged` | `false` | Skip a binding whose value is identical to the one it last applied, so a keystroke re-renders only what changed. Renderers and `elementUpdate` then stop seeing repeats; `inspect().revisions.skippedUnchanged` counts the skips. |
|
|
731
|
-
| `sanitizerPolicy` | `'compat'` | `'strict'` (the 2.0 default, set by `defaults: 'v2'`) strips `id`, `name` and `data-payload-*` from rich-text/HTML writes and passes other `data-*` only by list — see docs/security.md §3. |
|
|
732
|
-
| `dependencies` | `{}` | Fields whose change re-applies other bindings even when their own value did not change: `{ price: ['priceLabel'] }`. Used only with `skipUnchanged`. |
|
|
733
|
-
| `intersectionRootMargin` | `'200px'` | Pre-render margin for the visibility gate. |
|
|
734
|
-
| `disableReferrerDetection` | `false` | Opt out of `document.referrer` origin auto-detection. |
|
|
735
|
-
| `disableLocalhostMatching` | `false` | Opt out of dev-mode localhost origin matching. |
|
|
736
|
-
|
|
737
|
-
`LivePreviewClient` additionally accepts the programmatic-only `mergeFetch`,
|
|
738
|
-
`a11yLocale`, `root`, `autoStart`, and `validateToken` options. `validateToken` is a
|
|
739
|
-
package extension: an unmodified Payload admin does not send the required token.
|
|
740
|
-
|
|
741
|
-
Adapter options vary by framework. Shared adapter options include `inject`
|
|
742
|
-
(`'preview-only'` default / `'always'`), `previewQueryParams`, `previewSignals`
|
|
743
|
-
(choose which client-controlled intent signals count), `autoInject`, `manageCsp`
|
|
744
|
-
(`'frame-ancestors'` default / `'full'` / `false`), `strictDynamic`,
|
|
745
|
-
`frameAncestorsExtra`, and `scriptSrcExtra`. Astro, Next.js, and SvelteKit also expose
|
|
746
|
-
`shouldInject` as a script route/content filter only; it is not authorization and
|
|
747
|
-
does not suppress CSP handling. Astro additionally accepts `mode` (`'inline'` /
|
|
748
|
-
`'middleware'`). The Astro, Next.js, and Nuxt manual render helpers accept a
|
|
749
|
-
per-request `nonce`; `generateInlineScript()` itself returns a script body, so pass
|
|
750
|
-
the nonce to `wrapWithScriptTag()` when embedding it manually.
|
|
751
|
-
|
|
752
|
-
Bundle-size note: the root barrel tree-shakes, and that is measured rather
|
|
753
|
-
than declared — `npm run test:treeshake` bundles one-symbol consumers with
|
|
754
|
-
Vite against the built package and holds each to a budget. Importing
|
|
755
|
-
`escapeHtml` from the root ships 220 B gzip, `lexicalToHtml` 4.3 KB,
|
|
756
|
-
`initLivePreview` 30.5 KB (the client with its built-in renderers, Lexical
|
|
757
|
-
included), `generateInlineScript` 24.8 KB (the inline runtime source and
|
|
758
|
-
nothing of the client). The focused entries under [Package entries](#package-entries)
|
|
759
|
-
give a bundler less to look through and a reader a smaller surface; the
|
|
760
|
-
bytes are the same. Hot-path timings and the tree-shaking table live in
|
|
761
|
-
[docs/benchmarks.md](docs/benchmarks.md).
|
|
762
|
-
|
|
763
|
-
The four real-app browser fixtures in `examples/` cover Astro 7, Next.js 16, SvelteKit 2, and Nuxt 3 in Chromium, Firefox, and WebKit. The Astro 4–7 peer range is broader than the single Astro-major browser fixture.
|
|
764
|
-
|
|
765
|
-
**How the protocol coverage is layered** (so you know exactly what's proven):
|
|
766
|
-
|
|
767
|
-
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.
|
|
768
|
-
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.
|
|
769
|
-
3. **Wire corpus** (`tests/fixtures/wire-corpus/`, one file per Payload version, recorded from a real admin by `tests/real-payload/record-wire-corpus.spec.ts` with `PLP_RECORD_CORPUS=1`) is replayed through the real runtime by `tests/integration/wire-corpus.test.ts`: every capture must validate, render, and demonstrate exactly the capabilities the runtime then reports. The protocol watch also checks that the official client still recognises every captured message. The original **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.
|
|
770
|
-
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.
|
|
771
|
-
|
|
772
|
-
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.
|
|
168
|
+
Every event, transforms, custom field renderers, the built-in plugins and the plugin ownership contract: [docs/renderers.md](docs/renderers.md).
|
|
773
169
|
|
|
774
170
|
## Security model
|
|
775
171
|
|
|
776
|
-
- **Preview intent is not authorization
|
|
777
|
-
- **Origin validation
|
|
778
|
-
- **
|
|
779
|
-
- **
|
|
780
|
-
- **
|
|
781
|
-
- **Policed attribute writes** — `data-payload-attribute` refuses event handlers, `style`, `srcdoc`, `formaction`, `id`/`name` (DOM clobbering) and validates URL attributes.
|
|
782
|
-
- **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).
|
|
783
|
-
- **No prototype pollution** — nested field lookups refuse `__proto__`, `prototype`, `constructor`; incoming data is never merged into existing objects.
|
|
172
|
+
- **Preview intent is not authorization.** The query parameter, the iframe destination and the referer are client-controlled; the adapters count the query alone by default. `allowedOrigins` governs browser `postMessage` senders and `shouldInject` filters routes; neither authenticates the request. `authorizePreviewRequest()` does, and that one result controls draft reads, `private, no-store` caching, CSP changes and runtime injection.
|
|
173
|
+
- **Origin validation.** Every incoming message is checked against `allowedOrigins`; `document.referrer` is ignored by default, and after the first accepted update the runtime locks to that origin. The adapters merge a `frame-ancestors` policy for the admin origins without clobbering the rest of your CSP.
|
|
174
|
+
- **Sanitization and URL validation.** Lexical and HTML writes pass a DOM sanitizer with a curated allow-list — `<script>`, `<form>`, `<iframe>`, `<svg>`, event handlers and `style` are rejected — and every `href`, `src`, `srcset` and `poster` must be `http(s)`, `mailto:`, `tel:` or relative; external links get `rel="noopener noreferrer"`.
|
|
175
|
+
- **Binding attributes are disclosure.** `data-payload-field` names a CMS field and `data-payload-owner` a document. `createPreviewBindings({ authorization })` suppresses them, companions included, on public responses. Never key CSS off `data-payload-*`.
|
|
176
|
+
- **Policed writes, no prototype pollution.** `data-payload-attribute` refuses event handlers, `style`, `srcdoc`, `formaction`, `id` and `name`; nested lookups refuse `__proto__`, `prototype` and `constructor`, and incoming data is never merged into existing objects.
|
|
784
177
|
|
|
785
178
|
Full details in [docs/security.md](docs/security.md). Report vulnerabilities per [SECURITY.md](SECURITY.md).
|
|
786
179
|
|
|
787
|
-
##
|
|
788
|
-
|
|
789
|
-
When a preview misbehaves, `inspect()` returns a point-in-time snapshot of what
|
|
790
|
-
the runtime actually sees. It performs no I/O and transmits nothing.
|
|
791
|
-
|
|
792
|
-
Adapter users reach it on the global handle inside the preview iframe — that is
|
|
793
|
-
the point, because there is no client object to call a method on:
|
|
794
|
-
|
|
795
|
-
```js
|
|
796
|
-
// In the browser console, inside the preview iframe
|
|
797
|
-
__livePreview.inspect();
|
|
798
|
-
```
|
|
799
|
-
|
|
800
|
-
Consumers driving the runtime themselves call it on the client:
|
|
801
|
-
|
|
802
|
-
```ts
|
|
803
|
-
const client = initLivePreview({ allowedOrigins: ['https://cms.example.com'] });
|
|
804
|
-
console.log(client.inspect());
|
|
805
|
-
```
|
|
806
|
-
|
|
807
|
-
The snapshot answers the questions that are otherwise guesswork:
|
|
808
|
-
|
|
809
|
-
| Reading | What it tells you |
|
|
810
|
-
| ---------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
811
|
-
| `bindings.orphanFields` | Field names that arrived but matched no element. A name here is a markup problem; a name in neither this nor `bindings.fieldNames` was never sent. |
|
|
812
|
-
| `scheduler.deferred` with `scheduler.visibilityGateActive` | Updates the visibility gate is holding until the element scrolls into view. On a page nobody scrolls, that is "never" — the symptom is a preview that stops updating below the fold. |
|
|
813
|
-
| `revisions.superseded` | Updates abandoned because a newer one arrived. Tracking `accepted` closely is normal for fast typing; it only matters when the _last_ update is among them. |
|
|
814
|
-
| `revisions.completed` | Updates whose flush ran — their writes reached the DOM, or there was nothing to write. `accepted − superseded − completed` is in flight or cancelled. |
|
|
815
|
-
| `origins.locked` | The origin the runtime locked onto after its first accepted update. Every other origin is refused from then on. |
|
|
816
|
-
| `bindings.ownerScoped` with `bindings.owners` | Whether owner scoping is on, and which documents the page declares. Under scoping, an unowned binding receives nothing. |
|
|
817
|
-
| `protocol.negotiated` | The version both sides share, which caps the capabilities in `protocol.capabilities`. |
|
|
818
|
-
| `protocol.observed` | Capabilities seen on the wire rather than granted by version — the stock admin announces no version, so this is how its abilities become known (`locale`, `schema-json`, `document-events`, `relationship-events`, `preview-token`). Each capability declares a fallback; see `CAPABILITY_DOCUMENTATION`. |
|
|
819
|
-
| `protocol.profile` | What the observed capabilities imply: `payload-2` (a schema on the wire; the admin populates relationships itself, so no REST merge), `payload-3` (document or relationship events seen), or `unknown` (treated like 3.x for merging). |
|
|
820
|
-
| `fragments` | Server-rendered boundaries: `handler` (a fragment client is configured), `inFlight`, `rendered`, `failed`, `superseded`. |
|
|
821
|
-
| `route` | Route refreshes: `handler`, `refreshes`, `failed`, `loopStopped` (second requests for one revision, refused with `LP0805`). |
|
|
822
|
-
|
|
823
|
-
This is deliberately not gated to development builds. A snapshot discloses
|
|
824
|
-
nothing that is not already on the page — the trusted origins are inside the
|
|
825
|
-
injected script and the field names are `data-payload-field` attributes in the
|
|
826
|
-
DOM — and a preview that misbehaves only on the deployed site is exactly the
|
|
827
|
-
case where the information is worth having.
|
|
828
|
-
|
|
829
|
-
## Auditing a deployment: `pll doctor`
|
|
830
|
-
|
|
831
|
-
`inspect()` answers "what is this runtime doing right now" from inside the page.
|
|
832
|
-
`pll doctor` answers the question one step earlier: **what is this deployment
|
|
833
|
-
actually serving?**
|
|
834
|
-
|
|
835
|
-
```
|
|
836
|
-
npx pll doctor https://example.com/some-page --admin https://cms.example.com
|
|
837
|
-
```
|
|
838
|
-
|
|
839
|
-
It fetches the URL twice — once as an ordinary visitor, once with the headers
|
|
840
|
-
the admin's iframe sends — and reports the difference. That comparison is the
|
|
841
|
-
point: a config can say `allowedOrigins: [...]` while a proxy strips the
|
|
842
|
-
header, an adapter runs in a mode nobody remembers choosing, or a build emits
|
|
843
|
-
binding attributes on public pages.
|
|
844
|
-
|
|
845
|
-
What it checks:
|
|
846
|
-
|
|
847
|
-
| Code | Finding |
|
|
848
|
-
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
849
|
-
| `LP0701` | No inline runtime in the preview response. A warning, not an error: the audit cannot tell an adapter that missed the request from a consumer who starts `LivePreviewClient` themselves. Also reports, as information, when the runtime reaches anonymous visitors (correct for `inject: 'always'`). |
|
|
850
|
-
| `LP0702` | No `frame-ancestors` on the preview response, or a policy that does not admit `--admin`. A bare `'self'` counts as admitting it when admin and site share an origin. |
|
|
851
|
-
| `LP0703` | `X-Frame-Options` forbids framing — browsers honour it independently of CSP. `SAMEORIGIN` is accepted when `--admin` shares the page's origin; `DENY` never is. |
|
|
852
|
-
| `LP0704` | Binding attributes served to anonymous visitors. |
|
|
853
|
-
| `LP0705` | More bindings than the default `visibilityGateThreshold` writes eagerly. |
|
|
854
|
-
| `LP0706` | Bindings outside every owner marker, which receive nothing under `scopeBindingsByOwner`. |
|
|
855
|
-
| `LP0707` | The runtime is present with nothing to write into. |
|
|
856
|
-
|
|
857
|
-
Exit codes: `0` no error-level findings, `1` usage error or the URL could not be
|
|
858
|
-
fetched, `2` at least one error-level finding — so it drops into CI as a smoke
|
|
859
|
-
test against a deploy preview. `--json` emits the report as data.
|
|
860
|
-
|
|
861
|
-
The audit makes exactly the two requests it is told to make, sends no
|
|
862
|
-
credentials, and reports no telemetry. `analyzeProbe()` is exported from
|
|
863
|
-
`payload-live-preview/doctor` for callers who fetch the responses themselves.
|
|
864
|
-
|
|
865
|
-
## Diagnostic codes
|
|
866
|
-
|
|
867
|
-
Every message the runtime reports carries a stable code. Prose gets reworded; a
|
|
868
|
-
code does not — so a log filter, an alert rule, or a bug report that names
|
|
869
|
-
`LP0301` still means the same thing after the sentence around it changes.
|
|
870
|
-
|
|
871
|
-
| Code | Meaning | What to do |
|
|
872
|
-
| -------- | -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
|
|
873
|
-
| `LP0101` | No trusted origin configured in production | Set `PAYLOAD_ADMIN_ORIGIN` or pass `allowedOrigins`. Nothing is accepted until you do. |
|
|
874
|
-
| `LP0102` | Origin trust rests on `document.referrer` | Any framing site is trusted. Set `allowedOrigins` and a `frame-ancestors` CSP. |
|
|
875
|
-
| `LP0201` | An update named a field with no binding on the page | Render the binding anchor unconditionally so edits to an initially-empty field have somewhere to land. |
|
|
876
|
-
| `LP0202` | Owner scoping is on and the update names no document | The message carries neither a global slug nor a collection slug plus id. |
|
|
877
|
-
| `LP0301` | The visibility gate held offscreen writes back | Raise `visibilityGateThreshold`, or accept that below-the-fold updates wait for a scroll. |
|
|
878
|
-
| `LP0401` | A value was refused as an unsafe attribute write | The attribute or the value is not safe to write; see the security model. |
|
|
879
|
-
| `LP0402` | A text element has structured children | Move `data-payload-field` to the value element, or add `data-payload-text`. |
|
|
880
|
-
| `LP0403` | A structural container has no array template | Add `data-payload-array-template`. |
|
|
881
|
-
| `LP0404` | A structural item has no `id` | Give items a stable `id`; without one they pair by position and an insert re-renders every row after it. |
|
|
882
|
-
| `LP0405` | Two structural items share a key | Make `id` unique per item; later duplicates pair by position. |
|
|
883
|
-
| `LP0406` | Every structural key changed at once | The source generates keys per message; the morph cannot retain nodes across such updates. |
|
|
884
|
-
| `LP0407` | A binding asks for a strategy this release does not have | Only `patch` and `fragment` exist; the element is left unchanged. |
|
|
885
|
-
| `LP0801` | A fragment request failed | Network, timeout or a 5xx from the endpoint; the boundary was patched from the same revision instead. |
|
|
886
|
-
| `LP0802` | A fragment response was invalid | Wrong content type, shape, size or boundary; patched instead. |
|
|
887
|
-
| `LP0803` | The fragment endpoint refused the preview | 401/403 — the page's authorization did not hold for the endpoint; patched instead. |
|
|
888
|
-
| `LP0804` | A late fragment response was discarded | It belonged to a superseded revision; nothing was applied. |
|
|
889
|
-
| `LP0805` | A route refresh loop was stopped | The same revision asked for a second refresh; the guard refused it. |
|
|
890
|
-
| `LP0806` | A fragment boundary has no fragment client | Configure `fragments` (the endpoint) to render it on the server; until then it is patched. |
|
|
891
|
-
| `LP0501` | A message was rejected before the update pipeline | Reason is one of origin, shape, type, token. Visible with `debug: true`. |
|
|
892
|
-
| `LP0502` | A preview token was rejected | Also reported when your `validateToken` throws — a throwing validator fails closed. |
|
|
893
|
-
| `LP0601` | A consumer event handler threw | Your `on(...)` handler; the runtime continued. |
|
|
894
|
-
| `LP0602` | A consumer transform threw | The original value was kept. |
|
|
895
|
-
| `LP0603` | A renderer threw while writing | That one write was abandoned. |
|
|
896
|
-
| `LP0605` | Runtime startup failed | |
|
|
897
|
-
| `LP0606` | Sending the ready handshake failed | |
|
|
898
|
-
|
|
899
|
-
Codes on the `error` event can be branched on directly:
|
|
180
|
+
## Troubleshooting
|
|
900
181
|
|
|
901
|
-
|
|
902
|
-
|
|
182
|
+
- **Nothing updates.** Run `__livePreview.inspect()` in the preview iframe's console first; it names the cause in most cases. The common ones: the admin origin is missing from `allowedOrigins`, the page is not inside an iframe, the bound element does not exist (an empty field needs an anchor).
|
|
183
|
+
- **Relationship fields show IDs.** Set `serverURL` with `mergeDepth`; Payload 3.x posts unpopulated form values.
|
|
184
|
+
- **The preview iframe refuses to load.** The host sets `X-Frame-Options` or a restrictive `frame-ancestors`. The adapters merge `frame-ancestors` on authorized preview responses; an `X-Frame-Options: DENY` from a proxy must go.
|
|
185
|
+
- **The adapter refuses to start.** The strict default needs `authorizePreview`, a non-empty `allowedOrigins` (`https:` in production) and no referer trust; `defaults: 'v1'` stages a migration one row at a time.
|
|
903
186
|
|
|
904
|
-
|
|
905
|
-
if (e.code === DIAGNOSTIC_CODES.TransformThrew) reportToSentry(e.error);
|
|
906
|
-
});
|
|
907
|
-
```
|
|
187
|
+
`inspect()` readings, `pll doctor` and every diagnostic code: [docs/troubleshooting.md](docs/troubleshooting.md).
|
|
908
188
|
|
|
909
|
-
|
|
910
|
-
is reserved and unassigned.
|
|
189
|
+
## Documentation
|
|
911
190
|
|
|
912
|
-
|
|
191
|
+
The reading path, with a glossary: [docs/README.md](docs/README.md).
|
|
913
192
|
|
|
914
|
-
-
|
|
915
|
-
-
|
|
916
|
-
-
|
|
917
|
-
-
|
|
193
|
+
- Framework guides: [Astro](docs/astro.md) · [Next.js](docs/nextjs.md) · [SvelteKit](docs/sveltekit.md) · [Nuxt](docs/nuxt.md) · [Plain HTML](docs/html.md) · [React hook](docs/react.md) · [Vue composable](docs/vue.md)
|
|
194
|
+
- [docs/bindings.md](docs/bindings.md) — data attributes, field types, owners, typed bindings and codegen
|
|
195
|
+
- [docs/options.md](docs/options.md) — package entries, every option and its default, Payload 3.x population
|
|
196
|
+
- [docs/authorization.md](docs/authorization.md) — strategies, signed tokens, the initial draft read
|
|
197
|
+
- [docs/hybrid.md](docs/hybrid.md) — patch, fragment and route
|
|
198
|
+
- [docs/renderers.md](docs/renderers.md) — events, transforms, renderers and plugins
|
|
199
|
+
- [docs/deployment.md](docs/deployment.md) — CSP, caches, proxies
|
|
200
|
+
- [docs/troubleshooting.md](docs/troubleshooting.md) — `inspect()`, `pll doctor`, diagnostic codes
|
|
201
|
+
- [docs/security.md](docs/security.md) · [docs/migration.md](docs/migration.md) · [docs/interop.md](docs/interop.md) · [docs/reveal.md](docs/reveal.md)
|
|
202
|
+
- Maintainers: [docs/testing.md](docs/testing.md), [docs/benchmarks.md](docs/benchmarks.md), [architecture decisions](docs/architecture/README.md), [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
918
203
|
|
|
919
204
|
## License
|
|
920
205
|
|