@avocadostudio-ai/site-sdk 0.2.1 → 0.3.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
@@ -10,7 +10,66 @@ SDK for integrating any Next.js site with the Avocado Studio. Provides the contr
10
10
  npm install @avocadostudio-ai/site-sdk
11
11
  ```
12
12
 
13
- ### 2. Create the page component
13
+ ### 2. Wrap your Next config
14
+
15
+ ```ts
16
+ // next.config.ts
17
+ import { withAvocado } from "@avocadostudio-ai/site-sdk/next-config"
18
+
19
+ export default withAvocado({ /* your config */ })
20
+ ```
21
+
22
+ Not optional, and the failures it prevents all look like something else:
23
+
24
+ - **Native binaries and provider SDKs get bundled.** `better-sqlite3` and
25
+ `sharp` build fine and die loading their `.node` file on the first request;
26
+ Turbopack resolves `orchestrator-core`'s optional `await import(...)` peers
27
+ statically and fails the build over a package you deliberately never
28
+ installed. `serverExternalPackages` alone does not fix it — `transpilePackages`
29
+ overrides server externals for a *transitive* dependency. This sets both.
30
+ - **Generated images 500.** Avocado writes image URLs from hosts your site never
31
+ chose (Unsplash, the image models' blob storage), and `next/image` refuses any
32
+ host missing from `images.remotePatterns`.
33
+ - **`trailingSlash: true` makes the editor unreachable** — see below.
34
+
35
+ Pass `{ images: false }`, `{ serverExternals: false }` or
36
+ `{ trailingSlash: false }` as a second argument to manage any of them yourself.
37
+
38
+ #### If your site sets `trailingSlash: true`, read this
39
+
40
+ Next applies its trailing-slash 308 to `/api/*` as well, so
41
+ `/api/editor/blocks` answers `308 → /api/editor/blocks/`. `fetch` follows a 308.
42
+ **A CORS preflight does not** — a browser treats a redirect on `OPTIONS` as a
43
+ network failure — and the editor calls those routes from its own origin. Every
44
+ editor API call therefore fails before it is sent, and the browser reports a
45
+ generic CORS error that names nothing. Middleware cannot repair it either: the
46
+ redirect runs before middleware.
47
+
48
+ `withAvocado` sets `skipTrailingSlashRedirect: true` for you. **That half alone
49
+ is a regression** — it stops your site redirecting `/about` to `/about/`, on
50
+ every URL you have ever published. The SDK's proxy puts the redirect back for
51
+ page routes, and you have to turn it on:
52
+
53
+ ```ts
54
+ // proxy.ts (middleware.ts on Next 15)
55
+ import { createEditorProxy } from "@avocadostudio-ai/site-sdk/proxy"
56
+
57
+ export const proxy = createEditorProxy({ trailingSlash: true }).proxy
58
+
59
+ export const config = {
60
+ matcher: ["/((?!_next|preview-draft|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)"],
61
+ }
62
+ ```
63
+
64
+ These two are a pair. Nothing checks that you set both, because the config
65
+ cannot see your proxy and the proxy cannot read the config.
66
+
67
+ > Do not hand-roll that redirect from `request.nextUrl.clone()`. NextURL
68
+ > normalises the trailing slash back *off* when it stringifies, so the 308
69
+ > points at the URL it is trying to leave and the browser gives up after five
70
+ > hops. `createEditorProxy` builds it from `request.url`.
71
+
72
+ ### 3. Create the page component
14
73
 
15
74
  ```tsx
16
75
  // app/[[...slug]]/page.tsx
@@ -38,7 +97,7 @@ Export `generateMetadata` too, or every page inherits the root layout's
38
97
  from the page and calls `notFound()` for an unknown slug, so add an
39
98
  `app/not-found.tsx` if you want your own chrome around the 404.
40
99
 
41
- ### 3. Create the editor API route
100
+ ### 4. Create the editor API route
42
101
 
43
102
  ```tsx
44
103
  // app/api/editor/[...path]/route.ts
@@ -55,14 +114,14 @@ export const { GET, POST, OPTIONS } = createEditorApiHandler({
55
114
  })
56
115
  ```
57
116
 
58
- ### 4. Add styles
117
+ ### 5. Add styles
59
118
 
60
119
  ```tsx
61
120
  // app/layout.tsx
62
121
  import "@avocadostudio-ai/blocks/styles.css"
63
122
  ```
64
123
 
65
- ### 5. Set environment variables
124
+ ### 6. Set environment variables
66
125
 
67
126
  ```env
68
127
  # Required
@@ -86,14 +145,34 @@ That's it. Your site now works with the AI editor.
86
145
 
87
146
  ## Exports
88
147
 
148
+ All seventeen, because the six this table used to list were not the six an
149
+ integration needs — `createOrchestrator` is the whole of library mode and was in
150
+ none of them.
151
+
89
152
  | Import | What it provides |
90
153
  |---|---|
91
154
  | `@avocadostudio-ai/site-sdk` | Types (`PageDoc`, `BlockInstance`), `buildSlug`, `renderBlocks` |
92
- | `@avocadostudio-ai/site-sdk/page` | `createSitePage` — page component factory |
93
- | `@avocadostudio-ai/site-sdk/routes` | `createEditorApiHandler` — API route factory |
94
- | `@avocadostudio-ai/site-sdk/draft` | `resolveEditorContext`, `fetchEditorPage` |
95
- | `@avocadostudio-ai/site-sdk/editor` | `renderBlocks`, `EditorOverlay` |
96
- | `@avocadostudio-ai/site-sdk/navigation` | `buildNavItems`, `buildSiteHeaderBlock` |
155
+ | `.../page` | `createSitePage` — page component factory |
156
+ | `.../routes` | `createEditorApiHandler` — the `/api/editor/*` route factory |
157
+ | `.../routes/core` | The same handlers without Next's `draftMode()`, for a non-Next host |
158
+ | `.../server` | **`createOrchestrator`**, `jsonFileAdapter`, `editorApiAdapter`, `resolveCapabilities`, and the `CmsAdapter` / `CreateOrchestratorConfig` types |
159
+ | `.../next-config` | `withAvocado`, `AVOCADO_IMAGE_HOSTS`, `AVOCADO_SERVER_EXTERNALS` |
160
+ | `.../proxy` | `createEditorProxy` — the Next 16 `proxy.ts` rewrite |
161
+ | `.../middleware` | `createEditorMiddleware` — the same thing under the Next 15 name |
162
+ | `.../matcher` | `buildEditorMatcher` — just the matcher string, no `next/server` import |
163
+ | `.../draft` | `resolveEditorContext`, `fetchEditorPage`, `fetchEditorSlugs`, `fetchEditorSiteConfig` |
164
+ | `.../draft/core` | The draft context without Next's cookie APIs |
165
+ | `.../editor` | `EditorOverlay`, `LivePreviewProvider`, `buildEditorQuerySuffix` |
166
+ | `.../editor-manifest` | `buildBlockManifest` — the built-in blocks as a manifest |
167
+ | `.../publish` | `diffPage`, `groupPatches`, `describeUnsupported` — the field-level publish walk |
168
+ | `.../publish-handlers/json-file` | A ready-made `onPublish` that writes a JSON file |
169
+ | `.../navigation` | `buildNavItems`, `buildSiteHeaderBlock` |
170
+ | `.../seo` | `buildPageMetadata` and the title/description derivation |
171
+
172
+ `useLivePreviewBlocks` — the hook that reads `LivePreviewProvider`, and the only
173
+ way to render a live draft through **your own** components rather than Avocado's
174
+ — is not here. It lives in `@avocadostudio-ai/preview-adapter`, which you have to
175
+ install directly.
97
176
 
98
177
  ## API Contract
99
178
 
@@ -107,6 +186,149 @@ Your site exposes these endpoints via `createEditorApiHandler`:
107
186
  | `/api/editor/pages` | GET | Return all published pages |
108
187
  | `/api/editor/publish` | POST | Receive pages from editor, persist to CMS |
109
188
 
189
+ ## Library mode mounts two handlers, not one
190
+
191
+ `createOrchestrator` is not a replacement for `createEditorApiHandler`. It is an
192
+ addition, and the editor does not work without both.
193
+
194
+ ```ts
195
+ // app/api/avocado/[[...path]]/route.ts — the orchestrator
196
+ import { createOrchestrator } from "@avocadostudio-ai/site-sdk/server"
197
+
198
+ const handler = createOrchestrator({ basePath: "/api/avocado", adapter: myAdapter })
199
+ export const GET = handler
200
+ export const POST = handler
201
+ export const OPTIONS = handler
202
+ ```
203
+
204
+ ```ts
205
+ // app/api/editor/[...path]/route.ts — still required
206
+ import { createEditorApiHandler } from "@avocadostudio-ai/site-sdk/routes"
207
+
208
+ export const { GET, POST, OPTIONS } = createEditorApiHandler({ getPages, getManifest })
209
+ ```
210
+
211
+ The editor UI asks the orchestrator for plans and operations, and asks **your
212
+ site's own origin** for `/api/editor/blocks` and `/api/editor/pages`. Mount only
213
+ the orchestrator and the editor loads, connects, and answers four requests with
214
+ `net::ERR_FAILED` in a console you have to open to see.
215
+
216
+ ## The adapter contract
217
+
218
+ `createOrchestrator({ adapter })` is the whole of library mode: SQLite is the
219
+ working copy for drafts, undo and chat state, and the adapter is your actual
220
+ content store. It is read on a cold session and written on publish.
221
+
222
+ ```ts
223
+ import type { CmsAdapter } from "@avocadostudio-ai/site-sdk/server"
224
+
225
+ const myAdapter: CmsAdapter = {
226
+ /** Short, DNS-safe. Used for telemetry and the bootstrap cache. */
227
+ id: "sanity",
228
+
229
+ /** Does `getPages` honour `options.perspective`? Silence means no. */
230
+ perspectives: Boolean(process.env.SANITY_API_READ_TOKEN),
231
+
232
+ /** Every page for this site. Failures are logged, not fatal — the session starts empty. */
233
+ async getPages(options) {
234
+ return fetchPages({ draft: options?.perspective === "draft" })
235
+ },
236
+
237
+ /** Optional. Absent means publish is a local no-op. */
238
+ async onPublish(pages, config, context) {
239
+ // context.published — what you last reported, when the orchestrator has it
240
+ // context.assets — base64 blobs from chat-generated images, for you to upload
241
+ return { ok: true, unsupported: [] }
242
+ },
243
+
244
+ /** Optional. Fills the editor's image picker from your media library. */
245
+ async getMedia({ query, page, limit }) {
246
+ const res = await searchAssets(query, page, limit)
247
+ return { items: res.assets, totalPages: res.pageCount, label: "Sanity" }
248
+ },
249
+
250
+ /** Static, because /whoami must answer it with the CMS unreachable. */
251
+ capabilities: { createPages: false },
252
+ }
253
+ ```
254
+
255
+ Two parts of that are easy to get wrong:
256
+
257
+ **`perspectives` defaults to "no", and the other flags default to "yes".** That
258
+ asymmetry is deliberate: capabilities are permissions, where the safe answer is
259
+ to allow what nobody forbade; `perspectives` is an ability, where the safe answer
260
+ is not to claim one nobody implemented. An adapter wrongly believed to read
261
+ drafts makes the publish diff assert that unpublished work is live.
262
+
263
+ **`onPublish` should diff, not overwrite.** Every CMS read is a projection — an
264
+ asset reference flattened to a URL, rich text flattened to a string — and writing
265
+ the projection back destroys what it was projected from. `context.published` is
266
+ the baseline; `undefined` means *no baseline available*, never *the site was
267
+ empty*. See [Publishing back to a real CMS](#publishing-back-to-a-real-cms).
268
+
269
+ `CmsAdapter`, `CmsCapabilities`, `CmsPublishContext`, `CmsPerspective` and
270
+ `CreateOrchestratorConfig` are all exported from
271
+ `@avocadostudio-ai/site-sdk/server`.
272
+
273
+ ### Filling the image picker
274
+
275
+ `getMedia` is optional and silent by default: implement it and the editor's
276
+ asset picker grows a CMS tab, leave it off and it does not. `/status/planner`
277
+ reports `features.cmsMedia` and `/whoami` reports `capabilities.readsMedia`,
278
+ both derived from whether the method exists — an adapter cannot claim a media
279
+ library it did not implement.
280
+
281
+ If your CMS is Contentful, Sanity or Strapi, `cmsMediaSource` writes the method
282
+ for you:
283
+
284
+ ```ts
285
+ import { cmsMediaSource, type CmsAdapter } from "@avocadostudio-ai/site-sdk/server"
286
+
287
+ export function sanityAdapter(): CmsAdapter {
288
+ return {
289
+ id: "sanity",
290
+ getPages: (options) => getSanityPages(options?.perspective),
291
+ getMedia: cmsMediaSource({
292
+ provider: "sanity",
293
+ projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
294
+ dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
295
+ token: process.env.SANITY_API_TOKEN
296
+ })
297
+ }
298
+ }
299
+ ```
300
+
301
+ It is a convenience, not the contract. Any other CMS returns the same shape
302
+ itself:
303
+
304
+ ```ts
305
+ getMedia: async ({ query, page, limit }) => {
306
+ const res = await myCms.assets.search({ q: query, page, perPage: limit })
307
+ return {
308
+ items: res.assets.map((a) => ({
309
+ id: a.id,
310
+ name: a.filename,
311
+ imageUrl: a.url,
312
+ thumbUrl: a.thumb ?? a.url,
313
+ alt: a.altText
314
+ })),
315
+ totalPages: res.pageCount,
316
+ label: "My CMS"
317
+ }
318
+ }
319
+ ```
320
+
321
+ `query` is a free-text filter and may be absent; `page` is 1-based and `limit`
322
+ is clamped to 50 before it reaches you. Return an empty page rather than
323
+ throwing when the upstream call fails — the picker renders an empty grid, and a
324
+ rejected promise inside a modal shows the user nothing at all.
325
+
326
+ The credentials stay on the server. Before this method existed the picker
327
+ called Contentful, Sanity and Strapi from the browser, over a union of exactly
328
+ those three compiled into the editor — which is why a fourth CMS could bring
329
+ its own adapter, blocks and publish handler and still had no way to bring its
330
+ own images.
331
+
110
332
  ## Library mode needs a credential
111
333
 
112
334
  `createOrchestrator()` mounts on your own domain and can edit and publish your
@@ -187,6 +409,62 @@ A workspace link to `@avocadostudio-ai/orchestrator-core` is **not** enough. A
187
409
  linked package's own dependencies are never materialised in the host's tree, and
188
410
  a native module has to be resolvable from there.
189
411
 
412
+ ## The editor frames your page, not the preview route
413
+
414
+ The iframe's `src` is your site's own URL with a query parameter added:
415
+
416
+ ```
417
+ http://localhost:3000/de?__editor=1&session=dev&siteId=my-site
418
+ ```
419
+
420
+ `createEditorProxy` then rewrites that request onto the preview route. **The
421
+ rewrite does not re-run Next's header matching**, so `headers()` was already
422
+ evaluated against `/de` — the public path — and the preview HTML comes back
423
+ wearing the public site's headers.
424
+
425
+ That matters for exactly one thing, and it is the first thing an adopter sees
426
+ after wiring everything up correctly: **a site that sends `frame-ancestors` or
427
+ `X-Frame-Options` cannot be framed**, and the failure is a blank rectangle. The
428
+ response is correct, the render is correct, and the browser refuses to display
429
+ it.
430
+
431
+ Scope the exception to the query parameter, which is the only part of the
432
+ request Next sees before the rewrite:
433
+
434
+ ```js
435
+ async headers() {
436
+ const editor = "http://localhost:4100 http://127.0.0.1:4100"
437
+ return [
438
+ {
439
+ source: "/:path*",
440
+ has: [{ type: "query", key: "__editor" }],
441
+ headers: [{ key: "Content-Security-Policy", value: `frame-ancestors 'self' ${editor};` }]
442
+ },
443
+ {
444
+ source: "/:path*",
445
+ missing: [{ type: "query", key: "__editor" }],
446
+ headers: [
447
+ { key: "Content-Security-Policy", value: "frame-ancestors 'self';" },
448
+ { key: "X-Frame-Options", value: "SAMEORIGIN" }
449
+ ]
450
+ }
451
+ ]
452
+ }
453
+ ```
454
+
455
+ Two things that are easy to get wrong here:
456
+
457
+ **The `missing:` on the public rule is load-bearing.** Without it both rules
458
+ match an editor request and the browser gets two `Content-Security-Policy`
459
+ headers — which it reads as their *intersection*, putting `frame-ancestors
460
+ 'self'` back and blocking the frame again.
461
+
462
+ **Name both spellings of the loopback address.** The CLI binds `127.0.0.1` and
463
+ prints that; everything written down says `localhost`. `frame-ancestors` and
464
+ CORS both compare origins as strings, so a rule copied from this page and an
465
+ editor opened at the URL the CLI printed will never match. The SDK's own
466
+ development CORS default now accepts both; your CSP has to say both too.
467
+
190
468
  ## Telling the orchestrator where your site is
191
469
 
192
470
  An agent's only way to see what it just edited is `POST /preview/screenshot`,
@@ -220,11 +498,11 @@ string, a document reference resolved to one language's href, rich text
220
498
  flattened to markdown. Writing the projection back replaces the reference with
221
499
  the flattening and destroys the document.
222
500
 
223
- So publish a **diff**, not a snapshot. `@ai-site-editor/site-sdk/publish` owns
501
+ So publish a **diff**, not a snapshot. `@avocadostudio-ai/site-sdk/publish` owns
224
502
  the walk:
225
503
 
226
504
  ```ts
227
- import { diffPage, groupPatches, describeUnsupported } from "@ai-site-editor/site-sdk/publish"
505
+ import { diffPage, groupPatches, describeUnsupported } from "@avocadostudio-ai/site-sdk/publish"
228
506
 
229
507
  const diff = diffPage({
230
508
  page,
@@ -264,9 +542,14 @@ Two things worth knowing before you write one.
264
542
 
265
543
  **List items match on their own key, not their position.** An index-addressed
266
544
  patch lands on the wrong row the moment anything reorders the array upstream.
267
- `sanityPaths` (the default) addresses `field[_key=="…"]`; `indexPaths` exists for
268
- stores with no element identity and is only correct when your publish is the
269
- only writer.
545
+ `sanityPaths` addresses `field[_key=="…"]`; `indexPaths` exists for stores with
546
+ no element identity and is only correct when your publish is the only writer.
547
+
548
+ `paths` is a **required** argument to both `diffFields` and `diffPage`. It used
549
+ to default to `sanityPaths`, which meant a Contentful or Strapi publisher that
550
+ never mentioned it emitted `_key` patches against a store that has no `_key` —
551
+ the wrong answer, arrived at quietly. Nothing can infer which CMS you are
552
+ writing to, so it asks.
270
553
 
271
554
  **Refusing is a result, not an error.** Four things a field diff cannot express
272
555
  — a replaced image, a retyped link, a list item added or removed, a new block —
@@ -281,6 +564,88 @@ absent after a restart that reloaded the draft from storage. Treat `undefined`
281
564
  as "no baseline available", never as "the site was empty": publishing every
282
565
  field on that assumption is the overwrite all of this exists to prevent.
283
566
 
567
+ ## Registering your own block schemas
568
+
569
+ The manifest at `/api/editor/blocks` tells the **editor** what exists. It is not
570
+ what validates an edit — that is the global block registry in
571
+ `@avocadostudio-ai/shared`, which starts out holding only Avocado's built-ins.
572
+ Ship a manifest and no registration and the editor looks completely wired up
573
+ until the first AI edit:
574
+
575
+ ```json
576
+ {"error":"Invalid props for PricingTable: Unknown block type: PricingTable","errorCode":"schema_violation"}
577
+ ```
578
+
579
+ ```ts
580
+ // lib/register-blocks.ts
581
+ import { registerBlock, z } from "@avocadostudio-ai/shared"
582
+
583
+ export function registerMyBlocks() {
584
+ registerBlock("PricingTable", {
585
+ schema: z.object({
586
+ title: z.string().min(1),
587
+ // A list needs BOTH halves — see the warning below.
588
+ tiers: z.array(z.object({
589
+ name: z.string().min(1),
590
+ price: z.string().min(1),
591
+ })).optional(),
592
+ }).catchall(z.unknown()),
593
+
594
+ meta: {
595
+ displayName: "Pricing Table",
596
+ fields: { title: { kind: "text" } },
597
+ listFields: {
598
+ tiers: { label: "Tiers", itemFields: { name: { kind: "text" }, price: { kind: "text" } } },
599
+ },
600
+ },
601
+ })
602
+ }
603
+ ```
604
+
605
+ Hand that function to whichever handler you mount — both re-run it after the
606
+ built-ins have registered, so your definitions land on top:
607
+
608
+ ```ts
609
+ createEditorApiHandler({ getPages, getManifest, registerBlocks: registerMyBlocks })
610
+ createOrchestrator({ adapter, registerBlocks: registerMyBlocks })
611
+ ```
612
+
613
+ Do **not** instead rely on a side-effect `import "@/lib/register-blocks"` placed
614
+ last in the file. Next's bundler does not reliably preserve module order across
615
+ the RSC, SSR and route-handler layers, so the built-in schemas sometimes
616
+ re-register on top of yours. The hook exists to replace that trick.
617
+
618
+ ### Import `z` from `@avocadostudio-ai/shared`, not from `zod`
619
+
620
+ `registerBlock` takes a `ZodObject`, and a Zod object is assignable only to one
621
+ built by the *same copy* of the library. Your own `import { z } from "zod"`
622
+ resolves to whatever your tree hoisted — on any site that also uses Sanity that
623
+ is zod 3 — and the mismatch reports as a structural type error listing methods
624
+ you have never called (`loose`, `safeExtend`, `exactPartial`, `def`, "and 21
625
+ more"), with nothing anywhere saying there are two copies of zod. Importing `z`
626
+ from `shared` gives you ours, and you need no direct `zod` dependency at all.
627
+
628
+ ### `schema` and `meta` are two halves that must agree
629
+
630
+ `meta.listFields` says how to *label* a list's rows. The props schema is what
631
+ says the list exists, and the property panel renders rows from the schema — so a
632
+ list named only in the meta shows no rows and no Add control, with no error
633
+ anywhere. Validation passes, operations apply, the preview renders, publishing
634
+ diffs the rows correctly. The only symptom is an absence in one panel.
635
+
636
+ That failure hides especially well behind `.catchall(z.unknown())`, which most
637
+ CMS integrations need (see [Publishing back to a real CMS](#publishing-back-to-a-real-cms),
638
+ which recommends keeping a `__source` snapshot in block props): the catchall
639
+ swallows the undeclared array as an unmodelled extra.
640
+
641
+ `registerBlock` now warns when it sees the contradiction:
642
+
643
+ ```
644
+ [avocado] PricingTable: meta.listFields declares "tiers" but the schema has no `tiers`.
645
+ The property panel renders list rows from the schema, so this list will show no rows
646
+ and no Add control. Declare it alongside the meta, e.g. tiers: z.array(z.object({ … })).optional()
647
+ ```
648
+
284
649
  ## Telling the orchestrator which blocks you render
285
650
 
286
651
  Importing anything from `@avocadostudio-ai/shared` registers Avocado's 18
@@ -309,6 +674,39 @@ Declaring a type you never registered is not silently dropped: it has no schema
309
674
  to describe, so it cannot reach the manifest, and the orchestrator logs a warning
310
675
  naming it the first time the manifest is served.
311
676
 
677
+ ## Making fields editable
678
+
679
+ The manifest tells the editor which blocks exist and what props they take. It
680
+ does not tell it **where on the page a prop is rendered**, and nothing can derive
681
+ that — so a site whose components carry no annotation gets block selection, the
682
+ badge, move and delete, and not one editable field.
683
+
684
+ Put `data-editable-target` on the DOM node that renders each prop:
685
+
686
+ ```tsx
687
+ <h1 data-editable-target="heading">{heading}</h1>
688
+ <p data-editable-target="subheading">{subheading}</p>
689
+
690
+ {cards.map((card, i) => (
691
+ <article key={card._key}>
692
+ <h3 data-editable-target={`cards[${i}].title`}>{card.title}</h3>
693
+ <img data-editable-target={`cards[${i}].imageUrl`} src={card.imageUrl} />
694
+ </article>
695
+ ))}
696
+ ```
697
+
698
+ The path grammar is the same one operations use: `heading` for a scalar,
699
+ `cards[0].title` for a list item's field, `cards[0].imageUrl` for its image.
700
+
701
+ This is the one part of an integration that **cannot** live in an integration
702
+ layer — it has to go inside your own components, one attribute per prop you want
703
+ editable. Budget for it: it is usually the largest single cost of adopting the
704
+ editor, and there is no way to add it from the outside.
705
+
706
+ Two optional siblings control the labels the overlay draws:
707
+ `data-editable-target-label` (the CSS `::before` tooltip) and
708
+ `data-editable-label` (the floating pill). Both default to the target path.
709
+
312
710
  ## Environment Variables
313
711
 
314
712
  | Variable | Required | Description |
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * CORS for the editor API routes a host app mounts.
3
3
  *
4
- * The dev editor runs on its own origin (`localhost:4100`), so the routes have
4
+ * The dev editor runs on its own origin (`localhost:4100`, or the `127.0.0.1`
5
+ * spelling the CLI prints), so the routes have
5
6
  * to answer cross-origin requests from it. That default used to be added
6
7
  * unconditionally, which meant a deployed site kept answering CORS-approved
7
8
  * requests to a `localhost` origin: any page a visitor had open could read that
@@ -1,14 +1,27 @@
1
1
  /**
2
2
  * CORS for the editor API routes a host app mounts.
3
3
  *
4
- * The dev editor runs on its own origin (`localhost:4100`), so the routes have
4
+ * The dev editor runs on its own origin (`localhost:4100`, or the `127.0.0.1`
5
+ * spelling the CLI prints), so the routes have
5
6
  * to answer cross-origin requests from it. That default used to be added
6
7
  * unconditionally, which meant a deployed site kept answering CORS-approved
7
8
  * requests to a `localhost` origin: any page a visitor had open could read that
8
9
  * site's draft content out of its own browser. Now the development default is
9
10
  * only assumed in development, and a deployment says what it allows.
10
11
  */
11
- const DEV_EDITOR_ORIGIN = "http://localhost:4100";
12
+ /*
13
+ * Both spellings of the same loopback address, because the product uses both.
14
+ *
15
+ * The CLI binds `127.0.0.1` and prints `http://127.0.0.1:4100`; every README,
16
+ * env table and example says `http://localhost:4100`. CORS and
17
+ * `frame-ancestors` compare origins as strings, so an editor started by our own
18
+ * CLI, opened at the URL our own CLI printed, was rejected by our own default —
19
+ * and the symptom is a blank iframe with a console message naming an origin
20
+ * that looks like it should have matched.
21
+ *
22
+ * Development only, as below: a deployment still has to name its editor.
23
+ */
24
+ const DEV_EDITOR_ORIGINS = ["http://localhost:4100", "http://127.0.0.1:4100"];
12
25
  let cachedOrigins;
13
26
  function parseOrigins(value) {
14
27
  return (value ?? "")
@@ -27,7 +40,7 @@ export function getEditorCorsOrigins() {
27
40
  */
28
41
  const declaredEditor = parseOrigins(process.env.NEXT_PUBLIC_EDITOR_ORIGIN);
29
42
  const isProduction = process.env.NODE_ENV === "production";
30
- const defaults = isProduction ? [] : [DEV_EDITOR_ORIGIN];
43
+ const defaults = isProduction ? [] : DEV_EDITOR_ORIGINS;
31
44
  cachedOrigins = new Set([...defaults, ...declaredEditor, ...configured]);
32
45
  return cachedOrigins;
33
46
  }
@@ -64,3 +64,22 @@ test("an allowed origin gets the CORS headers and a disallowed one does not", ()
64
64
  assert.equal(denied.headers.get("Vary"), "Origin");
65
65
  });
66
66
  });
67
+ /*
68
+ * The CLI binds `127.0.0.1` and prints that URL; the docs all say `localhost`.
69
+ * Origins compare as strings, so the editor our own CLI starts has to be
70
+ * allowed under the spelling our own CLI printed.
71
+ */
72
+ test("development allows both spellings of the loopback editor origin", () => {
73
+ withEnv({ ...CLEAN, NODE_ENV: "development" }, () => {
74
+ const origins = getEditorCorsOrigins();
75
+ assert.ok(origins.has("http://localhost:4100"), "the spelling the docs use");
76
+ assert.ok(origins.has("http://127.0.0.1:4100"), "the spelling the CLI prints");
77
+ });
78
+ });
79
+ test("production still allows neither by default", () => {
80
+ withEnv({ ...CLEAN, NODE_ENV: "production" }, () => {
81
+ const origins = getEditorCorsOrigins();
82
+ assert.equal(origins.has("http://localhost:4100"), false);
83
+ assert.equal(origins.has("http://127.0.0.1:4100"), false);
84
+ });
85
+ });
@@ -124,7 +124,15 @@ export declare function diffFields<Ctx>(args: {
124
124
  /** Path prefix this object sits at, e.g. `pageBuilder[_key=="b1"].`. */
125
125
  prefix?: string;
126
126
  where: string;
127
- paths?: PathSyntax;
127
+ /**
128
+ * How this CMS addresses one element of a list. Required, and deliberately
129
+ * so: it used to default to `sanityPaths`, which meant a Contentful or
130
+ * Strapi publisher that never mentioned it emitted `field[_key=="…"]`
131
+ * patches against a store with no `_key`. The wrong answer was the quiet
132
+ * one — a default cannot know which CMS it is writing to, and the cost of
133
+ * guessing wrong is a patch applied to the wrong row.
134
+ */
135
+ paths: PathSyntax;
128
136
  }): FieldDiff;
129
137
  /**
130
138
  * Fold a flat patch list into one `set` object per document.
@@ -180,7 +188,8 @@ export declare function diffPage<Ctx>(args: {
180
188
  type: string;
181
189
  props: Record<string, unknown>;
182
190
  }): BlockTarget<Ctx> | BlockTarget<Ctx>[] | null;
183
- paths?: PathSyntax;
191
+ /** How this CMS addresses one element of a list. See `diffFields`. */
192
+ paths: PathSyntax;
184
193
  /** How a block is named in a report. Defaults to `"/slug → type"`. */
185
194
  where?(pageSlug: string, block: {
186
195
  id: string;
@@ -71,7 +71,11 @@ function defaultItemKey(item) {
71
71
  * cannot make a patch land on the wrong row.
72
72
  */
73
73
  export function diffFields(args) {
74
- const paths = args.paths ?? sanityPaths;
74
+ /* A JS caller gets no compile error, so say it loudly rather than picking
75
+ * one. Silently choosing a syntax is the bug this parameter exists to stop. */
76
+ if (!args.paths)
77
+ throw new TypeError("diffFields: `paths` is required — pass sanityPaths or indexPaths");
78
+ const paths = args.paths;
75
79
  const prefix = args.prefix ?? "";
76
80
  const patches = [];
77
81
  const unsupported = [];
@@ -14,7 +14,8 @@ test("an unchanged field emits nothing", () => {
14
14
  source: { heading: "Same" },
15
15
  ctx,
16
16
  documentId: "doc1",
17
- where: "/ → hero"
17
+ where: "/ → hero",
18
+ paths: sanityPaths
18
19
  });
19
20
  assert.deepEqual(diff.patches, []);
20
21
  assert.deepEqual(diff.unsupported, []);
@@ -27,7 +28,8 @@ test("a changed field is set at the path that owns it", () => {
27
28
  ctx,
28
29
  documentId: "doc1",
29
30
  prefix: 'pageBuilder[_key=="b1"].',
30
- where: "/ → hero"
31
+ where: "/ → hero",
32
+ paths: sanityPaths
31
33
  });
32
34
  assert.deepEqual(diff.patches, [
33
35
  { documentId: "doc1", path: 'pageBuilder[_key=="b1"].heading', value: "After" }
@@ -40,7 +42,8 @@ test("cmsKey writes to the field the CMS actually has", () => {
40
42
  source: { heading: "Before" },
41
43
  ctx,
42
44
  documentId: "doc1",
43
- where: "/ → hero"
45
+ where: "/ → hero",
46
+ paths: sanityPaths
44
47
  });
45
48
  assert.equal(diff.patches[0].path, "headingOverride");
46
49
  });
@@ -62,7 +65,8 @@ test("rehydrate receives the stored value, so an inversion can be partial", () =
62
65
  source: { image: { _type: "image", asset: { _ref: "image-abc" }, alt: "Old alt" } },
63
66
  ctx,
64
67
  documentId: "doc1",
65
- where: "/ → hero"
68
+ where: "/ → hero",
69
+ paths: sanityPaths
66
70
  });
67
71
  assert.deepEqual(diff.patches[0].value, {
68
72
  _type: "image",
@@ -94,7 +98,8 @@ test("refusal 1 — a projection that cannot be inverted is reported, not guesse
94
98
  source: { image: { url: "https://cdn.example.com/old.png", alt: "Old" } },
95
99
  ctx,
96
100
  documentId: "doc1",
97
- where: "/ → hero"
101
+ where: "/ → hero",
102
+ paths: sanityPaths
98
103
  });
99
104
  assert.deepEqual(diff.patches, [], "nothing may be written for a value that cannot be expressed");
100
105
  assert.equal(diff.unsupported.length, 1);
@@ -114,7 +119,8 @@ test("refusal 2 — adding or removing list items is a document-shaped change",
114
119
  source: { cards: [{ _key: "k1", title: "One" }] },
115
120
  ctx,
116
121
  documentId: "doc1",
117
- where: "/ → cardGrid"
122
+ where: "/ → cardGrid",
123
+ paths: sanityPaths
118
124
  });
119
125
  assert.ok(diff.unsupported.some((u) => /added to or removed/.test(u.change)));
120
126
  });
@@ -131,7 +137,8 @@ test("refusal 3 — an item the CMS has never seen has no path to patch", () =>
131
137
  source: { cards: [{ _key: "k1", title: "One" }, { _key: "k2", title: "Two" }] },
132
138
  ctx,
133
139
  documentId: "doc1",
134
- where: "/ → cardGrid"
140
+ where: "/ → cardGrid",
141
+ paths: sanityPaths
135
142
  });
136
143
  assert.ok(diff.unsupported.some((u) => /new item/.test(u.change)));
137
144
  assert.ok(!diff.patches.some((p) => p.value === "Brand new"), "a keyless item must not be written to whatever path happens to be at its index");
@@ -143,6 +150,7 @@ test("refusal 4 — a block with no upstream document is reported, never skipped
143
150
  blocks: [{ id: "b_new", type: "pba_pricingSection", props: { heading: "Hi" } }]
144
151
  },
145
152
  ctx,
153
+ paths: sanityPaths,
146
154
  locate: () => null
147
155
  });
148
156
  assert.deepEqual(diff.patches, []);
@@ -168,7 +176,8 @@ test("a list item is patched at its own key, not at its position", () => {
168
176
  source: { cards: [{ _key: "k1", title: "One" }, { _key: "k2", title: "Two" }] },
169
177
  ctx,
170
178
  documentId: "doc1",
171
- where: "/ → cardGrid"
179
+ where: "/ → cardGrid",
180
+ paths: sanityPaths
172
181
  });
173
182
  assert.deepEqual(diff.patches, [
174
183
  { documentId: "doc1", path: 'cards[_key=="k2"].title', value: "Renamed" }
@@ -226,6 +235,7 @@ test("one block can write to two documents", () => {
226
235
  blocks: [{ id: "b1", type: "sharedSection", props: { heading: "Page heading", body: "Shared body" } }]
227
236
  },
228
237
  ctx,
238
+ paths: sanityPaths,
229
239
  locate: () => [
230
240
  {
231
241
  documentId: "page-1",
@@ -250,6 +260,7 @@ test("a page with nothing changed issues no write at all", () => {
250
260
  const diff = diffPage({
251
261
  page: { slug: "/", blocks: [{ id: "b1", type: "hero", props: { heading: "Same" } }] },
252
262
  ctx,
263
+ paths: sanityPaths,
253
264
  locate: () => ({
254
265
  documentId: "page-1",
255
266
  prefix: "",
@@ -1 +1 @@
1
- export { createOrchestrator, jsonFileAdapter, editorApiAdapter, resolveCapabilities, type CreateOrchestratorConfig, type OrchestratorHandler, type OrchestratorAuth, type AuthContext, type CmsAdapter, type CmsCapabilities, type CmsInlineAsset, type CmsPublishContext, type CmsPublishResult, type CmsPerspective, type CmsReadOptions, type ResolvedCapabilities } from "@avocadostudio-ai/orchestrator-core";
1
+ export { createOrchestrator, jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel, type CreateOrchestratorConfig, type OrchestratorHandler, type OrchestratorAuth, type AuthContext, type CmsAdapter, type CmsCapabilities, type CmsInlineAsset, type CmsPublishContext, type CmsPublishResult, type CmsPerspective, type CmsReadOptions, type CmsMediaItem, type CmsMediaPage, type CmsMediaQuery, type CmsMediaSource, type CmsMediaSourceConfig, type ResolvedCapabilities } from "@avocadostudio-ai/orchestrator-core";
@@ -11,4 +11,4 @@
11
11
  //
12
12
  // This file stays so `@avocadostudio-ai/site-sdk/server` — the entry point
13
13
  // every example, README and docs page uses — keeps resolving unchanged.
14
- export { createOrchestrator, jsonFileAdapter, editorApiAdapter, resolveCapabilities } from "@avocadostudio-ai/orchestrator-core";
14
+ export { createOrchestrator, jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel } from "@avocadostudio-ai/orchestrator-core";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/site-sdk",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -107,16 +107,16 @@
107
107
  ],
108
108
  "dependencies": {
109
109
  "zod": "^4.3.6",
110
- "@avocadostudio-ai/shared": "0.2.1",
111
- "@avocadostudio-ai/blocks": "0.2.1",
112
- "@avocadostudio-ai/preview-adapter": "0.2.1"
110
+ "@avocadostudio-ai/preview-adapter": "^0.3.0",
111
+ "@avocadostudio-ai/blocks": "^0.3.0",
112
+ "@avocadostudio-ai/shared": "^0.3.0"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "next": ">=15.0.0",
116
116
  "react": ">=19.0.0",
117
117
  "react-dom": ">=19.0.0",
118
118
  "better-sqlite3": ">=12.0.0",
119
- "@avocadostudio-ai/orchestrator-core": "^0.2.1"
119
+ "@avocadostudio-ai/orchestrator-core": "^0.3.0"
120
120
  },
121
121
  "peerDependenciesMeta": {
122
122
  "@avocadostudio-ai/orchestrator-core": {
@@ -132,6 +132,17 @@
132
132
  "typescript": "^5.7.3"
133
133
  },
134
134
  "description": "SDK for adding Avocado Studio AI editing and live preview to a Next.js site",
135
+ "keywords": [
136
+ "avocado",
137
+ "avocado-studio",
138
+ "nextjs",
139
+ "cms",
140
+ "visual-editing",
141
+ "page-builder",
142
+ "ai",
143
+ "content-editor",
144
+ "draft-mode"
145
+ ],
135
146
  "license": "Apache-2.0",
136
147
  "homepage": "https://github.com/avocadostudio-ai/avocado/tree/main/packages/site-sdk#readme",
137
148
  "bugs": {