@avocadostudio-ai/site-sdk 0.1.0 → 0.2.1
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 +212 -2
- package/dist/cli/register.js +23 -1
- package/dist/create-site-page.d.ts +38 -8
- package/dist/create-site-page.js +59 -8
- package/dist/draft-common.d.ts +32 -0
- package/dist/draft-common.js +58 -0
- package/dist/draft-context-core.js +39 -6
- package/dist/draft-context-core.test.d.ts +10 -0
- package/dist/draft-context-core.test.js +146 -0
- package/dist/draft-fetch.d.ts +9 -10
- package/dist/draft-fetch.js +71 -5
- package/dist/draft-fetch.test.d.ts +1 -0
- package/dist/draft-fetch.test.js +87 -0
- package/dist/editor-cors.d.ts +12 -0
- package/dist/editor-cors.js +31 -6
- package/dist/editor-cors.test.d.ts +1 -0
- package/dist/editor-cors.test.js +66 -0
- package/dist/editor-manifest.d.ts +2 -3
- package/dist/editor-manifest.js +12 -64
- package/dist/editor-matcher.d.ts +27 -0
- package/dist/editor-matcher.js +34 -0
- package/dist/editor-query.js +7 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/integration-check.js +11 -1
- package/dist/manifest-utils.d.ts +13 -0
- package/dist/manifest-utils.js +30 -3
- package/dist/manifest-utils.test.d.ts +1 -0
- package/dist/manifest-utils.test.js +72 -0
- package/dist/middleware.d.ts +21 -19
- package/dist/middleware.js +19 -22
- package/dist/next-config.test.d.ts +1 -0
- package/dist/next-config.test.js +355 -0
- package/dist/page-metadata.d.ts +66 -0
- package/dist/page-metadata.js +110 -0
- package/dist/page-metadata.test.d.ts +1 -0
- package/dist/page-metadata.test.js +105 -0
- package/dist/proxy.d.ts +95 -0
- package/dist/proxy.js +76 -0
- package/dist/proxy.test.d.ts +1 -0
- package/dist/proxy.test.js +123 -0
- package/dist/publish/field-diff.d.ts +191 -0
- package/dist/publish/field-diff.js +252 -0
- package/dist/publish/field-diff.test.d.ts +1 -0
- package/dist/publish/field-diff.test.js +286 -0
- package/dist/server/orchestrator.d.ts +1 -117
- package/dist/server/orchestrator.js +14 -733
- package/next-config.d.ts +78 -0
- package/next-config.mjs +468 -0
- package/package.json +63 -19
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Publishing an edited page back to a CMS, as a field-level diff.
|
|
3
|
+
*
|
|
4
|
+
* `onPublish(pages, config)` means "here are the full PageDocs, store them".
|
|
5
|
+
* That is implementable when the CMS shape *is* the editor shape — a JSON file
|
|
6
|
+
* — and not otherwise. Every real CMS read is a projection: an asset reference
|
|
7
|
+
* flattened to a URL string, a document reference resolved to an href for one
|
|
8
|
+
* language, a rich-text tree flattened to markdown. Writing the projection back
|
|
9
|
+
* replaces the reference with the flattening and destroys the document.
|
|
10
|
+
*
|
|
11
|
+
* So a real integration publishes a diff: walk the same field specs used to
|
|
12
|
+
* project, compare each field against the value it was projected from, and emit
|
|
13
|
+
* a set at the key-addressed path that owns it. The first integration to do
|
|
14
|
+
* this hand-wrote about 230 lines of it. This is that shape, generalised, so
|
|
15
|
+
* the next one does not.
|
|
16
|
+
*
|
|
17
|
+
* **What is here and what is not.** The mechanism is general: the walk, the
|
|
18
|
+
* unchanged-field skip, list items matched on a stable key rather than an
|
|
19
|
+
* index, routing a block's patches to a document other than the page's, and the
|
|
20
|
+
* vocabulary for reporting a change that cannot be expressed. The *inversions*
|
|
21
|
+
* are not, and cannot be — `rehydrate` undoes a projection only the integration
|
|
22
|
+
* knows it made. Neither is uploading an asset or re-resolving a reference;
|
|
23
|
+
* those are per-CMS capabilities, and until an integration has them the honest
|
|
24
|
+
* answer is `unsupported`, which is why that is a first-class result here
|
|
25
|
+
* rather than a thrown error.
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* Sanity addresses array elements by `_key`, never by index — an index-addressed
|
|
29
|
+
* patch races any concurrent edit that reorders the array.
|
|
30
|
+
*/
|
|
31
|
+
export const sanityPaths = {
|
|
32
|
+
field: (prefix, field) => `${prefix}${field}`,
|
|
33
|
+
item: (prefix, field, key) => `${prefix}${field}[_key=="${key}"]`
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Index addressing, for stores that have no element identity (Strapi component
|
|
37
|
+
* lists, a plain JSON array). Correct only when the publish is the only writer:
|
|
38
|
+
* anything that reorders between read and write silently patches the wrong row,
|
|
39
|
+
* which is exactly what `_key` exists to prevent.
|
|
40
|
+
*/
|
|
41
|
+
export const indexPaths = {
|
|
42
|
+
field: (prefix, field) => `${prefix}${field}`,
|
|
43
|
+
item: (prefix, field, _key, index) => `${prefix}${field}[${index}]`
|
|
44
|
+
};
|
|
45
|
+
/** Structural equality, for "did this field actually change". */
|
|
46
|
+
export function deepEqual(a, b) {
|
|
47
|
+
if (a === b)
|
|
48
|
+
return true;
|
|
49
|
+
if (a === null || a === undefined || b === null || b === undefined) {
|
|
50
|
+
return (a === null || a === undefined) && (b === null || b === undefined);
|
|
51
|
+
}
|
|
52
|
+
if (typeof a !== "object" || typeof b !== "object")
|
|
53
|
+
return false;
|
|
54
|
+
if (Array.isArray(a) !== Array.isArray(b))
|
|
55
|
+
return false;
|
|
56
|
+
const ak = Object.keys(a);
|
|
57
|
+
const bk = Object.keys(b);
|
|
58
|
+
if (ak.length !== bk.length)
|
|
59
|
+
return false;
|
|
60
|
+
return ak.every((k) => deepEqual(a[k], b[k]));
|
|
61
|
+
}
|
|
62
|
+
function defaultItemKey(item) {
|
|
63
|
+
const key = item._key ?? item._id ?? item.id;
|
|
64
|
+
return typeof key === "string" && key.length > 0 ? key : undefined;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Diff one object's fields against the source it was projected from.
|
|
68
|
+
*
|
|
69
|
+
* `props` is what the editor holds; `source` is what the CMS holds. Recurses
|
|
70
|
+
* into list fields, matching items on their own key so a reorder upstream
|
|
71
|
+
* cannot make a patch land on the wrong row.
|
|
72
|
+
*/
|
|
73
|
+
export function diffFields(args) {
|
|
74
|
+
const paths = args.paths ?? sanityPaths;
|
|
75
|
+
const prefix = args.prefix ?? "";
|
|
76
|
+
const patches = [];
|
|
77
|
+
const unsupported = [];
|
|
78
|
+
const emit = (path, value) => patches.push({ documentId: args.documentId, path, value });
|
|
79
|
+
const reject = (where) => (change, remedy) => unsupported.push({ where, change, ...(remedy ? { remedy } : {}) });
|
|
80
|
+
for (const [name, spec] of Object.entries(args.specs)) {
|
|
81
|
+
const field = spec.cmsKey ?? name;
|
|
82
|
+
const before = args.source?.[name];
|
|
83
|
+
const after = spec.rehydrate(args.props, before, args.ctx);
|
|
84
|
+
if (deepEqual(before, after))
|
|
85
|
+
continue;
|
|
86
|
+
const path = paths.field(prefix, field);
|
|
87
|
+
if (spec.itemFields) {
|
|
88
|
+
const nested = diffList({
|
|
89
|
+
spec,
|
|
90
|
+
before,
|
|
91
|
+
after,
|
|
92
|
+
rows: args.props[name],
|
|
93
|
+
ctx: args.ctx,
|
|
94
|
+
documentId: args.documentId,
|
|
95
|
+
prefix,
|
|
96
|
+
field,
|
|
97
|
+
name,
|
|
98
|
+
where: args.where,
|
|
99
|
+
paths
|
|
100
|
+
});
|
|
101
|
+
patches.push(...nested.patches);
|
|
102
|
+
unsupported.push(...nested.unsupported);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (typeof spec.write === "function") {
|
|
106
|
+
spec.write({ before, after, path, where: args.where, ctx: args.ctx, emit, reject: reject(args.where) });
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
emit(path, after);
|
|
110
|
+
}
|
|
111
|
+
return { patches, unsupported };
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Cards, tiers, accordion rows — recursed into and matched on the item's own
|
|
115
|
+
* key.
|
|
116
|
+
*
|
|
117
|
+
* An item without one cannot be addressed, which is exactly the case of a row
|
|
118
|
+
* the editor added: the CMS has never seen it and has no key to patch. Adding
|
|
119
|
+
* and removing rows is a document-shaped write, not a field-shaped one, so it
|
|
120
|
+
* is reported rather than attempted.
|
|
121
|
+
*/
|
|
122
|
+
function diffList(args) {
|
|
123
|
+
const itemFields = args.spec.itemFields;
|
|
124
|
+
if (!itemFields)
|
|
125
|
+
return { patches: [], unsupported: [] };
|
|
126
|
+
const previous = Array.isArray(args.before) ? args.before : [];
|
|
127
|
+
const next = Array.isArray(args.after) ? args.after : [];
|
|
128
|
+
const rows = Array.isArray(args.rows) ? args.rows : [];
|
|
129
|
+
const keyOf = args.spec.itemKey ?? defaultItemKey;
|
|
130
|
+
const patches = [];
|
|
131
|
+
const unsupported = [];
|
|
132
|
+
if (previous.length !== next.length) {
|
|
133
|
+
unsupported.push({
|
|
134
|
+
where: args.where,
|
|
135
|
+
change: `items were added to or removed from "${args.name}"`,
|
|
136
|
+
remedy: "Only edits to existing items publish as a field diff; the list's length is a document-level change."
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const byKey = new Map();
|
|
140
|
+
for (const entry of previous) {
|
|
141
|
+
const key = keyOf(entry);
|
|
142
|
+
if (key)
|
|
143
|
+
byKey.set(key, entry);
|
|
144
|
+
}
|
|
145
|
+
next.forEach((entry, index) => {
|
|
146
|
+
const key = keyOf(entry);
|
|
147
|
+
const was = key ? byKey.get(key) : undefined;
|
|
148
|
+
if (!key || !was) {
|
|
149
|
+
unsupported.push({
|
|
150
|
+
where: args.where,
|
|
151
|
+
change: `a new item in "${args.name}" cannot be published`,
|
|
152
|
+
remedy: "It has no upstream key, so there is no path to patch. Create it in the CMS first."
|
|
153
|
+
});
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
const nested = diffFields({
|
|
157
|
+
specs: itemFields,
|
|
158
|
+
props: rows[index] ?? {},
|
|
159
|
+
source: was,
|
|
160
|
+
ctx: args.ctx,
|
|
161
|
+
documentId: args.documentId,
|
|
162
|
+
prefix: `${args.paths.item(args.prefix, args.field, key, index)}.`,
|
|
163
|
+
where: args.where,
|
|
164
|
+
paths: args.paths
|
|
165
|
+
});
|
|
166
|
+
patches.push(...nested.patches);
|
|
167
|
+
unsupported.push(...nested.unsupported);
|
|
168
|
+
});
|
|
169
|
+
return { patches, unsupported };
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Fold a flat patch list into one `set` object per document.
|
|
173
|
+
*
|
|
174
|
+
* Most CMS clients take a single patch per document rather than one per field,
|
|
175
|
+
* and a caller that concatenates diffs from several blocks will have several
|
|
176
|
+
* entries for the same document. Documents with nothing to set are dropped, so
|
|
177
|
+
* an unchanged page issues no write at all.
|
|
178
|
+
*/
|
|
179
|
+
export function groupPatches(patches) {
|
|
180
|
+
const byDocument = new Map();
|
|
181
|
+
for (const patch of patches) {
|
|
182
|
+
let set = byDocument.get(patch.documentId);
|
|
183
|
+
if (!set) {
|
|
184
|
+
set = {};
|
|
185
|
+
byDocument.set(patch.documentId, set);
|
|
186
|
+
}
|
|
187
|
+
set[patch.path] = patch.value;
|
|
188
|
+
}
|
|
189
|
+
return [...byDocument.entries()]
|
|
190
|
+
.filter(([, set]) => Object.keys(set).length > 0)
|
|
191
|
+
.map(([documentId, set]) => ({ documentId, set }));
|
|
192
|
+
}
|
|
193
|
+
/** Merge diffs from several blocks or pages into one. */
|
|
194
|
+
export function mergeDiffs(diffs) {
|
|
195
|
+
return {
|
|
196
|
+
patches: diffs.flatMap((d) => d.patches),
|
|
197
|
+
unsupported: diffs.flatMap((d) => d.unsupported)
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* Diff every block on a page.
|
|
202
|
+
*
|
|
203
|
+
* The routing decision stays with the integration — `locate` is where a block
|
|
204
|
+
* placed by a shared section writes to the *section* document rather than the
|
|
205
|
+
* page, and where a per-placement heading override stays page-local. Only the
|
|
206
|
+
* integration knows its own document graph. What is owned here is the loop, the
|
|
207
|
+
* report for a block with nowhere to write, and the `where` string a person has
|
|
208
|
+
* to be able to recognise.
|
|
209
|
+
*
|
|
210
|
+
* Returning `null` from `locate` reports the block as unpublishable rather than
|
|
211
|
+
* skipping it silently. That is almost always a block the editor added: the CMS
|
|
212
|
+
* has no document behind it, so there is no path to patch, and a publisher that
|
|
213
|
+
* quietly dropped it would report success for content the site will never show.
|
|
214
|
+
*/
|
|
215
|
+
export function diffPage(args) {
|
|
216
|
+
const nameOf = args.where ?? ((slug, block) => `${slug} → ${block.type}`);
|
|
217
|
+
const diffs = [];
|
|
218
|
+
for (const block of args.page.blocks) {
|
|
219
|
+
const where = nameOf(args.page.slug, block);
|
|
220
|
+
const located = args.locate(block);
|
|
221
|
+
if (!located) {
|
|
222
|
+
diffs.push({
|
|
223
|
+
patches: [],
|
|
224
|
+
unsupported: [
|
|
225
|
+
{
|
|
226
|
+
where,
|
|
227
|
+
change: `a new "${block.type}" block was added`,
|
|
228
|
+
remedy: "There is no upstream document behind it to patch. Create the block in the CMS first."
|
|
229
|
+
}
|
|
230
|
+
]
|
|
231
|
+
});
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
for (const target of Array.isArray(located) ? located : [located]) {
|
|
235
|
+
diffs.push(diffFields({
|
|
236
|
+
specs: target.specs,
|
|
237
|
+
props: block.props ?? {},
|
|
238
|
+
source: target.source,
|
|
239
|
+
ctx: args.ctx,
|
|
240
|
+
documentId: target.documentId,
|
|
241
|
+
prefix: target.prefix,
|
|
242
|
+
where,
|
|
243
|
+
paths: args.paths
|
|
244
|
+
}));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return mergeDiffs(diffs);
|
|
248
|
+
}
|
|
249
|
+
/** One line per refusal, in the editor's terms. For surfacing to a person. */
|
|
250
|
+
export function describeUnsupported(unsupported) {
|
|
251
|
+
return unsupported.map((u) => `${u.where}: ${u.change}${u.remedy ? ` — ${u.remedy}` : ""}`);
|
|
252
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
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";
|