@avocadostudio-ai/site-sdk 0.3.2 → 0.4.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
@@ -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;
package/dist/editor.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import type { FieldKind } from "@avocadostudio-ai/shared";
1
2
  export { EditorOverlay } from "./editor-overlay.tsx";
2
3
  export { buildEditorQuerySuffix } from "./editor-query.ts";
3
4
  export declare function getPreviewWrapperProps(editorMode: boolean, blockId: string, blockType: string): {
@@ -13,6 +14,57 @@ export declare function getPreviewWrapperProps(editorMode: boolean, blockId: str
13
14
  readonly viewTransitionName: `block-${string}`;
14
15
  };
15
16
  };
17
+ /**
18
+ * Mark the element that draws one editable field.
19
+ *
20
+ * This is the second half of instrumenting a preview, and until now it was the
21
+ * half with no helper. `getPreviewWrapperProps` covers the block boundary and
22
+ * is named, exported and documented; the per-field attributes were prose in the
23
+ * integration guide with a link to a renderer to copy from. The predictable
24
+ * result is an integration that wraps every block and marks no fields — which
25
+ * frames, renders, selects, scrolls and opens the property panel correctly, and
26
+ * silently has no inline text editing, no field pills and no image buttons in
27
+ * the preview, because every one of those is found by walking
28
+ * `[data-editable-target]`.
29
+ *
30
+ * ```tsx
31
+ * <div className="hero__media" {...editableProps("imageUrl", { kind: "image" })}>
32
+ * <Image src={props.imageUrl} … />
33
+ * </div>
34
+ * ```
35
+ *
36
+ * The path is the same grammar an operation uses — `title`, `cards[0].title`,
37
+ * `links[0].children[1].label` — because it is the same path: what the overlay
38
+ * reads here is what it sends back as the field to patch.
39
+ *
40
+ * **Put it on an element, not on the image.** For an image field the attribute
41
+ * belongs on the wrapper around the `<img>`, never on the image itself: the
42
+ * overlay appends its Change button *into* the marked element, and nothing can
43
+ * be appended into an `<img>`.
44
+ *
45
+ * **`kind` is how a site says what a field is in its own vocabulary.** Without
46
+ * it the overlay has to guess an image from the prop name, against Avocado's
47
+ * own naming (`imageUrl`, `*.src`) — so a site whose field is `photoUrl` or
48
+ * `heroSrc` gets a picker in the property panel and no button in the preview,
49
+ * with no error on either side. Pass the same word the block manifest uses and
50
+ * the guess never runs.
51
+ *
52
+ * There is no `editorMode` argument on purpose. These are inert data attributes
53
+ * that cost a few bytes and do nothing unless the overlay is mounted (all of
54
+ * its styling is scoped under `[data-editor-active]`), and most sites render
55
+ * their blocks through components shared with the public pages, where threading
56
+ * a flag down to every field is the step that does not get done.
57
+ */
58
+ export declare function editableProps(path: string, options?: {
59
+ /** Text for the hover pill. Defaults to the path. */
60
+ label?: string;
61
+ /** What kind of field this is — the same vocabulary as the block manifest. */
62
+ kind?: FieldKind;
63
+ }): {
64
+ readonly "data-editable-kind"?: FieldKind | undefined;
65
+ readonly "data-editable-target": string;
66
+ readonly "data-editable-target-label": string;
67
+ };
16
68
  export { renderBlocks } from "./render-blocks.tsx";
17
69
  export { RenderedBlocks, PreviewBlock } from "./live-preview-blocks.tsx";
18
70
  export { LivePreviewProvider } from "@avocadostudio-ai/preview-adapter";
package/dist/editor.js CHANGED
@@ -13,6 +13,54 @@ export function getPreviewWrapperProps(editorMode, blockId, blockType) {
13
13
  style: { viewTransitionName: `block-${blockId}` }
14
14
  };
15
15
  }
16
+ /**
17
+ * Mark the element that draws one editable field.
18
+ *
19
+ * This is the second half of instrumenting a preview, and until now it was the
20
+ * half with no helper. `getPreviewWrapperProps` covers the block boundary and
21
+ * is named, exported and documented; the per-field attributes were prose in the
22
+ * integration guide with a link to a renderer to copy from. The predictable
23
+ * result is an integration that wraps every block and marks no fields — which
24
+ * frames, renders, selects, scrolls and opens the property panel correctly, and
25
+ * silently has no inline text editing, no field pills and no image buttons in
26
+ * the preview, because every one of those is found by walking
27
+ * `[data-editable-target]`.
28
+ *
29
+ * ```tsx
30
+ * <div className="hero__media" {...editableProps("imageUrl", { kind: "image" })}>
31
+ * <Image src={props.imageUrl} … />
32
+ * </div>
33
+ * ```
34
+ *
35
+ * The path is the same grammar an operation uses — `title`, `cards[0].title`,
36
+ * `links[0].children[1].label` — because it is the same path: what the overlay
37
+ * reads here is what it sends back as the field to patch.
38
+ *
39
+ * **Put it on an element, not on the image.** For an image field the attribute
40
+ * belongs on the wrapper around the `<img>`, never on the image itself: the
41
+ * overlay appends its Change button *into* the marked element, and nothing can
42
+ * be appended into an `<img>`.
43
+ *
44
+ * **`kind` is how a site says what a field is in its own vocabulary.** Without
45
+ * it the overlay has to guess an image from the prop name, against Avocado's
46
+ * own naming (`imageUrl`, `*.src`) — so a site whose field is `photoUrl` or
47
+ * `heroSrc` gets a picker in the property panel and no button in the preview,
48
+ * with no error on either side. Pass the same word the block manifest uses and
49
+ * the guess never runs.
50
+ *
51
+ * There is no `editorMode` argument on purpose. These are inert data attributes
52
+ * that cost a few bytes and do nothing unless the overlay is mounted (all of
53
+ * its styling is scoped under `[data-editor-active]`), and most sites render
54
+ * their blocks through components shared with the public pages, where threading
55
+ * a flag down to every field is the step that does not get done.
56
+ */
57
+ export function editableProps(path, options) {
58
+ return {
59
+ "data-editable-target": path,
60
+ "data-editable-target-label": options?.label ?? path,
61
+ ...(options?.kind ? { "data-editable-kind": options.kind } : {})
62
+ };
63
+ }
16
64
  // Block rendering helper
17
65
  export { renderBlocks } from "./render-blocks.js";
18
66
  // Live-preview store renderer (streams field drafts through React)
@@ -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",
@@ -1 +1 @@
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";
1
+ export { createOrchestrator, jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, 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 CmsMediaUpload, type CmsMediaSource, type CmsMediaUploader, 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, cmsMediaSource, cmsMediaLabel } from "@avocadostudio-ai/orchestrator-core";
14
+ export { createOrchestrator, jsonFileAdapter, editorApiAdapter, resolveCapabilities, cmsMediaSource, cmsMediaUploader, 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.2",
3
+ "version": "0.4.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.3.2",
111
- "@avocadostudio-ai/preview-adapter": "^0.3.2",
112
- "@avocadostudio-ai/shared": "^0.3.2"
110
+ "@avocadostudio-ai/blocks": "^0.4.0",
111
+ "@avocadostudio-ai/preview-adapter": "^0.4.0",
112
+ "@avocadostudio-ai/shared": "^0.4.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.3.2"
119
+ "@avocadostudio-ai/orchestrator-core": "^0.4.0"
120
120
  },
121
121
  "peerDependenciesMeta": {
122
122
  "@avocadostudio-ai/orchestrator-core": {