@8ux-co/eelzap 0.0.0-stage → 0.10.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/CHANGELOG.md +145 -0
- package/LICENSE +21 -0
- package/README.md +1061 -2
- package/dist/chunk-2IUU53QA.js +283 -0
- package/dist/chunk-2IUU53QA.js.map +1 -0
- package/dist/chunk-QN53G5P7.js +64 -0
- package/dist/chunk-QN53G5P7.js.map +1 -0
- package/dist/chunk-VW4EH7NY.js +1162 -0
- package/dist/chunk-VW4EH7NY.js.map +1 -0
- package/dist/fields.cjs +132 -0
- package/dist/fields.cjs.map +1 -0
- package/dist/fields.d.cts +116 -0
- package/dist/fields.d.ts +116 -0
- package/dist/fields.js +105 -0
- package/dist/fields.js.map +1 -0
- package/dist/index.cjs +2055 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +2409 -0
- package/dist/index.d.ts +2409 -0
- package/dist/index.js +1998 -0
- package/dist/index.js.map +1 -0
- package/dist/next.d.ts +281 -0
- package/dist/next.js +214 -0
- package/dist/next.js.map +1 -0
- package/dist/preview.d.ts +932 -0
- package/dist/preview.js +885 -0
- package/dist/preview.js.map +1 -0
- package/dist/react.d.ts +181 -0
- package/dist/react.js +225 -0
- package/dist/react.js.map +1 -0
- package/dist/signin-2QOSWZFK.js +305 -0
- package/dist/signin-2QOSWZFK.js.map +1 -0
- package/dist/suggest-5RHURHED.js +1256 -0
- package/dist/suggest-5RHURHED.js.map +1 -0
- package/package.json +109 -4
package/README.md
CHANGED
|
@@ -1,3 +1,1062 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @8ux-co/eelzap
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://www.npmjs.com/package/@8ux-co/eelzap)
|
|
4
|
+
[](./LICENSE)
|
|
5
|
+
|
|
6
|
+
The Eel Zap client: typed content delivery and writes, field helpers that tag
|
|
7
|
+
themselves in preview, Next.js and React preview helpers, and the preview
|
|
8
|
+
overlay Zap's editor talks to. One package, zero runtime dependencies.
|
|
9
|
+
|
|
10
|
+
| Import | What | Runs on | Size, gzip |
|
|
11
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------------ | ------------------ | ------------------ |
|
|
12
|
+
| `@8ux-co/eelzap` | `createClient`: delivery and writes, preview reads, cache strategies, versions, webhook verify, `cleanStega` | server and browser | under 6 KB to read |
|
|
13
|
+
| `@8ux-co/eelzap/fields` | `fields(record)`: typed pick helpers that tag themselves in preview | server and browser | under 1 KB |
|
|
14
|
+
| `@8ux-co/eelzap/next` | The draft-mode route, `isZapPreview()`, `<ZapPreview />` | Next.js | under 2 KB |
|
|
15
|
+
| `@8ux-co/eelzap/react` | `<ZapPreview />`, `useZapLiveUpdates(entry)`, `onValues` | browser | under 2.25 KB |
|
|
16
|
+
| `@8ux-co/eelzap/preview` | The overlay itself. You rarely import it: the boot loads it from Zap's CDN in a preview session | browser | under 14 KB |
|
|
17
|
+
|
|
18
|
+
`./analytics` is reserved for a future cookieless beacon.
|
|
19
|
+
|
|
20
|
+
The CLI that generates types from your schema is a separate dev dependency,
|
|
21
|
+
[`@8ux-co/eelzap-cli`](https://www.npmjs.com/package/@8ux-co/eelzap-cli)
|
|
22
|
+
(`npx eelzap codegen`), so neither it nor its prompts install on your site.
|
|
23
|
+
|
|
24
|
+
Every subpath is ES2020, side-effect free and dependency-free; React and Next
|
|
25
|
+
are optional peers. `.` and `./fields` ship ESM and CommonJS, the browser
|
|
26
|
+
subpaths ESM.
|
|
27
|
+
|
|
28
|
+
## Installation
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
npm install @8ux-co/eelzap
|
|
32
|
+
npm install -D @8ux-co/eelzap-cli # optional: generated types
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Quick Start
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
import { createClient } from '@8ux-co/eelzap'
|
|
39
|
+
|
|
40
|
+
const cms = createClient({
|
|
41
|
+
apiKey: process.env.EELZAP_API_KEY!,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
const { data: posts } = await cms.items.list('blog-posts', {
|
|
45
|
+
pageSize: 10,
|
|
46
|
+
sort: '-publishedAt',
|
|
47
|
+
locale: 'en',
|
|
48
|
+
})
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## Configuration
|
|
52
|
+
|
|
53
|
+
`createClient` accepts:
|
|
54
|
+
|
|
55
|
+
| Option | Type | Required | Default |
|
|
56
|
+
| ---------------- | --------------------------------- | -------- | ------------------------ |
|
|
57
|
+
| `apiKey` | `string` | Yes | — |
|
|
58
|
+
| `baseUrl` | `string` | No | `https://api.eelzap.com` |
|
|
59
|
+
| `pathPrefix` | `string` | No | `/v1` |
|
|
60
|
+
| `locale` | `string` | No | Site default locale |
|
|
61
|
+
| `status` | `'published' \| 'draft' \| 'all'` | No | `published` |
|
|
62
|
+
| `preview` | `boolean` | No | `true` for a `zpt_` key |
|
|
63
|
+
| `fetch` | `typeof fetch` | No | Global `fetch` |
|
|
64
|
+
| `defaultHeaders` | `HeadersInit` | No | — |
|
|
65
|
+
| `timeout` | `number` | No | `30000`, per attempt |
|
|
66
|
+
| `retry` | `boolean \| RetryOptions` | No | On |
|
|
67
|
+
|
|
68
|
+
`apiKey` is a site API key (`secret_…` or `public_…`), or a preview token
|
|
69
|
+
(`zpt_…`) from a draft-mode URL.
|
|
70
|
+
|
|
71
|
+
### Draft preview
|
|
72
|
+
|
|
73
|
+
`preview: true` sends `preview=1` on the item and document reads
|
|
74
|
+
(`items.list`, `items.get`, `documents.list`, `documents.get`; the query
|
|
75
|
+
builder follows the client's setting): every entry comes back whatever its status, an entry with an open
|
|
76
|
+
draft comes back with the draft's content, and nothing is cached. It needs a
|
|
77
|
+
secret key that may read drafts, or a preview token; a public key is refused
|
|
78
|
+
with `DRAFT_ACCESS_DENIED`. Set it per client or per request — the request
|
|
79
|
+
wins. A client built with a `zpt_` token has it on by default.
|
|
80
|
+
|
|
81
|
+
```ts
|
|
82
|
+
const preview = createClient({ apiKey: previewToken }) // preview on
|
|
83
|
+
const post = await cms.items.get('blog-posts', 'hello-world', { preview: true })
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
### Pointing the client at another host
|
|
87
|
+
|
|
88
|
+
`baseUrl` and `pathPrefix` compose: the request URL is `baseUrl` +
|
|
89
|
+
`pathPrefix` + the resource path. The defaults are `https://api.eelzap.com`
|
|
90
|
+
and `/v1`.
|
|
91
|
+
|
|
92
|
+
```ts
|
|
93
|
+
// A Zap running locally on port 5047
|
|
94
|
+
createClient({
|
|
95
|
+
apiKey,
|
|
96
|
+
baseUrl: 'http://localhost:5047',
|
|
97
|
+
pathPrefix: '/api/public/v1',
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
// Env-driven, which is what apps should do
|
|
101
|
+
createClient({
|
|
102
|
+
apiKey: process.env.EELZAP_API_KEY!,
|
|
103
|
+
baseUrl: process.env.EELZAP_BASE_URL, // undefined → default
|
|
104
|
+
pathPrefix: process.env.EELZAP_PATH_PREFIX,
|
|
105
|
+
})
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
### Rate limits and retries
|
|
109
|
+
|
|
110
|
+
A site key may send 100 requests a minute (`public_`: 60), and past that the
|
|
111
|
+
API answers `429` with `Retry-After`. The client waits that long and tries
|
|
112
|
+
again, so a build that renders every page, or a seed script, does not fail
|
|
113
|
+
half-way. A `503` that carries `Retry-After` is retried the same way.
|
|
114
|
+
|
|
115
|
+
- **What is retried:** reads (`GET`), and writes that send an
|
|
116
|
+
`Idempotency-Key` (every `create`, `media.fromUrl`, and `comments.create`,
|
|
117
|
+
`reply` and `saveToDraft`), which the API deduplicates. Any other write (`update`, `delete`,
|
|
118
|
+
`publish` …) is never repeated: the `429` is thrown.
|
|
119
|
+
- **How long it waits:** `Retry-After`, in seconds or as an HTTP date, plus up
|
|
120
|
+
to a second of jitter. Without it, a `429` backs off exponentially from
|
|
121
|
+
`baseDelayMs` (500 ms, then 1 s, 2 s …), half of each step random. A `503`
|
|
122
|
+
without `Retry-After` is not retried.
|
|
123
|
+
- **When it stops:** after `retries` retries (default 3), or at once when
|
|
124
|
+
`Retry-After` is longer than `maxDelayMs` (default 60 s). The last answer is
|
|
125
|
+
thrown as an `EelZapError` with its status.
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
createClient({
|
|
129
|
+
apiKey: process.env.EELZAP_API_KEY!,
|
|
130
|
+
retry: {
|
|
131
|
+
retries: 5, // default 3
|
|
132
|
+
maxDelayMs: 30_000, // default 60000; a longer Retry-After is thrown, not waited
|
|
133
|
+
baseDelayMs: 1_000, // default 500, used when there is no Retry-After
|
|
134
|
+
onRetry: ({ attempt, delayMs, status, method, url }) =>
|
|
135
|
+
console.warn(`[cms] ${status} on ${method} ${url}, retry ${attempt} in ${delayMs} ms`),
|
|
136
|
+
},
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
createClient({ apiKey, retry: false }) // never retry
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`timeout` applies to each attempt, not to the whole call, so a call that waits
|
|
143
|
+
out a `Retry-After` can take longer than `timeout`.
|
|
144
|
+
|
|
145
|
+
## Usage
|
|
146
|
+
|
|
147
|
+
### Collections
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
const collections = await cms.collections.list()
|
|
151
|
+
const blog = await cms.collections.get('blog-posts')
|
|
152
|
+
const created = await cms.collections.create({
|
|
153
|
+
name: 'Blog Posts',
|
|
154
|
+
key: 'blog-posts',
|
|
155
|
+
})
|
|
156
|
+
await cms.collections.fields.create('blog-posts', {
|
|
157
|
+
key: 'title',
|
|
158
|
+
name: 'Title',
|
|
159
|
+
type: 'TEXT',
|
|
160
|
+
})
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
Field types are the API's (`FieldType`): `TEXT`, `LONG_TEXT`, `RICH_TEXT`, `NUMBER`, `INTEGER`,
|
|
164
|
+
`BOOLEAN`, `DATE`, `DATETIME`, `CURRENCY`, `ENUM`, `GALLERY`, `IMAGE`, `VIDEO`, `FILE`, `URL` and
|
|
165
|
+
`EMAIL`. The field routes (`collections.fields`, `documents.fields`) accept and answer these, and
|
|
166
|
+
the delivery schema reads (`collections.list`, `collections.get`, `documents.list`) publish the same
|
|
167
|
+
names.
|
|
168
|
+
|
|
169
|
+
### Items
|
|
170
|
+
|
|
171
|
+
```ts
|
|
172
|
+
const products = await cms.items.list('products', {
|
|
173
|
+
page: 1,
|
|
174
|
+
pageSize: 20,
|
|
175
|
+
sort: '-price',
|
|
176
|
+
fields: ['title', 'price', 'category'],
|
|
177
|
+
filter: {
|
|
178
|
+
category: 'electronics',
|
|
179
|
+
price: { gte: 100, lt: 500 },
|
|
180
|
+
},
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
const product = await cms.items.get('products', 'noise-cancelling-headphones', {
|
|
184
|
+
locale: 'en',
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
await cms.items.create('products', {
|
|
188
|
+
slug: 'noise-cancelling-headphones',
|
|
189
|
+
values: {
|
|
190
|
+
title: 'Noise Cancelling Headphones',
|
|
191
|
+
},
|
|
192
|
+
})
|
|
193
|
+
await cms.items.publish('products', 'noise-cancelling-headphones')
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
### Query Builder
|
|
197
|
+
|
|
198
|
+
```ts
|
|
199
|
+
const result = await cms.items
|
|
200
|
+
.collection('products')
|
|
201
|
+
.locale('en')
|
|
202
|
+
.filter('category', 'electronics')
|
|
203
|
+
.filter('price', { gte: 100 })
|
|
204
|
+
.sort('-price')
|
|
205
|
+
.fields(['title', 'price'])
|
|
206
|
+
.page(1)
|
|
207
|
+
.pageSize(20)
|
|
208
|
+
.get()
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Documents
|
|
212
|
+
|
|
213
|
+
```ts
|
|
214
|
+
const documents = await cms.documents.list({ locale: 'en' })
|
|
215
|
+
const homepage = await cms.documents.get('homepage', {
|
|
216
|
+
locale: 'en',
|
|
217
|
+
status: 'draft',
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
await cms.documents.update('homepage', {
|
|
221
|
+
name: 'Homepage',
|
|
222
|
+
key: 'homepage-v2',
|
|
223
|
+
description: 'The main landing page',
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
await cms.documents.values.update('homepage', {
|
|
227
|
+
hero_title: 'Welcome',
|
|
228
|
+
})
|
|
229
|
+
await cms.documents.seo.update('homepage', {
|
|
230
|
+
metaTitle: 'Homepage',
|
|
231
|
+
structuredData: {
|
|
232
|
+
'@context': 'https://schema.org',
|
|
233
|
+
'@type': 'WebPage',
|
|
234
|
+
name: 'Homepage',
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
```
|
|
238
|
+
|
|
239
|
+
### Site
|
|
240
|
+
|
|
241
|
+
```ts
|
|
242
|
+
const site = await cms.site.get()
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
### Media
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
const media = await cms.media.upload({
|
|
249
|
+
file: new Blob(['hello'], { type: 'text/plain' }),
|
|
250
|
+
filename: 'hello.txt',
|
|
251
|
+
contentType: 'text/plain',
|
|
252
|
+
title: 'Greeting',
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
await cms.media.publish(media.id)
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
### Content Versioning
|
|
259
|
+
|
|
260
|
+
Items and documents keep a version history. An entry has at most one open
|
|
261
|
+
draft: open it, stage changes on it, then publish or discard it. The live
|
|
262
|
+
entry does not change until the draft is published. Writing needs a secret
|
|
263
|
+
key; publishing and rolling back need one with publish rights.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
// Open a draft from the live item (409 DUPLICATE_KEY if one is already open)
|
|
267
|
+
const draft = await cms.itemVersions.createDraft('blog', 'hello-world', {
|
|
268
|
+
note: 'Update hero image',
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
// Stage values on the draft (and optionally a new slug)
|
|
272
|
+
await cms.itemVersions.updateDraft('blog', 'hello-world', {
|
|
273
|
+
values: { hero_image: 'new-image-id' },
|
|
274
|
+
locale: 'en',
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
// Publish it…
|
|
278
|
+
await cms.itemVersions.publishDraft('blog', 'hello-world')
|
|
279
|
+
// …or throw it away
|
|
280
|
+
await cms.itemVersions.discardDraft('blog', 'hello-world')
|
|
281
|
+
|
|
282
|
+
// History, newest first, and one version's snapshot
|
|
283
|
+
const { versions } = await cms.itemVersions.list('blog', 'hello-world')
|
|
284
|
+
const snapshot = await cms.itemVersions.get('blog', 'hello-world', versions[0].id)
|
|
285
|
+
|
|
286
|
+
// Restore an older version: it goes live as a NEW published version.
|
|
287
|
+
// Refused while a draft is open, and for a draft version.
|
|
288
|
+
await cms.itemVersions.rollback('blog', 'hello-world', versions[2].id)
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Documents work the same way, keyed by the document key:
|
|
292
|
+
|
|
293
|
+
```ts
|
|
294
|
+
await cms.documentVersions.createDraft('homepage')
|
|
295
|
+
await cms.documentVersions.updateDraft('homepage', { values: { title: 'Welcome' } })
|
|
296
|
+
await cms.documentVersions.publishDraft('homepage')
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
`list`, `createDraft`, `publishDraft` and `rollback` answer a summary
|
|
300
|
+
(`ItemVersionSummary` / `DocumentVersionSummary`: number, status, author,
|
|
301
|
+
note, dates); `get` and `updateDraft` answer the snapshot too
|
|
302
|
+
(`ItemVersionDetail` / `DocumentVersionDetail`: the stored values, SEO and,
|
|
303
|
+
for items, slugs). A snapshot's values are the stored rows (`valueText`,
|
|
304
|
+
`valueInt`, …, keyed by `fieldKey`), not the delivery API's resolved values.
|
|
305
|
+
|
|
306
|
+
### Media URLs
|
|
307
|
+
|
|
308
|
+
Media values carry `url` when the file is published, and a short-lived
|
|
309
|
+
`signedUrl` instead when it is a draft the key may read. `getMediaUrl` picks
|
|
310
|
+
the one to render, or `null`:
|
|
311
|
+
|
|
312
|
+
```ts
|
|
313
|
+
import { getMediaUrl } from '@8ux-co/eelzap'
|
|
314
|
+
|
|
315
|
+
const src = getMediaUrl(item.content.heroImage)
|
|
316
|
+
```
|
|
317
|
+
|
|
318
|
+
Rich text fields need no helper: the delivery API returns them as sanitised
|
|
319
|
+
HTML strings, with embedded images already resolved to URLs.
|
|
320
|
+
|
|
321
|
+
## Next.js
|
|
322
|
+
|
|
323
|
+
```ts
|
|
324
|
+
// lib/cms.ts
|
|
325
|
+
import { createClient } from '@8ux-co/eelzap'
|
|
326
|
+
|
|
327
|
+
export const cms = createClient({
|
|
328
|
+
apiKey: process.env.EELZAP_API_KEY!,
|
|
329
|
+
baseUrl: process.env.EELZAP_BASE_URL,
|
|
330
|
+
pathPrefix: process.env.EELZAP_PATH_PREFIX,
|
|
331
|
+
})
|
|
332
|
+
```
|
|
333
|
+
|
|
334
|
+
```tsx
|
|
335
|
+
// app/blog/page.tsx
|
|
336
|
+
import { cms } from '@/lib/cms'
|
|
337
|
+
|
|
338
|
+
export const revalidate = 60
|
|
339
|
+
|
|
340
|
+
export default async function BlogPage() {
|
|
341
|
+
const { data } = await cms.items.list('blog-posts', { pageSize: 10 })
|
|
342
|
+
return <pre>{JSON.stringify(data, null, 2)}</pre>
|
|
343
|
+
}
|
|
344
|
+
```
|
|
345
|
+
|
|
346
|
+
## Generated types
|
|
347
|
+
|
|
348
|
+
`npx eelzap codegen` (from [`@8ux-co/eelzap-cli`](https://www.npmjs.com/package/@8ux-co/eelzap-cli))
|
|
349
|
+
writes a type per collection and document, importing from `@8ux-co/eelzap`:
|
|
350
|
+
|
|
351
|
+
```ts
|
|
352
|
+
import type { BlogPostsItem } from './generated/cms'
|
|
353
|
+
|
|
354
|
+
const post = await cms.items.get<BlogPostsItem['content']>('blog-posts', 'hello-world')
|
|
355
|
+
```
|
|
356
|
+
|
|
357
|
+
Fields come in the order Zap's field builder shows them, sections included. A
|
|
358
|
+
required field is typed non-null, and a gallery is always an array; any other
|
|
359
|
+
field is `T | null`. The CLI's README says what that rests on.
|
|
360
|
+
|
|
361
|
+
## Preview in Zap's editor
|
|
362
|
+
|
|
363
|
+
Zap's editor shows your real page beside the form. With this package on the
|
|
364
|
+
page, it shows drafts as you type, unpublished entries included, and clicking
|
|
365
|
+
an element focuses its field. Three pieces, all optional, all free for
|
|
366
|
+
visitors:
|
|
367
|
+
|
|
368
|
+
1. **The boot**, `<ZapPreview />`: about 1 KB, and it makes no request
|
|
369
|
+
unless a preview session is present (the page is framed by Zap, the URL
|
|
370
|
+
carries `?zap`, the suggestion shortcut is pressed, or a draft-mode session
|
|
371
|
+
is active). Only then does it load the overlay from Zap, pinned by an SRI
|
|
372
|
+
hash compiled into the package, once per page: React StrictMode, a remount
|
|
373
|
+
or a second `<ZapPreview />` add no second script and no second overlay.
|
|
374
|
+
2. **The draft-mode route** (`./next`), so saved drafts and unpublished
|
|
375
|
+
entries render on the first load.
|
|
376
|
+
3. **Tags**: text fields need none (below); non-text fields take one spread
|
|
377
|
+
from `fields`.
|
|
378
|
+
|
|
379
|
+
### Next.js
|
|
380
|
+
|
|
381
|
+
```tsx
|
|
382
|
+
// app/layout.tsx
|
|
383
|
+
import { draftMode } from 'next/headers'
|
|
384
|
+
import { ZapPreview } from '@8ux-co/eelzap/next'
|
|
385
|
+
|
|
386
|
+
export default async function RootLayout({ children }: { children: React.ReactNode }) {
|
|
387
|
+
return (
|
|
388
|
+
<html lang="es">
|
|
389
|
+
<body>
|
|
390
|
+
{children}
|
|
391
|
+
<ZapPreview
|
|
392
|
+
siteKey="your-site-key"
|
|
393
|
+
siteId="your-site-id"
|
|
394
|
+
preview={(await draftMode()).isEnabled}
|
|
395
|
+
/>
|
|
396
|
+
</body>
|
|
397
|
+
</html>
|
|
398
|
+
)
|
|
399
|
+
}
|
|
400
|
+
```
|
|
401
|
+
|
|
402
|
+
```ts
|
|
403
|
+
// app/api/zap-preview/route.ts
|
|
404
|
+
import { cookies, draftMode } from 'next/headers'
|
|
405
|
+
import { createDraftModeRoute } from '@8ux-co/eelzap/next'
|
|
406
|
+
|
|
407
|
+
export const GET = createDraftModeRoute({
|
|
408
|
+
siteKey: 'your-site-key',
|
|
409
|
+
apiKey: process.env.EELZAP_API_KEY!, // one of the site's own API keys
|
|
410
|
+
draftMode,
|
|
411
|
+
cookies,
|
|
412
|
+
})
|
|
413
|
+
```
|
|
414
|
+
|
|
415
|
+
`<ZapPreview />` tells the editor where the route lives (`/api/zap-preview`
|
|
416
|
+
by default; `draftRoute="/es/preview"` or `draftRoute={null}` otherwise).
|
|
417
|
+
The page names only a path: the editor joins it to the page's own verified
|
|
418
|
+
origin, mints a ten-minute preview token and loads
|
|
419
|
+
`{origin}{route}?token=…&path=…`. The route checks the token with Zap
|
|
420
|
+
(minted for this site, live), enables draft mode with cookies that survive
|
|
421
|
+
inside Zap's frame (`SameSite=None; Secure; Partitioned`) and redirects to the
|
|
422
|
+
page without the token. `createDraftModeExitRoute({ draftMode })` ends it.
|
|
423
|
+
|
|
424
|
+
Read drafts on the server with the token as the credential:
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
import { cookies, draftMode, headers } from 'next/headers'
|
|
428
|
+
import { createClient } from '@8ux-co/eelzap'
|
|
429
|
+
import { getValidPreviewToken } from '@8ux-co/eelzap/next'
|
|
430
|
+
|
|
431
|
+
const token = (await draftMode()).isEnabled
|
|
432
|
+
? await getValidPreviewToken(
|
|
433
|
+
{ headers: await headers(), cookies: await cookies() },
|
|
434
|
+
{ siteKey: 'your-site-key', apiKey: process.env.EELZAP_API_KEY! },
|
|
435
|
+
)
|
|
436
|
+
: null
|
|
437
|
+
const cms = createClient({ apiKey: token ?? process.env.EELZAP_API_KEY! })
|
|
438
|
+
```
|
|
439
|
+
|
|
440
|
+
`getValidPreviewToken` checks the token with Zap on every request that serves
|
|
441
|
+
drafts and returns it only when Zap confirms it is live and minted for your
|
|
442
|
+
site (verdicts cached in memory, 60 seconds at most). `isZapPreview(request)`
|
|
443
|
+
is the cheap shape check for rendering choices; it must not gate drafts.
|
|
444
|
+
Browsers that block cookies in frames (Safari) send the token in the
|
|
445
|
+
`x-zap-preview-token` header instead, which both read.
|
|
446
|
+
|
|
447
|
+
### Other React frameworks
|
|
448
|
+
|
|
449
|
+
```tsx
|
|
450
|
+
import { ZapPreview, useZapLiveUpdates } from '@8ux-co/eelzap/react'
|
|
451
|
+
;<ZapPreview siteKey="your-site-key" siteId="your-site-id" draftRoute={null} />
|
|
452
|
+
|
|
453
|
+
const post = useZapLiveUpdates(props.post) // the entry with unsaved values merged in
|
|
454
|
+
```
|
|
455
|
+
|
|
456
|
+
### Without a bundler
|
|
457
|
+
|
|
458
|
+
```html
|
|
459
|
+
<script
|
|
460
|
+
src="https://zap.eel.software/js/preview/boot.v1.<hash>.js"
|
|
461
|
+
integrity="sha384-…"
|
|
462
|
+
crossorigin="anonymous"
|
|
463
|
+
data-site="your-site-key"
|
|
464
|
+
data-site-id="your-site-id"
|
|
465
|
+
async
|
|
466
|
+
></script>
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
The exact URL and `integrity` are in your site's settings in Zap.
|
|
470
|
+
`data-zap-draft-route="/your/route"` announces a draft-mode route.
|
|
471
|
+
|
|
472
|
+
### Local development
|
|
473
|
+
|
|
474
|
+
The site and Zap can both run on your machine, with no shim. Zap accepts a
|
|
475
|
+
site URL (and «Otros dominios») over `http` or `https` on `localhost`,
|
|
476
|
+
`127.0.0.1` and `*.localhost` names, any port; every other host must be
|
|
477
|
+
`https`. Pass the Zap you work against:
|
|
478
|
+
|
|
479
|
+
```tsx
|
|
480
|
+
<ZapPreview siteKey="your-site-key" zapOrigin={process.env.EELZAP_ORIGIN} />
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
The boot loads the overlay from the Zap that framed the page when it is a Zap
|
|
484
|
+
origin (production, or a local Zap seen from a local page), else from
|
|
485
|
+
`zapOrigin` under the same rule, else from `https://zap.eel.software`. The path
|
|
486
|
+
and the SRI hash are compiled in, so only the released bytes run, whichever Zap
|
|
487
|
+
serves them, and the overlay trusts the Zap it came from. A local `zapOrigin`
|
|
488
|
+
left in production code is ignored. On the tag, it is `data-zap-origin`;
|
|
489
|
+
`authOrigin` (`data-auth-origin`) names a local Eel under the same rule.
|
|
490
|
+
|
|
491
|
+
### Text fields find themselves: stega
|
|
492
|
+
|
|
493
|
+
Preview reads (`preview: true`) append an invisible marker to TEXT and
|
|
494
|
+
LONG_TEXT values and to the last text node of each RICH_TEXT block, naming
|
|
495
|
+
the record, the field and the locale. The overlay reads the markers in the
|
|
496
|
+
page's text, so any element that renders a text field is found, in lists and
|
|
497
|
+
cards too, with no tag. Published reads never carry a marker.
|
|
498
|
+
|
|
499
|
+
Where your code compares, parses or puts a preview value in an attribute, a
|
|
500
|
+
URL or `<head>`, clean it, or read without markers:
|
|
501
|
+
|
|
502
|
+
```ts
|
|
503
|
+
import { cleanStega } from '@8ux-co/eelzap'
|
|
504
|
+
|
|
505
|
+
const slugLike = cleanStega(post.content.title).toLowerCase()
|
|
506
|
+
const seo = await cms.documents.get('home', { preview: true, stega: false })
|
|
507
|
+
```
|
|
508
|
+
|
|
509
|
+
Only prose fields are encoded; slugs, URLs, emails, numbers, dates, enums,
|
|
510
|
+
booleans, JSON and media never are.
|
|
511
|
+
|
|
512
|
+
### Non-text fields: `fields`
|
|
513
|
+
|
|
514
|
+
```tsx
|
|
515
|
+
import { fields } from '@8ux-co/eelzap/fields'
|
|
516
|
+
|
|
517
|
+
const f = fields(home) // a HomeDocument from `eelzap codegen`
|
|
518
|
+
|
|
519
|
+
<h1>{f.text('hero_title')}</h1>
|
|
520
|
+
<img {...f.image('hero_image')} />
|
|
521
|
+
<a href={f.value('cta_url') ?? '/'} {...f.attrs('cta_url')}>Ver más</a>
|
|
522
|
+
<a href={`mailto:${f.value('email')}`} {...f.attrs('email')}>Escríbenos</a>
|
|
523
|
+
|
|
524
|
+
// Numbered fields, `stat_1` … `stat_4`
|
|
525
|
+
{f.list('stat', 4).map((s) => <p key={s.key}>{s.text()}</p>)}
|
|
526
|
+
|
|
527
|
+
// Slots of several fields, `nav_1_texto` + `nav_1_url` … `nav_5_…`
|
|
528
|
+
{f.list('nav', 5)
|
|
529
|
+
.filter((nav) => !nav.empty)
|
|
530
|
+
.map((nav) => (
|
|
531
|
+
<a key={nav.key} href={nav.value('url') ?? '/'} {...nav.attrs('url')}>
|
|
532
|
+
{nav.text('texto')}
|
|
533
|
+
</a>
|
|
534
|
+
))}
|
|
535
|
+
```
|
|
536
|
+
|
|
537
|
+
Keys are typed from your generated types, so a typo fails to compile.
|
|
538
|
+
Outside preview every helper returns plain values and empty attribute
|
|
539
|
+
objects, so production HTML is byte-identical to reading the values
|
|
540
|
+
directly. In preview, text keeps its marker and the other helpers add
|
|
541
|
+
`data-zap="record#field"`. `value`, `image` and `attrs` never carry a marker.
|
|
542
|
+
The tag grammar by hand: `data-zap="blog-posts/my-post#cover"` for an entry,
|
|
543
|
+
`data-zap="doc:home#cta_url"` for a document. A manual tag on an element wins
|
|
544
|
+
over a marker in its text.
|
|
545
|
+
|
|
546
|
+
A URL or EMAIL field tagged on a link updates the link's `href` as the editor
|
|
547
|
+
types (`mailto:` for an email) and leaves its label alone: «Ver más» stays «Ver
|
|
548
|
+
más». Tag the `<a>` or an element around it; with no link inside, the address
|
|
549
|
+
shows as text. An image field updates `src` and `alt`, never text.
|
|
550
|
+
|
|
551
|
+
`f.list(prefix, n)` returns slots `1` … `n`, each a `FieldSlot` with the same
|
|
552
|
+
helpers scoped to it: without a suffix they read `prefix_{i}` (`s.text()`,
|
|
553
|
+
`s.value()`, `s.attrs()`, `s.image()`), with one `prefix_{i}_{suffix}`
|
|
554
|
+
(`nav.text('texto')`, `nav.value('url')`). Suffixes are typed from your
|
|
555
|
+
generated types. Each slot has `key` (`nav_1`), `index` (from 1) and `empty`
|
|
556
|
+
(every field of the slot is empty).
|
|
557
|
+
|
|
558
|
+
### Values your site computes
|
|
559
|
+
|
|
560
|
+
The overlay updates every element it found as you type. For values your code
|
|
561
|
+
derives (excerpts, counts), `useZapLiveUpdates(entry)` returns the entry with
|
|
562
|
+
the unsaved values merged in, and `onValues(entry, handler)` from
|
|
563
|
+
`@8ux-co/eelzap/react` calls you with each patch (it is framework-free; the
|
|
564
|
+
overlay also announces each patch as an `eelzap:values` window event).
|
|
565
|
+
|
|
566
|
+
### Suggestions on the live site
|
|
567
|
+
|
|
568
|
+
With `siteId`, people with a Zap seat in your workspace can suggest changes on
|
|
569
|
+
your live site: `?zap` in the URL, or Shift Z (`shortcut="K"` picks another
|
|
570
|
+
letter, `shortcut={false}` turns it off), shows «Editar o comentar» at the
|
|
571
|
+
bottom centre of the page, clear of the corners sites keep for their own
|
|
572
|
+
floating buttons, and it opens a sign-in with Eel in a popup. The client must also run on your home page `/`, where the sign-in
|
|
573
|
+
lands. With a Content-Security-Policy, allow `https://zap.eel.software` in
|
|
574
|
+
`script-src` and `connect-src` and `https://auth.eel.software` in
|
|
575
|
+
`connect-src`. Everything renders in a closed Shadow DOM.
|
|
576
|
+
|
|
577
|
+
## Resilience & Caching
|
|
578
|
+
|
|
579
|
+
The SDK ships a `cachedFetch` helper and a `CacheAdapter` interface for
|
|
580
|
+
application-level content caching, with two strategies:
|
|
581
|
+
|
|
582
|
+
- `network-first` (default): always fetch fresh content, cache it on success,
|
|
583
|
+
and fall back to the last cached value when the fetch fails.
|
|
584
|
+
- `cache-first`: return cached content immediately when there is some, and
|
|
585
|
+
fetch only on a miss.
|
|
586
|
+
|
|
587
|
+
If nothing is cached and the fetch fails, the original error is re-thrown so
|
|
588
|
+
your app can handle it explicitly.
|
|
589
|
+
|
|
590
|
+
### Quick examples
|
|
591
|
+
|
|
592
|
+
```ts
|
|
593
|
+
import { createClient, cachedFetch, MemoryCacheAdapter } from '@8ux-co/eelzap'
|
|
594
|
+
|
|
595
|
+
const cms = createClient({ apiKey: process.env.EELZAP_API_KEY! })
|
|
596
|
+
const cache = new MemoryCacheAdapter()
|
|
597
|
+
|
|
598
|
+
// Positional form: always network-first
|
|
599
|
+
const homepage = await cachedFetch('homepage', () => cms.documents.get('homepage'), cache)
|
|
600
|
+
|
|
601
|
+
// Options form: choose the strategy
|
|
602
|
+
const posts = await cachedFetch({
|
|
603
|
+
key: 'blog:page-1',
|
|
604
|
+
adapter: cache,
|
|
605
|
+
strategy: 'cache-first',
|
|
606
|
+
fetcher: () => cms.items.list('blog-posts', { pageSize: 10 }),
|
|
607
|
+
})
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
#### `network-first`
|
|
611
|
+
|
|
612
|
+
1. Runs your `fetcher`.
|
|
613
|
+
2. Stores the fresh result in the adapter and returns it.
|
|
614
|
+
3. If the fetch fails, returns the last cached value when there is one.
|
|
615
|
+
|
|
616
|
+
#### `cache-first`
|
|
617
|
+
|
|
618
|
+
1. Reads the adapter.
|
|
619
|
+
2. Returns the cached value on a hit.
|
|
620
|
+
3. On a miss, runs your `fetcher`, stores the result and returns it.
|
|
621
|
+
|
|
622
|
+
Pair `cache-first` with [webhooks](#webhooks) to drop keys when content
|
|
623
|
+
changes.
|
|
624
|
+
|
|
625
|
+
### Custom cache adapters
|
|
626
|
+
|
|
627
|
+
`MemoryCacheAdapter` works for long-lived servers but data is lost on
|
|
628
|
+
restart. Implement the `CacheAdapter` interface to persist to any
|
|
629
|
+
backend:
|
|
630
|
+
|
|
631
|
+
```ts
|
|
632
|
+
import type { CacheAdapter } from '@8ux-co/eelzap'
|
|
633
|
+
|
|
634
|
+
// Example: filesystem adapter (Node.js)
|
|
635
|
+
class FsCacheAdapter<T = unknown> implements CacheAdapter<T> {
|
|
636
|
+
#dir: string
|
|
637
|
+
constructor(dir: string) {
|
|
638
|
+
this.#dir = dir
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
async get(key: string): Promise<T | undefined> {
|
|
642
|
+
try {
|
|
643
|
+
const raw = await fs.readFile(path.join(this.#dir, `${key}.json`), 'utf8')
|
|
644
|
+
return JSON.parse(raw) as T
|
|
645
|
+
} catch {
|
|
646
|
+
return undefined
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
async set(key: string, value: T): Promise<void> {
|
|
651
|
+
await fs.mkdir(this.#dir, { recursive: true })
|
|
652
|
+
await fs.writeFile(path.join(this.#dir, `${key}.json`), JSON.stringify(value))
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
```
|
|
656
|
+
|
|
657
|
+
Other backends that work well: Vercel KV, Cloudflare KV, Redis,
|
|
658
|
+
IndexedDB (for client-side apps), or your framework's built-in cache.
|
|
659
|
+
|
|
660
|
+
### Recommendations
|
|
661
|
+
|
|
662
|
+
| Scenario | Recommended adapter | Notes |
|
|
663
|
+
| -------------------------------------- | --------------------------------- | --------------------------------------- |
|
|
664
|
+
| Long-running server (Express, Fastify) | `MemoryCacheAdapter` | Fast, no I/O; lost on restart |
|
|
665
|
+
| Serverless (Lambda, Vercel Functions) | File system or KV store | Memory is discarded between invocations |
|
|
666
|
+
| Edge (Cloudflare Workers) | KV or Durable Objects | Workers have no filesystem |
|
|
667
|
+
| Static builds (Astro, Gatsby) | Not needed | Content is fetched at build time |
|
|
668
|
+
| Client-side SPA | IndexedDB or localStorage adapter | Survives page reloads |
|
|
669
|
+
|
|
670
|
+
### Why not hardcoded fallbacks?
|
|
671
|
+
|
|
672
|
+
Hardcoded default strings go stale immediately and create a maintenance
|
|
673
|
+
burden. With `cachedFetch`, your site always serves real CMS content:
|
|
674
|
+
|
|
675
|
+
- **First deploy:** content is fetched fresh and cached.
|
|
676
|
+
- **CMS goes down:** last-known-good content is served seamlessly.
|
|
677
|
+
- **CMS recovers:** the cache is silently refreshed on the next request.
|
|
678
|
+
- **Content never fetched:** the error propagates — you decide how to
|
|
679
|
+
handle it (error page, skeleton, etc.) rather than showing stale
|
|
680
|
+
placeholder text.
|
|
681
|
+
|
|
682
|
+
## Webhooks
|
|
683
|
+
|
|
684
|
+
Zap sends webhooks to the workspace endpoints set up in Nest (workspace
|
|
685
|
+
settings, Webhooks), subscribed to the `zap.*` events you want and narrowed to sites.
|
|
686
|
+
Each delivery is a JSON envelope signed with the endpoint's `whsec_…` secret:
|
|
687
|
+
|
|
688
|
+
| Header | Value |
|
|
689
|
+
| ----------------- | ---------------------------------------------------------- |
|
|
690
|
+
| `X-Eel-Timestamp` | Unix seconds when it was sent |
|
|
691
|
+
| `X-Eel-Signature` | `v1=` + hex HMAC-SHA256 of `` `${timestamp}.${rawBody}` `` |
|
|
692
|
+
| `X-Eel-Event` | The event name, e.g. `zap.item.published` |
|
|
693
|
+
| `X-Eel-Event-Id` | The envelope `id`, the same for every endpoint and retry |
|
|
694
|
+
| `X-Eel-Delivery` | This endpoint's delivery id |
|
|
695
|
+
| `X-Eel-Attempt` | The attempt number, from 1 |
|
|
696
|
+
|
|
697
|
+
### Verifying a delivery
|
|
698
|
+
|
|
699
|
+
`verifyWebhookSignature(payload, headers, secret, options?)` checks the
|
|
700
|
+
signature over the raw body and timestamp, and refuses a timestamp more than
|
|
701
|
+
five minutes away (`toleranceSeconds` changes that), which is what stops a
|
|
702
|
+
captured delivery being replayed. It resolves `false` for anything
|
|
703
|
+
inauthentic or malformed. It uses WebCrypto only, so it runs in Node 20+,
|
|
704
|
+
edge runtimes, workers and browsers; it throws if the runtime has no
|
|
705
|
+
`globalThis.crypto` (Node 18 needs `globalThis.crypto` set to `node:crypto`'s
|
|
706
|
+
`webcrypto`).
|
|
707
|
+
|
|
708
|
+
```ts
|
|
709
|
+
import { verifyWebhookSignature, type WebhookPayload } from '@8ux-co/eelzap'
|
|
710
|
+
|
|
711
|
+
export async function POST(request: Request) {
|
|
712
|
+
// The raw body, exactly as received — never a re-serialised parse.
|
|
713
|
+
const payload = await request.text()
|
|
714
|
+
|
|
715
|
+
const valid = await verifyWebhookSignature(
|
|
716
|
+
payload,
|
|
717
|
+
request.headers, // a Headers object, or a plain object such as Express's req.headers
|
|
718
|
+
process.env.EELZAP_WEBHOOK_SECRET!,
|
|
719
|
+
)
|
|
720
|
+
if (!valid) {
|
|
721
|
+
return new Response('Invalid signature', { status: 401 })
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
const event = JSON.parse(payload) as WebhookPayload
|
|
725
|
+
// `event.id` is the same across retries: dedupe on it.
|
|
726
|
+
return new Response('ok')
|
|
727
|
+
}
|
|
728
|
+
```
|
|
729
|
+
|
|
730
|
+
### Payload
|
|
731
|
+
|
|
732
|
+
Every delivery is the same envelope; `data` depends on the event and is
|
|
733
|
+
minimal by design — ids, keys, slugs, version numbers and the names of what
|
|
734
|
+
changed, never a field value. Read the content itself with the client.
|
|
735
|
+
|
|
736
|
+
```ts
|
|
737
|
+
interface WebhookPayload<TData = unknown> {
|
|
738
|
+
id: string // idempotency key
|
|
739
|
+
type: WebhookEventName // 'zap.item.published', 'zap.document.updated', 'ping', …
|
|
740
|
+
version: number // payload contract version: 2
|
|
741
|
+
app: string // 'zap'
|
|
742
|
+
workspace_id: string
|
|
743
|
+
occurred_at: string // ISO timestamp
|
|
744
|
+
actor: { user_id: string | null; display_name: string; kind: 'USER' | 'SYSTEM' | 'API_KEY' }
|
|
745
|
+
subject: { workspace_id: string; site_id?: string; collection_id?: string }
|
|
746
|
+
data: TData
|
|
747
|
+
}
|
|
748
|
+
```
|
|
749
|
+
|
|
750
|
+
The item, document and media events have typed `data`:
|
|
751
|
+
`WebhookItemEventData` (`site`, `collection`, `items`),
|
|
752
|
+
`WebhookDocumentEventData` (`site`, `documents`) and `WebhookMediaEventData`
|
|
753
|
+
(`site`, `media`).
|
|
754
|
+
|
|
755
|
+
The `zap.comment.*` events (`created`, `replied`, `resolved`, `reopened`,
|
|
756
|
+
`updated`) carry `{ site, collection, comments }`, where each entry has the
|
|
757
|
+
thread's `id`, its `url` and its `target`, plus the event's own fields. They
|
|
758
|
+
never include comment text. Their types are `WebhookCommentCreatedData`,
|
|
759
|
+
`WebhookCommentRepliedData`, `WebhookCommentResolvedData`,
|
|
760
|
+
`WebhookCommentReopenedData` and `WebhookCommentUpdatedData`.
|
|
761
|
+
|
|
762
|
+
### Cache invalidation
|
|
763
|
+
|
|
764
|
+
`webhookChanges(payload)` flattens an item, document or media event into one
|
|
765
|
+
`WebhookChange` per entry — `{ type, action, id, resourceKey, collectionKey?,
|
|
766
|
+
siteKey }`, where `resourceKey` is the item's slug, the document's key or the
|
|
767
|
+
file's id. Any other event gives an empty list.
|
|
768
|
+
|
|
769
|
+
```ts
|
|
770
|
+
import {
|
|
771
|
+
MemoryCacheAdapter,
|
|
772
|
+
verifyWebhookSignature,
|
|
773
|
+
webhookChanges,
|
|
774
|
+
type WebhookPayload,
|
|
775
|
+
} from '@8ux-co/eelzap'
|
|
776
|
+
|
|
777
|
+
const cache = new MemoryCacheAdapter()
|
|
778
|
+
|
|
779
|
+
export async function POST(request: Request) {
|
|
780
|
+
const payload = await request.text()
|
|
781
|
+
if (
|
|
782
|
+
!(await verifyWebhookSignature(payload, request.headers, process.env.EELZAP_WEBHOOK_SECRET!))
|
|
783
|
+
) {
|
|
784
|
+
return new Response('Invalid signature', { status: 401 })
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
for (const change of webhookChanges(JSON.parse(payload) as WebhookPayload)) {
|
|
788
|
+
if (change.type === 'document') cache.delete(change.resourceKey)
|
|
789
|
+
if (change.type === 'item') cache.delete(`${change.collectionKey}:${change.resourceKey}`)
|
|
790
|
+
}
|
|
791
|
+
return new Response('ok')
|
|
792
|
+
}
|
|
793
|
+
```
|
|
794
|
+
|
|
795
|
+
`WEBHOOK_SIGNATURE_HEADER`, `WEBHOOK_TIMESTAMP_HEADER` and
|
|
796
|
+
`WEBHOOK_TOLERANCE_SECONDS` export the header names and the default window.
|
|
797
|
+
|
|
798
|
+
## Error Handling
|
|
799
|
+
|
|
800
|
+
```ts
|
|
801
|
+
import { isEelZapError } from '@8ux-co/eelzap'
|
|
802
|
+
|
|
803
|
+
try {
|
|
804
|
+
await cms.items.get('blog-posts', 'missing-post')
|
|
805
|
+
} catch (error) {
|
|
806
|
+
if (isEelZapError(error)) {
|
|
807
|
+
console.error(error.code, error.status, error.message)
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
```
|
|
811
|
+
|
|
812
|
+
## TypeScript
|
|
813
|
+
|
|
814
|
+
Use generics when you know a content shape:
|
|
815
|
+
|
|
816
|
+
```ts
|
|
817
|
+
type BlogPost = {
|
|
818
|
+
title: string
|
|
819
|
+
excerpt: string
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const post = await cms.items.get<BlogPost>('blog-posts', 'hello-world')
|
|
823
|
+
post.content.title
|
|
824
|
+
```
|
|
825
|
+
|
|
826
|
+
## Client Reference
|
|
827
|
+
|
|
828
|
+
Everything `.` exports, at a glance (TypeDoc has the details: `npm run docs`).
|
|
829
|
+
|
|
830
|
+
| `EelZapClient` member | Methods |
|
|
831
|
+
| ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
|
|
832
|
+
| `site` | `get` |
|
|
833
|
+
| `collections` | `list`, `get`, `create`, `update`, `delete` |
|
|
834
|
+
| `collections.fields` | `list`, `create`, `update`, `delete`, `reorder`, `listDeleted`, `restore` |
|
|
835
|
+
| `collections.sections` | `list`, `get`, `create`, `update`, `delete` |
|
|
836
|
+
| `items` | `list`, `get`, `collection` (query builder), `create`, `update`, `delete`, `publish`, `unpublish` |
|
|
837
|
+
| `items.seo` | `get`, `update` |
|
|
838
|
+
| `itemVersions` | `list`, `get`, `createDraft`, `updateDraft`, `discardDraft`, `publishDraft`, `rollback` |
|
|
839
|
+
| `documents` | `list`, `get`, `create`, `update`, `delete`, `publish`, `unpublish` |
|
|
840
|
+
| `documents.fields` | `list`, `get`, `create`, `update`, `delete`, `reorder`, `listDeleted`, `restore` |
|
|
841
|
+
| `documents.sections` | `list`, `get`, `create`, `update`, `delete` |
|
|
842
|
+
| `documents.values` | `get`, `update` |
|
|
843
|
+
| `documents.seo` | `get`, `update` |
|
|
844
|
+
| `documentVersions` | `list`, `get`, `createDraft`, `updateDraft`, `discardDraft`, `publishDraft`, `rollback` |
|
|
845
|
+
| `media` | `list`, `get`, `update`, `delete`, `publish`, `unpublish`, `createUploadUrl`, `confirmUpload`, `upload`, `fromUrl` |
|
|
846
|
+
| `sites` | `list` |
|
|
847
|
+
| `previewTokens` | `validate` |
|
|
848
|
+
| `comments` | `list`, `get`, `create`, `update`, `reply`, `apply`, `saveToDraft`, `onPage` |
|
|
849
|
+
| `toString()` | The client with its API key masked |
|
|
850
|
+
|
|
851
|
+
`ItemQueryBuilder` (from `items.collection(key)`): `locale`, `status`,
|
|
852
|
+
`filter`, `sort`, `fields`, `page`, `pageSize`, `get`, `toJSON`.
|
|
853
|
+
|
|
854
|
+
| Function | Purpose |
|
|
855
|
+
| ------------------------------------------------------------- | ------------------------------------------------------------------------ |
|
|
856
|
+
| `createClient(config)` | Build a client ([Configuration](#configuration)) |
|
|
857
|
+
| `cachedFetch(key, fetcher, adapter)` / `cachedFetch(options)` | [Resilience & Caching](#resilience--caching) |
|
|
858
|
+
| `getMediaUrl(media)` | [Media URLs](#media-urls) |
|
|
859
|
+
| `verifyWebhookSignature(payload, headers, secret, options?)` | [Webhooks](#webhooks) |
|
|
860
|
+
| `webhookChanges(payload)` | [Cache invalidation](#cache-invalidation) |
|
|
861
|
+
| `isEelZapError(error)` | [Error Handling](#error-handling) |
|
|
862
|
+
| `cleanStega(value)` / `hasStega(value)` | [Text fields find themselves: stega](#text-fields-find-themselves-stega) |
|
|
863
|
+
|
|
864
|
+
Classes: `EelZapClient`, `MemoryCacheAdapter`, `EelZapError`,
|
|
865
|
+
`EelZapNetworkError`, `ItemQueryBuilder`, and one `…Resource` class per row
|
|
866
|
+
of the table above. Codegen lives in `@8ux-co/eelzap-cli`.
|
|
867
|
+
|
|
868
|
+
### Credentials, scopes and the 0.10.0 additions
|
|
869
|
+
|
|
870
|
+
Credentials, in the order the API checks them:
|
|
871
|
+
|
|
872
|
+
- **Secret key** (`secret_…`): one site, everything on it, while site-key management is on.
|
|
873
|
+
- **Public key** (`public_…`): one site, published delivery reads only.
|
|
874
|
+
- **Preview token** (`zpt_…`): one site, ten minutes, delivery reads only, always as preview.
|
|
875
|
+
- **Suite bearer** (`eel_sk_…` workspace key or `eel_at_…` OAuth token): a person in a workspace,
|
|
876
|
+
limited by scopes. Send the site in `X-Eel-Site` on every call except `sites.list()`:
|
|
877
|
+
|
|
878
|
+
```ts
|
|
879
|
+
const zap = createClient({
|
|
880
|
+
apiKey: process.env.EEL_API_KEY!,
|
|
881
|
+
defaultHeaders: { 'X-Eel-Site': 'verdeorigen' },
|
|
882
|
+
})
|
|
883
|
+
```
|
|
884
|
+
|
|
885
|
+
Every create the API deduplicates sends an `Idempotency-Key`, which suite bearers must send:
|
|
886
|
+
`collections.create`, `collections.fields.create`, `collections.sections.create`,
|
|
887
|
+
`items.create`, `documents.create`, `documents.fields.create`, `documents.sections.create`,
|
|
888
|
+
`media.fromUrl`, and `comments.create`, `reply` and `saveToDraft`. The SDK makes a fresh key for
|
|
889
|
+
each call (`crypto.randomUUID()`) and never retries on its own. To make your retry safe, pass
|
|
890
|
+
your own key as the last argument, `{ idempotencyKey }`, and reuse it on the retry:
|
|
891
|
+
|
|
892
|
+
```ts
|
|
893
|
+
const key = crypto.randomUUID()
|
|
894
|
+
const create = () => zap.items.create('blog', { slug: 'hola', values: {} }, { idempotencyKey: key })
|
|
895
|
+
const item = await create().catch(create) // the retry replays the first answer, no duplicate
|
|
896
|
+
```
|
|
897
|
+
|
|
898
|
+
Make these writes from server code: the API's CORS rules do not allow the `Idempotency-Key`
|
|
899
|
+
header from a browser.
|
|
900
|
+
|
|
901
|
+
#### Preview reads: `stega`
|
|
902
|
+
|
|
903
|
+
`preview: true` responses add invisible stega markers to prose fields (TEXT, LONG_TEXT, and the
|
|
904
|
+
last text node of each RICH_TEXT block), so the preview overlay can find them on the page.
|
|
905
|
+
Pass `stega: false` to turn the markers off for one request. It sends `stega=0` and only has an
|
|
906
|
+
effect together with `preview`. Use it in server code that compares text or uses it as a key, and
|
|
907
|
+
for `<title>` and meta tags. A read without `preview` never has markers and sends nothing.
|
|
908
|
+
|
|
909
|
+
```ts
|
|
910
|
+
const doc = await zap.documents.get('home', { preview: true, stega: false })
|
|
911
|
+
const items = await zap.items.list('blog', { preview: true, stega: false })
|
|
912
|
+
```
|
|
913
|
+
|
|
914
|
+
The `cachedFetch` strategies (`network-first` and `cache-first`) return preview responses but never
|
|
915
|
+
store them. This covers any read with `preview: true` and any read made with a `zpt_` token.
|
|
916
|
+
|
|
917
|
+
#### `sites`
|
|
918
|
+
|
|
919
|
+
`list(): SiteListEntry[]` calls `GET /sites`. It needs a suite bearer with `zap:sites:read` and no
|
|
920
|
+
`X-Eel-Site`, because this call is how you find out which values `X-Eel-Site` accepts. It returns
|
|
921
|
+
the workspace's sites, newest first, as `GET /site` does, plus `url`. Site keys are refused.
|
|
922
|
+
|
|
923
|
+
```ts
|
|
924
|
+
const zap = createClient({ apiKey: process.env.EEL_API_KEY! })
|
|
925
|
+
const sites = await zap.sites.list()
|
|
926
|
+
const site = createClient({
|
|
927
|
+
apiKey: process.env.EEL_API_KEY!,
|
|
928
|
+
defaultHeaders: { 'X-Eel-Site': sites[0]!.key },
|
|
929
|
+
})
|
|
930
|
+
```
|
|
931
|
+
|
|
932
|
+
#### `previewTokens`
|
|
933
|
+
|
|
934
|
+
`validate(siteApiKey): PreviewTokenValidation` calls `GET /preview/token`. It checks that the
|
|
935
|
+
client's preview token is live and was minted for the site that `siteApiKey` belongs to. Run it
|
|
936
|
+
before you trust a token taken from a URL. The client's `apiKey` must be the `zpt_` token. Any
|
|
937
|
+
other bearer gets 403. `siteApiKey` is one of the site's own keys (public or secret) and is sent as
|
|
938
|
+
`X-Zap-Site-Api-Key`. The response is `{ site: { key }, expiresAt? }`. An unknown, revoked or
|
|
939
|
+
expired token rejects with 401, and another site's token with 403 `WRONG_SITE`.
|
|
940
|
+
|
|
941
|
+
```ts
|
|
942
|
+
const zap = createClient({ apiKey: tokenFromUrl })
|
|
943
|
+
const { site, expiresAt } = await zap.previewTokens.validate(process.env.ZAP_PUBLIC_KEY!)
|
|
944
|
+
```
|
|
945
|
+
|
|
946
|
+
#### `media.fromUrl`
|
|
947
|
+
|
|
948
|
+
`fromUrl(input, opts?): MediaDetail` calls `POST /media/from-url`. It needs a secret key, or a
|
|
949
|
+
suite bearer with `zap:media:write`. Zap downloads the file on its own servers, with SSRF checks
|
|
950
|
+
on every redirect, a list of allowed types and a size limit. The new file starts as `DRAFT`, so
|
|
951
|
+
the response has a 15-minute `signedUrl` and no `url` until you call `media.publish(id)`.
|
|
952
|
+
`filename` defaults to the last segment of the URL. Errors: 422 `URL_NOT_ALLOWED`, 415
|
|
953
|
+
`UNSUPPORTED_MEDIA_TYPE`, 413 `FILE_TOO_LARGE`, 502 `FETCH_FAILED`. Call it from server code only,
|
|
954
|
+
because the API's general CORS rules do not allow the `Idempotency-Key` header from a browser.
|
|
955
|
+
|
|
956
|
+
```ts
|
|
957
|
+
const media = await zap.media.fromUrl({
|
|
958
|
+
url: 'https://example.com/hero.jpg',
|
|
959
|
+
alt: 'Hero',
|
|
960
|
+
})
|
|
961
|
+
await zap.media.publish(media.id)
|
|
962
|
+
```
|
|
963
|
+
|
|
964
|
+
#### `comments`
|
|
965
|
+
|
|
966
|
+
«Comentarios» are comment threads on an entry or a document. A thread flagged `isChangeRequest`
|
|
967
|
+
can also have an assignee, proposed values and, once resolved, a resolution (`APPLIED` or
|
|
968
|
+
`DISMISSED`). A plain comment has none of these. These methods need a suite bearer and
|
|
969
|
+
`X-Eel-Site`, because every comment is written by a person. Site keys are refused. Records are
|
|
970
|
+
named by key: an entry by `collection` + `slug`, or a document by `document`. Fields in anchors
|
|
971
|
+
and proposed values are also named by key.
|
|
972
|
+
|
|
973
|
+
| Method | Route | Scope |
|
|
974
|
+
| ---------------------------- | ------------------------------ | ------------------------------------------ |
|
|
975
|
+
| `list(opts?)` | `GET /comments` | `zap:content:read` |
|
|
976
|
+
| `get(id)` | `GET /comments/{id}` | `zap:content:read` |
|
|
977
|
+
| `create(input, opts?)` | `POST /comments` | `zap:content:write` or `zap:suggest:write` |
|
|
978
|
+
| `update(id, input)` | `PATCH /comments/{id}` | `zap:content:write` |
|
|
979
|
+
| `reply(id, { body }, opts?)` | `POST /comments/{id}/replies` | `zap:content:write` or `zap:suggest:write` |
|
|
980
|
+
| `apply(id)` | `POST /comments/{id}/apply` | `zap:content:write` |
|
|
981
|
+
| `saveToDraft(input, opts?)` | `POST /comments/save-to-draft` | `zap:content:write` or `zap:suggest:write` |
|
|
982
|
+
| `onPage(url, refs?)` | `GET /comments/on-page` | the site's own suggestion client only |
|
|
983
|
+
|
|
984
|
+
- `list` filters by record, `isChangeRequest`, `status` (`OPEN` or `RESOLVED`), `resolution`,
|
|
985
|
+
`pageUrl` and `assignee` (`me`, `none` or a user id), with `page` and `pageSize` (at most 100).
|
|
986
|
+
It returns `{ items, counts: { OPEN, RESOLVED }, page, pageSize, total, hasMore }`.
|
|
987
|
+
- `get`, `create` and `update` return `{ thread, comments }`. `create` takes the first comment as
|
|
988
|
+
`body`, 0 to 20 `anchors` (a field, an element, both, or a `spot` pin) and the `pageUrl` they were
|
|
989
|
+
made on. Element and spot anchors need that `pageUrl`. `assigneeId` and `proposedValues` are for
|
|
990
|
+
change requests only. Without `assigneeId`, a change request is assigned to the record's
|
|
991
|
+
Responsable when they hold Zap.
|
|
992
|
+
- `update` changes `status`, a change request's `resolution`, the `isChangeRequest` flag, the
|
|
993
|
+
assignee, or the first comment's `body`. Turning the flag off for a thread with proposed values is
|
|
994
|
+
409 `HAS_PROPOSAL`, and for one with any linked Swarm task (open or done) 422 `HAS_LINKED_TASKS`:
|
|
995
|
+
unlink the tasks in the editor first. Giving an assignee or a resolution to a plain comment is 422
|
|
996
|
+
`NOT_A_CHANGE_REQUEST`.
|
|
997
|
+
- `apply` writes an open change request's proposed values into the record's draft and resolves it
|
|
998
|
+
as `APPLIED`. It returns `{ thread, comments, draftVersionId }`. It fails with 409 `NO_PROPOSAL`
|
|
999
|
+
or `NOT_OPEN`.
|
|
1000
|
+
- `saveToDraft` writes values straight into the draft. It records them as a change request that is
|
|
1001
|
+
already resolved as `APPLIED`, and nobody is notified. It is 403 when the site has live editing
|
|
1002
|
+
turned off.
|
|
1003
|
+
- `create`, `reply` and `saveToDraft` send an `Idempotency-Key`, as `media.fromUrl` does.
|
|
1004
|
+
|
|
1005
|
+
```ts
|
|
1006
|
+
const zap = createClient({
|
|
1007
|
+
apiKey: process.env.EEL_API_KEY!,
|
|
1008
|
+
defaultHeaders: { 'X-Eel-Site': 'verdeorigen' },
|
|
1009
|
+
})
|
|
1010
|
+
const { thread } = await zap.comments.create({
|
|
1011
|
+
collection: 'blog',
|
|
1012
|
+
slug: 'hello-world',
|
|
1013
|
+
isChangeRequest: true,
|
|
1014
|
+
body: 'A shorter title?',
|
|
1015
|
+
proposedValues: [{ fieldKey: 'title', value: 'Hello' }],
|
|
1016
|
+
})
|
|
1017
|
+
await zap.comments.reply(thread.id, { body: 'Agreed.' })
|
|
1018
|
+
await zap.comments.apply(thread.id)
|
|
1019
|
+
```
|
|
1020
|
+
|
|
1021
|
+
Threads send the `zap.comment.created`, `zap.comment.replied`, `zap.comment.resolved`,
|
|
1022
|
+
`zap.comment.reopened` and `zap.comment.updated` webhook events (see [Webhooks](#webhooks)).
|
|
1023
|
+
|
|
1024
|
+
#### Archived fields: `collections.fields` and `documents.fields`
|
|
1025
|
+
|
|
1026
|
+
Deleting a field archives it and keeps its values.
|
|
1027
|
+
|
|
1028
|
+
- `listDeleted(key): FieldInfo[]` calls `GET /collections/{key}/fields/deleted` or
|
|
1029
|
+
`GET /documents/{key}/fields/deleted`. It needs a secret key, or a suite bearer with
|
|
1030
|
+
`zap:content:read`. The most recently deleted field comes first.
|
|
1031
|
+
- `restore(key, fieldId): FieldInfo` calls `POST …/fields/{fieldId}/restore`. It needs a secret
|
|
1032
|
+
key, or a suite bearer with `zap:schema:write` and the Zap ADMIN role. The field comes back with
|
|
1033
|
+
all its values, at the end of the root list and outside any section.
|
|
1034
|
+
|
|
1035
|
+
```ts
|
|
1036
|
+
const [archived] = await zap.collections.fields.listDeleted('blog')
|
|
1037
|
+
if (archived) await zap.collections.fields.restore('blog', archived.id)
|
|
1038
|
+
```
|
|
1039
|
+
|
|
1040
|
+
## Security
|
|
1041
|
+
|
|
1042
|
+
- Keep secret keys on the server only; use public keys for browser clients.
|
|
1043
|
+
- Preview responses carry drafts: they need a draft-capable credential, are
|
|
1044
|
+
`private, no-store`, and the SDK's cache strategies never store them.
|
|
1045
|
+
- `EelZapClient#toString()` masks the API key for safer logging.
|
|
1046
|
+
- The boot loads the overlay only from the CDN URL and SRI hash compiled into
|
|
1047
|
+
the package, never from a URL in the page.
|
|
1048
|
+
|
|
1049
|
+
## Development
|
|
1050
|
+
|
|
1051
|
+
```bash
|
|
1052
|
+
npm install
|
|
1053
|
+
npm run typecheck
|
|
1054
|
+
npm test
|
|
1055
|
+
npm run build
|
|
1056
|
+
```
|
|
1057
|
+
|
|
1058
|
+
`npm run docs` generates the TypeDoc reference into `docs/`.
|
|
1059
|
+
|
|
1060
|
+
## License
|
|
1061
|
+
|
|
1062
|
+
MIT
|