@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 +17 -3
- package/dist/editor-api-blocks-catalogue.test.d.ts +1 -0
- package/dist/editor-api-blocks-catalogue.test.js +73 -0
- package/dist/editor-api-handler.d.ts +19 -1
- package/dist/editor-api-handler.js +6 -2
- package/dist/publish/field-diff.js +11 -1
- package/dist/publish/field-diff.test.js +56 -3
- package/package.json +5 -5
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
|
-
|
|
710
|
-
|
|
711
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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: {
|
|
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.
|
|
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.
|
|
111
|
-
"@avocadostudio-ai/preview-adapter": "^0.3.
|
|
112
|
-
"@avocadostudio-ai/shared": "^0.3.
|
|
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.
|
|
119
|
+
"@avocadostudio-ai/orchestrator-core": "^0.3.3"
|
|
120
120
|
},
|
|
121
121
|
"peerDependenciesMeta": {
|
|
122
122
|
"@avocadostudio-ai/orchestrator-core": {
|