@avocadostudio-ai/site-sdk 0.2.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -241,8 +241,14 @@ const myAdapter: CmsAdapter = {
241
241
  return { ok: true, unsupported: [] }
242
242
  },
243
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
+
244
250
  /** Static, because /whoami must answer it with the CMS unreachable. */
245
- capabilities: { createPage: false },
251
+ capabilities: { createPages: false },
246
252
  }
247
253
  ```
248
254
 
@@ -264,6 +270,65 @@ empty*. See [Publishing back to a real CMS](#publishing-back-to-a-real-cms).
264
270
  `CreateOrchestratorConfig` are all exported from
265
271
  `@avocadostudio-ai/site-sdk/server`.
266
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
+
267
332
  ## Library mode needs a credential
268
333
 
269
334
  `createOrchestrator()` mounts on your own domain and can edit and publish your
@@ -344,6 +409,62 @@ A workspace link to `@avocadostudio-ai/orchestrator-core` is **not** enough. A
344
409
  linked package's own dependencies are never materialised in the host's tree, and
345
410
  a native module has to be resolvable from there.
346
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
+
347
468
  ## Telling the orchestrator where your site is
348
469
 
349
470
  An agent's only way to see what it just edited is `POST /preview/screenshot`,
@@ -421,9 +542,14 @@ Two things worth knowing before you write one.
421
542
 
422
543
  **List items match on their own key, not their position.** An index-addressed
423
544
  patch lands on the wrong row the moment anything reorders the array upstream.
424
- `sanityPaths` (the default) addresses `field[_key=="…"]`; `indexPaths` exists for
425
- stores with no element identity and is only correct when your publish is the
426
- only writer.
545
+ `sanityPaths` addresses `field[_key=="…"]`; `indexPaths` exists for stores with
546
+ no element identity and is only correct when your publish is the only writer.
547
+
548
+ `paths` is a **required** argument to both `diffFields` and `diffPage`. It used
549
+ to default to `sanityPaths`, which meant a Contentful or Strapi publisher that
550
+ never mentioned it emitted `_key` patches against a store that has no `_key` —
551
+ the wrong answer, arrived at quietly. Nothing can infer which CMS you are
552
+ writing to, so it asks.
427
553
 
428
554
  **Refusing is a result, not an error.** Four things a field diff cannot express
429
555
  — a replaced image, a retyped link, a list item added or removed, a new block —
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * CORS for the editor API routes a host app mounts.
3
3
  *
4
- * The dev editor runs on its own origin (`localhost:4100`), so the routes have
4
+ * The dev editor runs on its own origin (`localhost:4100`, or the `127.0.0.1`
5
+ * spelling the CLI prints), so the routes have
5
6
  * to answer cross-origin requests from it. That default used to be added
6
7
  * unconditionally, which meant a deployed site kept answering CORS-approved
7
8
  * requests to a `localhost` origin: any page a visitor had open could read that
@@ -1,14 +1,27 @@
1
1
  /**
2
2
  * CORS for the editor API routes a host app mounts.
3
3
  *
4
- * The dev editor runs on its own origin (`localhost:4100`), so the routes have
4
+ * The dev editor runs on its own origin (`localhost:4100`, or the `127.0.0.1`
5
+ * spelling the CLI prints), so the routes have
5
6
  * to answer cross-origin requests from it. That default used to be added
6
7
  * unconditionally, which meant a deployed site kept answering CORS-approved
7
8
  * requests to a `localhost` origin: any page a visitor had open could read that
8
9
  * site's draft content out of its own browser. Now the development default is
9
10
  * only assumed in development, and a deployment says what it allows.
10
11
  */
11
- const DEV_EDITOR_ORIGIN = "http://localhost:4100";
12
+ /*
13
+ * Both spellings of the same loopback address, because the product uses both.
14
+ *
15
+ * The CLI binds `127.0.0.1` and prints `http://127.0.0.1:4100`; every README,
16
+ * env table and example says `http://localhost:4100`. CORS and
17
+ * `frame-ancestors` compare origins as strings, so an editor started by our own
18
+ * CLI, opened at the URL our own CLI printed, was rejected by our own default —
19
+ * and the symptom is a blank iframe with a console message naming an origin
20
+ * that looks like it should have matched.
21
+ *
22
+ * Development only, as below: a deployment still has to name its editor.
23
+ */
24
+ const DEV_EDITOR_ORIGINS = ["http://localhost:4100", "http://127.0.0.1:4100"];
12
25
  let cachedOrigins;
13
26
  function parseOrigins(value) {
14
27
  return (value ?? "")
@@ -27,7 +40,7 @@ export function getEditorCorsOrigins() {
27
40
  */
28
41
  const declaredEditor = parseOrigins(process.env.NEXT_PUBLIC_EDITOR_ORIGIN);
29
42
  const isProduction = process.env.NODE_ENV === "production";
30
- const defaults = isProduction ? [] : [DEV_EDITOR_ORIGIN];
43
+ const defaults = isProduction ? [] : DEV_EDITOR_ORIGINS;
31
44
  cachedOrigins = new Set([...defaults, ...declaredEditor, ...configured]);
32
45
  return cachedOrigins;
33
46
  }
@@ -64,3 +64,22 @@ test("an allowed origin gets the CORS headers and a disallowed one does not", ()
64
64
  assert.equal(denied.headers.get("Vary"), "Origin");
65
65
  });
66
66
  });
67
+ /*
68
+ * The CLI binds `127.0.0.1` and prints that URL; the docs all say `localhost`.
69
+ * Origins compare as strings, so the editor our own CLI starts has to be
70
+ * allowed under the spelling our own CLI printed.
71
+ */
72
+ test("development allows both spellings of the loopback editor origin", () => {
73
+ withEnv({ ...CLEAN, NODE_ENV: "development" }, () => {
74
+ const origins = getEditorCorsOrigins();
75
+ assert.ok(origins.has("http://localhost:4100"), "the spelling the docs use");
76
+ assert.ok(origins.has("http://127.0.0.1:4100"), "the spelling the CLI prints");
77
+ });
78
+ });
79
+ test("production still allows neither by default", () => {
80
+ withEnv({ ...CLEAN, NODE_ENV: "production" }, () => {
81
+ const origins = getEditorCorsOrigins();
82
+ assert.equal(origins.has("http://localhost:4100"), false);
83
+ assert.equal(origins.has("http://127.0.0.1:4100"), false);
84
+ });
85
+ });
@@ -124,7 +124,15 @@ export declare function diffFields<Ctx>(args: {
124
124
  /** Path prefix this object sits at, e.g. `pageBuilder[_key=="b1"].`. */
125
125
  prefix?: string;
126
126
  where: string;
127
- paths?: PathSyntax;
127
+ /**
128
+ * How this CMS addresses one element of a list. Required, and deliberately
129
+ * so: it used to default to `sanityPaths`, which meant a Contentful or
130
+ * Strapi publisher that never mentioned it emitted `field[_key=="…"]`
131
+ * patches against a store with no `_key`. The wrong answer was the quiet
132
+ * one — a default cannot know which CMS it is writing to, and the cost of
133
+ * guessing wrong is a patch applied to the wrong row.
134
+ */
135
+ paths: PathSyntax;
128
136
  }): FieldDiff;
129
137
  /**
130
138
  * Fold a flat patch list into one `set` object per document.
@@ -180,7 +188,8 @@ export declare function diffPage<Ctx>(args: {
180
188
  type: string;
181
189
  props: Record<string, unknown>;
182
190
  }): BlockTarget<Ctx> | BlockTarget<Ctx>[] | null;
183
- paths?: PathSyntax;
191
+ /** How this CMS addresses one element of a list. See `diffFields`. */
192
+ paths: PathSyntax;
184
193
  /** How a block is named in a report. Defaults to `"/slug → type"`. */
185
194
  where?(pageSlug: string, block: {
186
195
  id: string;
@@ -71,7 +71,11 @@ function defaultItemKey(item) {
71
71
  * cannot make a patch land on the wrong row.
72
72
  */
73
73
  export function diffFields(args) {
74
- const paths = args.paths ?? sanityPaths;
74
+ /* A JS caller gets no compile error, so say it loudly rather than picking
75
+ * one. Silently choosing a syntax is the bug this parameter exists to stop. */
76
+ if (!args.paths)
77
+ throw new TypeError("diffFields: `paths` is required — pass sanityPaths or indexPaths");
78
+ const paths = args.paths;
75
79
  const prefix = args.prefix ?? "";
76
80
  const patches = [];
77
81
  const unsupported = [];
@@ -14,7 +14,8 @@ test("an unchanged field emits nothing", () => {
14
14
  source: { heading: "Same" },
15
15
  ctx,
16
16
  documentId: "doc1",
17
- where: "/ → hero"
17
+ where: "/ → hero",
18
+ paths: sanityPaths
18
19
  });
19
20
  assert.deepEqual(diff.patches, []);
20
21
  assert.deepEqual(diff.unsupported, []);
@@ -27,7 +28,8 @@ test("a changed field is set at the path that owns it", () => {
27
28
  ctx,
28
29
  documentId: "doc1",
29
30
  prefix: 'pageBuilder[_key=="b1"].',
30
- where: "/ → hero"
31
+ where: "/ → hero",
32
+ paths: sanityPaths
31
33
  });
32
34
  assert.deepEqual(diff.patches, [
33
35
  { documentId: "doc1", path: 'pageBuilder[_key=="b1"].heading', value: "After" }
@@ -40,7 +42,8 @@ test("cmsKey writes to the field the CMS actually has", () => {
40
42
  source: { heading: "Before" },
41
43
  ctx,
42
44
  documentId: "doc1",
43
- where: "/ → hero"
45
+ where: "/ → hero",
46
+ paths: sanityPaths
44
47
  });
45
48
  assert.equal(diff.patches[0].path, "headingOverride");
46
49
  });
@@ -62,7 +65,8 @@ test("rehydrate receives the stored value, so an inversion can be partial", () =
62
65
  source: { image: { _type: "image", asset: { _ref: "image-abc" }, alt: "Old alt" } },
63
66
  ctx,
64
67
  documentId: "doc1",
65
- where: "/ → hero"
68
+ where: "/ → hero",
69
+ paths: sanityPaths
66
70
  });
67
71
  assert.deepEqual(diff.patches[0].value, {
68
72
  _type: "image",
@@ -94,7 +98,8 @@ test("refusal 1 — a projection that cannot be inverted is reported, not guesse
94
98
  source: { image: { url: "https://cdn.example.com/old.png", alt: "Old" } },
95
99
  ctx,
96
100
  documentId: "doc1",
97
- where: "/ → hero"
101
+ where: "/ → hero",
102
+ paths: sanityPaths
98
103
  });
99
104
  assert.deepEqual(diff.patches, [], "nothing may be written for a value that cannot be expressed");
100
105
  assert.equal(diff.unsupported.length, 1);
@@ -114,7 +119,8 @@ test("refusal 2 — adding or removing list items is a document-shaped change",
114
119
  source: { cards: [{ _key: "k1", title: "One" }] },
115
120
  ctx,
116
121
  documentId: "doc1",
117
- where: "/ → cardGrid"
122
+ where: "/ → cardGrid",
123
+ paths: sanityPaths
118
124
  });
119
125
  assert.ok(diff.unsupported.some((u) => /added to or removed/.test(u.change)));
120
126
  });
@@ -131,7 +137,8 @@ test("refusal 3 — an item the CMS has never seen has no path to patch", () =>
131
137
  source: { cards: [{ _key: "k1", title: "One" }, { _key: "k2", title: "Two" }] },
132
138
  ctx,
133
139
  documentId: "doc1",
134
- where: "/ → cardGrid"
140
+ where: "/ → cardGrid",
141
+ paths: sanityPaths
135
142
  });
136
143
  assert.ok(diff.unsupported.some((u) => /new item/.test(u.change)));
137
144
  assert.ok(!diff.patches.some((p) => p.value === "Brand new"), "a keyless item must not be written to whatever path happens to be at its index");
@@ -143,6 +150,7 @@ test("refusal 4 — a block with no upstream document is reported, never skipped
143
150
  blocks: [{ id: "b_new", type: "pba_pricingSection", props: { heading: "Hi" } }]
144
151
  },
145
152
  ctx,
153
+ paths: sanityPaths,
146
154
  locate: () => null
147
155
  });
148
156
  assert.deepEqual(diff.patches, []);
@@ -168,7 +176,8 @@ test("a list item is patched at its own key, not at its position", () => {
168
176
  source: { cards: [{ _key: "k1", title: "One" }, { _key: "k2", title: "Two" }] },
169
177
  ctx,
170
178
  documentId: "doc1",
171
- where: "/ → cardGrid"
179
+ where: "/ → cardGrid",
180
+ paths: sanityPaths
172
181
  });
173
182
  assert.deepEqual(diff.patches, [
174
183
  { documentId: "doc1", path: 'cards[_key=="k2"].title', value: "Renamed" }
@@ -226,6 +235,7 @@ test("one block can write to two documents", () => {
226
235
  blocks: [{ id: "b1", type: "sharedSection", props: { heading: "Page heading", body: "Shared body" } }]
227
236
  },
228
237
  ctx,
238
+ paths: sanityPaths,
229
239
  locate: () => [
230
240
  {
231
241
  documentId: "page-1",
@@ -250,6 +260,7 @@ test("a page with nothing changed issues no write at all", () => {
250
260
  const diff = diffPage({
251
261
  page: { slug: "/", blocks: [{ id: "b1", type: "hero", props: { heading: "Same" } }] },
252
262
  ctx,
263
+ paths: sanityPaths,
253
264
  locate: () => ({
254
265
  documentId: "page-1",
255
266
  prefix: "",
@@ -1 +1 @@
1
- export { createOrchestrator, jsonFileAdapter, editorApiAdapter, resolveCapabilities, type CreateOrchestratorConfig, type OrchestratorHandler, type OrchestratorAuth, type AuthContext, type CmsAdapter, type CmsCapabilities, type CmsInlineAsset, type CmsPublishContext, type CmsPublishResult, type CmsPerspective, type CmsReadOptions, type ResolvedCapabilities } from "@avocadostudio-ai/orchestrator-core";
1
+ export { createOrchestrator, jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel, type CreateOrchestratorConfig, type OrchestratorHandler, type OrchestratorAuth, type AuthContext, type CmsAdapter, type CmsCapabilities, type CmsInlineAsset, type CmsPublishContext, type CmsPublishResult, type CmsPerspective, type CmsReadOptions, type CmsMediaItem, type CmsMediaPage, type CmsMediaQuery, type CmsMediaSource, type CmsMediaSourceConfig, type ResolvedCapabilities } from "@avocadostudio-ai/orchestrator-core";
@@ -11,4 +11,4 @@
11
11
  //
12
12
  // This file stays so `@avocadostudio-ai/site-sdk/server` — the entry point
13
13
  // every example, README and docs page uses — keeps resolving unchanged.
14
- export { createOrchestrator, jsonFileAdapter, editorApiAdapter, resolveCapabilities } from "@avocadostudio-ai/orchestrator-core";
14
+ export { createOrchestrator, jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaLabel } from "@avocadostudio-ai/orchestrator-core";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/site-sdk",
3
- "version": "0.2.3",
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/blocks": "0.2.3",
111
- "@avocadostudio-ai/shared": "0.2.3",
112
- "@avocadostudio-ai/preview-adapter": "0.2.3"
110
+ "@avocadostudio-ai/preview-adapter": "^0.3.0",
111
+ "@avocadostudio-ai/blocks": "^0.3.0",
112
+ "@avocadostudio-ai/shared": "^0.3.0"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "next": ">=15.0.0",
116
116
  "react": ">=19.0.0",
117
117
  "react-dom": ">=19.0.0",
118
118
  "better-sqlite3": ">=12.0.0",
119
- "@avocadostudio-ai/orchestrator-core": "^0.2.3"
119
+ "@avocadostudio-ai/orchestrator-core": "^0.3.0"
120
120
  },
121
121
  "peerDependenciesMeta": {
122
122
  "@avocadostudio-ai/orchestrator-core": {