@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/README.md CHANGED
@@ -1,3 +1,1062 @@
1
- # Temporary Holding Version
1
+ # @8ux-co/eelzap
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/%408ux-co%2Feelzap)](https://www.npmjs.com/package/@8ux-co/eelzap)
4
+ [![license](https://img.shields.io/npm/l/%408ux-co%2Feelzap)](./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