@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 +412 -14
- package/dist/editor-cors.d.ts +2 -1
- package/dist/editor-cors.js +16 -3
- package/dist/editor-cors.test.js +19 -0
- package/dist/publish/field-diff.d.ts +11 -2
- package/dist/publish/field-diff.js +5 -1
- package/dist/publish/field-diff.test.js +19 -8
- package/dist/server/orchestrator.d.ts +1 -1
- package/dist/server/orchestrator.js +1 -1
- 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.
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
###
|
|
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
|
-
|
|
|
93
|
-
|
|
|
94
|
-
|
|
|
95
|
-
|
|
|
96
|
-
|
|
|
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
|
|
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
|
|
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`
|
|
268
|
-
|
|
269
|
-
|
|
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 |
|
package/dist/editor-cors.d.ts
CHANGED
|
@@ -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
|
|
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
|
package/dist/editor-cors.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 ? [] :
|
|
43
|
+
const defaults = isProduction ? [] : DEV_EDITOR_ORIGINS;
|
|
31
44
|
cachedOrigins = new Set([...defaults, ...declaredEditor, ...configured]);
|
|
32
45
|
return cachedOrigins;
|
|
33
46
|
}
|
package/dist/editor-cors.test.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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/
|
|
111
|
-
"@avocadostudio-ai/blocks": "0.
|
|
112
|
-
"@avocadostudio-ai/
|
|
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.
|
|
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": {
|