@preprio/toolkit 0.5.0-rc.3 → 0.5.0-rc.5

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 (39) hide show
  1. package/README.md +226 -151
  2. package/dist/astro/index.cjs +27 -7
  3. package/dist/astro/index.js +27 -7
  4. package/dist/{chunk-SXUDBGLZ.js → chunk-3X55JL52.js} +1 -1
  5. package/dist/{chunk-42Z2VW7G.js → chunk-74KY5EAM.js} +67 -9
  6. package/dist/{chunk-MDAV2FLM.js → chunk-CMUFTCG3.js} +1 -1
  7. package/dist/{chunk-PP3O7RB4.js → chunk-SBLZANL5.js} +1 -1
  8. package/dist/{chunk-6WY3MWCX.js → chunk-TG6N3C22.js} +1 -1
  9. package/dist/index.cjs +68 -10
  10. package/dist/index.d.cts +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/index.js +5 -5
  13. package/dist/{live-preview-EADA3ZL4.js → live-preview-H4643QYF.js} +2 -2
  14. package/dist/nextjs/components.cjs +1 -1
  15. package/dist/nextjs/components.js +2 -2
  16. package/dist/nextjs/index.cjs +27 -7
  17. package/dist/nextjs/index.d.cts +2 -0
  18. package/dist/nextjs/index.d.ts +2 -0
  19. package/dist/nextjs/index.js +27 -7
  20. package/dist/nextjs/live-action.js +3 -3
  21. package/dist/nextjs/live.cjs +73 -12
  22. package/dist/nextjs/live.d.cts +3 -1
  23. package/dist/nextjs/live.d.ts +3 -1
  24. package/dist/nextjs/live.js +5 -2
  25. package/dist/nuxt/index.cjs +1 -1
  26. package/dist/nuxt/index.js +1 -1
  27. package/dist/nuxt/{live-preview-PTEC3GPC.js → live-preview-O2ZTJNW4.js} +67 -9
  28. package/dist/nuxt/live.cjs +69 -11
  29. package/dist/nuxt/live.js +1 -1
  30. package/dist/react/components.cjs +1 -1
  31. package/dist/react/components.js +1 -1
  32. package/dist/sveltekit/index.cjs +1 -1
  33. package/dist/sveltekit/index.js +1 -1
  34. package/dist/sveltekit/live-preview-O2ZTJNW4.js +1520 -0
  35. package/dist/sveltekit/live.cjs +1731 -0
  36. package/dist/sveltekit/live.d.cts +59 -0
  37. package/dist/sveltekit/live.d.ts +59 -0
  38. package/dist/sveltekit/live.js +97 -0
  39. package/package.json +6 -1
package/README.md CHANGED
@@ -17,8 +17,9 @@ Every integration is the same three steps:
17
17
  1. **Middleware** — resolves the visitor's customer ID, segment, and A/B variant into request headers.
18
18
  2. **Layout** — mounts the preview toolbar and the tracking pixel.
19
19
  3. **Data fetching** — forwards the Prepr headers on your GraphQL requests, so Prepr returns personalized content.
20
+ 4. **Live preview** (optional) — shows the editor's unsaved changes in Prepr's side-by-side preview while they type. See [Live preview](#live-preview).
20
21
 
21
- Jump to your framework: [Next.js](#nextjs) · [Astro](#astro) · [SvelteKit](#sveltekit) · [Nuxt](#nuxt) · [React without a framework](#react-no-framework) · [anything else](#any-other-framework).
22
+ Jump to your framework: [Next.js](#nextjs) · [Astro](#astro) · [SvelteKit](#sveltekit) · [Nuxt](#nuxt) · [React without a framework](#react-no-framework) · [anything else](#any-other-framework) · [Live preview](#live-preview).
22
23
 
23
24
  Coming from `@preprio/prepr-nextjs`? See the [migration guide](https://github.com/preprio/prepr-toolkit/blob/main/MIGRATION.md).
24
25
 
@@ -122,9 +123,12 @@ Per-framework peer dependencies — all optional; installing without them is fin
122
123
  | `@preprio/toolkit` (vanilla core) | none |
123
124
  | `@preprio/toolkit/react` | `react` >= 17, `react-dom` >= 17 |
124
125
  | `@preprio/toolkit/nextjs` | `next` >= 13, `react` >= 17, `react-dom` >= 17 |
126
+ | `@preprio/toolkit/nextjs/live` | `next` >= 13, `react` >= 18 (live preview) |
125
127
  | `@preprio/toolkit/astro` | none (`.astro` components compile in your own pipeline) |
126
128
  | `@preprio/toolkit/sveltekit` | `svelte` (only for the `.svelte` components) |
129
+ | `@preprio/toolkit/sveltekit/live` | none (returns a plain Svelte store) |
127
130
  | `@preprio/toolkit/nuxt` | `vue` (only for the `.vue` components) |
131
+ | `@preprio/toolkit/nuxt/live` | `vue` >= 3.3 |
128
132
 
129
133
  ## The preview gate
130
134
 
@@ -250,154 +254,83 @@ The loader rewrites `w_` to the width Next asks for and scales `h_` alongside it
250
254
 
251
255
  See the runnable example at `examples/nextjs` for the full source of truth.
252
256
 
253
- ### Live preview (beta)
257
+ ### Live preview in Next.js
254
258
 
255
- True live preview inside the Prepr editor iframe: keystrokes patch your query
256
- results instantly by item `_id`; saves refetch the affected queries.
259
+ Two additions turn on [live preview](#live-preview) for an App Router site.
260
+ Both take your preview flag, the same one you pass the middleware.
257
261
 
258
- **Requirements.** Live preview runs only where the Prepr middleware handles
259
- the request with `preview: true` (`createPreprMiddleware(request, { preview })`
260
- in `proxy.ts`). Make sure your middleware `matcher` covers every page you
261
- preview: on a page it skips, live preview does nothing and shows no error.
262
- Pass your preview flag as `enabled` to every live preview entry point below;
263
- each one does nothing without it.
262
+ **1. Run each query result through `normalizeQuery`**, with whatever client
263
+ you fetch with:
264
264
 
265
- Live updates and click-to-edit find content by id, never by position, so
266
- they stay correct while an editor adds, removes or reorders blocks before
267
- saving:
265
+ ```ts
266
+ import { normalizeQuery } from '@preprio/toolkit/nextjs';
268
267
 
269
- - Select `_id` (and ideally `__typename`) on every model and component level
270
- that should live-update. Items without `_id` silently stay static.
271
- - In dynamic content fields, select `_id` on every entry type (`Text`,
272
- `Quote`, …). `Assets` entries have no `_id` of their own: select
273
- `items { _id }` instead.
274
- - Render each dynamic content entry inside an element marked with
275
- `data-prepr-edit-target="<field>.<index>"` and
276
- `data-prepr-entry-id="<entry _id>"` (for `Assets`, the first asset's
277
- `_id`). The index is the fallback when no id matches.
278
- - Values without a hidden edit link (images, videos, other URLs) are made
279
- click-to-edit with `previewAttrs({ id, field }, { enabled: isPreview })`
280
- on the element that renders them.
281
- - Where the page renders a linked item whose content comes from items nested
282
- inside it (for example a personalization or a shared section referenced
283
- from a stack), wrap it in an element with
284
- `data-prepr-reference="<linked item _id>"`. The editor only knows the items
285
- the open item links to directly; content inside the wrapper is outlined as
286
- that linked item, and clicking it opens the link in the editor.
268
+ const preview = process.env.VERCEL_ENV !== 'production';
287
269
 
288
- Configure once in a small client component mounted in your root layout:
270
+ const data = await fetchFromPrepr(GetPageDocument, { slug }); // any client
271
+ const page = await normalizeQuery(GetPageDocument, data, { enabled: preview });
272
+ ```
273
+
274
+ **2. Mount `PreprRefresh` once in the root layout:**
289
275
 
290
276
  ```tsx
291
- 'use client';
292
- import { useEffect } from 'react';
293
- import { initLivePreview } from '@preprio/toolkit/nextjs/live';
277
+ import { PreprRefresh } from '@preprio/toolkit/nextjs/live';
294
278
 
295
- export default function LivePreviewInit({ preview }: { preview: boolean }) {
296
- useEffect(() => {
297
- initLivePreview({
298
- // Your preview flag. Without it nothing starts.
299
- enabled: preview,
300
- // Runs a registered query against Prepr GraphQL. `query` is whatever
301
- // you registered (string or DocumentNode) — you own the transport.
302
- fetcher: async (query, variables) => {
303
- /* POST to your Prepr GraphQL endpoint, return json.data */
304
- },
305
- });
306
- }, [preview]);
307
- return null;
279
+ {
280
+ preview && <PreprRefresh enabled />;
308
281
  }
309
282
  ```
310
283
 
311
- Register query results in client components with `useLive`:
284
+ While an editor types, `PreprRefresh` sends the unsaved changes to a server
285
+ action and the route re-renders with them; on save it calls
286
+ `router.refresh()` for the stored content. Server components need nothing
287
+ else. Requires Next.js 16 or later; on Next.js 15 and older pages update on
288
+ save only.
289
+
290
+ **Client components** can skip the server round trip: wrap the data they
291
+ render in `useLive`, and keystrokes patch it in the browser. Import it from
292
+ `@preprio/toolkit/nextjs/live` — the `/nextjs` barrel also exports
293
+ server-only helpers (`next/headers`), which a client module graph must not
294
+ include.
312
295
 
313
296
  ```tsx
314
297
  'use client';
315
298
  import { useLive } from '@preprio/toolkit/nextjs/live';
316
299
 
317
- export default function PageContent({ data, slug }) {
318
- const liveData = useLive(data, {
319
- query: GetPageBySlugDocument,
320
- variables: { slug },
321
- });
322
- // render from liveData — it re-renders on editor keystrokes and saves
300
+ export default function Hero({ hero }: { hero: HeroFragment }) {
301
+ const live = useLive(hero);
302
+ return <h1>{live.title}</h1>;
323
303
  }
324
304
  ```
325
305
 
326
- In `'use client'` files always import from `@preprio/toolkit/nextjs/live` —
327
- the `/nextjs` barrel also exports server-only helpers (`next/headers`), which
328
- a client module graph must not include.
329
-
330
- Omitting `query`/`variables` degrades that registration to keystroke pushes
331
- only. For server-component pages, mount `<PreprRefresh enabled={preview} />`
332
- instead — it calls `router.refresh()` on every save. Don't combine
333
- `PreprRefresh` with `useLive` registrations that pass a `query`: a save would
334
- both refetch and refresh. `useLive` without a `query` works alongside it.
335
-
336
- Without `enabled`, and outside the Prepr editor iframe, all of this is a
337
- passthrough with zero overhead. The CMS editor must emit `prepr:fieldChange` (per keystroke) and
338
- `prepr:itemChanged` (on save, with the root content item id) into the preview
339
- iframe — `examples/editor-emulator` emulates this for local development.
340
- Unsaved structural changes (adding/reordering components) appear after save;
341
- typed text is live immediately.
342
-
343
- #### Unsaved changes in server components: `normalizeQuery` (experimental, Next.js 16+)
344
-
345
- Server components show the editor's unsaved changes when you pass their query
346
- results through `normalizeQuery`, with whatever client you fetch with:
306
+ **Client-fetched data** (no server components): instead of `PreprRefresh`,
307
+ call `initLivePreview({ enabled: preview, fetcher })` once in a client
308
+ component, and pass `{ query, variables }` to `useLive`. On save the toolkit
309
+ runs the query again through your `fetcher`:
347
310
 
348
- ```ts
349
- import { normalizeQuery } from '@preprio/toolkit/nextjs';
311
+ ```tsx
312
+ 'use client';
313
+ import { useEffect } from 'react';
314
+ import { initLivePreview, useLive } from '@preprio/toolkit/nextjs/live';
350
315
 
351
- const preview = process.env.VERCEL_ENV !== 'production';
316
+ export function LivePreviewInit({ preview }: { preview: boolean }) {
317
+ useEffect(() => {
318
+ initLivePreview({
319
+ enabled: preview,
320
+ // Runs a registered query against Prepr GraphQL; return `json.data`.
321
+ fetcher: (query, variables) => fetchFromPrepr(query, variables),
322
+ });
323
+ }, [preview]);
324
+ return null;
325
+ }
352
326
 
353
- const data = await fetchFromPrepr(GetPageDocument, { slug }); // any client
354
- const page = await normalizeQuery(GetPageDocument, data, { enabled: preview });
327
+ // In a client component:
328
+ const liveData = useLive(data, { query: GetPageDocument, variables: { slug } });
355
329
  ```
356
330
 
357
- Mount `<PreprRefresh enabled={preview} />` in the root layout. While an
358
- editor types, it sends the unsaved changes to the server and the route
359
- re-renders with them; on save it refreshes with the stored content.
360
-
361
- - The query (source text, a `DocumentNode`, or a typed document string) tells
362
- the toolkit which response key holds which field. Changes land under
363
- aliases, inside single components, on dynamic content entries, and on every
364
- place the same item appears. Fields the query did not select are left
365
- alone. Select `_id` on every item and component that should update.
366
- - Changed text keeps its hidden edit link, so it stays click-to-edit.
367
- - `useLive` data keeps the typed values across these re-renders.
368
-
369
- **Production safety.** Pass `enabled` from the same preview flag you give the
370
- middleware, and never `true` on a production deployment:
371
-
372
- - With `enabled: false` (the default) the result is returned untouched and
373
- the request is never read, so static and ISR pages stay static.
374
- `PreprRefresh`, `initLivePreview` and `useLive` don't listen to the editor
375
- at all.
376
- - Next.js registers the toolkit's server action in every build that imports
377
- `@preprio/toolkit/nextjs/live`, so anyone can call it, browsers and scripts
378
- alike. It does nothing (no parsing, no storage, no re-render) unless the
379
- Prepr middleware handled the request with `preview: true`. The middleware
380
- drops `Prepr-*` headers a client sends, so this can't be forged on a page
381
- the middleware runs for. On a page its `matcher` skips, a forged header can
382
- at most trigger an extra render, never change content, because
383
- `normalizeQuery` there still needs `enabled`.
384
- - Changes only reach the re-render of the editor's own server action call.
385
- The action stores them in the server process under a random, single-use
386
- token and sets only that token as a cookie that expires immediately. Any
387
- other request carrying the cookie, such as a plain or cacheable GET that
388
- replays a token, sees nothing.
389
- - Changes are only accepted from the Prepr editor (`https://*.prepr.io`, or
390
- your `allowedEditorOrigins` via `initLivePreview`), never from another page
391
- that frames your site. They are validated as hostile input: link fields
392
- never take `javascript:`, `vbscript:` or `data:` URLs. HTML text is passed
393
- on as typed, like rich text from the API.
394
-
395
- On Next.js 15 and older the action does nothing and pages update on save.
396
-
397
- For Astro and Nuxt, see their sections. The framework-free core exports,
398
- `normalizeQuery(query, result, { log })`, `startHtmlLivePreview()` and
399
- `readLiveLogFromRequest(request)` from `@preprio/toolkit`, cover any other
400
- server-rendered stack.
331
+ Don't combine `PreprRefresh` with `useLive` registrations that pass a
332
+ `query`: a save would both refetch and refresh. `useLive` without a `query`
333
+ works alongside it.
401
334
 
402
335
  ## Astro
403
336
 
@@ -450,11 +383,11 @@ The `.astro` components ship as source, compiled by your own Astro/Vite pipeline
450
383
 
451
384
  See the runnable example at `examples/astro` for the full source of truth.
452
385
 
453
- ### Live preview (experimental)
386
+ ### Live preview in Astro
454
387
 
455
- Show the editor's unsaved changes while they type. Pass `preview` to
456
- `onPreprRequest` as above, and run each query result through
457
- `normalizeQuery` with your preview flag:
388
+ To turn on [live preview](#live-preview), pass `preview` to `onPreprRequest`
389
+ as above, and run each query result through `normalizeQuery` with the same
390
+ flag:
458
391
 
459
392
  ```astro
460
393
  ---
@@ -470,13 +403,8 @@ const page = normalizeQuery(GetPageDocument, data, {
470
403
 
471
404
  `PreprToolbar.astro` does the rest. Inside the Prepr editor it requests the
472
405
  page again with the unsaved changes and updates only the parts of the DOM that
473
- changed; on save it shows the stored content. Preview must render on demand
474
- (`output: 'server'`).
475
-
476
- Safety: the middleware only accepts changes in preview, from a same-origin
477
- `POST` that carries the toolkit's header and a JSON body, and marks that
478
- response `Cache-Control: private, no-store`. With `enabled: false` the result
479
- is returned untouched. Changes are accepted only from the Prepr editor.
406
+ changed; on save it shows the stored content. Preview pages must render on
407
+ demand (`output: 'server'`, or `export const prerender = false`).
480
408
 
481
409
  ## SvelteKit
482
410
 
@@ -538,6 +466,35 @@ The `.svelte` components ship as source (compiled by your own Vite/Svelte pipeli
538
466
 
539
467
  See the runnable example at `examples/sveltekit` for the full source of truth.
540
468
 
469
+ ### Live preview in SvelteKit
470
+
471
+ To turn on [live preview](#live-preview), return your preview flag from
472
+ `load`, then run the query result through `normalizedQuery`. It applies the
473
+ editor's changes in the browser, with no server round trip:
474
+
475
+ ```svelte
476
+ <script lang="ts">
477
+ import { invalidateAll } from '$app/navigation';
478
+ import { normalizedQuery } from '@preprio/toolkit/sveltekit/live';
479
+
480
+ let { data } = $props();
481
+
482
+ const result = $derived(
483
+ normalizedQuery(GetPageDocument, data.result, {
484
+ enabled: data.preview,
485
+ onSave: invalidateAll,
486
+ }),
487
+ );
488
+ </script>
489
+
490
+ <h1>{$result.Page.title}</h1>
491
+ ```
492
+
493
+ `normalizedQuery` returns a Svelte store: render from `$result`. Pass the
494
+ result in the shape the query returns (`{ Page: … }`, not just the page), so
495
+ the toolkit can tell which response key holds which field. On save,
496
+ `invalidateAll` reruns your `load` functions for the stored content.
497
+
541
498
  ## Nuxt
542
499
 
543
500
  Requires Nitro's Node.js preset (the Nuxt default).
@@ -589,24 +546,25 @@ The `.vue` components ship as source (compiled by your own Vite/Vue pipeline) an
589
546
 
590
547
  See the runnable example at `examples/nuxt` for the full source of truth.
591
548
 
592
- ### Live preview (experimental)
549
+ ### Live preview in Nuxt
593
550
 
594
- Show the editor's unsaved changes while they type, with no server round trip:
551
+ To turn on [live preview](#live-preview), run the `useAsyncData` result
552
+ through `useNormalizedQuery` with your preview flag. It applies the editor's
553
+ changes in the browser, with no server round trip:
595
554
 
596
555
  ```ts
597
556
  import { useNormalizedQuery } from '@preprio/toolkit/nuxt/live';
598
557
 
599
558
  const { data, refresh } = await useAsyncData('page', () => fetchPage(slug));
600
559
  const page = useNormalizedQuery(GetPageDocument, data, {
601
- enabled: preview, // your preview flag; nothing starts without it
560
+ enabled: preview,
602
561
  onSave: refresh,
603
562
  });
604
563
  ```
605
564
 
606
565
  Render from `page`. Nuxt hydrates the whole page, so every component that
607
- shows a changed field updates. Without `enabled`, on the server and outside
608
- the Prepr editor's preview iframe it returns the data unchanged. Changes are accepted only from
609
- the Prepr editor.
566
+ shows a changed field updates. On save, `refresh` refetches the stored
567
+ content.
610
568
 
611
569
  ## React (no framework)
612
570
 
@@ -729,6 +687,115 @@ const controller = createPreprPreview({ props: toolbarProps });
729
687
 
730
688
  Serialize the server-computed props into the page — a `<script type="application/json">` tag works well — and read them back on the client. See the runnable example at `examples/express` for the full source of truth.
731
689
 
690
+ ## Live preview
691
+
692
+ Inside Prepr's side-by-side preview, live preview shows what an editor types
693
+ before they save: text, links and other fields update on the page as they
694
+ type, and a save shows the stored content. Outside the editor, and when it is
695
+ not enabled, it does nothing and costs nothing: the runtime loads only inside
696
+ the editor iframe.
697
+
698
+ | Framework | Entry point | Applies changes | Setup |
699
+ | --------- | --------------------------------- | ------------------------------------- | --------------------------------------- |
700
+ | Next.js | `@preprio/toolkit/nextjs/live` | On the server (`normalizeQuery`) | [Next.js](#live-preview-in-nextjs) |
701
+ | Astro | `@preprio/toolkit/astro` | On the server (`normalizeQuery`) | [Astro](#live-preview-in-astro) |
702
+ | SvelteKit | `@preprio/toolkit/sveltekit/live` | In the browser (`normalizedQuery`) | [SvelteKit](#live-preview-in-sveltekit) |
703
+ | Nuxt | `@preprio/toolkit/nuxt/live` | In the browser (`useNormalizedQuery`) | [Nuxt](#live-preview-in-nuxt) |
704
+
705
+ Other server-rendered stacks can use the framework-free core exports from
706
+ `@preprio/toolkit`: `readLiveLogFromRequest(request)` in the server handler,
707
+ `normalizeQuery(query, result, { log })` on each result, and
708
+ `startHtmlLivePreview()` in the browser. The Astro wrapper is built from
709
+ exactly these.
710
+
711
+ ### Before you start
712
+
713
+ 1. **Set up the toolbar for your framework first.** Live preview runs only
714
+ where the Prepr middleware handles the request with `preview: true`. Make
715
+ sure the middleware covers every page you preview: on a page it skips,
716
+ live preview does nothing and shows no error.
717
+ 2. **Pass your preview flag as `enabled`** to every live preview entry point.
718
+ Each one does nothing without it. Never pass `true` on a production
719
+ deployment.
720
+ 3. **Point Prepr at your preview deployment.** In Prepr, set the preview URL
721
+ to a deployment that uses the **GraphQL Preview** access token, with
722
+ **Enable edit mode** checked.
723
+ 4. **Allow Prepr to frame your site.** If your server sends a
724
+ `Content-Security-Policy`, add your Prepr environments to
725
+ `frame-ancestors`, for example
726
+ `frame-ancestors https://acme.prepr.io https://dev-acme.prepr.io`.
727
+
728
+ ### Preparing your queries
729
+
730
+ Live updates and click-to-edit find content by id, never by position, so
731
+ they stay correct while an editor adds, removes or reorders blocks before
732
+ saving:
733
+
734
+ - Select `_id` (and ideally `__typename`) on every model and component level
735
+ that should live-update. Items without `_id` stay static.
736
+ - In dynamic content fields, select `_id` on every entry type (`Text`,
737
+ `Quote`, …). `Assets` entries have no `_id` of their own: select
738
+ `items { _id }` instead.
739
+ - A `Text` entry's content updates whether the query selects `body`,
740
+ `html` or `text` (the latter with tags stripped).
741
+ - Render each dynamic content entry inside an element marked with
742
+ `data-prepr-edit-target="<field>.<index>"` and
743
+ `data-prepr-entry-id="<entry _id>"` (for `Assets`, the first asset's
744
+ `_id`). The index is the fallback when no id matches.
745
+ - Values without a hidden edit link (images, videos, other URLs) are made
746
+ click-to-edit with `previewAttrs({ id, field }, { enabled: preview })`
747
+ on the element that renders them.
748
+ - Where the page renders a linked item whose content comes from items nested
749
+ inside it (for example a personalization or a shared section referenced
750
+ from a stack), wrap it in an element with
751
+ `data-prepr-reference="<linked item _id>"`. The editor only knows the items
752
+ the open item links to directly; content inside the wrapper is outlined as
753
+ that linked item, and clicking it opens the link in the editor.
754
+
755
+ Typed text and changed fields show immediately. Structural changes, such as
756
+ adding or reordering components, appear after save.
757
+
758
+ ### Options
759
+
760
+ `PreprRefresh`, `initLivePreview`, `normalizedQuery` and `useNormalizedQuery`
761
+ take the same two options as the toolbar (in Astro, set them on the
762
+ toolbar's `options`):
763
+
764
+ - **`allowedEditorOrigins`** — exact editor origins, replacing the
765
+ `https://*.prepr.io` default. Only needed when Prepr runs on your own domain.
766
+ - **`strictMode`** — set it when your preview token uses GraphQL strict mode
767
+ (required fields never null). The editor then holds back new entries until
768
+ their required fields are filled, and never sends a cleared required field
769
+ as null.
770
+
771
+ ### Security
772
+
773
+ - **Disabled means untouched.** With `enabled: false` (the default) results
774
+ are returned as they are and the request is never read, so static and ISR
775
+ pages stay static.
776
+ - **Only the Prepr editor can send changes.** Changes are accepted only from
777
+ `https://*.prepr.io` (or your `allowedEditorOrigins`), never from another
778
+ page that frames your site. They are validated as hostile input: link
779
+ fields never take `javascript:`, `vbscript:` or `data:` URLs. HTML text is
780
+ passed on as typed, like rich text from the API.
781
+ - **One editor's changes never reach anyone else.** On the server-side
782
+ frameworks, changes are read only in preview. Astro accepts them only from
783
+ a same-origin `POST` carrying the toolkit's header and marks that response
784
+ `Cache-Control: private, no-store`. Next.js stores them in the server
785
+ process under a random, single-use token, so a replayed or cached request
786
+ sees nothing.
787
+ - **The Next.js server action is public but inert.** Next.js registers the
788
+ toolkit's server action in every build that imports
789
+ `@preprio/toolkit/nextjs/live`, so anyone can call it. It does nothing
790
+ unless the Prepr middleware handled the request with `preview: true`, and
791
+ the middleware drops `Prepr-*` headers a client sends.
792
+
793
+ ### Trying it locally
794
+
795
+ The Next.js, Astro, SvelteKit and Nuxt examples all have live preview wired
796
+ up. Open them from a Prepr environment whose preview URL points at your local
797
+ server.
798
+
732
799
  ## API Reference
733
800
 
734
801
  ### Framework server helpers
@@ -890,15 +957,15 @@ createPreprMiddleware(request, response, { preview });
890
957
  createPreprPreview({ props, options: { debug: true, locale: 'nl' } });
891
958
  ```
892
959
 
893
- | Option | Type | Default | Description |
894
- | ---------------------- | ------------------------------ | ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
895
- | `debug` | `boolean` | `false` | Enable debug logging. |
896
- | `locale` | `'en' \| 'nl' \| 'fr' \| 'de'` | auto-detected | UI language. Falls back to the first supported browser language, then `en`. Inside the Prepr editor, the editor's interface language wins. |
897
- | `features` | `PreprFeatures` | all enabled | Which features run. See [Feature flags](#feature-flags). |
898
- | `ui` | `boolean` | `true` | Mount the visible toolbar. See [Headless preview](#headless-preview-no-toolbar-ui). |
899
- | `allowedEditorOrigins` | `string[]` | `*.prepr.io` | Exact editor origins allowed to drive this preview, for self-hosted or local editors. Replaces the wildcard. `['*']` accepts any origin — development only. |
900
- | `strictMode` | `boolean` | `false` | Your preview token uses GraphQL strict mode (required fields never null). The editor then holds back new entries until their required fields are filled and never sends a cleared required field as null. Also on `PreprRefresh`, `initLivePreview` and `useNormalizedQuery`. |
901
- | `autoClean` | `boolean` | `true` | Strip the invisible stega characters from visible text. See [Stega auto-cleaning](#stega-auto-cleaning). |
960
+ | Option | Type | Default | Description |
961
+ | ---------------------- | ------------------------------ | ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
962
+ | `debug` | `boolean` | `false` | Enable debug logging. |
963
+ | `locale` | `'en' \| 'nl' \| 'fr' \| 'de'` | auto-detected | UI language. Falls back to the first supported browser language, then `en`. Inside the Prepr editor, the editor's interface language wins. |
964
+ | `features` | `PreprFeatures` | all enabled | Which features run. See [Feature flags](#feature-flags). |
965
+ | `ui` | `boolean` | `true` | Mount the visible toolbar. See [Headless preview](#headless-preview-no-toolbar-ui). |
966
+ | `allowedEditorOrigins` | `string[]` | `*.prepr.io` | Exact editor origins allowed to drive this preview, for self-hosted or local editors. Replaces the wildcard. `['*']` accepts any origin — development only. |
967
+ | `strictMode` | `boolean` | `false` | Your preview token uses GraphQL strict mode (required fields never null). The editor then holds back new entries until their required fields are filled and never sends a cleared required field as null. Also on `PreprRefresh`, `initLivePreview`, `normalizedQuery` and `useNormalizedQuery`. |
968
+ | `autoClean` | `boolean` | `true` | Strip the invisible stega characters from visible text. See [Stega auto-cleaning](#stega-auto-cleaning). |
902
969
 
903
970
  `features` and `ui` are independent: `features` decides _what runs_, `ui` decides _whether the toolbar is visible_.
904
971
 
@@ -1034,6 +1101,14 @@ createPreprPreview({ props, options: { locale: 'nl' } });
1034
1101
  - **API calls**: personalization only works if you spread `getPreprHeaders()` into your GraphQL fetch. Missing headers is the most common reason segments appear to have no effect.
1035
1102
  - **Chained middleware**: when chaining, pass the existing `NextResponse` as the second argument. Creating a fresh `NextResponse.next()` afterwards discards Prepr's request headers.
1036
1103
 
1104
+ ### Live preview does not update
1105
+
1106
+ - **Check `enabled`**: every live preview entry point needs your preview flag, and the middleware must run with `preview: true` for that page.
1107
+ - **Check the preview URL in Prepr**: it must point at a deployment that uses the GraphQL Preview token. A production build with preview off shows saved content only.
1108
+ - **Check `_id`**: an item or component whose query does not select `_id` stays static. See [Preparing your queries](#preparing-your-queries).
1109
+ - **Check the frame**: a `Refused to frame` error in the browser console means your `Content-Security-Policy` `frame-ancestors` does not list your Prepr environment.
1110
+ - **Next.js only shows changes on save**: `normalizeQuery` with `PreprRefresh` needs Next.js 16 or later.
1111
+
1037
1112
  ### Segments switch but content does not change
1038
1113
 
1039
1114
  The toolbar sets the segment; your data fetch decides what to do with it. Check that the fetch forwards `getPreprHeaders()` and is not being served from cache — use `cache: 'no-store'`, or Apollo's `fetchPolicy: 'no-cache'`, on personalized queries.
@@ -31,7 +31,7 @@ __export(astro_exports, {
31
31
  module.exports = __toCommonJS(astro_exports);
32
32
 
33
33
  // src/version.ts
34
- var VERSION = "0.5.0-rc.3";
34
+ var VERSION = "0.5.0-rc.5";
35
35
 
36
36
  // src/core/features.ts
37
37
  function isEnabled(config) {
@@ -409,6 +409,20 @@ var UPPERCASE_BOUNDARY = /(.)(?=[A-Z])/g;
409
409
  function graphqlFieldName(apiId) {
410
410
  return apiId.replace(UPPERCASE_BOUNDARY, "$1_").toLowerCase();
411
411
  }
412
+ var TEXT_BODY_FIELDS = [
413
+ { name: "body", plain: false },
414
+ { name: "html", plain: false },
415
+ { name: "text", plain: true }
416
+ ];
417
+ var TAG_PATTERN = /<[^>]+>/g;
418
+ function plainTextFromHtml(html) {
419
+ return html.replace(TAG_PATTERN, "");
420
+ }
421
+ function isTextEntry(record) {
422
+ const typename = record.__typename;
423
+ if (typeof typename === "string") return typename === "Text";
424
+ return "html" in record || "text" in record && "format" in record;
425
+ }
412
426
 
413
427
  // src/core/live-preview/index-snapshot.ts
414
428
  var debug = createScopedLogger("live-preview");
@@ -546,7 +560,6 @@ function listKeysOf(entry) {
546
560
  }
547
561
  return keys;
548
562
  }
549
- var TAG_PATTERN = /<[^>]+>/g;
550
563
  function buildText(entry, template) {
551
564
  const body = typeof entry.body === "string" ? entry.body : "";
552
565
  const full = {
@@ -554,7 +567,7 @@ function buildText(entry, template) {
554
567
  _id: entry._id,
555
568
  body,
556
569
  html: body,
557
- text: body.replace(TAG_PATTERN, ""),
570
+ text: plainTextFromHtml(body),
558
571
  format: entry.format ?? null
559
572
  };
560
573
  if (!template) return full;
@@ -1157,11 +1170,13 @@ function applyPatch(root, index, patch, doc, result) {
1157
1170
  const segments = patch.field.split(".");
1158
1171
  if (segments.some((s) => !s || FORBIDDEN_SEGMENTS2.has(s))) return root;
1159
1172
  const targets = [];
1173
+ const plain = /* @__PURE__ */ new Set();
1160
1174
  for (const ownerPath of ownerPaths) {
1161
1175
  if (doc) {
1162
1176
  const resolved = resolveTargets(root, doc, ownerPath, segments);
1163
1177
  root = resolved.root;
1164
1178
  targets.push(...resolved.targets);
1179
+ resolved.plain.forEach((target) => plain.add(target));
1165
1180
  } else {
1166
1181
  targets.push([...ownerPath, ...segments.map(toPathSegment)]);
1167
1182
  }
@@ -1175,7 +1190,8 @@ function applyPatch(root, index, patch, doc, result) {
1175
1190
  }
1176
1191
  if (patch.type === "field") {
1177
1192
  for (const target of targets) {
1178
- let value = keepEditLink(readAtPath(root, target), patch.value);
1193
+ const pushed = plain.has(target) && typeof patch.value === "string" ? plainTextFromHtml(patch.value) : patch.value;
1194
+ let value = keepEditLink(readAtPath(root, target), pushed);
1179
1195
  if (patch.edit && typeof value === "string" && !(0, import_stega2.vercelStegaSplit)(value).encoded) {
1180
1196
  value = editLinksFor(result, patch.edit.item)?.encode(
1181
1197
  value,
@@ -1239,9 +1255,10 @@ function resolveTargets(root, doc, ownerPath, segments) {
1239
1255
  owner = owner[step];
1240
1256
  }
1241
1257
  const selections = selectionsAt(root, doc, ownerPath);
1242
- if (!selections) return { root, targets: [] };
1258
+ if (!selections) return { root, targets: [], plain: /* @__PURE__ */ new Set() };
1243
1259
  let current = root;
1244
1260
  const out = [];
1261
+ const plain = /* @__PURE__ */ new Set();
1245
1262
  const walk = (node, sel, i, path) => {
1246
1263
  if (i === segments.length) {
1247
1264
  out.push(path);
@@ -1262,12 +1279,15 @@ function resolveTargets(root, doc, ownerPath, segments) {
1262
1279
  if (node === null || typeof node !== "object") return;
1263
1280
  const record = node;
1264
1281
  const wanted = graphqlFieldName(segment);
1282
+ const textBody = i === segments.length - 1 && segment === "body" && isTextEntry(record) ? TEXT_BODY_FIELDS : null;
1265
1283
  let matched = false;
1266
1284
  for (const field of collectFields(sel, typenameOf(node), doc).values()) {
1267
- if (field.name !== segment && field.name !== wanted) continue;
1285
+ const alias = textBody?.find((f) => f.name === field.name);
1286
+ if (field.name !== segment && field.name !== wanted && !alias) continue;
1268
1287
  if (!Object.prototype.hasOwnProperty.call(record, field.key)) continue;
1269
1288
  matched = true;
1270
1289
  const fieldPath = [...path, field.key];
1290
+ if (alias?.plain) plain.add(fieldPath);
1271
1291
  let value = record[field.key];
1272
1292
  const next = segments[i + 1];
1273
1293
  if (value === null && field.selections.length && next !== void 0 && next !== "items" && !INDEX_SEGMENT.test(next)) {
@@ -1290,7 +1310,7 @@ function resolveTargets(root, doc, ownerPath, segments) {
1290
1310
  if (!matched && segment === "items") walk(node, sel, i + 1, path);
1291
1311
  };
1292
1312
  walk(owner, selections, 0, ownerPath);
1293
- return { root: current, targets: out };
1313
+ return { root: current, targets: out, plain };
1294
1314
  }
1295
1315
  var NON_ALPHANUMERIC = /[^a-z0-9]/gi;
1296
1316
  var typeKey = (name) => name.replace(NON_ALPHANUMERIC, "").toLowerCase();