payload-live-preview 1.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/LICENSE +21 -0
- package/README.md +389 -0
- package/dist/adapters/astro/RichText.astro +55 -0
- package/dist/adapters/astro/index.cjs +370 -0
- package/dist/adapters/astro/index.cjs.map +1 -0
- package/dist/adapters/astro/index.d.cts +105 -0
- package/dist/adapters/astro/index.d.ts +105 -0
- package/dist/adapters/astro/index.js +364 -0
- package/dist/adapters/astro/index.js.map +1 -0
- package/dist/adapters/astro/middleware-entry.cjs +303 -0
- package/dist/adapters/astro/middleware-entry.cjs.map +1 -0
- package/dist/adapters/astro/middleware-entry.d.cts +15 -0
- package/dist/adapters/astro/middleware-entry.d.ts +15 -0
- package/dist/adapters/astro/middleware-entry.js +297 -0
- package/dist/adapters/astro/middleware-entry.js.map +1 -0
- package/dist/adapters/nextjs/index.cjs +309 -0
- package/dist/adapters/nextjs/index.cjs.map +1 -0
- package/dist/adapters/nextjs/index.d.cts +75 -0
- package/dist/adapters/nextjs/index.d.ts +75 -0
- package/dist/adapters/nextjs/index.js +306 -0
- package/dist/adapters/nextjs/index.js.map +1 -0
- package/dist/adapters/nuxt/index.cjs +299 -0
- package/dist/adapters/nuxt/index.cjs.map +1 -0
- package/dist/adapters/nuxt/index.d.cts +126 -0
- package/dist/adapters/nuxt/index.d.ts +126 -0
- package/dist/adapters/nuxt/index.js +294 -0
- package/dist/adapters/nuxt/index.js.map +1 -0
- package/dist/adapters/sveltekit/index.cjs +275 -0
- package/dist/adapters/sveltekit/index.cjs.map +1 -0
- package/dist/adapters/sveltekit/index.d.cts +80 -0
- package/dist/adapters/sveltekit/index.d.ts +80 -0
- package/dist/adapters/sveltekit/index.js +273 -0
- package/dist/adapters/sveltekit/index.js.map +1 -0
- package/dist/bind-BSgxoQhN.d.cts +1137 -0
- package/dist/bind-BSgxoQhN.d.ts +1137 -0
- package/dist/codegen-astro.cjs +612 -0
- package/dist/codegen-astro.cjs.map +1 -0
- package/dist/codegen-astro.d.cts +54 -0
- package/dist/codegen-astro.d.ts +54 -0
- package/dist/codegen-astro.js +610 -0
- package/dist/codegen-astro.js.map +1 -0
- package/dist/codegen-cli.cjs +669 -0
- package/dist/codegen-cli.cjs.map +1 -0
- package/dist/codegen-cli.d.cts +19 -0
- package/dist/codegen-cli.d.ts +19 -0
- package/dist/codegen-cli.js +667 -0
- package/dist/codegen-cli.js.map +1 -0
- package/dist/codegen.cjs +551 -0
- package/dist/codegen.cjs.map +1 -0
- package/dist/codegen.d.cts +162 -0
- package/dist/codegen.d.ts +162 -0
- package/dist/codegen.js +547 -0
- package/dist/codegen.js.map +1 -0
- package/dist/core.cjs +3684 -0
- package/dist/core.cjs.map +1 -0
- package/dist/core.d.cts +24 -0
- package/dist/core.d.ts +24 -0
- package/dist/core.js +3657 -0
- package/dist/core.js.map +1 -0
- package/dist/index.cjs +4083 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +472 -0
- package/dist/index.d.ts +472 -0
- package/dist/index.js +4040 -0
- package/dist/index.js.map +1 -0
- package/dist/middleware-BwIlf0jW.d.cts +168 -0
- package/dist/middleware-BwIlf0jW.d.ts +168 -0
- package/dist/payload.cjs +31 -0
- package/dist/payload.cjs.map +1 -0
- package/dist/payload.d.cts +87 -0
- package/dist/payload.d.ts +87 -0
- package/dist/payload.js +29 -0
- package/dist/payload.js.map +1 -0
- package/dist/preview-request-DAvxm18T.d.cts +68 -0
- package/dist/preview-request-DAvxm18T.d.ts +68 -0
- package/package.json +186 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 relative23
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
# payload-live-preview
|
|
2
|
+
|
|
3
|
+
[](https://github.com/relative23/payload-live-preview/actions/workflows/ci.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](package.json)
|
|
6
|
+
[](https://payloadcms.com)
|
|
7
|
+
|
|
8
|
+
> Framework-agnostic live preview for Payload CMS. Astro-first; works with Next.js, SvelteKit, Nuxt, and plain HTML.
|
|
9
|
+
|
|
10
|
+
Bind DOM elements to Payload fields with `data-payload-*` attributes; the runtime listens for the Payload admin's `postMessage` updates and patches the DOM in place — no framework re-render, no page reload.
|
|
11
|
+
|
|
12
|
+
## Highlights
|
|
13
|
+
|
|
14
|
+
| | |
|
|
15
|
+
|---|---|
|
|
16
|
+
| **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. |
|
|
17
|
+
| **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. |
|
|
18
|
+
| **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. |
|
|
19
|
+
| **Preview-gated injection** | Server adapters inject the runtime only into preview requests (`?preview=true`, `Sec-Fetch-Dest: iframe`, admin referer) — production traffic is untouched. |
|
|
20
|
+
| **Strict security** | Escape-by-default sanitizer with curated whitelist, URL and `srcset` validation, prototype-pollution guards, policed attribute writes, CSP helpers with union-merge. |
|
|
21
|
+
| **Per-instance** | No module-level singletons — multiple clients coexist, `destroy()` only affects its own listeners. |
|
|
22
|
+
| **Typed DSL + codegen** | `pll-codegen` emits TypeScript interfaces from your `payload.config.ts`; `bind<T>()` gives compile-time-checked field bindings. |
|
|
23
|
+
| **First-class adapters** | Astro (integration + middleware), Next.js, SvelteKit, Nuxt — all share the same core. |
|
|
24
|
+
|
|
25
|
+
## Compatibility
|
|
26
|
+
|
|
27
|
+
| | Payload 2.x | Payload 3.x |
|
|
28
|
+
|---|---|---|
|
|
29
|
+
| Scalar field updates | ✅ | ✅ |
|
|
30
|
+
| Rich text (Lexical) | ✅ | ✅ |
|
|
31
|
+
| Relationship / upload population | ✅ (admin merges client-side) | ✅ with `serverURL` (REST merge) |
|
|
32
|
+
| Schema-driven field typing (`fieldSchemaJSON`) | ✅ | — (3.x removed it; DOM heuristics + Lexical auto-detection take over) |
|
|
33
|
+
|
|
34
|
+
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).
|
|
35
|
+
|
|
36
|
+
**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.
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
```bash
|
|
41
|
+
npm install payload-live-preview
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Configure Payload
|
|
45
|
+
|
|
46
|
+
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:
|
|
47
|
+
|
|
48
|
+
```ts
|
|
49
|
+
import { buildLivePreviewUrl } from 'payload-live-preview/payload';
|
|
50
|
+
|
|
51
|
+
export default buildConfig({
|
|
52
|
+
admin: {
|
|
53
|
+
livePreview: {
|
|
54
|
+
url: buildLivePreviewUrl({
|
|
55
|
+
baseUrl: process.env.FRONTEND_URL ?? 'http://localhost:4321',
|
|
56
|
+
collections: {
|
|
57
|
+
posts: ({ data }) => `/blog/${String(data.slug ?? '')}`,
|
|
58
|
+
services: ({ data, locale }) => `/${locale}/services/${String(data.slug ?? '')}`,
|
|
59
|
+
},
|
|
60
|
+
globals: {
|
|
61
|
+
homepage: '/',
|
|
62
|
+
},
|
|
63
|
+
fallback: '/', // new drafts without a slug land here
|
|
64
|
+
}),
|
|
65
|
+
breakpoints: [
|
|
66
|
+
{ label: 'Mobile', name: 'mobile', width: 375, height: 667 },
|
|
67
|
+
{ label: 'Desktop', name: 'desktop', width: 1440, height: 900 },
|
|
68
|
+
],
|
|
69
|
+
collections: ['posts', 'services'],
|
|
70
|
+
globals: ['homepage'],
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
});
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
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.
|
|
77
|
+
|
|
78
|
+
## Quick start
|
|
79
|
+
|
|
80
|
+
### Astro
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
// astro.config.mjs
|
|
84
|
+
import { defineConfig } from 'astro/config';
|
|
85
|
+
import { livePreview } from 'payload-live-preview/astro';
|
|
86
|
+
|
|
87
|
+
export default defineConfig({
|
|
88
|
+
integrations: [
|
|
89
|
+
livePreview({
|
|
90
|
+
allowedOrigins: [import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN],
|
|
91
|
+
// Payload 3.x: populate relationship/upload fields via REST merge
|
|
92
|
+
serverURL: import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN,
|
|
93
|
+
}),
|
|
94
|
+
],
|
|
95
|
+
});
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Annotate the elements you want bound:
|
|
99
|
+
|
|
100
|
+
```astro
|
|
101
|
+
<h1 data-payload-field="title">{title}</h1>
|
|
102
|
+
<div data-payload-field="body">…server-rendered rich text…</div>
|
|
103
|
+
<img data-payload-field="hero" alt={alt} src={url} />
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
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.
|
|
107
|
+
|
|
108
|
+
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:
|
|
109
|
+
|
|
110
|
+
```astro
|
|
111
|
+
---
|
|
112
|
+
import RichText from 'payload-live-preview/astro/RichText.astro';
|
|
113
|
+
---
|
|
114
|
+
<RichText value={page.body} field="body" class="prose" />
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
**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**:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
livePreview({
|
|
121
|
+
mode: 'middleware',
|
|
122
|
+
allowedOrigins: [import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN],
|
|
123
|
+
serverURL: import.meta.env.PUBLIC_PAYLOAD_ADMIN_ORIGIN,
|
|
124
|
+
}),
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
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).
|
|
128
|
+
|
|
129
|
+
If you already have your own middleware, compose with the exported building blocks instead:
|
|
130
|
+
|
|
131
|
+
```ts
|
|
132
|
+
import { isPreviewRequest, mergeCspHeader, buildFrameAncestors } from 'payload-live-preview';
|
|
133
|
+
|
|
134
|
+
if (isPreviewRequest(request, { adminOrigins: [ADMIN] })) {
|
|
135
|
+
headers.set('content-security-policy', mergeCspHeader(existing, {
|
|
136
|
+
'frame-ancestors': buildFrameAncestors({ origins: [ADMIN] }),
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### Next.js (App Router)
|
|
142
|
+
|
|
143
|
+
> **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.
|
|
144
|
+
|
|
145
|
+
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:
|
|
146
|
+
|
|
147
|
+
```tsx
|
|
148
|
+
// app/layout.tsx — script executes because it is part of the SSR HTML
|
|
149
|
+
import { generateInlineScript } from 'payload-live-preview';
|
|
150
|
+
|
|
151
|
+
const previewScript = generateInlineScript({
|
|
152
|
+
allowedOrigins: [process.env.PAYLOAD_ADMIN_ORIGIN!],
|
|
153
|
+
serverURL: process.env.PAYLOAD_ADMIN_ORIGIN!,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
157
|
+
return (
|
|
158
|
+
<html>
|
|
159
|
+
<head>
|
|
160
|
+
<script dangerouslySetInnerHTML={{ __html: previewScript }} />
|
|
161
|
+
</head>
|
|
162
|
+
<body>{children}</body>
|
|
163
|
+
</html>
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
// middleware.ts — frame-ancestors on preview requests
|
|
170
|
+
import { NextResponse, type NextRequest } from 'next/server';
|
|
171
|
+
import { createLivePreviewMiddleware } from 'payload-live-preview/nextjs';
|
|
172
|
+
|
|
173
|
+
const livePreview = createLivePreviewMiddleware({
|
|
174
|
+
allowedOrigins: [process.env.PAYLOAD_ADMIN_ORIGIN!],
|
|
175
|
+
autoInject: false,
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
export async function middleware(request: NextRequest) {
|
|
179
|
+
return livePreview(request, NextResponse.next());
|
|
180
|
+
}
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### SvelteKit
|
|
184
|
+
|
|
185
|
+
```ts
|
|
186
|
+
// src/hooks.server.ts
|
|
187
|
+
import { livePreviewHandle } from 'payload-live-preview/sveltekit';
|
|
188
|
+
export const handle = livePreviewHandle({
|
|
189
|
+
allowedOrigins: [process.env.PAYLOAD_ADMIN_ORIGIN!],
|
|
190
|
+
serverURL: process.env.PAYLOAD_ADMIN_ORIGIN!,
|
|
191
|
+
});
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
### Nuxt 3
|
|
195
|
+
|
|
196
|
+
```ts
|
|
197
|
+
// server/plugins/live-preview.ts
|
|
198
|
+
import { livePreviewNitroPlugin } from 'payload-live-preview/nuxt';
|
|
199
|
+
|
|
200
|
+
export default defineNitroPlugin(
|
|
201
|
+
livePreviewNitroPlugin({
|
|
202
|
+
allowedOrigins: [process.env.NUXT_PUBLIC_PAYLOAD_ADMIN_ORIGIN!],
|
|
203
|
+
serverURL: process.env.NUXT_PUBLIC_PAYLOAD_ADMIN_ORIGIN!,
|
|
204
|
+
}),
|
|
205
|
+
);
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
The plugin hooks `render:html`, injects the script into preview responses, and merges the CSP header. For manual embedding use `renderLivePreviewScript()` with `useHead`.
|
|
209
|
+
|
|
210
|
+
> 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.
|
|
211
|
+
|
|
212
|
+
### Plain HTML (advanced)
|
|
213
|
+
|
|
214
|
+
```ts
|
|
215
|
+
import { generateInlineScript, wrapWithScriptTag } from 'payload-live-preview';
|
|
216
|
+
const script = generateInlineScript({
|
|
217
|
+
allowedOrigins: ['https://admin.example.com'],
|
|
218
|
+
serverURL: 'https://admin.example.com',
|
|
219
|
+
});
|
|
220
|
+
// Inject via `<script>${script}</script>` — or wrapWithScriptTag(script, { nonce }).
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Payload 3.x: populated relationships (`serverURL`)
|
|
224
|
+
|
|
225
|
+
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.
|
|
226
|
+
|
|
227
|
+
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`).
|
|
228
|
+
|
|
229
|
+
⚠️ **`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.
|
|
230
|
+
|
|
231
|
+
## Draft documents on first load
|
|
232
|
+
|
|
233
|
+
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:
|
|
234
|
+
|
|
235
|
+
```ts
|
|
236
|
+
// Astro example — in your page/loader code
|
|
237
|
+
import { isPreviewRequest, fetchPreviewDocument } from 'payload-live-preview';
|
|
238
|
+
|
|
239
|
+
const page = await fetchPreviewDocument<Page>({
|
|
240
|
+
serverURL: import.meta.env.PAYLOAD_URL,
|
|
241
|
+
collection: 'pages',
|
|
242
|
+
where: { slug: { equals: Astro.params.slug } },
|
|
243
|
+
draft: isPreviewRequest(Astro.request), // published for normal traffic
|
|
244
|
+
depth: 1, // keep equal to mergeDepth!
|
|
245
|
+
headers: { Authorization: `users API-Key ${import.meta.env.PAYLOAD_PREVIEW_KEY}` },
|
|
246
|
+
});
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
## Data-attribute reference
|
|
250
|
+
|
|
251
|
+
| Attribute | Purpose | Example |
|
|
252
|
+
|-----------|---------|---------|
|
|
253
|
+
| `data-payload-field` | Bind element to a Payload field path | `data-payload-field="hero.title"` |
|
|
254
|
+
| `data-payload-type` | Force a specific renderer | `data-payload-type="image"` |
|
|
255
|
+
| `data-payload-attribute` | Write the value into an attribute instead of content (unsafe attributes and URLs are refused) | `data-payload-attribute="datetime"` |
|
|
256
|
+
| `data-payload-href` | Read `href` from a sibling field | `data-payload-href="ctaUrl"` |
|
|
257
|
+
| `data-payload-src` | Read `src` from a sibling field | `data-payload-src="hero.url"` |
|
|
258
|
+
| `data-payload-alt` | Read `alt` from a sibling field | `data-payload-alt="hero.alt"` |
|
|
259
|
+
| `data-payload-richtext` | Force Lexical rendering (usually auto-detected) | `data-payload-richtext` |
|
|
260
|
+
| `data-payload-html` | Render value as sanitised HTML | `data-payload-html` |
|
|
261
|
+
| `data-payload-array` | Treat value as an array | `data-payload-array` |
|
|
262
|
+
| `data-payload-array-template` | HTML template per array item | `data-payload-array-template="<li>{{title}}</li>"` |
|
|
263
|
+
| `data-payload-array-separator` | Separator for primitive arrays | `data-payload-array-separator=" · "` |
|
|
264
|
+
| `data-payload-structural` | Use diff-based structural updates | `data-payload-structural` |
|
|
265
|
+
| `data-payload-locale` | Override locale for this element | `data-payload-locale="de-AT"` |
|
|
266
|
+
|
|
267
|
+
**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:
|
|
268
|
+
|
|
269
|
+
```astro
|
|
270
|
+
<div data-payload-field="subtitle">{subtitle ?? ''}</div>
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
## Field types
|
|
274
|
+
|
|
275
|
+
`text` · `textarea` · `richText` · `html` · `email` · `number` · `checkbox` · `date` · `select` · `radio` · `relationship` · `upload` · `image` · `url` · `array` · `blocks` · `structural-array`
|
|
276
|
+
|
|
277
|
+
Custom renderers register via the plugin system:
|
|
278
|
+
|
|
279
|
+
```ts
|
|
280
|
+
import { LivePreviewClient } from 'payload-live-preview';
|
|
281
|
+
|
|
282
|
+
const client = new LivePreviewClient({ allowedOrigins: ['https://admin.example.com'] });
|
|
283
|
+
await client.use({
|
|
284
|
+
name: 'currency',
|
|
285
|
+
init: (ctx) => {
|
|
286
|
+
ctx.registerFieldRenderer({
|
|
287
|
+
name: 'text',
|
|
288
|
+
render: (target, value) => {
|
|
289
|
+
target.element.textContent = new Intl.NumberFormat('de-AT', {
|
|
290
|
+
style: 'currency',
|
|
291
|
+
currency: 'EUR',
|
|
292
|
+
}).format(Number(value));
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
},
|
|
296
|
+
});
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## Typed bindings and codegen
|
|
300
|
+
|
|
301
|
+
Generate interfaces straight from your Payload config:
|
|
302
|
+
|
|
303
|
+
```bash
|
|
304
|
+
npx pll-codegen --config ../backend/src/payload.config.ts --out src/lib/bind-types.ts
|
|
305
|
+
```
|
|
306
|
+
|
|
307
|
+
(`ts-morph` must be installed — it is an optional peer dependency. Flags: `-c/--config`, `-o/--out`, `--tsconfig`, `-q/--quiet`.)
|
|
308
|
+
|
|
309
|
+
Then bind with compile-time checking:
|
|
310
|
+
|
|
311
|
+
```astro
|
|
312
|
+
---
|
|
313
|
+
import { bind } from 'payload-live-preview';
|
|
314
|
+
import type { Homepage } from '../lib/bind-types';
|
|
315
|
+
---
|
|
316
|
+
<h1 {...bind<Homepage>('heroTitle')}>{data.heroTitle}</h1>
|
|
317
|
+
<img {...bind<Homepage>('heroImage', { attribute: 'src' })} />
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
`bind('title')` emits `data-payload-field="title"`; misspelled field names fail the build. `bindByPath<T>(d => d.hero.title)` is the rename-safe proxy variant. There is also an Astro codegen integration: `import { livePreviewCodegen } from 'payload-live-preview/codegen/astro'`.
|
|
321
|
+
|
|
322
|
+
## Events and plugins
|
|
323
|
+
|
|
324
|
+
```ts
|
|
325
|
+
const client = new LivePreviewClient({ allowedOrigins: [ADMIN] });
|
|
326
|
+
|
|
327
|
+
client.events.on('connect', (e) => console.log('connected to', e.origin));
|
|
328
|
+
client.events.on('beforeUpdate', (e) => { if (frozen) e.cancel(); });
|
|
329
|
+
client.events.on('documentSave', () => location.reload());
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
Events: `init` · `connect` · `disconnect` · `beforeUpdate` · `afterUpdate` · `elementUpdate` · `documentSave` · `cacheRefresh` · `error` · `destroy`.
|
|
333
|
+
|
|
334
|
+
Built-in plugins:
|
|
335
|
+
|
|
336
|
+
| Plugin | Effect |
|
|
337
|
+
|--------|--------|
|
|
338
|
+
| `highlightPlugin` | Flashes an outline on updated elements (respects reduced-motion). |
|
|
339
|
+
| `debugPlugin` | Logs every lifecycle event to the console. |
|
|
340
|
+
| `createAnalyticsPlugin()` | Collects update statistics, exposed via `getStats()`. |
|
|
341
|
+
| `documentSavePlugin({ strategy })` | Reacts to admin saves: `'silent'` · `'reload'` (scroll-preserving) · `'revalidate'` · `'fetch'`. |
|
|
342
|
+
|
|
343
|
+
## Configuration reference
|
|
344
|
+
|
|
345
|
+
Options accepted by `generateInlineScript`, the adapters, and `LivePreviewClient`:
|
|
346
|
+
|
|
347
|
+
| Option | Default | Meaning |
|
|
348
|
+
|--------|---------|---------|
|
|
349
|
+
| `allowedOrigins` | `[]` | Trusted admin origins (recommended in production). |
|
|
350
|
+
| `serverURL` | — | Payload origin for REST data merging (Payload 3.x population). |
|
|
351
|
+
| `apiRoute` | `/api` | REST route prefix used with `serverURL`. |
|
|
352
|
+
| `mergeDepth` | `1` | Population depth used with `serverURL`. |
|
|
353
|
+
| `debug` | `false` | Verbose console logging. |
|
|
354
|
+
| `debounceMs` | `50` | Debounce window for incoming updates. |
|
|
355
|
+
| `heartbeatMs` | `0` (off) | Idle timeout. Leave off — Payload sends no keepalive. |
|
|
356
|
+
| `enableA11y` | `true` | `aria-live` region announcing connect/update/disconnect. |
|
|
357
|
+
| `disableVisibilityGate` | `false` | Apply updates to off-screen elements immediately. |
|
|
358
|
+
| `visibilityGateThreshold` | `50` | Cache size above which off-screen updates are queued. |
|
|
359
|
+
| `intersectionRootMargin` | `'200px'` | Pre-render margin for the visibility gate. |
|
|
360
|
+
| `disableReferrerDetection` | `false` | Opt out of `document.referrer` origin auto-detection. |
|
|
361
|
+
| `disableLocalhostMatching` | `false` | Opt out of dev-mode localhost origin matching. |
|
|
362
|
+
|
|
363
|
+
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'`).
|
|
364
|
+
|
|
365
|
+
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).
|
|
366
|
+
|
|
367
|
+
All three framework wirings are E2E-tested against real apps in `examples/` (Astro 7, Next.js 16, SvelteKit 2 — Chromium, Firefox and WebKit).
|
|
368
|
+
|
|
369
|
+
## Security model
|
|
370
|
+
|
|
371
|
+
- **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).
|
|
372
|
+
- **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).
|
|
373
|
+
- **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"`.
|
|
374
|
+
- **Policed attribute writes** — `data-payload-attribute` refuses event handlers, `style`, `srcdoc`, `formaction`, `id`/`name` (DOM clobbering) and validates URL attributes.
|
|
375
|
+
- **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).
|
|
376
|
+
- **No prototype pollution** — nested field lookups refuse `__proto__`, `prototype`, `constructor`; incoming data is never merged into existing objects.
|
|
377
|
+
|
|
378
|
+
Full details in [docs/security.md](docs/security.md). Report vulnerabilities per [SECURITY.md](SECURITY.md).
|
|
379
|
+
|
|
380
|
+
## Troubleshooting
|
|
381
|
+
|
|
382
|
+
- **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).
|
|
383
|
+
- **Relationship fields show IDs** — set `serverURL` (Payload 3.x sends unpopulated form values).
|
|
384
|
+
- **`Referrer-Policy: no-referrer`** on the admin breaks zero-config origin detection — set `allowedOrigins` explicitly.
|
|
385
|
+
- **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.
|
|
386
|
+
|
|
387
|
+
## License
|
|
388
|
+
|
|
389
|
+
MIT © relative23
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
---
|
|
2
|
+
/**
|
|
3
|
+
* Server-rendered Lexical rich text with a live-preview binding.
|
|
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:
|
|
8
|
+
*
|
|
9
|
+
* - **Empty-anchor pattern**: the wrapper renders even when the
|
|
10
|
+
* value is empty, so the runtime has a node to patch when the
|
|
11
|
+
* editor fills a previously-empty field.
|
|
12
|
+
* - **One-attribute binding**: emits `data-payload-field` +
|
|
13
|
+
* `data-payload-richtext` for you.
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
*
|
|
17
|
+
* ```astro
|
|
18
|
+
* ---
|
|
19
|
+
* import RichText from 'payload-live-preview/astro/RichText.astro';
|
|
20
|
+
* ---
|
|
21
|
+
* <RichText value={page.body} field="body" class="prose" />
|
|
22
|
+
* ```
|
|
23
|
+
*
|
|
24
|
+
* Custom block renderers registered via `registerBlockRenderer` (from
|
|
25
|
+
* the main entry) apply here as well — register them in a module
|
|
26
|
+
* imported by your page before this component renders.
|
|
27
|
+
*/
|
|
28
|
+
// Self-referencing import so the shipped copy in dist/ resolves
|
|
29
|
+
// through the package exports map in consumer projects.
|
|
30
|
+
import { isLexicalContent, lexicalToHtml } from 'payload-live-preview';
|
|
31
|
+
|
|
32
|
+
interface Props {
|
|
33
|
+
/** The Lexical value from Payload (may be null/undefined/empty). */
|
|
34
|
+
value: unknown;
|
|
35
|
+
/**
|
|
36
|
+
* Payload field path to bind for live preview, e.g. `"body"` or
|
|
37
|
+
* `"sections.intro"`. Omit to render without a live binding.
|
|
38
|
+
*/
|
|
39
|
+
field?: string;
|
|
40
|
+
/** Wrapper element. Default `div`. */
|
|
41
|
+
as?: string;
|
|
42
|
+
class?: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const { value, field, as: tag = 'div', class: className } = Astro.props;
|
|
46
|
+
const Tag = tag;
|
|
47
|
+
const html = isLexicalContent(value) ? lexicalToHtml(value) : '';
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
<Tag
|
|
51
|
+
class={className}
|
|
52
|
+
data-payload-field={field}
|
|
53
|
+
data-payload-richtext={field !== undefined ? '' : undefined}
|
|
54
|
+
set:html={html}
|
|
55
|
+
/>
|