@avocadostudio-ai/site-sdk 0.2.3 → 0.3.1
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 +242 -8
- 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/editor-overlay.d.ts +17 -0
- package/dist/editor-overlay.js +17 -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 +6 -6
package/README.md
CHANGED
|
@@ -169,10 +169,23 @@ none of them.
|
|
|
169
169
|
| `.../navigation` | `buildNavItems`, `buildSiteHeaderBlock` |
|
|
170
170
|
| `.../seo` | `buildPageMetadata` and the title/description derivation |
|
|
171
171
|
|
|
172
|
-
`useLivePreviewBlocks`
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
172
|
+
`useLivePreviewBlocks` is not here. It lives in
|
|
173
|
+
`@avocadostudio-ai/preview-adapter`, which you must depend on directly to import
|
|
174
|
+
from it (`LivePreviewProvider` itself is re-exported above, the hook is not).
|
|
175
|
+
|
|
176
|
+
It is worth being precise about what it is for, because an earlier version of
|
|
177
|
+
this paragraph was not. Rendering a live draft through **your own** components
|
|
178
|
+
does not require it — that is the default, and it is what an existing site
|
|
179
|
+
should do: skip `createSitePage`, skip `@avocadostudio-ai/blocks/styles.css`,
|
|
180
|
+
render the draft page with your own components, and mount `EditorOverlay`.
|
|
181
|
+
Streamed edits reach the page as DOM writes keyed on `data-editable-target`.
|
|
182
|
+
|
|
183
|
+
What the hook buys is having those edits arrive as **React state** instead, so
|
|
184
|
+
components that own their own markup are not fighting an `innerHTML` write. It
|
|
185
|
+
is the newer and less exercised of the two paths, and it re-renders on the
|
|
186
|
+
client — anything resolved from a server-only registry blanks. Take the default
|
|
187
|
+
path first. See
|
|
188
|
+
[Existing sites keep their own components](https://docs.avocadostudio.dev/integration/nextjs-integration#existing-sites-keep-their-own-components).
|
|
176
189
|
|
|
177
190
|
## API Contract
|
|
178
191
|
|
|
@@ -213,6 +226,29 @@ site's own origin** for `/api/editor/blocks` and `/api/editor/pages`. Mount only
|
|
|
213
226
|
the orchestrator and the editor loads, connects, and answers four requests with
|
|
214
227
|
`net::ERR_FAILED` in a console you have to open to see.
|
|
215
228
|
|
|
229
|
+
### And then run the editor
|
|
230
|
+
|
|
231
|
+
The two handlers above are the whole backend. The editor UI itself is a
|
|
232
|
+
prebuilt SPA that ships inside `@avocadostudio-ai/cli`, so there is nothing to
|
|
233
|
+
clone and no third service to deploy:
|
|
234
|
+
|
|
235
|
+
```bash
|
|
236
|
+
npx @avocadostudio-ai/cli start \
|
|
237
|
+
--orchestrator http://localhost:3000/api/avocado \
|
|
238
|
+
--preview http://localhost:3000
|
|
239
|
+
```
|
|
240
|
+
|
|
241
|
+
That serves the editor on `http://localhost:4100`, pointed at the orchestrator
|
|
242
|
+
you just mounted. `--preview` is the origin the editor frames, so it is your
|
|
243
|
+
site's dev server — **your** port, which is only 3000 if that is where your
|
|
244
|
+
`dev` script runs.
|
|
245
|
+
|
|
246
|
+
This line is here because the composition is not obvious from either package on
|
|
247
|
+
its own: the CLI's own README says it does not bundle an orchestrator and
|
|
248
|
+
points at one you deploy separately, which is true and reads as "you need a
|
|
249
|
+
third service". You do not. `createOrchestrator` above *is* the orchestrator,
|
|
250
|
+
and the CLI is the other half of it.
|
|
251
|
+
|
|
216
252
|
## The adapter contract
|
|
217
253
|
|
|
218
254
|
`createOrchestrator({ adapter })` is the whole of library mode: SQLite is the
|
|
@@ -241,8 +277,14 @@ const myAdapter: CmsAdapter = {
|
|
|
241
277
|
return { ok: true, unsupported: [] }
|
|
242
278
|
},
|
|
243
279
|
|
|
280
|
+
/** Optional. Fills the editor's image picker from your media library. */
|
|
281
|
+
async getMedia({ query, page, limit }) {
|
|
282
|
+
const res = await searchAssets(query, page, limit)
|
|
283
|
+
return { items: res.assets, totalPages: res.pageCount, label: "Sanity" }
|
|
284
|
+
},
|
|
285
|
+
|
|
244
286
|
/** Static, because /whoami must answer it with the CMS unreachable. */
|
|
245
|
-
capabilities: {
|
|
287
|
+
capabilities: { createPages: false },
|
|
246
288
|
}
|
|
247
289
|
```
|
|
248
290
|
|
|
@@ -260,10 +302,76 @@ the projection back destroys what it was projected from. `context.published` is
|
|
|
260
302
|
the baseline; `undefined` means *no baseline available*, never *the site was
|
|
261
303
|
empty*. See [Publishing back to a real CMS](#publishing-back-to-a-real-cms).
|
|
262
304
|
|
|
305
|
+
It survives a process restart: a restart reloads the draft from SQLite without
|
|
306
|
+
re-seeding it, and the baseline used to be dropped on that path, so a publisher
|
|
307
|
+
written the way this section recommends — diff against the baseline, refuse when
|
|
308
|
+
there is none — could never publish again afterwards. The orchestrator now
|
|
309
|
+
re-reads the adapter for the baseline alone in that case. It can still be
|
|
310
|
+
`undefined` if that read fails, so keep the refusal.
|
|
311
|
+
|
|
263
312
|
`CmsAdapter`, `CmsCapabilities`, `CmsPublishContext`, `CmsPerspective` and
|
|
264
313
|
`CreateOrchestratorConfig` are all exported from
|
|
265
314
|
`@avocadostudio-ai/site-sdk/server`.
|
|
266
315
|
|
|
316
|
+
### Filling the image picker
|
|
317
|
+
|
|
318
|
+
`getMedia` is optional and silent by default: implement it and the editor's
|
|
319
|
+
asset picker grows a CMS tab, leave it off and it does not. `/status/planner`
|
|
320
|
+
reports `features.cmsMedia` and `/whoami` reports `capabilities.readsMedia`,
|
|
321
|
+
both derived from whether the method exists — an adapter cannot claim a media
|
|
322
|
+
library it did not implement.
|
|
323
|
+
|
|
324
|
+
If your CMS is Contentful, Sanity or Strapi, `cmsMediaSource` writes the method
|
|
325
|
+
for you:
|
|
326
|
+
|
|
327
|
+
```ts
|
|
328
|
+
import { cmsMediaSource, type CmsAdapter } from "@avocadostudio-ai/site-sdk/server"
|
|
329
|
+
|
|
330
|
+
export function sanityAdapter(): CmsAdapter {
|
|
331
|
+
return {
|
|
332
|
+
id: "sanity",
|
|
333
|
+
getPages: (options) => getSanityPages(options?.perspective),
|
|
334
|
+
getMedia: cmsMediaSource({
|
|
335
|
+
provider: "sanity",
|
|
336
|
+
projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
|
|
337
|
+
dataset: process.env.NEXT_PUBLIC_SANITY_DATASET,
|
|
338
|
+
token: process.env.SANITY_API_TOKEN
|
|
339
|
+
})
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
It is a convenience, not the contract. Any other CMS returns the same shape
|
|
345
|
+
itself:
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
getMedia: async ({ query, page, limit }) => {
|
|
349
|
+
const res = await myCms.assets.search({ q: query, page, perPage: limit })
|
|
350
|
+
return {
|
|
351
|
+
items: res.assets.map((a) => ({
|
|
352
|
+
id: a.id,
|
|
353
|
+
name: a.filename,
|
|
354
|
+
imageUrl: a.url,
|
|
355
|
+
thumbUrl: a.thumb ?? a.url,
|
|
356
|
+
alt: a.altText
|
|
357
|
+
})),
|
|
358
|
+
totalPages: res.pageCount,
|
|
359
|
+
label: "My CMS"
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
```
|
|
363
|
+
|
|
364
|
+
`query` is a free-text filter and may be absent; `page` is 1-based and `limit`
|
|
365
|
+
is clamped to 50 before it reaches you. Return an empty page rather than
|
|
366
|
+
throwing when the upstream call fails — the picker renders an empty grid, and a
|
|
367
|
+
rejected promise inside a modal shows the user nothing at all.
|
|
368
|
+
|
|
369
|
+
The credentials stay on the server. Before this method existed the picker
|
|
370
|
+
called Contentful, Sanity and Strapi from the browser, over a union of exactly
|
|
371
|
+
those three compiled into the editor — which is why a fourth CMS could bring
|
|
372
|
+
its own adapter, blocks and publish handler and still had no way to bring its
|
|
373
|
+
own images.
|
|
374
|
+
|
|
267
375
|
## Library mode needs a credential
|
|
268
376
|
|
|
269
377
|
`createOrchestrator()` mounts on your own domain and can edit and publish your
|
|
@@ -344,6 +452,62 @@ A workspace link to `@avocadostudio-ai/orchestrator-core` is **not** enough. A
|
|
|
344
452
|
linked package's own dependencies are never materialised in the host's tree, and
|
|
345
453
|
a native module has to be resolvable from there.
|
|
346
454
|
|
|
455
|
+
## The editor frames your page, not the preview route
|
|
456
|
+
|
|
457
|
+
The iframe's `src` is your site's own URL with a query parameter added:
|
|
458
|
+
|
|
459
|
+
```
|
|
460
|
+
http://localhost:3000/de?__editor=1&session=dev&siteId=my-site
|
|
461
|
+
```
|
|
462
|
+
|
|
463
|
+
`createEditorProxy` then rewrites that request onto the preview route. **The
|
|
464
|
+
rewrite does not re-run Next's header matching**, so `headers()` was already
|
|
465
|
+
evaluated against `/de` — the public path — and the preview HTML comes back
|
|
466
|
+
wearing the public site's headers.
|
|
467
|
+
|
|
468
|
+
That matters for exactly one thing, and it is the first thing an adopter sees
|
|
469
|
+
after wiring everything up correctly: **a site that sends `frame-ancestors` or
|
|
470
|
+
`X-Frame-Options` cannot be framed**, and the failure is a blank rectangle. The
|
|
471
|
+
response is correct, the render is correct, and the browser refuses to display
|
|
472
|
+
it.
|
|
473
|
+
|
|
474
|
+
Scope the exception to the query parameter, which is the only part of the
|
|
475
|
+
request Next sees before the rewrite:
|
|
476
|
+
|
|
477
|
+
```js
|
|
478
|
+
async headers() {
|
|
479
|
+
const editor = "http://localhost:4100 http://127.0.0.1:4100"
|
|
480
|
+
return [
|
|
481
|
+
{
|
|
482
|
+
source: "/:path*",
|
|
483
|
+
has: [{ type: "query", key: "__editor" }],
|
|
484
|
+
headers: [{ key: "Content-Security-Policy", value: `frame-ancestors 'self' ${editor};` }]
|
|
485
|
+
},
|
|
486
|
+
{
|
|
487
|
+
source: "/:path*",
|
|
488
|
+
missing: [{ type: "query", key: "__editor" }],
|
|
489
|
+
headers: [
|
|
490
|
+
{ key: "Content-Security-Policy", value: "frame-ancestors 'self';" },
|
|
491
|
+
{ key: "X-Frame-Options", value: "SAMEORIGIN" }
|
|
492
|
+
]
|
|
493
|
+
}
|
|
494
|
+
]
|
|
495
|
+
}
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Two things that are easy to get wrong here:
|
|
499
|
+
|
|
500
|
+
**The `missing:` on the public rule is load-bearing.** Without it both rules
|
|
501
|
+
match an editor request and the browser gets two `Content-Security-Policy`
|
|
502
|
+
headers — which it reads as their *intersection*, putting `frame-ancestors
|
|
503
|
+
'self'` back and blocking the frame again.
|
|
504
|
+
|
|
505
|
+
**Name both spellings of the loopback address.** The CLI binds `127.0.0.1` and
|
|
506
|
+
prints that; everything written down says `localhost`. `frame-ancestors` and
|
|
507
|
+
CORS both compare origins as strings, so a rule copied from this page and an
|
|
508
|
+
editor opened at the URL the CLI printed will never match. The SDK's own
|
|
509
|
+
development CORS default now accepts both; your CSP has to say both too.
|
|
510
|
+
|
|
347
511
|
## Telling the orchestrator where your site is
|
|
348
512
|
|
|
349
513
|
An agent's only way to see what it just edited is `POST /preview/screenshot`,
|
|
@@ -421,9 +585,14 @@ Two things worth knowing before you write one.
|
|
|
421
585
|
|
|
422
586
|
**List items match on their own key, not their position.** An index-addressed
|
|
423
587
|
patch lands on the wrong row the moment anything reorders the array upstream.
|
|
424
|
-
`sanityPaths`
|
|
425
|
-
|
|
426
|
-
|
|
588
|
+
`sanityPaths` addresses `field[_key=="…"]`; `indexPaths` exists for stores with
|
|
589
|
+
no element identity and is only correct when your publish is the only writer.
|
|
590
|
+
|
|
591
|
+
`paths` is a **required** argument to both `diffFields` and `diffPage`. It used
|
|
592
|
+
to default to `sanityPaths`, which meant a Contentful or Strapi publisher that
|
|
593
|
+
never mentioned it emitted `_key` patches against a store that has no `_key` —
|
|
594
|
+
the wrong answer, arrived at quietly. Nothing can infer which CMS you are
|
|
595
|
+
writing to, so it asks.
|
|
427
596
|
|
|
428
597
|
**Refusing is a result, not an error.** Four things a field diff cannot express
|
|
429
598
|
— a replaced image, a retyped link, a list item added or removed, a new block —
|
|
@@ -548,6 +717,71 @@ Declaring a type you never registered is not silently dropped: it has no schema
|
|
|
548
717
|
to describe, so it cannot reach the manifest, and the orchestrator logs a warning
|
|
549
718
|
naming it the first time the manifest is served.
|
|
550
719
|
|
|
720
|
+
## Mounting the overlay
|
|
721
|
+
|
|
722
|
+
Nothing on the page is clickable until `EditorOverlay` is on it. It is the whole
|
|
723
|
+
of the selection UI — the block outline, the type badge, the field pills, the
|
|
724
|
+
postMessage channel back to the editor — and a preview route without it renders
|
|
725
|
+
correctly, frames correctly, and does not respond to a click.
|
|
726
|
+
|
|
727
|
+
It takes two required props, and the second one is not guessable:
|
|
728
|
+
|
|
729
|
+
```tsx
|
|
730
|
+
// app/preview-draft/[[...slug]]/page.tsx
|
|
731
|
+
import { resolveEditorContext } from "@avocadostudio-ai/site-sdk/draft"
|
|
732
|
+
import { EditorOverlay } from "@avocadostudio-ai/site-sdk/editor"
|
|
733
|
+
import { buildSlug } from "@avocadostudio-ai/site-sdk"
|
|
734
|
+
|
|
735
|
+
export default async function PreviewPage({ params, searchParams }) {
|
|
736
|
+
const { slug } = await params
|
|
737
|
+
const ctx = await resolveEditorContext(await searchParams)
|
|
738
|
+
const path = buildSlug(slug) // ["fr","pricing"] -> "/fr/pricing"
|
|
739
|
+
|
|
740
|
+
return (
|
|
741
|
+
<>
|
|
742
|
+
<MyPage slug={path} />
|
|
743
|
+
{ctx && <EditorOverlay slug={path} editorOrigin={ctx.editorOrigin} />}
|
|
744
|
+
</>
|
|
745
|
+
)
|
|
746
|
+
}
|
|
747
|
+
```
|
|
748
|
+
|
|
749
|
+
- **`slug`** is the page's slug as the orchestrator knows it — the same
|
|
750
|
+
leading-slash path `getPages()` returned, not the URL the visitor typed. Build
|
|
751
|
+
it with `buildSlug` rather than joining segments by hand.
|
|
752
|
+
- **`editorOrigin`** is the origin the overlay will `postMessage` to, and every
|
|
753
|
+
message is scoped to it. `resolveEditorContext` already returns it as its
|
|
754
|
+
third field; there is no need to plumb an env var through for this.
|
|
755
|
+
|
|
756
|
+
The component renders `null` outside an iframe, so it is inert on a normal page
|
|
757
|
+
load and safe to mount unconditionally within the preview route.
|
|
758
|
+
|
|
759
|
+
### Then turn selection on in the editor
|
|
760
|
+
|
|
761
|
+
Mounting the overlay is necessary and not sufficient. Clicking is gated on
|
|
762
|
+
`data-editor-selection-mode`, which the bridge sets only when the editor sends
|
|
763
|
+
`setSelectionMode {enabled: true}` — and the editor's own default is **off**.
|
|
764
|
+
|
|
765
|
+
So a correctly-wired integration's first browser session looks like this: the
|
|
766
|
+
site frames, renders, and does not respond to a click. Nothing is broken; the
|
|
767
|
+
picker is not on. Turn it on with the crosshair button in the chat composer
|
|
768
|
+
("Select element"), or press Esc to leave it again.
|
|
769
|
+
|
|
770
|
+
Check this before debugging anything else — an integrator who assumes their
|
|
771
|
+
markup is wrong can spend an afternoon proving that it isn't. In the iframe's
|
|
772
|
+
console:
|
|
773
|
+
|
|
774
|
+
```js
|
|
775
|
+
document.documentElement.hasAttribute("data-editor-active") // overlay mounted
|
|
776
|
+
document.documentElement.hasAttribute("data-editor-selection-mode") // picker on
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
Both true and clicks still doing nothing is a real bug. The first true and the
|
|
780
|
+
second false is the default.
|
|
781
|
+
|
|
782
|
+
Once the picker is on, blocks are selectable. Individual *fields* are not,
|
|
783
|
+
until:
|
|
784
|
+
|
|
551
785
|
## Making fields editable
|
|
552
786
|
|
|
553
787
|
The manifest tells the editor which blocks exist and what props they take. It
|
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
|
+
});
|
package/dist/editor-overlay.d.ts
CHANGED
|
@@ -1,3 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The selection UI: block outlines, the type badge, the field pills, and the
|
|
3
|
+
* postMessage channel the editor talks to.
|
|
4
|
+
*
|
|
5
|
+
* Mount it inside the preview route. A preview page without it renders and
|
|
6
|
+
* frames correctly and simply does not respond to a click — which is a hard
|
|
7
|
+
* failure to diagnose, because nothing is broken, something is absent.
|
|
8
|
+
*
|
|
9
|
+
* @param slug The page's slug **as the orchestrator knows it**: the same
|
|
10
|
+
* leading-slash path `getPages()` returned, not the URL the visitor typed.
|
|
11
|
+
* `buildSlug(segments)` produces it from a catch-all route's params.
|
|
12
|
+
* @param editorOrigin The origin every message is scoped to. It is the third
|
|
13
|
+
* field of what `resolveEditorContext()` already returns — there is nothing
|
|
14
|
+
* to plumb through for it.
|
|
15
|
+
*
|
|
16
|
+
* Renders `null` outside an iframe, so it is inert on an ordinary page load.
|
|
17
|
+
*/
|
|
1
18
|
export declare function EditorOverlay({ slug, editorOrigin }: {
|
|
2
19
|
slug: string;
|
|
3
20
|
editorOrigin: string;
|
package/dist/editor-overlay.js
CHANGED
|
@@ -3,6 +3,23 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import dynamic from "next/dynamic";
|
|
4
4
|
import { useState, useEffect } from "react";
|
|
5
5
|
const PreviewBridgeLoader = dynamic(() => import("./editor-overlay-inner.js").then((m) => ({ default: m.EditorOverlayInner })), { ssr: false });
|
|
6
|
+
/**
|
|
7
|
+
* The selection UI: block outlines, the type badge, the field pills, and the
|
|
8
|
+
* postMessage channel the editor talks to.
|
|
9
|
+
*
|
|
10
|
+
* Mount it inside the preview route. A preview page without it renders and
|
|
11
|
+
* frames correctly and simply does not respond to a click — which is a hard
|
|
12
|
+
* failure to diagnose, because nothing is broken, something is absent.
|
|
13
|
+
*
|
|
14
|
+
* @param slug The page's slug **as the orchestrator knows it**: the same
|
|
15
|
+
* leading-slash path `getPages()` returned, not the URL the visitor typed.
|
|
16
|
+
* `buildSlug(segments)` produces it from a catch-all route's params.
|
|
17
|
+
* @param editorOrigin The origin every message is scoped to. It is the third
|
|
18
|
+
* field of what `resolveEditorContext()` already returns — there is nothing
|
|
19
|
+
* to plumb through for it.
|
|
20
|
+
*
|
|
21
|
+
* Renders `null` outside an iframe, so it is inert on an ordinary page load.
|
|
22
|
+
*/
|
|
6
23
|
export function EditorOverlay({ slug, editorOrigin }) {
|
|
7
24
|
const [inIframe, setInIframe] = useState(false);
|
|
8
25
|
useEffect(() => {
|
|
@@ -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.1",
|
|
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/blocks": "0.
|
|
111
|
-
"@avocadostudio-ai/
|
|
112
|
-
"@avocadostudio-ai/
|
|
110
|
+
"@avocadostudio-ai/blocks": "^0.3.1",
|
|
111
|
+
"@avocadostudio-ai/preview-adapter": "^0.3.1",
|
|
112
|
+
"@avocadostudio-ai/shared": "^0.3.1"
|
|
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.1"
|
|
120
120
|
},
|
|
121
121
|
"peerDependenciesMeta": {
|
|
122
122
|
"@avocadostudio-ai/orchestrator-core": {
|
|
@@ -144,7 +144,7 @@
|
|
|
144
144
|
"draft-mode"
|
|
145
145
|
],
|
|
146
146
|
"license": "Apache-2.0",
|
|
147
|
-
"homepage": "https://
|
|
147
|
+
"homepage": "https://docs.avocadostudio.dev",
|
|
148
148
|
"bugs": {
|
|
149
149
|
"url": "https://github.com/avocadostudio-ai/avocado/issues"
|
|
150
150
|
},
|