payload-live-preview 1.0.3 → 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.
Files changed (71) hide show
  1. package/README.md +146 -88
  2. package/dist/adapters/astro/RichText.astro +8 -4
  3. package/dist/adapters/astro/index.d.ts +84 -9
  4. package/dist/adapters/astro/index.js +1 -363
  5. package/dist/adapters/astro/index.js.map +1 -1
  6. package/dist/adapters/astro/middleware-entry.d.ts +1 -1
  7. package/dist/adapters/astro/middleware-entry.js +1 -296
  8. package/dist/adapters/astro/middleware-entry.js.map +1 -1
  9. package/dist/adapters/nextjs/index.d.ts +24 -10
  10. package/dist/adapters/nextjs/index.js +1 -305
  11. package/dist/adapters/nextjs/index.js.map +1 -1
  12. package/dist/adapters/nuxt/index.d.ts +20 -10
  13. package/dist/adapters/nuxt/index.js +1 -293
  14. package/dist/adapters/nuxt/index.js.map +1 -1
  15. package/dist/adapters/sveltekit/index.d.ts +22 -6
  16. package/dist/adapters/sveltekit/index.js +1 -272
  17. package/dist/adapters/sveltekit/index.js.map +1 -1
  18. package/dist/codegen-astro.js +1 -609
  19. package/dist/codegen-astro.js.map +1 -1
  20. package/dist/codegen-cli.js +1 -665
  21. package/dist/codegen-cli.js.map +1 -1
  22. package/dist/codegen.cjs +1 -550
  23. package/dist/codegen.cjs.map +1 -1
  24. package/dist/codegen.js +1 -546
  25. package/dist/codegen.js.map +1 -1
  26. package/dist/core.cjs +1 -3723
  27. package/dist/core.cjs.map +1 -1
  28. package/dist/core.d.cts +1210 -7
  29. package/dist/core.d.ts +1210 -7
  30. package/dist/core.js +1 -3696
  31. package/dist/core.js.map +1 -1
  32. package/dist/index.cjs +1 -4122
  33. package/dist/index.cjs.map +1 -1
  34. package/dist/index.d.cts +1359 -34
  35. package/dist/index.d.ts +1359 -34
  36. package/dist/index.js +1 -4079
  37. package/dist/index.js.map +1 -1
  38. package/dist/{middleware-BwIlf0jW.d.cts → middleware-CkSeLZ-9.d.ts} +35 -19
  39. package/dist/payload.cjs +1 -30
  40. package/dist/payload.cjs.map +1 -1
  41. package/dist/payload.d.cts +4 -3
  42. package/dist/payload.d.ts +4 -3
  43. package/dist/payload.js +1 -28
  44. package/dist/payload.js.map +1 -1
  45. package/package.json +55 -16
  46. package/dist/adapters/astro/index.cjs +0 -370
  47. package/dist/adapters/astro/index.cjs.map +0 -1
  48. package/dist/adapters/astro/index.d.cts +0 -105
  49. package/dist/adapters/astro/middleware-entry.cjs +0 -303
  50. package/dist/adapters/astro/middleware-entry.cjs.map +0 -1
  51. package/dist/adapters/astro/middleware-entry.d.cts +0 -15
  52. package/dist/adapters/nextjs/index.cjs +0 -309
  53. package/dist/adapters/nextjs/index.cjs.map +0 -1
  54. package/dist/adapters/nextjs/index.d.cts +0 -75
  55. package/dist/adapters/nuxt/index.cjs +0 -299
  56. package/dist/adapters/nuxt/index.cjs.map +0 -1
  57. package/dist/adapters/nuxt/index.d.cts +0 -126
  58. package/dist/adapters/sveltekit/index.cjs +0 -275
  59. package/dist/adapters/sveltekit/index.cjs.map +0 -1
  60. package/dist/adapters/sveltekit/index.d.cts +0 -80
  61. package/dist/bind-BSgxoQhN.d.cts +0 -1137
  62. package/dist/bind-BSgxoQhN.d.ts +0 -1137
  63. package/dist/codegen-astro.cjs +0 -612
  64. package/dist/codegen-astro.cjs.map +0 -1
  65. package/dist/codegen-astro.d.cts +0 -54
  66. package/dist/codegen-cli.cjs +0 -669
  67. package/dist/codegen-cli.cjs.map +0 -1
  68. package/dist/codegen-cli.d.cts +0 -19
  69. package/dist/middleware-BwIlf0jW.d.ts +0 -168
  70. package/dist/preview-request-DAvxm18T.d.cts +0 -68
  71. 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 for a hydrated SPA, but useless for an Astro site. 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.
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** | One TypeScript runtime compiled to a self-contained IIFE (~57 KB raw, ~19 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
- | **Preview-gated injection** | Server adapters inject the runtime only into preview requests (`?preview=true`, `Sec-Fetch-Dest: iframe`, admin referer) — production traffic is untouched. |
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** | No shared mutable client state — event emitter, plugins, renderers, connection, and structural-diff memory all live on the instance. Multiple clients coexist; `destroy()` tears down only its own listeners and clears the `window.__livePreview` handle. (The only module-level state is deterministic, read-only lookup tables.) |
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
+ | | |
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
- | | 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) |
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** (E2E-tested on 4.16 and 7.0), Node ≥ 20.19. Protocol verified against `@payloadcms/live-preview` 3.86 (a weekly CI job watches for wire-format drift).
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' preview detection recognises the request. 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.
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 renders the field **through the same Lexical renderer the runtime uses for live patches** — SSR markup and preview updates cannot diverge, and the binding plus the empty-anchor pattern come for free:
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'`), switch to request-time injection that touches **only preview requests**:
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 preview requests (`?preview=true` / `?draft=true` query, `Sec-Fetch-Dest: iframe`, or an admin referer — restrict via `previewSignals: ['query']` for high-security setups), injects the script into their `<head>`, and merges a `frame-ancestors` CSP directive so the admin may embed the page. Everything else streams through untouched; prerendered pages are skipped. The same middleware can also be registered manually via `createLivePreviewMiddleware()` in `src/middleware.ts` (needed when you want a `shouldInject` callback).
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 you already have your own middleware, compose with the exported building blocks instead:
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
- import { isPreviewRequest, mergeCspHeader, buildFrameAncestors } from 'payload-live-preview';
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
- if (isPreviewRequest(request, { adminOrigins: [ADMIN] })) {
140
- headers.set('content-security-policy', mergeCspHeader(existing, {
141
- 'frame-ancestors': buildFrameAncestors({ origins: [ADMIN] }),
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 requests
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 responses, and merges the CSP header. For manual embedding use `renderLivePreviewScript()` with `useHead`.
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 when rendering a preview request, otherwise editors see stale published content until their first keystroke. `fetchPreviewDocument` / `fetchPreviewGlobal` wrap the query with the right flags:
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
- import { isPreviewRequest, fetchPreviewDocument } from 'payload-live-preview';
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: isPreviewRequest(Astro.request), // published for normal traffic
274
+ draft: authorization !== null, // published for unauthorised/normal traffic
249
275
  depth: 1, // keep equal to mergeDepth!
250
- headers: { Authorization: `users API-Key ${import.meta.env.PAYLOAD_PREVIEW_KEY}` },
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 | Purpose | Example |
257
- |-----------|---------|---------|
258
- | `data-payload-field` | Bind element to a Payload field path | `data-payload-field="hero.title"` |
259
- | `data-payload-type` | Force a specific renderer | `data-payload-type="image"` |
260
- | `data-payload-attribute` | Write the value into an attribute instead of content (unsafe attributes and URLs are refused) | `data-payload-attribute="datetime"` |
261
- | `data-payload-href` | Read `href` from a sibling field | `data-payload-href="ctaUrl"` |
262
- | `data-payload-src` | Read `src` from a sibling field | `data-payload-src="hero.url"` |
263
- | `data-payload-alt` | Read `alt` from a sibling field | `data-payload-alt="hero.alt"` |
264
- | `data-payload-richtext` | Force Lexical rendering (usually auto-detected) | `data-payload-richtext` |
265
- | `data-payload-html` | Render value as sanitised HTML | `data-payload-html` |
266
- | `data-payload-array` | Treat value as an array | `data-payload-array` |
267
- | `data-payload-array-template` | HTML template per array item | `data-payload-array-template="<li>{{title}}</li>"` |
268
- | `data-payload-array-separator` | Separator for primitive arrays | `data-payload-array-separator=" · "` |
269
- | `data-payload-structural` | Use diff-based structural updates | `data-payload-structural` |
270
- | `data-payload-locale` | Override locale for this element | `data-payload-locale="de-AT"` |
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,59 +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) => { if (frozen) e.cancel(); });
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 | Effect |
342
- |--------|--------|
343
- | `highlightPlugin` | Flashes an outline on updated elements (respects reduced-motion). |
344
- | `debugPlugin` | Logs every lifecycle event to the console. |
345
- | `createAnalyticsPlugin()` | Collects update statistics, exposed via `getStats()`. |
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`, the adapters, and `LivePreviewClient`:
351
-
352
- | Option | Default | Meaning |
353
- |--------|---------|---------|
354
- | `allowedOrigins` | `[]` | Trusted admin origins (recommended in production). |
355
- | `serverURL` | — | Payload origin for REST data merging (Payload 3.x population). |
356
- | `apiRoute` | `/api` | REST route prefix used with `serverURL`. |
357
- | `mergeDepth` | `1` | Population depth used with `serverURL`. |
358
- | `debug` | `false` | Verbose console logging. |
359
- | `debounceMs` | `50` | Debounce window for incoming updates. |
360
- | `heartbeatMs` | `0` (off) | Idle timeout. Leave off — Payload sends no keepalive. |
361
- | `enableA11y` | `true` | `aria-live` region announcing connect/update/disconnect. |
362
- | `disableVisibilityGate` | `false` | Apply updates to off-screen elements immediately. |
363
- | `visibilityGateThreshold` | `50` | Cache size above which off-screen updates are queued. |
364
- | `intersectionRootMargin` | `'200px'` | Pre-render margin for the visibility gate. |
365
- | `disableReferrerDetection` | `false` | Opt out of `document.referrer` origin auto-detection. |
366
- | `disableLocalhostMatching` | `false` | Opt out of dev-mode localhost origin matching. |
367
-
368
- Adapter-only options: `inject` (`'preview-only'` default / `'always'`), `previewQueryParams`, `previewSignals` (restrict which signals count — `['query']` for strict setups), `autoInject`, `shouldInject`, `manageCsp` (`'frame-ancestors'` default / `'full'` / `false`), `strictDynamic`, `frameAncestorsExtra`, `scriptSrcExtra`, `nonce`; Astro integration additionally: `mode` (`'inline'` / `'middleware'`).
369
-
370
- Bundle-size note: `import … from 'payload-live-preview/core'` is a lighter entry without the Lexical renderer, plugins, and inline generator. Hot-path timings live in [docs/benchmarks.md](docs/benchmarks.md).
371
-
372
- All three framework wirings are E2E-tested against real apps in `examples/` (Astro 7, Next.js 16, SvelteKit 2 — Chromium, Firefox and WebKit).
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.
373
429
 
374
430
  **How the protocol coverage is layered** (so you know exactly what's proven):
375
431
 
376
- 1. **Browser E2E** (`tests/e2e/`) drives a real browser + real iframe: `postMessage → runtime → DOM`. Its `/admin` page *emulates* the Payload admin.
377
- 2. **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 — closing the "does it handle the shape Payload actually sends?" gap (envelope quirks included: `collectionSlug` absent on a global, `externallyUpdatedRelationship: null`, `_status`/`id` alongside real fields).
378
- 3. **Weekly protocol-watch** (`.github/workflows/protocol-watch.yml`) asserts the wire-format invariants against `@payloadcms/live-preview@latest` **and `@canary`** (Payload 4.0 pre-releases).
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.
379
436
 
380
- A full browser-in-browser E2E against a live Payload backend (real admin driving our runtime end to end) has been verified manually against a real Payload 3.85 + Astro deployment; automating that tier in CI (dockerised Payload) is a tracked follow-up.
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.
381
438
 
382
439
  ## Security model
383
440
 
384
- - **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 valid handshake the detector locks to that exact origin. ⚠️ In referrer-fallback mode any site that frames the page becomes a trusted sender — the runtime logs a warning; set explicit `allowedOrigins` and serve a `frame-ancestors` CSP in production (the adapters do the latter by default on preview responses).
385
- - **HTML sanitisation** — Lexical output and HTML-typed fields run through an isomorphic DOM sanitiser with a curated tag/attribute whitelist (media tags allowed; `<script>`, `<form>`, `<iframe>`, `<svg>`, event handlers, `style` rejected; `srcset` candidates URL-validated).
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.
386
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"`.
387
445
  - **Policed attribute writes** — `data-payload-attribute` refuses event handlers, `style`, `srcdoc`, `formaction`, `id`/`name` (DOM clobbering) and validates URL attributes.
388
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).
@@ -392,10 +450,10 @@ Full details in [docs/security.md](docs/security.md). Report vulnerabilities per
392
450
 
393
451
  ## Troubleshooting
394
452
 
395
- - **Nothing updates** — open the browser console inside the preview iframe (enable `debug: true`). 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 — the runtime warns about orphan updates in debug mode).
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).
396
454
  - **Relationship fields show IDs** — set `serverURL` (Payload 3.x sends unpopulated form values).
397
455
  - **`Referrer-Policy: no-referrer`** on the admin breaks zero-config origin detection — set `allowedOrigins` explicitly.
398
- - **Preview iframe refuses to load** — your host sets `X-Frame-Options` or a restrictive `frame-ancestors`. The adapters' CSP management overrides `frame-ancestors` on preview responses, but `X-Frame-Options: DENY` from a proxy must be removed for preview requests.
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.
399
457
 
400
458
  ## License
401
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 SAME Lexical renderer the runtime
6
- * uses for live updates, so preview patches and SSR output cannot
7
- * diverge. Also solves the two classic integration papercuts:
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-BwIlf0jW.js';
2
- export { a as LivePreviewMiddleware, N as NONCE_LOCALS_KEY, c as createLivePreviewMiddleware } from '../../middleware-BwIlf0jW.js';
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 requests only, and `frame-ancestors` CSP is managed.
28
- * Right choice for `output: 'server'` — production responses ship
29
- * zero preview bytes. (`shouldInject` is not supported in this
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 — pages opened directly (not in an iframe) are
62
- * unaffected.
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
- export { type AstroIntegrationLike, LivePreviewAstroOptions, type RenderScriptOptions, livePreview, renderLivePreviewScript };
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 };