@avocadostudio-ai/site-sdk 0.3.2 → 0.3.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -706,12 +706,26 @@ If you render Avocado's blocks (a scaffolded site does), skip this. If your site
706
706
  brought its own, declare them:
707
707
 
708
708
  ```ts
709
- export const { GET, POST } = createOrchestrator({
710
- adapter,
711
- blockTypes: ["acme_hero", "acme_splitSection", "acme_pricing"]
709
+ const SITE_BLOCK_TYPES = ["acme_hero", "acme_splitSection", "acme_pricing"]
710
+
711
+ // app/api/avocado/[[...path]]/route.ts
712
+ export const { GET, POST } = createOrchestrator({ adapter, blockTypes: SITE_BLOCK_TYPES })
713
+
714
+ // app/api/editor/[...path]/route.ts — the same list, again
715
+ export const { GET, POST, OPTIONS } = createEditorApiHandler({
716
+ getPages,
717
+ blockTypes: SITE_BLOCK_TYPES
712
718
  })
713
719
  ```
714
720
 
721
+ **Both handlers, every time.** The declaration is shared state, so setting it in
722
+ one place looks sufficient and is not: a Next route module is evaluated on the
723
+ first request *to that route*, and the editor asks `/api/editor/blocks` — served
724
+ by `createEditorApiHandler` — before it has any reason to call the orchestrator.
725
+ Declare it only on the orchestrator and the first manifest the editor sees is
726
+ your types plus all eighteen built-ins. Verify with
727
+ `curl -s localhost:3000/api/editor/blocks | jq '.blocks | length'`.
728
+
715
729
  The declaration is exclusive. The manifest narrows to it, the planner is only
716
730
  told about those types, and `add_block` refuses anything outside it with a
717
731
  message naming what the site does render. Nothing is removed from the registry,
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,73 @@
1
+ /*
2
+ * `/api/editor/blocks` is the endpoint the editor actually reads, and it is
3
+ * served by a different route module than the orchestrator.
4
+ *
5
+ * `createOrchestrator({ blockTypes })` declares the catalogue too, and because
6
+ * the declaration lives on `globalThis` that looked like enough for both. It is
7
+ * not: a Next route module is evaluated on the first request *to that route*,
8
+ * so an editor that asks for the manifest before anything has touched
9
+ * `/api/avocado/*` is answered by a process where `createOrchestrator` has never
10
+ * run. What comes back is the site's own blocks plus all of Avocado's built-ins,
11
+ * which the site has no renderer for — and an agent reading that list will
12
+ * happily add a `Hero` that applies cleanly and draws nothing.
13
+ *
14
+ * These tests therefore never construct an orchestrator.
15
+ */
16
+ import { test } from "node:test";
17
+ import assert from "node:assert/strict";
18
+ import { z, registerBlock, declareBlockCatalogue } from "@avocadostudio-ai/shared";
19
+ import { createEditorApiHandler } from "./editor-api-handler.js";
20
+ registerBlock("site_ownHero", {
21
+ schema: z.object({ headline: z.string() }).catchall(z.unknown()),
22
+ meta: { displayName: "Own Hero", fields: { headline: { kind: "text" } } }
23
+ });
24
+ async function manifestTypes(config) {
25
+ const { GET } = createEditorApiHandler(config);
26
+ const res = await GET(new Request("http://site.test/api/editor/blocks"), {
27
+ params: Promise.resolve({ path: ["blocks"] })
28
+ });
29
+ assert.equal(res.status, 200);
30
+ const body = (await res.json());
31
+ return body.blocks.map((b) => b.type);
32
+ }
33
+ test("without blockTypes the manifest still offers Avocado's built-ins", async () => {
34
+ declareBlockCatalogue(null);
35
+ const types = await manifestTypes({ getPages: () => [] });
36
+ assert.ok(types.includes("site_ownHero"));
37
+ assert.ok(types.includes("Hero"), "the built-ins are registered transitively and nothing has narrowed them");
38
+ });
39
+ test("blockTypes narrows /api/editor/blocks with no orchestrator in the process", async () => {
40
+ declareBlockCatalogue(null);
41
+ const types = await manifestTypes({ getPages: () => [], blockTypes: ["site_ownHero"] });
42
+ assert.deepEqual(types, ["site_ownHero"]);
43
+ });
44
+ test("blockTypes composes with registerBlocks", async () => {
45
+ declareBlockCatalogue(null);
46
+ let registered = 0;
47
+ const types = await manifestTypes({
48
+ getPages: () => [],
49
+ blockTypes: ["site_lateBlock"],
50
+ registerBlocks: () => {
51
+ registered += 1;
52
+ registerBlock("site_lateBlock", {
53
+ schema: z.object({ title: z.string() }).catchall(z.unknown()),
54
+ meta: { displayName: "Late Block", fields: { title: { kind: "text" } } }
55
+ });
56
+ }
57
+ });
58
+ assert.equal(registered, 1);
59
+ assert.deepEqual(types, ["site_lateBlock"]);
60
+ });
61
+ test("the declaration is re-made per request, so another copy cannot lift it", async () => {
62
+ declareBlockCatalogue(null);
63
+ const { GET } = createEditorApiHandler({ getPages: () => [], blockTypes: ["site_ownHero"] });
64
+ const ask = async () => {
65
+ const res = await GET(new Request("http://site.test/api/editor/blocks"), {
66
+ params: Promise.resolve({ path: ["blocks"] })
67
+ });
68
+ return (await res.json()).blocks.map((b) => b.type);
69
+ };
70
+ assert.deepEqual(await ask(), ["site_ownHero"]);
71
+ declareBlockCatalogue(null); // as if another module had lifted it
72
+ assert.deepEqual(await ask(), ["site_ownHero"]);
73
+ });
@@ -1,7 +1,7 @@
1
1
  import type { OnPublishFn } from "./editor-routes.ts";
2
2
  import { type BlockManifest } from "./editor-manifest.ts";
3
3
  import type { PageDoc } from "./types.ts";
4
- import type { SiteConfig } from "@avocadostudio-ai/shared";
4
+ import { type SiteConfig } from "@avocadostudio-ai/shared";
5
5
  export interface EditorApiHandlerConfig {
6
6
  getPages: () => PageDoc[] | Promise<PageDoc[]>;
7
7
  /**
@@ -24,6 +24,24 @@ export interface EditorApiHandlerConfig {
24
24
  * first, then `getManifest` is invoked.
25
25
  */
26
26
  registerBlocks?: () => void;
27
+ /**
28
+ * The block types this site renders, narrowing `/api/editor/blocks` to them.
29
+ *
30
+ * `createOrchestrator` takes the same option and declares the same catalogue,
31
+ * and for a while that was assumed to be enough for both routes: the
32
+ * declaration lives on `globalThis`, so one handler's call is visible to the
33
+ * other. What that reasoning missed is *when* it runs. A Next route module is
34
+ * evaluated on the first request to that route, so a site whose editor asks
35
+ * for `/api/editor/blocks` before anything has touched `/api/avocado/*` gets
36
+ * the manifest built before `createOrchestrator` has ever been called — and
37
+ * the answer is the site's own blocks plus all of Avocado's built-ins, which
38
+ * the site has no renderer for.
39
+ *
40
+ * Declaring it here closes that window, and passing it to both handlers is
41
+ * harmless: the declaration records names and the manifest is rebuilt per
42
+ * request.
43
+ */
44
+ blockTypes?: readonly string[];
27
45
  onPublish?: OnPublishFn;
28
46
  /** Secret token required for publish requests. Checked against x-publish-token header. */
29
47
  publishSecret?: string;
@@ -3,6 +3,7 @@ import { createBlocksHandler, createPagesHandler, createPublishHandler } from ".
3
3
  import { applyEditorCors } from "./editor-cors.js";
4
4
  import { checkIntegrationOnce } from "./integration-check.js";
5
5
  import { buildBlockManifest } from "./editor-manifest.js";
6
+ import { declareBlockCatalogue } from "@avocadostudio-ai/shared";
6
7
  /**
7
8
  * Creates a single catch-all route handler that serves all editor API endpoints.
8
9
  *
@@ -24,9 +25,12 @@ import { buildBlockManifest } from "./editor-manifest.js";
24
25
  export function createEditorApiHandler(config) {
25
26
  const draftEnable = createDraftEnableHandler();
26
27
  const draftDisable = createDraftDisableHandler();
27
- const manifestBuilder = config.registerBlocks
28
+ const needsPreamble = Boolean(config.registerBlocks || config.blockTypes);
29
+ const manifestBuilder = needsPreamble
28
30
  ? () => {
29
- config.registerBlocks();
31
+ config.registerBlocks?.();
32
+ if (config.blockTypes)
33
+ declareBlockCatalogue(config.blockTypes);
30
34
  return (config.getManifest ?? buildBlockManifest)();
31
35
  }
32
36
  : config.getManifest;
@@ -83,7 +83,17 @@ export function diffFields(args) {
83
83
  const reject = (where) => (change, remedy) => unsupported.push({ where, change, ...(remedy ? { remedy } : {}) });
84
84
  for (const [name, spec] of Object.entries(args.specs)) {
85
85
  const field = spec.cmsKey ?? name;
86
- const before = args.source?.[name];
86
+ /*
87
+ * Both of these read the CMS key, not the props key. They used to disagree:
88
+ * the baseline came from `source[name]` while the patch path was built from
89
+ * `cmsKey ?? name`. A spec that actually used `cmsKey` therefore compared
90
+ * against `undefined`, never matched, and emitted a patch on every publish
91
+ * whether or not anything had been edited — the precise overwrite this
92
+ * module exists to prevent. Worse, `rehydrate` was handed that `undefined`
93
+ * as the stored value, so a partial inversion had nothing to merge into and
94
+ * dropped everything the projection had not carried.
95
+ */
96
+ const before = args.source?.[field];
87
97
  const after = spec.rehydrate(args.props, before, args.ctx);
88
98
  if (deepEqual(before, after))
89
99
  continue;
@@ -39,13 +39,66 @@ test("cmsKey writes to the field the CMS actually has", () => {
39
39
  const diff = diffFields({
40
40
  specs: { heading: { ...plain("heading"), cmsKey: "headingOverride" } },
41
41
  props: { heading: "After" },
42
- source: { heading: "Before" },
42
+ // Keyed the way the CMS keys it. The version of this test that wrote
43
+ // `{ heading: "Before" }` passed against the bug below, because a baseline
44
+ // of `undefined` also produces a patch at the right path.
45
+ source: { headingOverride: "Before" },
46
+ ctx,
47
+ documentId: "doc1",
48
+ where: "/ → hero",
49
+ paths: sanityPaths
50
+ });
51
+ assert.deepEqual(diff.patches, [
52
+ { documentId: "doc1", path: "headingOverride", value: "After" }
53
+ ]);
54
+ });
55
+ test("an unchanged cmsKey field emits nothing", () => {
56
+ // The regression this file exists for. `diffFields` read the baseline from
57
+ // the *props* key while pathing the patch from `cmsKey`, so every spec that
58
+ // used the option compared its value against `undefined` and republished it
59
+ // on every publish, edited or not.
60
+ const diff = diffFields({
61
+ specs: { heading: { ...plain("heading"), cmsKey: "headingOverride" } },
62
+ props: { heading: "Same" },
63
+ source: { headingOverride: "Same" },
43
64
  ctx,
44
65
  documentId: "doc1",
45
66
  where: "/ → hero",
46
67
  paths: sanityPaths
47
68
  });
48
- assert.equal(diff.patches[0].path, "headingOverride");
69
+ assert.deepEqual(diff.patches, []);
70
+ });
71
+ test("a cmsKey spec's rehydrate receives the value the CMS holds", () => {
72
+ // Partial inversion is the whole point of passing `before`: return the stored
73
+ // object with one key replaced and everything the projection dropped
74
+ // survives. Hand `rehydrate` an `undefined` and it has nothing to merge into,
75
+ // so the patch overwrites instead of editing.
76
+ const seen = [];
77
+ const diff = diffFields({
78
+ specs: {
79
+ alt: {
80
+ cmsKey: "image",
81
+ rehydrate: (props, before) => {
82
+ seen.push(before);
83
+ return { ...before, alt: props.alt };
84
+ }
85
+ }
86
+ },
87
+ props: { alt: "A team in action" },
88
+ source: { image: { _type: "image", asset: { _ref: "image-abc" }, alt: "Old" } },
89
+ ctx,
90
+ documentId: "doc1",
91
+ where: "/ → hero",
92
+ paths: sanityPaths
93
+ });
94
+ assert.deepEqual(seen, [{ _type: "image", asset: { _ref: "image-abc" }, alt: "Old" }]);
95
+ assert.deepEqual(diff.patches, [
96
+ {
97
+ documentId: "doc1",
98
+ path: "image",
99
+ value: { _type: "image", asset: { _ref: "image-abc" }, alt: "A team in action" }
100
+ }
101
+ ]);
49
102
  });
50
103
  test("rehydrate receives the stored value, so an inversion can be partial", () => {
51
104
  // The whole reason a snapshot contract cannot work: the editor saw
@@ -241,7 +294,7 @@ test("one block can write to two documents", () => {
241
294
  documentId: "page-1",
242
295
  prefix: 'pageBuilder[_key=="b1"].',
243
296
  specs: { heading: { ...plain("heading"), cmsKey: "headingOverride" } },
244
- source: { heading: "Old page heading" }
297
+ source: { headingOverride: "Old page heading" }
245
298
  },
246
299
  {
247
300
  documentId: "section-9",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/site-sdk",
3
- "version": "0.3.2",
3
+ "version": "0.3.3",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -107,16 +107,16 @@
107
107
  ],
108
108
  "dependencies": {
109
109
  "zod": "^4.3.6",
110
- "@avocadostudio-ai/blocks": "^0.3.2",
111
- "@avocadostudio-ai/preview-adapter": "^0.3.2",
112
- "@avocadostudio-ai/shared": "^0.3.2"
110
+ "@avocadostudio-ai/blocks": "^0.3.3",
111
+ "@avocadostudio-ai/preview-adapter": "^0.3.3",
112
+ "@avocadostudio-ai/shared": "^0.3.3"
113
113
  },
114
114
  "peerDependencies": {
115
115
  "next": ">=15.0.0",
116
116
  "react": ">=19.0.0",
117
117
  "react-dom": ">=19.0.0",
118
118
  "better-sqlite3": ">=12.0.0",
119
- "@avocadostudio-ai/orchestrator-core": "^0.3.2"
119
+ "@avocadostudio-ai/orchestrator-core": "^0.3.3"
120
120
  },
121
121
  "peerDependenciesMeta": {
122
122
  "@avocadostudio-ai/orchestrator-core": {