@avocadostudio-ai/site-sdk 0.1.0 → 0.2.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.
Files changed (45) hide show
  1. package/README.md +212 -2
  2. package/dist/create-site-page.d.ts +38 -8
  3. package/dist/create-site-page.js +59 -8
  4. package/dist/draft-common.d.ts +32 -0
  5. package/dist/draft-common.js +58 -0
  6. package/dist/draft-context-core.js +39 -6
  7. package/dist/draft-context-core.test.d.ts +10 -0
  8. package/dist/draft-context-core.test.js +146 -0
  9. package/dist/editor-cors.d.ts +12 -0
  10. package/dist/editor-cors.js +31 -6
  11. package/dist/editor-cors.test.d.ts +1 -0
  12. package/dist/editor-cors.test.js +66 -0
  13. package/dist/editor-manifest.d.ts +2 -3
  14. package/dist/editor-manifest.js +12 -64
  15. package/dist/editor-matcher.d.ts +27 -0
  16. package/dist/editor-matcher.js +34 -0
  17. package/dist/editor-query.js +7 -1
  18. package/dist/index.d.ts +2 -0
  19. package/dist/index.js +2 -0
  20. package/dist/integration-check.js +11 -1
  21. package/dist/manifest-utils.d.ts +13 -0
  22. package/dist/manifest-utils.js +30 -3
  23. package/dist/manifest-utils.test.d.ts +1 -0
  24. package/dist/manifest-utils.test.js +72 -0
  25. package/dist/middleware.d.ts +21 -19
  26. package/dist/middleware.js +19 -22
  27. package/dist/next-config.test.d.ts +1 -0
  28. package/dist/next-config.test.js +253 -0
  29. package/dist/page-metadata.d.ts +66 -0
  30. package/dist/page-metadata.js +110 -0
  31. package/dist/page-metadata.test.d.ts +1 -0
  32. package/dist/page-metadata.test.js +105 -0
  33. package/dist/proxy.d.ts +58 -0
  34. package/dist/proxy.js +50 -0
  35. package/dist/proxy.test.d.ts +1 -0
  36. package/dist/proxy.test.js +72 -0
  37. package/dist/publish/field-diff.d.ts +191 -0
  38. package/dist/publish/field-diff.js +252 -0
  39. package/dist/publish/field-diff.test.d.ts +1 -0
  40. package/dist/publish/field-diff.test.js +286 -0
  41. package/dist/server/orchestrator.d.ts +1 -117
  42. package/dist/server/orchestrator.js +14 -733
  43. package/next-config.d.ts +68 -0
  44. package/next-config.mjs +358 -0
  45. package/package.json +63 -19
@@ -0,0 +1,286 @@
1
+ import { test } from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { diffFields, diffPage, groupPatches, mergeDiffs, describeUnsupported, deepEqual, sanityPaths, indexPaths } from "./field-diff.js";
4
+ const ctx = { lang: "de" };
5
+ /** The common case: the prop is the field, and the value round-trips. */
6
+ const plain = (key) => ({
7
+ rehydrate: (props) => props[key]
8
+ });
9
+ test("an unchanged field emits nothing", () => {
10
+ const specs = { heading: plain("heading") };
11
+ const diff = diffFields({
12
+ specs,
13
+ props: { heading: "Same" },
14
+ source: { heading: "Same" },
15
+ ctx,
16
+ documentId: "doc1",
17
+ where: "/ → hero"
18
+ });
19
+ assert.deepEqual(diff.patches, []);
20
+ assert.deepEqual(diff.unsupported, []);
21
+ });
22
+ test("a changed field is set at the path that owns it", () => {
23
+ const diff = diffFields({
24
+ specs: { heading: plain("heading") },
25
+ props: { heading: "After" },
26
+ source: { heading: "Before" },
27
+ ctx,
28
+ documentId: "doc1",
29
+ prefix: 'pageBuilder[_key=="b1"].',
30
+ where: "/ → hero"
31
+ });
32
+ assert.deepEqual(diff.patches, [
33
+ { documentId: "doc1", path: 'pageBuilder[_key=="b1"].heading', value: "After" }
34
+ ]);
35
+ });
36
+ test("cmsKey writes to the field the CMS actually has", () => {
37
+ const diff = diffFields({
38
+ specs: { heading: { ...plain("heading"), cmsKey: "headingOverride" } },
39
+ props: { heading: "After" },
40
+ source: { heading: "Before" },
41
+ ctx,
42
+ documentId: "doc1",
43
+ where: "/ → hero"
44
+ });
45
+ assert.equal(diff.patches[0].path, "headingOverride");
46
+ });
47
+ test("rehydrate receives the stored value, so an inversion can be partial", () => {
48
+ // The whole reason a snapshot contract cannot work: the editor saw
49
+ // `image.url`, the CMS holds an asset reference beside it, and only the
50
+ // integration knows how to put one back without destroying the other.
51
+ const specs = {
52
+ image: {
53
+ rehydrate: (props, before) => ({
54
+ ...before,
55
+ alt: props.imageAlt ?? undefined
56
+ })
57
+ }
58
+ };
59
+ const diff = diffFields({
60
+ specs,
61
+ props: { imageAlt: "New alt" },
62
+ source: { image: { _type: "image", asset: { _ref: "image-abc" }, alt: "Old alt" } },
63
+ ctx,
64
+ documentId: "doc1",
65
+ where: "/ → hero"
66
+ });
67
+ assert.deepEqual(diff.patches[0].value, {
68
+ _type: "image",
69
+ asset: { _ref: "image-abc" },
70
+ alt: "New alt"
71
+ });
72
+ });
73
+ // ---------------------------------------------------------------------------
74
+ // The refusals
75
+ // ---------------------------------------------------------------------------
76
+ test("refusal 1 — a projection that cannot be inverted is reported, not guessed", () => {
77
+ const specs = {
78
+ image: {
79
+ rehydrate: (props, before) => ({ ...before, url: props.imageUrl }),
80
+ write: ({ before, after, emit, reject, path }) => {
81
+ const was = before;
82
+ const now = after;
83
+ if (was?.alt !== now.alt)
84
+ emit(`${path}.alt`, now.alt);
85
+ if (was?.url !== now.url) {
86
+ reject(`the image was replaced (${now.url})`, "The CMS stores an asset reference; the file has to be uploaded first.");
87
+ }
88
+ }
89
+ }
90
+ };
91
+ const diff = diffFields({
92
+ specs,
93
+ props: { imageUrl: "https://example.com/new.png" },
94
+ source: { image: { url: "https://cdn.example.com/old.png", alt: "Old" } },
95
+ ctx,
96
+ documentId: "doc1",
97
+ where: "/ → hero"
98
+ });
99
+ assert.deepEqual(diff.patches, [], "nothing may be written for a value that cannot be expressed");
100
+ assert.equal(diff.unsupported.length, 1);
101
+ assert.match(diff.unsupported[0].change, /image was replaced/);
102
+ assert.match(String(diff.unsupported[0].remedy), /uploaded first/);
103
+ });
104
+ test("refusal 2 — adding or removing list items is a document-shaped change", () => {
105
+ const specs = {
106
+ cards: {
107
+ rehydrate: (props) => props.cards,
108
+ itemFields: { title: plain("title") }
109
+ }
110
+ };
111
+ const diff = diffFields({
112
+ specs,
113
+ props: { cards: [{ _key: "k1", title: "One" }, { _key: "k2", title: "Two" }] },
114
+ source: { cards: [{ _key: "k1", title: "One" }] },
115
+ ctx,
116
+ documentId: "doc1",
117
+ where: "/ → cardGrid"
118
+ });
119
+ assert.ok(diff.unsupported.some((u) => /added to or removed/.test(u.change)));
120
+ });
121
+ test("refusal 3 — an item the CMS has never seen has no path to patch", () => {
122
+ const specs = {
123
+ cards: {
124
+ rehydrate: (props) => props.cards,
125
+ itemFields: { title: plain("title") }
126
+ }
127
+ };
128
+ const diff = diffFields({
129
+ specs,
130
+ props: { cards: [{ _key: "k1", title: "One" }, { title: "Brand new" }] },
131
+ source: { cards: [{ _key: "k1", title: "One" }, { _key: "k2", title: "Two" }] },
132
+ ctx,
133
+ documentId: "doc1",
134
+ where: "/ → cardGrid"
135
+ });
136
+ assert.ok(diff.unsupported.some((u) => /new item/.test(u.change)));
137
+ 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");
138
+ });
139
+ test("refusal 4 — a block with no upstream document is reported, never skipped", () => {
140
+ const diff = diffPage({
141
+ page: {
142
+ slug: "/prices",
143
+ blocks: [{ id: "b_new", type: "pba_pricingSection", props: { heading: "Hi" } }]
144
+ },
145
+ ctx,
146
+ locate: () => null
147
+ });
148
+ assert.deepEqual(diff.patches, []);
149
+ assert.equal(diff.unsupported.length, 1);
150
+ assert.match(diff.unsupported[0].change, /new "pba_pricingSection" block/);
151
+ assert.equal(diff.unsupported[0].where, "/prices → pba_pricingSection");
152
+ });
153
+ // ---------------------------------------------------------------------------
154
+ // Lists, keys, and the reason index addressing is not the default
155
+ // ---------------------------------------------------------------------------
156
+ test("a list item is patched at its own key, not at its position", () => {
157
+ const specs = {
158
+ cards: {
159
+ rehydrate: (props) => props.cards,
160
+ itemFields: { title: plain("title") }
161
+ }
162
+ };
163
+ // The upstream order is the reverse of the editor's. Index addressing would
164
+ // write "Renamed" onto the wrong card.
165
+ const diff = diffFields({
166
+ specs,
167
+ props: { cards: [{ _key: "k2", title: "Renamed" }, { _key: "k1", title: "One" }] },
168
+ source: { cards: [{ _key: "k1", title: "One" }, { _key: "k2", title: "Two" }] },
169
+ ctx,
170
+ documentId: "doc1",
171
+ where: "/ → cardGrid"
172
+ });
173
+ assert.deepEqual(diff.patches, [
174
+ { documentId: "doc1", path: 'cards[_key=="k2"].title', value: "Renamed" }
175
+ ]);
176
+ });
177
+ test("index addressing is available and says what it costs", () => {
178
+ const specs = {
179
+ rows: {
180
+ rehydrate: (props) => props.rows,
181
+ itemFields: { label: plain("label") },
182
+ itemKey: (item) => String(item.id)
183
+ }
184
+ };
185
+ const diff = diffFields({
186
+ specs,
187
+ props: { rows: [{ id: "7", label: "After" }] },
188
+ source: { rows: [{ id: "7", label: "Before" }] },
189
+ ctx,
190
+ documentId: "doc1",
191
+ where: "/ → table",
192
+ paths: indexPaths
193
+ });
194
+ assert.deepEqual(diff.patches, [{ documentId: "doc1", path: "rows[0].label", value: "After" }]);
195
+ });
196
+ test("a custom itemKey identifies items that have no _key", () => {
197
+ const specs = {
198
+ rows: {
199
+ rehydrate: (props) => props.rows,
200
+ itemFields: { label: plain("label") },
201
+ itemKey: (item) => (typeof item.id === "string" ? item.id : undefined)
202
+ }
203
+ };
204
+ const diff = diffFields({
205
+ specs,
206
+ props: { rows: [{ id: "r1", label: "After" }] },
207
+ source: { rows: [{ id: "r1", label: "Before" }] },
208
+ ctx,
209
+ documentId: "doc1",
210
+ where: "/ → table",
211
+ paths: sanityPaths
212
+ });
213
+ assert.deepEqual(diff.patches, [
214
+ { documentId: "doc1", path: 'rows[_key=="r1"].label', value: "After" }
215
+ ]);
216
+ });
217
+ // ---------------------------------------------------------------------------
218
+ // Routing across documents
219
+ // ---------------------------------------------------------------------------
220
+ test("one block can write to two documents", () => {
221
+ // A block placed by a shared section: its heading override belongs to the
222
+ // page, the rest of it belongs to the section every page shares.
223
+ const diff = diffPage({
224
+ page: {
225
+ slug: "/",
226
+ blocks: [{ id: "b1", type: "sharedSection", props: { heading: "Page heading", body: "Shared body" } }]
227
+ },
228
+ ctx,
229
+ locate: () => [
230
+ {
231
+ documentId: "page-1",
232
+ prefix: 'pageBuilder[_key=="b1"].',
233
+ specs: { heading: { ...plain("heading"), cmsKey: "headingOverride" } },
234
+ source: { heading: "Old page heading" }
235
+ },
236
+ {
237
+ documentId: "section-9",
238
+ prefix: "content[0].",
239
+ specs: { body: plain("body") },
240
+ source: { body: "Old shared body" }
241
+ }
242
+ ]
243
+ });
244
+ assert.deepEqual(groupPatches(diff.patches), [
245
+ { documentId: "page-1", set: { 'pageBuilder[_key=="b1"].headingOverride': "Page heading" } },
246
+ { documentId: "section-9", set: { "content[0].body": "Shared body" } }
247
+ ]);
248
+ });
249
+ test("a page with nothing changed issues no write at all", () => {
250
+ const diff = diffPage({
251
+ page: { slug: "/", blocks: [{ id: "b1", type: "hero", props: { heading: "Same" } }] },
252
+ ctx,
253
+ locate: () => ({
254
+ documentId: "page-1",
255
+ prefix: "",
256
+ specs: { heading: plain("heading") },
257
+ source: { heading: "Same" }
258
+ })
259
+ });
260
+ assert.deepEqual(groupPatches(diff.patches), [], "an unchanged publish must not touch the CMS");
261
+ });
262
+ // ---------------------------------------------------------------------------
263
+ // Helpers
264
+ // ---------------------------------------------------------------------------
265
+ test("deepEqual treats null and undefined as the same absence", () => {
266
+ // A CMS that omits an empty field and an editor that sends `undefined` are
267
+ // saying the same thing; treating them as a change rewrites every optional
268
+ // field on every publish.
269
+ assert.ok(deepEqual(null, undefined));
270
+ assert.ok(deepEqual({ a: [1, { b: "c" }] }, { a: [1, { b: "c" }] }));
271
+ assert.ok(!deepEqual({ a: 1 }, { a: 1, b: 2 }));
272
+ assert.ok(!deepEqual([1, 2], { 0: 1, 1: 2 }));
273
+ });
274
+ test("mergeDiffs and describeUnsupported produce something a person can read", () => {
275
+ const merged = mergeDiffs([
276
+ { patches: [{ documentId: "d", path: "a", value: 1 }], unsupported: [] },
277
+ {
278
+ patches: [],
279
+ unsupported: [{ where: "/ → hero", change: "the image was replaced", remedy: "Upload it first." }]
280
+ }
281
+ ]);
282
+ assert.equal(merged.patches.length, 1);
283
+ assert.deepEqual(describeUnsupported(merged.unsupported), [
284
+ "/ → hero: the image was replaced — Upload it first."
285
+ ]);
286
+ });
@@ -1,117 +1 @@
1
- import { type AIProvider, type ModelKey } from "@avocadostudio-ai/orchestrator-core/state/session-state.js";
2
- import { type Logger } from "@avocadostudio-ai/orchestrator-core/logger.js";
3
- import type { CmsAdapter } from "@avocadostudio-ai/orchestrator-core/cms/adapter.js";
4
- export interface CreateOrchestratorConfig {
5
- /**
6
- * Provider model overrides. Defaults to env vars (OPENAI_MODEL_*, ANTHROPIC_MODEL_*,
7
- * GOOGLE_GENAI_MODEL_*). Pass an explicit object to override per-tier model names.
8
- */
9
- modelLookup?: Record<AIProvider, Record<ModelKey, string>>;
10
- /**
11
- * Override available providers. Defaults to whichever API keys are present in
12
- * process.env (OPENAI_API_KEY / ANTHROPIC_API_KEY / GOOGLE_GENAI_API_KEY).
13
- */
14
- availableProviders?: AIProvider[];
15
- /** Pino-shaped logger. Defaults to a console-backed implementation. */
16
- logger?: Logger;
17
- /**
18
- * Allowed CORS origins. Defaults to "*". For production, pass an explicit
19
- * allow-list (Next.js can also handle CORS via middleware — pass null here
20
- * to disable the SDK's CORS handling entirely).
21
- */
22
- corsOrigins?: string[] | "*" | null;
23
- /**
24
- * Register the default builtin tools (unsplash-search, image-generate,
25
- * gdrive-browse). Defaults to `false` — opt in only if you want those
26
- * features, since registering them pulls in sharp + googleapis + image-gen
27
- * SDKs at module load. Pass `true` for env-gated defaults, or an explicit
28
- * subset like `["unsplash-search"]`.
29
- */
30
- builtinTools?: boolean | Array<"unsplash-search" | "image-generate" | "gdrive-browse">;
31
- /**
32
- * URL path prefix the handler is mounted under. Everything matching this
33
- * prefix is stripped off `url.pathname` before route matching, so the rest
34
- * of the handler can reason in terms of `/chat`, `/chat/stream`, etc.
35
- *
36
- * Defaults to `/api/avocado` (the catch-all path used in the Next.js
37
- * example app). Set to `""` if you want to match against the full pathname
38
- * yourself, or override to e.g. `/api/orchestrator` if you mount the
39
- * catch-all there.
40
- */
41
- basePath?: string;
42
- /**
43
- * Source-of-truth adapter for the site's existing content. Called once per
44
- * session on the first chat request to seed SQLite with the site's pages.
45
- * Without an adapter the orchestrator starts with an empty draft, which
46
- * means the planner has no pages to edit — every "edit the homepage" turn
47
- * returns `page not found`.
48
- *
49
- * Provided implementations:
50
- * - `jsonFileAdapter({ path })` — read PageDoc[] from a JSON file
51
- * - `editorApiAdapter({ origin })` — fetch from `${origin}/api/editor/pages`
52
- *
53
- * Import from `@avocadostudio-ai/orchestrator-core/cms`.
54
- */
55
- adapter?: CmsAdapter;
56
- /**
57
- * Site identifier used to scope session state in SQLite. When `adapter` is
58
- * set, defaults to `"library"` so the demo-content seed path is bypassed
59
- * (an unscoped session falls back to the orchestrator's bundled demo pages).
60
- * Override if you want to run multiple distinct sites against one process.
61
- *
62
- * Precedence on incoming requests:
63
- * - adapter configured: `siteId` (or its `"library"` default) ALWAYS wins
64
- * over `body.siteId`. The library-mode orchestrator owns its identity.
65
- * - no adapter: explicit `siteId` wins; otherwise `body.siteId` is used.
66
- */
67
- siteId?: string;
68
- /**
69
- * Register the site's block schemas before the orchestrator's first chat
70
- * request. Use this instead of relying on side-effect import order: in
71
- * library mode, the orchestrator's transitive imports of
72
- * `@avocadostudio-ai/shared` re-register canonical Hero/CTA/etc. on the
73
- * shared globalThis registry, often AFTER the host app's overrides.
74
- *
75
- * **Fires once per orchestrator runtime** — at `buildRuntime` time, not
76
- * per-request. For long-lived production processes this is fine, but if
77
- * the host re-builds the runtime on hot-reload, ensure the schemas survive
78
- * (registerBlock is idempotent — calling again is safe). Pair this with
79
- * the same `registerBlocks` passed to `createEditorApiHandler`, which
80
- * re-runs on every `/blocks` request, to cover request-time too.
81
- */
82
- registerBlocks?: () => void;
83
- /**
84
- * Local directory where `POST /image/upload` writes uploaded files and
85
- * `GET /generated-images/:fileName` reads them back. Defaults to
86
- * `.data/generated-images` under the host process CWD.
87
- *
88
- * **POC-grade storage.** Files live on the orchestrator host's local disk
89
- * with no CDN, no image transforms, and no durability guarantee — on an
90
- * ephemeral filesystem (e.g. a container without a mounted volume) uploads
91
- * vanish on redeploy. See `docs/image-storage-options.md` for the path to a
92
- * blob/CDN backend; that swap is contained to these two routes.
93
- */
94
- imageDir?: string;
95
- }
96
- /**
97
- * A handler returned by {@link createOrchestrator}. Callable like the bare
98
- * Web `(Request) => Promise<Response>` it always was, with an extra
99
- * `dispose()` method to release the resumable-stream sweep timer (call this
100
- * during dev hot-reload or test teardown).
101
- */
102
- export type OrchestratorHandler = ((request: Request) => Promise<Response>) & {
103
- dispose(): Promise<void>;
104
- };
105
- export type { CmsAdapter, CmsInlineAsset, CmsPublishContext, CmsPublishResult } from "@avocadostudio-ai/orchestrator-core/cms/adapter.js";
106
- export { jsonFileAdapter, editorApiAdapter } from "@avocadostudio-ai/orchestrator-core/cms/index.js";
107
- /**
108
- * Build a Web-standard request handler that wraps the orchestrator brain.
109
- *
110
- * Usage in Next.js App Router (`app/api/avocado/[[...path]]/route.ts`):
111
- *
112
- * export const runtime = "nodejs"
113
- * const handler = createOrchestrator()
114
- * export const POST = handler
115
- * export const OPTIONS = handler
116
- */
117
- export declare function createOrchestrator(config?: CreateOrchestratorConfig): OrchestratorHandler;
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";