@preprio/toolkit 0.5.0-rc.2 → 0.6.0-alpha.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +308 -27
- package/dist/astro/chunk-3UVUDDNY.js +173 -0
- package/dist/astro/graphql.cjs +983 -0
- package/dist/astro/graphql.d.cts +231 -0
- package/dist/astro/graphql.d.ts +231 -0
- package/dist/astro/graphql.js +893 -0
- package/dist/astro/index-XT_z-RYT.d.cts +130 -0
- package/dist/astro/index-XT_z-RYT.d.ts +130 -0
- package/dist/astro/index.cjs +14 -1
- package/dist/astro/index.d.cts +1 -120
- package/dist/astro/index.d.ts +1 -120
- package/dist/astro/index.js +15 -153
- package/dist/{chunk-FLPNN3CV.js → chunk-BJ2LBLWO.js} +71 -276
- package/dist/chunk-DGZA7KX7.js +172 -0
- package/dist/chunk-YR7P4IWE.js +187 -0
- package/dist/graphql.cjs +894 -0
- package/dist/graphql.d.cts +231 -0
- package/dist/graphql.d.ts +231 -0
- package/dist/graphql.js +787 -0
- package/dist/index.cjs +76 -119
- package/dist/index.d.cts +12 -64
- package/dist/index.d.ts +12 -64
- package/dist/index.js +23 -167
- package/dist/nextjs/chunk-U4HXENF7.js +231 -0
- package/dist/nextjs/components.cjs +190 -122
- package/dist/nextjs/components.d.cts +33 -2
- package/dist/nextjs/components.d.ts +33 -2
- package/dist/nextjs/components.js +132 -4
- package/dist/nextjs/graphql.cjs +1315 -0
- package/dist/nextjs/graphql.d.cts +361 -0
- package/dist/nextjs/graphql.d.ts +361 -0
- package/dist/nextjs/graphql.js +1218 -0
- package/dist/nextjs/index.cjs +126 -6
- package/dist/nextjs/index.d.cts +53 -147
- package/dist/nextjs/index.d.ts +53 -147
- package/dist/nextjs/index.js +113 -203
- package/dist/nextjs/server-CjpELYvq.d.cts +156 -0
- package/dist/nextjs/server-CjpELYvq.d.ts +156 -0
- package/dist/nuxt/chunk-P6ENNAEE.js +417 -0
- package/dist/nuxt/graphql.cjs +990 -0
- package/dist/nuxt/graphql.d.cts +228 -0
- package/dist/nuxt/graphql.d.ts +228 -0
- package/dist/nuxt/graphql.js +894 -0
- package/dist/nuxt/index.cjs +14 -1
- package/dist/nuxt/index.d.cts +3 -148
- package/dist/nuxt/index.d.ts +3 -148
- package/dist/nuxt/index.js +15 -391
- package/dist/nuxt/server-8v6OHbsw.d.cts +158 -0
- package/dist/nuxt/server-8v6OHbsw.d.ts +158 -0
- package/dist/pixel-DwW7cXJm.d.cts +49 -0
- package/dist/pixel-DwW7cXJm.d.ts +49 -0
- package/dist/react/components.cjs +88 -120
- package/dist/react/components.d.cts +2 -8
- package/dist/react/components.d.ts +2 -8
- package/dist/react/components.js +88 -120
- package/dist/server-Brr1UgtW.d.ts +59 -0
- package/dist/server-DJuS3YU0.d.cts +59 -0
- package/dist/sveltekit/chunk-FSARVMG5.js +413 -0
- package/dist/sveltekit/graphql.cjs +996 -0
- package/dist/sveltekit/graphql.d.cts +233 -0
- package/dist/sveltekit/graphql.d.ts +233 -0
- package/dist/sveltekit/graphql.js +900 -0
- package/dist/sveltekit/index.cjs +14 -1
- package/dist/sveltekit/index.d.cts +3 -88
- package/dist/sveltekit/index.d.ts +3 -88
- package/dist/sveltekit/index.js +15 -387
- package/dist/sveltekit/server-CO847mEV.d.cts +98 -0
- package/dist/sveltekit/server-CO847mEV.d.ts +98 -0
- package/dist/{pixel-B3oQNbZ3.d.cts → types-BYH9XqmN.d.cts} +4 -58
- package/dist/{pixel-B3oQNbZ3.d.ts → types-BYH9XqmN.d.ts} +4 -58
- package/package.json +31 -1
package/README.md
CHANGED
|
@@ -200,25 +200,230 @@ To skip Prepr for some routes entirely, return early with `NextResponse.next()`
|
|
|
200
200
|
|
|
201
201
|
The pixel collects the interaction data that powers personalization and A/B testing. Include it in **all** environments, preview and production alike — the Quick Start layout already does. `PreprTrackingPixel` renders nothing and loads the CDN script on mount, so its position in the tree does not matter.
|
|
202
202
|
|
|
203
|
-
###
|
|
203
|
+
### Fetching content
|
|
204
|
+
|
|
205
|
+
`createPreprClient` sends codegen-typed queries to Prepr and returns Prepr's personalization verdict next to the data. It lives on its own subpath and needs the `graphql` package, which graphql-codegen already installs (otherwise: `npm install graphql`).
|
|
204
206
|
|
|
205
207
|
```typescript
|
|
206
|
-
import {
|
|
208
|
+
import { createPreprClient } from '@preprio/toolkit/nextjs/graphql';
|
|
207
209
|
import { GetPageBySlugDocument } from '@/gql/graphql';
|
|
208
|
-
import { getPreprHeaders } from '@preprio/toolkit/nextjs';
|
|
209
210
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
211
|
+
const prepr = createPreprClient({ url: process.env.PREPR_GRAPHQL_URL! });
|
|
212
|
+
|
|
213
|
+
// Same response for every visitor: goes through the Data Cache, tagged 'prepr'.
|
|
214
|
+
const { data } = await prepr.query(GetPageBySlugDocument, { slug });
|
|
215
|
+
|
|
216
|
+
// This visitor's segment / A-B variant: forwards the Prepr headers, never cached.
|
|
217
|
+
const { data, personalized } = await prepr.query(
|
|
218
|
+
GetPageBySlugDocument,
|
|
219
|
+
{ slug },
|
|
220
|
+
{ personalize: true },
|
|
221
|
+
);
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Codegen's default client-preset output works as is; `documentMode: 'string'` works too.
|
|
225
|
+
|
|
226
|
+
`personalized` is `true` when the queried fields contain segment variants or A/B tests, at any depth. Use it to decide whether a page can be served statically.
|
|
227
|
+
|
|
228
|
+
To serve pages from the cache until you publish, set `revalidate: false` and call `revalidateTag('prepr')` from a Prepr publish webhook. With `revalidate` unset, the installed Next version's fetch caching default applies.
|
|
229
|
+
|
|
230
|
+
In preview, pass `preview: true`: queries skip the cache and stega markers stay in text for click-to-edit. They are always stripped from URL-like fields.
|
|
231
|
+
|
|
232
|
+
Custom headers go in two places. Client-level `headers` (`createPreprClient({ url, headers })`) are sent with every request and keep queries cacheable, so use them for fixed values such as a header your proxy requires. Per-query `headers` (or `init.headers`) may carry per-visitor values, so they make that request `no-store`.
|
|
233
|
+
|
|
234
|
+
#### Personalization with Cache Components
|
|
235
|
+
|
|
236
|
+
With [Cache Components](https://nextjs.org/docs/app/getting-started/cache-components) (`cacheComponents: true`), a personalized page is still served from the cache: what never varies and every A/B test are in the static shell, and only the parts that differ per segment stream in. You keep your own query and your own components.
|
|
237
|
+
|
|
238
|
+
**Do you need this?** Only if your pages are cached. A page that renders per request already gets this visitor's variants from `query(..., { personalize: true })`; render the result as is and skip this section.
|
|
217
239
|
|
|
240
|
+
A normal page needs three changes:
|
|
241
|
+
|
|
242
|
+
```tsx
|
|
243
|
+
import { PreprStack } from '@preprio/toolkit/nextjs/graphql';
|
|
244
|
+
|
|
245
|
+
// 1. Fetch every variant, in a cached function. Same query, one extra option.
|
|
246
|
+
async function getPage(slug: string) {
|
|
247
|
+
'use cache';
|
|
248
|
+
const { data } = await prepr.query(
|
|
249
|
+
PageDocument,
|
|
250
|
+
{ slug },
|
|
251
|
+
{ variants: true },
|
|
252
|
+
);
|
|
218
253
|
return data;
|
|
219
254
|
}
|
|
255
|
+
|
|
256
|
+
export default async function Page({
|
|
257
|
+
params,
|
|
258
|
+
}: {
|
|
259
|
+
params: Promise<{ slug?: string[] }>;
|
|
260
|
+
}) {
|
|
261
|
+
const slug = (await params).slug?.join('/') || '/';
|
|
262
|
+
const data = await getPage(slug);
|
|
263
|
+
|
|
264
|
+
// 2. Ask which variants this visitor gets. Do not await it.
|
|
265
|
+
const decisions = prepr.decide(PageDocument, { slug }, { from: data });
|
|
266
|
+
|
|
267
|
+
// 3. Render the list through PreprStack instead of directly.
|
|
268
|
+
return (
|
|
269
|
+
<PreprStack
|
|
270
|
+
items={data.Page.content}
|
|
271
|
+
decisions={decisions}
|
|
272
|
+
render={(items, { index }) => <Sections items={items} offset={index} />}
|
|
273
|
+
/>
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
##### Your components: one extra prop
|
|
279
|
+
|
|
280
|
+
`PreprStack` splits the list into blocks (what never varies, each personalization group, each A/B test) and calls `render` once per block. Each call receives items exactly as a query without personalization would return them: the variant this visitor gets, with every list nested inside an item narrowed down the same way.
|
|
281
|
+
|
|
282
|
+
Because `render` runs per block, a position counted inside `Sections` (`items.map((item, i) => …)`) starts at 0 in every block. `index` is where the block starts in the whole page; add it, and the first section still owns the `<h1>` and the priority image:
|
|
283
|
+
|
|
284
|
+
```tsx
|
|
285
|
+
// Before
|
|
286
|
+
<Sections items={data.Page.content} />
|
|
287
|
+
|
|
288
|
+
// After
|
|
289
|
+
<PreprStack
|
|
290
|
+
items={data.Page.content}
|
|
291
|
+
decisions={decisions}
|
|
292
|
+
render={(items, { index }) => <Sections items={items} offset={index} />}
|
|
293
|
+
/>
|
|
294
|
+
|
|
295
|
+
// Sections: the only change is `offset + i`
|
|
296
|
+
function Sections({ items, offset = 0 }: { items: Section[]; offset?: number }) {
|
|
297
|
+
return items.map((item, i) => <Section key={item._id} item={item} position={offset + i} />);
|
|
298
|
+
}
|
|
220
299
|
```
|
|
221
300
|
|
|
301
|
+
A group takes one position, whichever variant shows. A group that shows nothing to a visitor (only a segment variant, no default) still takes its position: the page is prerendered before anyone's segment is known.
|
|
302
|
+
|
|
303
|
+
**The one rule:** a `variants: true` result holds every variant. Every list you render from it goes through `PreprStack`, or visitors see all variants at once.
|
|
304
|
+
|
|
305
|
+
##### Fitting your page
|
|
306
|
+
|
|
307
|
+
| Your page has… | Do this |
|
|
308
|
+
| --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
|
|
309
|
+
| One page-builder list (`content`, `sections`, `stack`) | One `PreprStack` around it. |
|
|
310
|
+
| Personalized lists inside sections (cards, buttons, models in a section) | Nothing. They are narrowed down with the section they sit in. |
|
|
311
|
+
| Several lists (hero, body, sidebar) | One `PreprStack` per list, all with the same `decisions`. |
|
|
312
|
+
| A personalized list outside any list you already wrap (e.g. `Page.buttons`) | Its own `PreprStack`. |
|
|
313
|
+
| Data used outside rendering (`generateMetadata`, JSON-LD) | `resolveVariants(data)` gives the default variants and A. |
|
|
314
|
+
| Impression tracking for Prepr's metrics | `trackVariants` on `PreprStack`; see [Impressions and conversions](#impressions-and-conversions). |
|
|
315
|
+
| No personalization on a page | Nothing to change. `PreprStack` renders it statically and `decide()` sends no request. |
|
|
316
|
+
|
|
317
|
+
##### Impressions and conversions
|
|
318
|
+
|
|
319
|
+
Prepr's tracking pixel counts impressions of an element carrying `data-prepr-variant-key`, and clicks on any `data-prepr-variant-event` element inside it ([Prepr docs](https://docs.prepr.io/personalization/setting-up-personalization#track-impressions-and-conversions)). The client already fetches every variant's key, so you never select `_context { variant_key }` yourself.
|
|
320
|
+
|
|
321
|
+
Opt in with `trackVariants`, and `PreprStack` puts the key on the section each personalized and A/B variant renders:
|
|
322
|
+
|
|
323
|
+
```tsx
|
|
324
|
+
<PreprStack items={data.Page.content} decisions={decisions} trackVariants render={…} />
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
For variants nested inside a section (cards, buttons, models), and outside Next.js, spread `preprVariantProps(item)` on the element that shows the item:
|
|
328
|
+
|
|
329
|
+
```tsx
|
|
330
|
+
<article {...preprVariantProps(card)}>…</article>
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
Conversions stay your choice: put `data-prepr-variant-event` (or `data-prepr-variant-event="QuoteRequest"`) on the element that counts, for example once in your button component.
|
|
334
|
+
|
|
335
|
+
- Keys are added after the page hydrates; the pixel picks them up then. Each item of a variant tags the element it renders, in order. A variant that renders a different number of elements than it has items (a section with several root elements, or one rendering nothing) tags only its first element.
|
|
336
|
+
- With `trackVariants`, each variant sits in a `display: contents` wrapper. It takes no box, but CSS child combinators (`>`) and `:first-child` from the stack's parent no longer reach the sections.
|
|
337
|
+
- An element that already has `data-prepr-variant-key` keeps it, so existing manual tagging keeps working.
|
|
338
|
+
- Tagging yourself (with your own `_context { variant_key }` or `preprVariantProps`)? Skip the tag when `render` gets `placeholder: true`: that render is the default variant standing in until the visitor's variant of a segment group streams in, and the pixel counts anything with a key that appears on screen, however briefly.
|
|
339
|
+
|
|
340
|
+
```tsx
|
|
341
|
+
render={(items, { index, placeholder }) => (
|
|
342
|
+
<Sections items={items} offset={index} tagVariants={!placeholder} />
|
|
343
|
+
)}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
- A hidden A/B variant never scrolls into view, so it records no impression.
|
|
347
|
+
|
|
348
|
+
##### A/B tests
|
|
349
|
+
|
|
350
|
+
`PreprStack` renders both variants of an A/B test in the static shell and shows the visitor's one before first paint; A is the default, also for crawlers. The visitor's bucket comes from a cookie set in your middleware. Prepr assigns the bucket, once per visitor, and only on pages that have an A/B test:
|
|
351
|
+
|
|
352
|
+
```ts
|
|
353
|
+
// proxy.ts
|
|
354
|
+
const response = createPreprMiddleware(request, { preview });
|
|
355
|
+
await resolvePreprBucket(request, response, {
|
|
356
|
+
url: process.env.PREPR_GRAPHQL_URL!,
|
|
357
|
+
preview,
|
|
358
|
+
onlyIf: () => prepr.hasAbTests(PageDocument, { slug }),
|
|
359
|
+
});
|
|
360
|
+
return response;
|
|
361
|
+
```
|
|
362
|
+
|
|
363
|
+
Sites without A/B tests leave this out. Under a strict Content-Security-Policy, pass `nonce` to `PreprStack`.
|
|
364
|
+
|
|
365
|
+
##### Personalized content above the fold
|
|
366
|
+
|
|
367
|
+
A segment group streams in: the default variant shows first, then this visitor's variant replaces it. Below the fold nobody sees that. In the hero they do, so for the blocks on screen before the visitor scrolls, decide in the proxy instead:
|
|
368
|
+
|
|
369
|
+
```tsx
|
|
370
|
+
<PreprStack items={data.Page.content} decisions={decisions} aboveTheFold={1} render={…} />
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
```ts
|
|
374
|
+
// proxy.ts, after createPreprMiddleware
|
|
375
|
+
await prepr.resolveDecisions(
|
|
376
|
+
request,
|
|
377
|
+
response,
|
|
378
|
+
PageDocument,
|
|
379
|
+
{ slug },
|
|
380
|
+
{
|
|
381
|
+
// Only pages whose personalized content is above the fold need it.
|
|
382
|
+
onlyIf: () => slug === '/',
|
|
383
|
+
},
|
|
384
|
+
);
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
`PreprStack` puts every variant of those blocks' segment groups in the cached page, and the A/B switch script shows the variant the proxy decided before first paint: nothing on screen changes. `resolveDecisions` forwards the visitor's headers (customer id, UTM parameters, IP, HubSpot id) to Prepr and stores the answer in the `prepr_decisions` cookie.
|
|
388
|
+
|
|
389
|
+
- It asks Prepr once per visit, not per page view: later requests reuse the stored decision unless something arrives that can change the segment (UTM parameters, a first visit, a segment forced in preview). Pass `reuse: false` to ask on every request. A visitor who joins a segment mid-visit sees it from the next request that asks.
|
|
390
|
+
- That request is uncached, so its time depends on how far your functions run from Prepr: deploy them in the region closest to your visitors and to Prepr, and use `onlyIf` to limit it to pages that need it. Pages without segment groups are skipped after a cached check (the same one `hasAbTests` uses).
|
|
391
|
+
- Without a decision (the lookup failed or took longer than `timeoutMs`, default 1000), the default variant shows.
|
|
392
|
+
- The page carries the HTML of every variant of those groups, and images in hidden variants still download.
|
|
393
|
+
- An item with a segment group nested inside it keeps streaming; only groups at the top level of the list are decided in the proxy.
|
|
394
|
+
|
|
395
|
+
##### Preview
|
|
396
|
+
|
|
397
|
+
Run preview in [draft mode](https://nextjs.org/docs/app/guides/draft-mode), so editors see an edit on the next load instead of the cached page:
|
|
398
|
+
|
|
399
|
+
```ts
|
|
400
|
+
// app/api/prepr/draft/route.ts
|
|
401
|
+
export const GET = createPreprDraftRoute({ preview });
|
|
402
|
+
|
|
403
|
+
// proxy.ts, before anything else
|
|
404
|
+
const draft = preprDraftRedirect(request, { preview });
|
|
405
|
+
if (draft) return draft;
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
Outside preview both do nothing (the route answers 404). Keep the route out of your proxy `matcher`, or pass its path as `route` when it lives elsewhere. Inside the Prepr editor, browsers that refuse the draft cookie get the cached page instead, so keep a publish webhook calling `revalidateTag('prepr')`.
|
|
409
|
+
|
|
410
|
+
<details>
|
|
411
|
+
<summary>How it works</summary>
|
|
412
|
+
|
|
413
|
+
- **`variants: true`** adds `personalize: false` to every personalizable field in your query, at any depth, and selects each item's `_context`. The client learns which fields are personalizable from one schema introspection request per client, kept in the Data Cache.
|
|
414
|
+
- **`decide()`** sends your query cut down to the lists that hold segment groups on this page (`from: data`), selecting only `_context`, with the visitor's headers and never cached. Prepr decides from everything the middleware forwards: customer profile, UTM parameters, visitor IP, HubSpot id. A page without segment groups makes no request.
|
|
415
|
+
- **`PreprStack`** renders items that never vary in the static shell. Each segment group, and each item with a segment group nested inside it, gets its own `<Suspense>` boundary: the default variant is the fallback and this visitor's variant streams in. A group with no default variant still gets its place. A failed decision falls back to the defaults.
|
|
416
|
+
- **A/B variants** are both in the HTML, each in a `display: contents` wrapper; an inline script (and a layout effect after in-app navigation) shows the bucket from the cookie. A hidden variant never becomes visible, so the tracking pixel records no impression for it.
|
|
417
|
+
- **`hasAbTests`** sends your query without its leaf fields and no visitor headers, so Prepr's CDN caches it, and reads the answer from the `Vary` header. Answers are reused for a minute.
|
|
418
|
+
- **Tags carry over.** The client's `'prepr'` fetch tag applies to the `'use cache'` entry, so `revalidateTag('prepr')` refreshes it without a `cacheTag()` call.
|
|
419
|
+
- **Outside `'use cache'`, set `revalidate`** on the client (`false` or seconds). Otherwise a query outside both `'use cache'` and `<Suspense>` fails the build with "uncached data was accessed outside of `<Suspense>`".
|
|
420
|
+
|
|
421
|
+
</details>
|
|
422
|
+
|
|
423
|
+
`examples/nextjs-cache-components` is a runnable version.
|
|
424
|
+
|
|
425
|
+
Do not catch and swallow errors from `query` without [`unstable_rethrow`](https://nextjs.org/docs/app/api-reference/functions/unstable_rethrow): Next.js signals dynamic rendering by throwing from inside `fetch`, and the client passes those signals through.
|
|
426
|
+
|
|
222
427
|
### Image loader
|
|
223
428
|
|
|
224
429
|
Prepr's stream CDN resizes at the edge. Routing `next/image` through the bundled loader makes Next request the exact width it needs, instead of pulling the full-resolution original and re-encoding it through `/_next/image`.
|
|
@@ -295,6 +500,19 @@ import { getPreprHeaders } from '@preprio/toolkit/astro';
|
|
|
295
500
|
const headers = getPreprHeaders(Astro.request.headers);
|
|
296
501
|
```
|
|
297
502
|
|
|
503
|
+
Or let the client forward the headers (needs `graphql`, see [Fetching content](#fetching-content)):
|
|
504
|
+
|
|
505
|
+
```typescript
|
|
506
|
+
import { createPreprClient } from '@preprio/toolkit/astro/graphql';
|
|
507
|
+
|
|
508
|
+
const prepr = createPreprClient({ url: import.meta.env.PREPR_GRAPHQL_URL });
|
|
509
|
+
const { data } = await prepr.query(
|
|
510
|
+
GetPageBySlugDocument,
|
|
511
|
+
{ slug },
|
|
512
|
+
{ personalize: true, event: Astro },
|
|
513
|
+
);
|
|
514
|
+
```
|
|
515
|
+
|
|
298
516
|
`import.meta.env.DEV` is Astro's own dev flag; swap in whatever signal marks preview in your deployment.
|
|
299
517
|
|
|
300
518
|
The `.astro` components ship as source, compiled by your own Astro/Vite pipeline. They use full-page navigation by default, matching Astro's MPA model — switching segment or variant re-runs the server-rendered output.
|
|
@@ -357,6 +575,23 @@ import { getPreprHeadersFromLocals } from '@preprio/toolkit/sveltekit';
|
|
|
357
575
|
const preprHeaders = getPreprHeadersFromLocals(event.locals);
|
|
358
576
|
```
|
|
359
577
|
|
|
578
|
+
Or let the client forward them (needs `graphql`, see [Fetching content](#fetching-content)). Passing the event also routes the request through `event.fetch`:
|
|
579
|
+
|
|
580
|
+
```typescript
|
|
581
|
+
import { createPreprClient } from '@preprio/toolkit/sveltekit/graphql';
|
|
582
|
+
|
|
583
|
+
const prepr = createPreprClient({ url: env.PREPR_GRAPHQL_URL });
|
|
584
|
+
|
|
585
|
+
export const load: PageServerLoad = async (event) => {
|
|
586
|
+
const { data } = await prepr.query(
|
|
587
|
+
GetPageBySlugDocument,
|
|
588
|
+
{ slug: event.params.slug },
|
|
589
|
+
{ personalize: true, event },
|
|
590
|
+
);
|
|
591
|
+
return { page: data.Page };
|
|
592
|
+
};
|
|
593
|
+
```
|
|
594
|
+
|
|
360
595
|
The `.svelte` components ship as source (compiled by your own Vite/Svelte pipeline) and mount client-side via `onMount`, so they never run during SSR.
|
|
361
596
|
|
|
362
597
|
See the runnable example at `examples/sveltekit` for the full source of truth.
|
|
@@ -408,6 +643,26 @@ import { getPreprHeadersFromEvent } from '@preprio/toolkit/nuxt';
|
|
|
408
643
|
const preprHeaders = getPreprHeadersFromEvent(event);
|
|
409
644
|
```
|
|
410
645
|
|
|
646
|
+
Or let the client forward them from a server route (needs `graphql`, see [Fetching content](#fetching-content)):
|
|
647
|
+
|
|
648
|
+
```typescript
|
|
649
|
+
// server/api/page.get.ts
|
|
650
|
+
import { createPreprClient } from '@preprio/toolkit/nuxt/graphql';
|
|
651
|
+
|
|
652
|
+
const prepr = createPreprClient({ url: useRuntimeConfig().preprGraphqlUrl });
|
|
653
|
+
|
|
654
|
+
export default defineEventHandler(async (event) => {
|
|
655
|
+
const { data } = await prepr.query(
|
|
656
|
+
GetPageBySlugDocument,
|
|
657
|
+
{ slug: getQuery(event).slug },
|
|
658
|
+
{ personalize: true, event },
|
|
659
|
+
);
|
|
660
|
+
return data;
|
|
661
|
+
});
|
|
662
|
+
```
|
|
663
|
+
|
|
664
|
+
`personalize: true` needs the h3 event. During client-side navigation there is none and the query throws `CONTEXT_ERROR`, so run personalized queries in server routes.
|
|
665
|
+
|
|
411
666
|
The `.vue` components ship as source (compiled by your own Vite/Vue pipeline) and mount client-side via `onMounted`, so they never run during SSR.
|
|
412
667
|
|
|
413
668
|
See the runnable example at `examples/nuxt` for the full source of truth.
|
|
@@ -549,6 +804,27 @@ Each wrapper exposes the same five helpers, differing only in how they reach the
|
|
|
549
804
|
|
|
550
805
|
`getPreprUUID()` returning `null` is a useful check when headers seem to be missing — it means the middleware did not cover the route.
|
|
551
806
|
|
|
807
|
+
### GraphQL client
|
|
808
|
+
|
|
809
|
+
`createPreprClient(options)` ships on `@preprio/toolkit/graphql` and on each framework's `/graphql` subpath (`@preprio/toolkit/nextjs/graphql`, `/nuxt/graphql`, `/sveltekit/graphql`, `/astro/graphql`). These subpaths need the `graphql` package (`>=16`); nothing else in the toolkit does. The core version takes explicit `headers`; the framework versions add `personalize`.
|
|
810
|
+
|
|
811
|
+
| Option | Default | Effect |
|
|
812
|
+
| ---------------------- | --------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
|
813
|
+
| `url` | — | Full `https://graphql.prepr.io/<token>` URL. Throws `PreprError` when invalid. |
|
|
814
|
+
| `preview` | `false` | Every query is `no-store`; stega defaults to `'urls'`. |
|
|
815
|
+
| `stega` | `'urls'` in preview, else `'all'` | Which strings get stega markers stripped: URL-like keys only, all, or `'none'`. |
|
|
816
|
+
| `fetch` | global `fetch` | Custom fetch. Also accepted per query. |
|
|
817
|
+
| `fastlyQuery` | `false` | Send queries as a GET with a `Fastly-Query` header, served by Prepr's edge. Opt-in: undocumented Prepr behavior. |
|
|
818
|
+
| `headers` | — | Sent with every request. Fixed per client, so queries stay cacheable. |
|
|
819
|
+
| `cacheTags` (Next.js) | `['prepr']` | Tags on cacheable fetches. |
|
|
820
|
+
| `revalidate` (Next.js) | unset | `next.revalidate` on cacheable fetches. |
|
|
821
|
+
|
|
822
|
+
`query(document, variables?, options?)` takes a query string, a `TypedDocumentString` or a `DocumentNode`, and resolves to `{ data, personalized }`. Per-query options: `headers`, `init`, `fetch`, plus `personalize` (framework versions) and `event` (Nuxt, SvelteKit, Astro). A request carrying per-query headers or `init.headers` is `no-store`; client-level `headers` are not.
|
|
823
|
+
|
|
824
|
+
Import `PreprError` from the same subpath as the client when checking `instanceof`: CommonJS entry points do not share classes. `error.code` works from anywhere.
|
|
825
|
+
|
|
826
|
+
`isPersonalizedResponse(response)`, exported from every entry point, reads the same verdict from a response fetched with your own client. It does not need `graphql`.
|
|
827
|
+
|
|
552
828
|
### Token helpers
|
|
553
829
|
|
|
554
830
|
Exported from every entry point, including the core.
|
|
@@ -694,14 +970,14 @@ createPreprMiddleware(request, response, { preview });
|
|
|
694
970
|
createPreprPreview({ props, options: { debug: true, locale: 'nl' } });
|
|
695
971
|
```
|
|
696
972
|
|
|
697
|
-
| Option | Type | Default | Description
|
|
698
|
-
| ---------------------- | --------------- | ------------- |
|
|
699
|
-
| `debug` | `boolean` | `false` | Enable debug logging.
|
|
700
|
-
| `locale` | `'en' \| 'nl'` | auto-detected | UI language. Falls back to the first supported browser language, then `en`.
|
|
701
|
-
| `features` | `PreprFeatures` | all enabled | Which features run. See [Feature flags](#feature-flags).
|
|
702
|
-
| `ui` | `boolean` | `true` | Mount the visible toolbar. See [Headless preview](#headless-preview-no-toolbar-ui).
|
|
703
|
-
| `allowedEditorOrigins` | `string[]` | `*.prepr.io` | Exact editor origins allowed to drive this preview, for self-hosted
|
|
704
|
-
| `autoClean` | `boolean` | `true` | Strip the invisible stega characters from visible text. See [Stega auto-cleaning](#stega-auto-cleaning).
|
|
973
|
+
| Option | Type | Default | Description |
|
|
974
|
+
| ---------------------- | --------------- | ------------- | -------------------------------------------------------------------------------------------------------- |
|
|
975
|
+
| `debug` | `boolean` | `false` | Enable debug logging. |
|
|
976
|
+
| `locale` | `'en' \| 'nl'` | auto-detected | UI language. Falls back to the first supported browser language, then `en`. |
|
|
977
|
+
| `features` | `PreprFeatures` | all enabled | Which features run. See [Feature flags](#feature-flags). |
|
|
978
|
+
| `ui` | `boolean` | `true` | Mount the visible toolbar. See [Headless preview](#headless-preview-no-toolbar-ui). |
|
|
979
|
+
| `allowedEditorOrigins` | `string[]` | `*.prepr.io` | Exact editor origins allowed to drive this preview, for self-hosted editors. Replaces the wildcard. |
|
|
980
|
+
| `autoClean` | `boolean` | `true` | Strip the invisible stega characters from visible text. See [Stega auto-cleaning](#stega-auto-cleaning). |
|
|
705
981
|
|
|
706
982
|
`features` and `ui` are independent: `features` decides _what runs_, `ui` decides _whether the toolbar is visible_.
|
|
707
983
|
|
|
@@ -831,6 +1107,10 @@ createPreprPreview({ props, options: { locale: 'nl' } });
|
|
|
831
1107
|
- **Check whether the encoded text sits in a hidden element.** The parent of the encoded text node is what becomes editable, and a parent that renders no box (`display: none`, `visibility: hidden`, the `hidden` attribute) is skipped — it cannot be hovered or clicked. Put `data-prepr-edit-target` on the visible ancestor that should be editable instead. See [Choosing the element that becomes editable](#choosing-the-element-that-becomes-editable).
|
|
832
1108
|
- **Inspect the tagged elements**: `document.querySelectorAll('[data-prepr-encoded]')` in the console lists everything edit mode found. An element you expected to be in that list points at one of the two cases above.
|
|
833
1109
|
|
|
1110
|
+
### Every variant shows at once
|
|
1111
|
+
|
|
1112
|
+
The list came from a `variants: true` query but was rendered directly. Pass it through `PreprStack` (or `resolveVariants`); see [Personalization with Cache Components](#personalization-with-cache-components).
|
|
1113
|
+
|
|
834
1114
|
### Headers not working
|
|
835
1115
|
|
|
836
1116
|
- **Middleware setup**: confirm `middleware.ts` sits in the project root (or `src/`) and its matcher covers the route.
|
|
@@ -839,7 +1119,7 @@ createPreprPreview({ props, options: { locale: 'nl' } });
|
|
|
839
1119
|
|
|
840
1120
|
### Segments switch but content does not change
|
|
841
1121
|
|
|
842
|
-
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.
|
|
1122
|
+
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. `createPreprClient` with `personalize: true` does both.
|
|
843
1123
|
|
|
844
1124
|
### Build issues
|
|
845
1125
|
|
|
@@ -865,7 +1145,7 @@ try {
|
|
|
865
1145
|
}
|
|
866
1146
|
```
|
|
867
1147
|
|
|
868
|
-
Codes: `INVALID_TOKEN`, `MISSING_TOKEN`, `HTTP_ERROR`, `FETCH_ERROR`, `INVALID_RESPONSE`, `CONTEXT_ERROR`.
|
|
1148
|
+
Codes: `INVALID_TOKEN`, `MISSING_TOKEN`, `HTTP_ERROR`, `FETCH_ERROR`, `INVALID_RESPONSE`, `CONTEXT_ERROR`, `GRAPHQL_ERROR`, `INVALID_DOCUMENT`.
|
|
869
1149
|
|
|
870
1150
|
`getToolbarProps` is the exception — it swallows these and returns an empty segment list, so a bad token cannot crash your app.
|
|
871
1151
|
|
|
@@ -963,13 +1243,14 @@ Without it the `<em>` would be the editable element; with it the whole figure is
|
|
|
963
1243
|
|
|
964
1244
|
Runnable examples live in the repository root:
|
|
965
1245
|
|
|
966
|
-
| Example
|
|
967
|
-
|
|
|
968
|
-
| `examples/nextjs`
|
|
969
|
-
| `examples/
|
|
970
|
-
| `examples/
|
|
971
|
-
| `examples/
|
|
972
|
-
| `examples/
|
|
1246
|
+
| Example | What it shows |
|
|
1247
|
+
| ---------------------------------- | ------------------------------------------------------------------ |
|
|
1248
|
+
| `examples/nextjs` | App Router, middleware, `createPreprClient`, custom image loader |
|
|
1249
|
+
| `examples/nextjs-cache-components` | Cache Components: cached page shell, personalized `<Suspense>` |
|
|
1250
|
+
| `examples/astro` | Astro middleware and `.astro` components |
|
|
1251
|
+
| `examples/sveltekit` | `hooks.server.ts`, `+layout.server.ts`, `.svelte` components |
|
|
1252
|
+
| `examples/nuxt` | `server/middleware`, `useAsyncData`, `.vue` components |
|
|
1253
|
+
| `examples/express` | Vanilla core — a hand-written adapter for an unsupported framework |
|
|
973
1254
|
|
|
974
1255
|
## Migrating from `@preprio/prepr-nextjs`
|
|
975
1256
|
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
// src/version.ts
|
|
2
|
+
var VERSION = "0.6.0-alpha.0";
|
|
3
|
+
|
|
4
|
+
// src/core/features.ts
|
|
5
|
+
function isEnabled(config) {
|
|
6
|
+
if (typeof config === "boolean") return config;
|
|
7
|
+
return config?.enabled ?? true;
|
|
8
|
+
}
|
|
9
|
+
function resolveFeatures(features) {
|
|
10
|
+
return {
|
|
11
|
+
segments: isEnabled(features?.segments),
|
|
12
|
+
abTesting: isEnabled(features?.abTesting),
|
|
13
|
+
editMode: isEnabled(features?.editMode)
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
var ALL_FEATURES_ENABLED = Object.freeze({
|
|
17
|
+
segments: true,
|
|
18
|
+
abTesting: true,
|
|
19
|
+
editMode: true
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
// src/core/server.ts
|
|
23
|
+
var PreprError = class extends Error {
|
|
24
|
+
constructor(message, code, context, originalError) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.context = context;
|
|
28
|
+
this.originalError = originalError;
|
|
29
|
+
this.name = "PreprError";
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
function getPreprUUIDFromHeaders(headers) {
|
|
33
|
+
return headers.get("prepr-customer-id");
|
|
34
|
+
}
|
|
35
|
+
function getActiveSegmentFromHeaders(headers) {
|
|
36
|
+
return headers.get("Prepr-Segments");
|
|
37
|
+
}
|
|
38
|
+
function getActiveVariantFromHeaders(headers) {
|
|
39
|
+
return headers.get("Prepr-ABtesting");
|
|
40
|
+
}
|
|
41
|
+
var PREPR_HEADER_KEYS = [
|
|
42
|
+
"prepr-customer-id",
|
|
43
|
+
"Prepr-Segments",
|
|
44
|
+
"Prepr-ABtesting",
|
|
45
|
+
"Prepr-Preview-Bar",
|
|
46
|
+
"Prepr-Context-utm_source",
|
|
47
|
+
"Prepr-Context-utm_medium",
|
|
48
|
+
"Prepr-Context-utm_term",
|
|
49
|
+
"Prepr-Context-utm_content",
|
|
50
|
+
"Prepr-Context-utm_campaign",
|
|
51
|
+
"Prepr-Context-initial_referral",
|
|
52
|
+
"Prepr-Visitor-IP",
|
|
53
|
+
"Prepr-Hubspot-Id",
|
|
54
|
+
"Prepr-Customer-Id-Created",
|
|
55
|
+
"User-Agent",
|
|
56
|
+
"Prepr-Package"
|
|
57
|
+
];
|
|
58
|
+
function getPreprHeadersFromHeaders(headers) {
|
|
59
|
+
const preprHeaders = {};
|
|
60
|
+
const copy = (key) => {
|
|
61
|
+
const value = headers.get(key);
|
|
62
|
+
if (value !== null) {
|
|
63
|
+
preprHeaders[key] = value;
|
|
64
|
+
}
|
|
65
|
+
};
|
|
66
|
+
for (const key of PREPR_HEADER_KEYS) {
|
|
67
|
+
copy(key);
|
|
68
|
+
}
|
|
69
|
+
return preprHeaders;
|
|
70
|
+
}
|
|
71
|
+
function validatePreprToken(token) {
|
|
72
|
+
if (!token) {
|
|
73
|
+
throw new PreprError(
|
|
74
|
+
"Token is required",
|
|
75
|
+
"MISSING_TOKEN",
|
|
76
|
+
"validatePreprToken"
|
|
77
|
+
);
|
|
78
|
+
}
|
|
79
|
+
if (!token.startsWith("https://")) {
|
|
80
|
+
throw new PreprError(
|
|
81
|
+
"Token must be a valid HTTPS URL",
|
|
82
|
+
"INVALID_TOKEN",
|
|
83
|
+
"validatePreprToken"
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function getPreprEnvironmentSegments(token) {
|
|
88
|
+
validatePreprToken(token);
|
|
89
|
+
try {
|
|
90
|
+
const response = await fetch(token, {
|
|
91
|
+
headers: {
|
|
92
|
+
"Prepr-Package": `@preprio/toolkit@${VERSION}`,
|
|
93
|
+
// Duplicates the version in Prepr-Package. It keeps the User-Agent
|
|
94
|
+
// format used by `@preprio/prepr-nextjs`, for Prepr API consumers that
|
|
95
|
+
// still identify the toolbar by that string.
|
|
96
|
+
"User-Agent": `Prepr-Preview-Bar/${VERSION}`,
|
|
97
|
+
"Content-Type": "application/json"
|
|
98
|
+
},
|
|
99
|
+
method: "POST",
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
query: `{
|
|
102
|
+
_Segments {
|
|
103
|
+
_id
|
|
104
|
+
name
|
|
105
|
+
}
|
|
106
|
+
}`
|
|
107
|
+
})
|
|
108
|
+
});
|
|
109
|
+
if (!response.ok) {
|
|
110
|
+
throw new PreprError(
|
|
111
|
+
`HTTP ${response.status}: ${response.statusText}`,
|
|
112
|
+
"HTTP_ERROR",
|
|
113
|
+
"getPreprEnvironmentSegments"
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
const json = await response.json();
|
|
117
|
+
if (!json || !json.data || !json.data._Segments) {
|
|
118
|
+
throw new PreprError(
|
|
119
|
+
"Invalid response format from Prepr API",
|
|
120
|
+
"INVALID_RESPONSE",
|
|
121
|
+
"getPreprEnvironmentSegments"
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return json.data._Segments;
|
|
125
|
+
} catch (error) {
|
|
126
|
+
if (error instanceof PreprError) {
|
|
127
|
+
throw error;
|
|
128
|
+
}
|
|
129
|
+
throw new PreprError(
|
|
130
|
+
"Failed to fetch segments from Prepr API",
|
|
131
|
+
"FETCH_ERROR",
|
|
132
|
+
"getPreprEnvironmentSegments",
|
|
133
|
+
error instanceof Error ? error : new Error(String(error))
|
|
134
|
+
);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
async function getToolbarPropsFromHeaders(headers, token, features) {
|
|
138
|
+
const resolved = resolveFeatures(features);
|
|
139
|
+
let segments = [];
|
|
140
|
+
if (resolved.segments) {
|
|
141
|
+
try {
|
|
142
|
+
segments = await getPreprEnvironmentSegments(token);
|
|
143
|
+
} catch {
|
|
144
|
+
segments = [];
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
activeSegment: resolved.segments ? getActiveSegmentFromHeaders(headers) : null,
|
|
149
|
+
activeVariant: resolved.abTesting ? getActiveVariantFromHeaders(headers) : null,
|
|
150
|
+
segments,
|
|
151
|
+
data: segments
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// src/core/personalization.ts
|
|
156
|
+
var PERSONALIZED_VARY = /\bPrepr-(Segments|ABTesting)/i;
|
|
157
|
+
function isPersonalizedResponse(source) {
|
|
158
|
+
const headers = "headers" in source ? source.headers : source;
|
|
159
|
+
return PERSONALIZED_VARY.test(headers.get("vary") ?? "");
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export {
|
|
163
|
+
VERSION,
|
|
164
|
+
resolveFeatures,
|
|
165
|
+
PreprError,
|
|
166
|
+
getPreprUUIDFromHeaders,
|
|
167
|
+
getActiveSegmentFromHeaders,
|
|
168
|
+
getActiveVariantFromHeaders,
|
|
169
|
+
getPreprHeadersFromHeaders,
|
|
170
|
+
validatePreprToken,
|
|
171
|
+
getToolbarPropsFromHeaders,
|
|
172
|
+
isPersonalizedResponse
|
|
173
|
+
};
|