@avocadostudio-ai/site-sdk 0.2.1 → 0.2.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/README.md +283 -11
  2. package/package.json +16 -5
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,84 @@ 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
+ /** Static, because /whoami must answer it with the CMS unreachable. */
245
+ capabilities: { createPage: false },
246
+ }
247
+ ```
248
+
249
+ Two parts of that are easy to get wrong:
250
+
251
+ **`perspectives` defaults to "no", and the other flags default to "yes".** That
252
+ asymmetry is deliberate: capabilities are permissions, where the safe answer is
253
+ to allow what nobody forbade; `perspectives` is an ability, where the safe answer
254
+ is not to claim one nobody implemented. An adapter wrongly believed to read
255
+ drafts makes the publish diff assert that unpublished work is live.
256
+
257
+ **`onPublish` should diff, not overwrite.** Every CMS read is a projection — an
258
+ asset reference flattened to a URL, rich text flattened to a string — and writing
259
+ the projection back destroys what it was projected from. `context.published` is
260
+ the baseline; `undefined` means *no baseline available*, never *the site was
261
+ empty*. See [Publishing back to a real CMS](#publishing-back-to-a-real-cms).
262
+
263
+ `CmsAdapter`, `CmsCapabilities`, `CmsPublishContext`, `CmsPerspective` and
264
+ `CreateOrchestratorConfig` are all exported from
265
+ `@avocadostudio-ai/site-sdk/server`.
266
+
110
267
  ## Library mode needs a credential
111
268
 
112
269
  `createOrchestrator()` mounts on your own domain and can edit and publish your
@@ -220,11 +377,11 @@ string, a document reference resolved to one language's href, rich text
220
377
  flattened to markdown. Writing the projection back replaces the reference with
221
378
  the flattening and destroys the document.
222
379
 
223
- So publish a **diff**, not a snapshot. `@ai-site-editor/site-sdk/publish` owns
380
+ So publish a **diff**, not a snapshot. `@avocadostudio-ai/site-sdk/publish` owns
224
381
  the walk:
225
382
 
226
383
  ```ts
227
- import { diffPage, groupPatches, describeUnsupported } from "@ai-site-editor/site-sdk/publish"
384
+ import { diffPage, groupPatches, describeUnsupported } from "@avocadostudio-ai/site-sdk/publish"
228
385
 
229
386
  const diff = diffPage({
230
387
  page,
@@ -281,6 +438,88 @@ absent after a restart that reloaded the draft from storage. Treat `undefined`
281
438
  as "no baseline available", never as "the site was empty": publishing every
282
439
  field on that assumption is the overwrite all of this exists to prevent.
283
440
 
441
+ ## Registering your own block schemas
442
+
443
+ The manifest at `/api/editor/blocks` tells the **editor** what exists. It is not
444
+ what validates an edit — that is the global block registry in
445
+ `@avocadostudio-ai/shared`, which starts out holding only Avocado's built-ins.
446
+ Ship a manifest and no registration and the editor looks completely wired up
447
+ until the first AI edit:
448
+
449
+ ```json
450
+ {"error":"Invalid props for PricingTable: Unknown block type: PricingTable","errorCode":"schema_violation"}
451
+ ```
452
+
453
+ ```ts
454
+ // lib/register-blocks.ts
455
+ import { registerBlock, z } from "@avocadostudio-ai/shared"
456
+
457
+ export function registerMyBlocks() {
458
+ registerBlock("PricingTable", {
459
+ schema: z.object({
460
+ title: z.string().min(1),
461
+ // A list needs BOTH halves — see the warning below.
462
+ tiers: z.array(z.object({
463
+ name: z.string().min(1),
464
+ price: z.string().min(1),
465
+ })).optional(),
466
+ }).catchall(z.unknown()),
467
+
468
+ meta: {
469
+ displayName: "Pricing Table",
470
+ fields: { title: { kind: "text" } },
471
+ listFields: {
472
+ tiers: { label: "Tiers", itemFields: { name: { kind: "text" }, price: { kind: "text" } } },
473
+ },
474
+ },
475
+ })
476
+ }
477
+ ```
478
+
479
+ Hand that function to whichever handler you mount — both re-run it after the
480
+ built-ins have registered, so your definitions land on top:
481
+
482
+ ```ts
483
+ createEditorApiHandler({ getPages, getManifest, registerBlocks: registerMyBlocks })
484
+ createOrchestrator({ adapter, registerBlocks: registerMyBlocks })
485
+ ```
486
+
487
+ Do **not** instead rely on a side-effect `import "@/lib/register-blocks"` placed
488
+ last in the file. Next's bundler does not reliably preserve module order across
489
+ the RSC, SSR and route-handler layers, so the built-in schemas sometimes
490
+ re-register on top of yours. The hook exists to replace that trick.
491
+
492
+ ### Import `z` from `@avocadostudio-ai/shared`, not from `zod`
493
+
494
+ `registerBlock` takes a `ZodObject`, and a Zod object is assignable only to one
495
+ built by the *same copy* of the library. Your own `import { z } from "zod"`
496
+ resolves to whatever your tree hoisted — on any site that also uses Sanity that
497
+ is zod 3 — and the mismatch reports as a structural type error listing methods
498
+ you have never called (`loose`, `safeExtend`, `exactPartial`, `def`, "and 21
499
+ more"), with nothing anywhere saying there are two copies of zod. Importing `z`
500
+ from `shared` gives you ours, and you need no direct `zod` dependency at all.
501
+
502
+ ### `schema` and `meta` are two halves that must agree
503
+
504
+ `meta.listFields` says how to *label* a list's rows. The props schema is what
505
+ says the list exists, and the property panel renders rows from the schema — so a
506
+ list named only in the meta shows no rows and no Add control, with no error
507
+ anywhere. Validation passes, operations apply, the preview renders, publishing
508
+ diffs the rows correctly. The only symptom is an absence in one panel.
509
+
510
+ That failure hides especially well behind `.catchall(z.unknown())`, which most
511
+ CMS integrations need (see [Publishing back to a real CMS](#publishing-back-to-a-real-cms),
512
+ which recommends keeping a `__source` snapshot in block props): the catchall
513
+ swallows the undeclared array as an unmodelled extra.
514
+
515
+ `registerBlock` now warns when it sees the contradiction:
516
+
517
+ ```
518
+ [avocado] PricingTable: meta.listFields declares "tiers" but the schema has no `tiers`.
519
+ The property panel renders list rows from the schema, so this list will show no rows
520
+ and no Add control. Declare it alongside the meta, e.g. tiers: z.array(z.object({ … })).optional()
521
+ ```
522
+
284
523
  ## Telling the orchestrator which blocks you render
285
524
 
286
525
  Importing anything from `@avocadostudio-ai/shared` registers Avocado's 18
@@ -309,6 +548,39 @@ Declaring a type you never registered is not silently dropped: it has no schema
309
548
  to describe, so it cannot reach the manifest, and the orchestrator logs a warning
310
549
  naming it the first time the manifest is served.
311
550
 
551
+ ## Making fields editable
552
+
553
+ The manifest tells the editor which blocks exist and what props they take. It
554
+ does not tell it **where on the page a prop is rendered**, and nothing can derive
555
+ that — so a site whose components carry no annotation gets block selection, the
556
+ badge, move and delete, and not one editable field.
557
+
558
+ Put `data-editable-target` on the DOM node that renders each prop:
559
+
560
+ ```tsx
561
+ <h1 data-editable-target="heading">{heading}</h1>
562
+ <p data-editable-target="subheading">{subheading}</p>
563
+
564
+ {cards.map((card, i) => (
565
+ <article key={card._key}>
566
+ <h3 data-editable-target={`cards[${i}].title`}>{card.title}</h3>
567
+ <img data-editable-target={`cards[${i}].imageUrl`} src={card.imageUrl} />
568
+ </article>
569
+ ))}
570
+ ```
571
+
572
+ The path grammar is the same one operations use: `heading` for a scalar,
573
+ `cards[0].title` for a list item's field, `cards[0].imageUrl` for its image.
574
+
575
+ This is the one part of an integration that **cannot** live in an integration
576
+ layer — it has to go inside your own components, one attribute per prop you want
577
+ editable. Budget for it: it is usually the largest single cost of adopting the
578
+ editor, and there is no way to add it from the outside.
579
+
580
+ Two optional siblings control the labels the overlay draws:
581
+ `data-editable-target-label` (the CSS `::before` tooltip) and
582
+ `data-editable-label` (the floating pill). Both default to the target path.
583
+
312
584
  ## Environment Variables
313
585
 
314
586
  | Variable | Required | Description |
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.2.3",
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/blocks": "0.2.3",
111
+ "@avocadostudio-ai/shared": "0.2.3",
112
+ "@avocadostudio-ai/preview-adapter": "0.2.3"
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.2.3"
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": {