@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,58 @@
1
+ import { NextResponse, type NextRequest } from "next/server";
2
+ export { DEFAULT_PREVIEW_ROUTE, buildEditorMatcher } from "./editor-matcher.ts";
3
+ /**
4
+ * Options for the editor proxy factory.
5
+ */
6
+ export type EditorProxyOptions = {
7
+ /**
8
+ * The internal route prefix that the dynamic editor/draft page lives under.
9
+ * @default "/preview-draft"
10
+ */
11
+ previewRoute?: string;
12
+ /**
13
+ * Query parameter that signals an editor iframe request.
14
+ * @default "__editor"
15
+ */
16
+ editorParam?: string;
17
+ /**
18
+ * Name of the Next.js draft-mode bypass cookie.
19
+ * @default "__prerender_bypass"
20
+ */
21
+ draftCookie?: string;
22
+ };
23
+ /**
24
+ * Create a Next.js proxy function that rewrites editor/draft requests
25
+ * to a dynamic preview route, keeping the main page route fully static.
26
+ *
27
+ * Next.js 16 renamed the `middleware` file convention to `proxy`. Usage in
28
+ * `proxy.ts` — note that `config` must be written as a literal, because Next 16
29
+ * rejects both `export const { proxy, config } = createEditorProxy()` and
30
+ * `export const config = editor.config` with "Next.js can't recognize the
31
+ * exported `config` field in route. It needs to be a static object":
32
+ *
33
+ * ```ts
34
+ * import { createEditorProxy } from "@avocadostudio-ai/site-sdk/proxy"
35
+ *
36
+ * export const proxy = createEditorProxy().proxy
37
+ *
38
+ * export const config = {
39
+ * matcher: ["/((?!_next|preview-draft|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)"],
40
+ * }
41
+ * ```
42
+ *
43
+ * On Next.js 15 the file is still called `middleware.ts` and the destructured
44
+ * form works — see `createEditorMiddleware` in
45
+ * `@avocadostudio-ai/site-sdk/middleware`.
46
+ *
47
+ * The returned `config` is kept for that Next 15 path and for tests; on Next 16
48
+ * inline the literal instead. {@link buildEditorMatcher} produces the string.
49
+ *
50
+ * The rewrite only reads the URL and cookies, so it runs unchanged on the
51
+ * Node.js runtime that `proxy` mandates.
52
+ */
53
+ export declare function createEditorProxy(options?: EditorProxyOptions): {
54
+ proxy: (request: NextRequest) => NextResponse<unknown>;
55
+ config: {
56
+ matcher: string[];
57
+ };
58
+ };
package/dist/proxy.js ADDED
@@ -0,0 +1,50 @@
1
+ import { NextResponse } from "next/server";
2
+ import { DEFAULT_PREVIEW_ROUTE, buildEditorMatcher } from "./editor-matcher.js";
3
+ export { DEFAULT_PREVIEW_ROUTE, buildEditorMatcher } from "./editor-matcher.js";
4
+ /**
5
+ * Create a Next.js proxy function that rewrites editor/draft requests
6
+ * to a dynamic preview route, keeping the main page route fully static.
7
+ *
8
+ * Next.js 16 renamed the `middleware` file convention to `proxy`. Usage in
9
+ * `proxy.ts` — note that `config` must be written as a literal, because Next 16
10
+ * rejects both `export const { proxy, config } = createEditorProxy()` and
11
+ * `export const config = editor.config` with "Next.js can't recognize the
12
+ * exported `config` field in route. It needs to be a static object":
13
+ *
14
+ * ```ts
15
+ * import { createEditorProxy } from "@avocadostudio-ai/site-sdk/proxy"
16
+ *
17
+ * export const proxy = createEditorProxy().proxy
18
+ *
19
+ * export const config = {
20
+ * matcher: ["/((?!_next|preview-draft|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)"],
21
+ * }
22
+ * ```
23
+ *
24
+ * On Next.js 15 the file is still called `middleware.ts` and the destructured
25
+ * form works — see `createEditorMiddleware` in
26
+ * `@avocadostudio-ai/site-sdk/middleware`.
27
+ *
28
+ * The returned `config` is kept for that Next 15 path and for tests; on Next 16
29
+ * inline the literal instead. {@link buildEditorMatcher} produces the string.
30
+ *
31
+ * The rewrite only reads the URL and cookies, so it runs unchanged on the
32
+ * Node.js runtime that `proxy` mandates.
33
+ */
34
+ export function createEditorProxy(options) {
35
+ const previewRoute = options?.previewRoute ?? DEFAULT_PREVIEW_ROUTE;
36
+ const editorParam = options?.editorParam ?? "__editor";
37
+ const draftCookie = options?.draftCookie ?? "__prerender_bypass";
38
+ function proxy(request) {
39
+ const isEditor = request.nextUrl.searchParams.get(editorParam) === "1";
40
+ const hasDraftCookie = request.cookies.has(draftCookie);
41
+ if (isEditor || hasDraftCookie) {
42
+ const url = request.nextUrl.clone();
43
+ url.pathname = `${previewRoute}${url.pathname}`;
44
+ return NextResponse.rewrite(url);
45
+ }
46
+ return NextResponse.next();
47
+ }
48
+ const config = { matcher: [buildEditorMatcher(previewRoute)] };
49
+ return { proxy, config };
50
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { NextRequest } from "next/server";
4
+ import { buildEditorMatcher, createEditorProxy, DEFAULT_PREVIEW_ROUTE } from "./proxy.js";
5
+ import { createEditorMiddleware } from "./middleware.js";
6
+ /*
7
+ * The rewrite that keeps the published route static and sends editor traffic to
8
+ * the dynamic one. It is the single piece of Next config every integrator has
9
+ * to copy, and until now nothing exercised it — the contract in the READMEs
10
+ * (`export const { middleware, config } = ...`) turned out not to work on
11
+ * Next 16 at all, and no test noticed.
12
+ */
13
+ const rewriteOf = (response) => response.headers.get("x-middleware-rewrite");
14
+ const request = (url, cookie) => new NextRequest(url, cookie ? { headers: { cookie } } : undefined);
15
+ test("an editor request is rewritten to the preview route", () => {
16
+ const { proxy } = createEditorProxy();
17
+ const rewrite = rewriteOf(proxy(request("https://site.test/about?__editor=1")));
18
+ assert.equal(rewrite, "https://site.test/preview-draft/about?__editor=1");
19
+ });
20
+ test("a draft-mode cookie is rewritten even without the query parameter", () => {
21
+ const { proxy } = createEditorProxy();
22
+ const rewrite = rewriteOf(proxy(request("https://site.test/about", "__prerender_bypass=abc")));
23
+ assert.equal(rewrite, "https://site.test/preview-draft/about");
24
+ });
25
+ test("an ordinary visitor is passed straight through", () => {
26
+ const { proxy } = createEditorProxy();
27
+ const response = proxy(request("https://site.test/about"));
28
+ assert.equal(rewriteOf(response), null, "a published page must not be rewritten to the dynamic route");
29
+ assert.equal(response.headers.get("x-middleware-next"), "1");
30
+ });
31
+ test("__editor with any value other than 1 is not an editor request", () => {
32
+ const { proxy } = createEditorProxy();
33
+ assert.equal(rewriteOf(proxy(request("https://site.test/?__editor=0"))), null);
34
+ });
35
+ test("the preview route and cookie name can be overridden together", () => {
36
+ const { proxy, config } = createEditorProxy({
37
+ previewRoute: "/draft",
38
+ editorParam: "edit",
39
+ draftCookie: "my_bypass",
40
+ });
41
+ assert.equal(rewriteOf(proxy(request("https://site.test/x?edit=1"))), "https://site.test/draft/x?edit=1");
42
+ assert.equal(rewriteOf(proxy(request("https://site.test/x", "my_bypass=1"))), "https://site.test/draft/x");
43
+ assert.match(config.matcher[0], /draft/);
44
+ });
45
+ test("the matcher skips the preview route itself, or the rewrite would loop", () => {
46
+ const matcher = new RegExp(`^${buildEditorMatcher()}$`);
47
+ assert.equal(matcher.test("/preview-draft/about"), false);
48
+ assert.equal(matcher.test("/_next/static/chunk.js"), false);
49
+ assert.equal(matcher.test("/api/editor/pages"), false);
50
+ assert.equal(matcher.test("/favicon.ico"), false);
51
+ assert.equal(matcher.test("/about"), true);
52
+ assert.equal(matcher.test("/"), true);
53
+ });
54
+ test("a custom preview route is escaped into the matcher", () => {
55
+ const matcher = new RegExp(`^${buildEditorMatcher("/draft.preview")}$`);
56
+ assert.equal(matcher.test("/draft.preview/about"), false);
57
+ // The `.` is escaped, so it does not match any character.
58
+ assert.equal(matcher.test("/draftxpreview/about"), true);
59
+ });
60
+ test("the deprecated middleware entry point is the same rewrite under the old name", () => {
61
+ /*
62
+ * Kept working for Next 15 sites, where `export const { middleware, config }`
63
+ * still parses. On Next 16 the file is `proxy.ts` and `config` has to be a
64
+ * literal — see `createEditorProxy`.
65
+ */
66
+ const { middleware, config } = createEditorMiddleware();
67
+ assert.equal(rewriteOf(middleware(request("https://site.test/about?__editor=1"))), "https://site.test/preview-draft/about?__editor=1");
68
+ assert.deepEqual(config, createEditorProxy().config);
69
+ });
70
+ test("DEFAULT_PREVIEW_ROUTE is what the factory actually defaults to", () => {
71
+ assert.equal(rewriteOf(createEditorProxy().proxy(request("https://site.test/a?__editor=1"))), `https://site.test${DEFAULT_PREVIEW_ROUTE}/a?__editor=1`);
72
+ });
@@ -0,0 +1,191 @@
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
+ /** How a CMS addresses one element of a list. */
28
+ export interface PathSyntax {
29
+ /** `prefix` + field, e.g. `pageBuilder[_key=="b1"].heading`. */
30
+ field(prefix: string, field: string): string;
31
+ /** The prefix for one item of a list, given its key and position. */
32
+ item(prefix: string, field: string, key: string, index: number): string;
33
+ }
34
+ /**
35
+ * Sanity addresses array elements by `_key`, never by index — an index-addressed
36
+ * patch races any concurrent edit that reorders the array.
37
+ */
38
+ export declare const sanityPaths: PathSyntax;
39
+ /**
40
+ * Index addressing, for stores that have no element identity (Strapi component
41
+ * lists, a plain JSON array). Correct only when the publish is the only writer:
42
+ * anything that reorders between read and write silently patches the wrong row,
43
+ * which is exactly what `_key` exists to prevent.
44
+ */
45
+ export declare const indexPaths: PathSyntax;
46
+ /** One `set` to apply, addressed at the field that owns the value. */
47
+ export interface FieldPatch {
48
+ documentId: string;
49
+ path: string;
50
+ value: unknown;
51
+ }
52
+ /**
53
+ * A change the editor made that this integration cannot express.
54
+ *
55
+ * Reported rather than guessed at. A publisher that silently drops these
56
+ * reports success for an edit the site will never show; one that guesses writes
57
+ * a URL where a reference belongs. Both are worse than saying so.
58
+ */
59
+ export interface UnsupportedChange {
60
+ /** In the editor's own terms — the page and block a person would recognise. */
61
+ where: string;
62
+ /** What was done. */
63
+ change: string;
64
+ /** What would have to exist for it to work, when that is known. */
65
+ remedy?: string;
66
+ }
67
+ export interface FieldDiff {
68
+ patches: FieldPatch[];
69
+ unsupported: UnsupportedChange[];
70
+ }
71
+ /**
72
+ * Emits patches for one changed field, or reports why it cannot.
73
+ *
74
+ * This is where a lossy projection is caught. `writeFigure` in the reference
75
+ * integration sets `alt` — which round-trips — and reports the image itself,
76
+ * because Sanity stores an asset reference and the editor hands back a URL.
77
+ */
78
+ export type FieldWriter<Ctx> = (change: {
79
+ before: unknown;
80
+ after: unknown;
81
+ /** The fully-addressed path of this field. */
82
+ path: string;
83
+ where: string;
84
+ ctx: Ctx;
85
+ emit: (path: string, value: unknown) => void;
86
+ reject: (change: string, remedy?: string) => void;
87
+ }) => void;
88
+ export interface PublishFieldSpec<Ctx = unknown> {
89
+ /** The CMS field name, when it differs from the props key. */
90
+ cmsKey?: string;
91
+ /**
92
+ * Undo the projection: given the edited props and the value the CMS holds,
93
+ * return what the CMS should hold now.
94
+ *
95
+ * `before` is passed so an inversion can be partial — return the stored
96
+ * object with one key replaced, and everything the projection dropped
97
+ * survives untouched. That is the difference between editing a field and
98
+ * overwriting a document.
99
+ */
100
+ rehydrate(props: Record<string, unknown>, before: unknown, ctx: Ctx): unknown;
101
+ /** `"set"` writes the value at its path. A function decides for itself. */
102
+ write?: "set" | FieldWriter<Ctx>;
103
+ /** For a list field: the specs for one item. */
104
+ itemFields?: Record<string, PublishFieldSpec<Ctx>>;
105
+ /** How an item identifies itself upstream. Defaults to reading `_key`. */
106
+ itemKey?(item: Record<string, unknown>): string | undefined;
107
+ }
108
+ export type FieldSpecs<Ctx> = Record<string, PublishFieldSpec<Ctx>>;
109
+ /** Structural equality, for "did this field actually change". */
110
+ export declare function deepEqual(a: unknown, b: unknown): boolean;
111
+ /**
112
+ * Diff one object's fields against the source it was projected from.
113
+ *
114
+ * `props` is what the editor holds; `source` is what the CMS holds. Recurses
115
+ * into list fields, matching items on their own key so a reorder upstream
116
+ * cannot make a patch land on the wrong row.
117
+ */
118
+ export declare function diffFields<Ctx>(args: {
119
+ specs: FieldSpecs<Ctx>;
120
+ props: Record<string, unknown>;
121
+ source: Record<string, unknown>;
122
+ ctx: Ctx;
123
+ documentId: string;
124
+ /** Path prefix this object sits at, e.g. `pageBuilder[_key=="b1"].`. */
125
+ prefix?: string;
126
+ where: string;
127
+ paths?: PathSyntax;
128
+ }): FieldDiff;
129
+ /**
130
+ * Fold a flat patch list into one `set` object per document.
131
+ *
132
+ * Most CMS clients take a single patch per document rather than one per field,
133
+ * and a caller that concatenates diffs from several blocks will have several
134
+ * entries for the same document. Documents with nothing to set are dropped, so
135
+ * an unchanged page issues no write at all.
136
+ */
137
+ export declare function groupPatches(patches: FieldPatch[]): Array<{
138
+ documentId: string;
139
+ set: Record<string, unknown>;
140
+ }>;
141
+ /** Merge diffs from several blocks or pages into one. */
142
+ export declare function mergeDiffs(diffs: FieldDiff[]): FieldDiff;
143
+ /** Where one block's values live upstream, and how to invert them. */
144
+ export interface BlockTarget<Ctx> {
145
+ /** The CMS document that owns these values — not necessarily the page's. */
146
+ documentId: string;
147
+ /** Path prefix within that document, e.g. `pageBuilder[_key=="b1"].`. */
148
+ prefix: string;
149
+ specs: FieldSpecs<Ctx>;
150
+ /** What the CMS holds for this block: the value the props were projected from. */
151
+ source: Record<string, unknown>;
152
+ }
153
+ /**
154
+ * Diff every block on a page.
155
+ *
156
+ * The routing decision stays with the integration — `locate` is where a block
157
+ * placed by a shared section writes to the *section* document rather than the
158
+ * page, and where a per-placement heading override stays page-local. Only the
159
+ * integration knows its own document graph. What is owned here is the loop, the
160
+ * report for a block with nowhere to write, and the `where` string a person has
161
+ * to be able to recognise.
162
+ *
163
+ * Returning `null` from `locate` reports the block as unpublishable rather than
164
+ * skipping it silently. That is almost always a block the editor added: the CMS
165
+ * has no document behind it, so there is no path to patch, and a publisher that
166
+ * quietly dropped it would report success for content the site will never show.
167
+ */
168
+ export declare function diffPage<Ctx>(args: {
169
+ page: {
170
+ slug: string;
171
+ blocks: Array<{
172
+ id: string;
173
+ type: string;
174
+ props: Record<string, unknown>;
175
+ }>;
176
+ };
177
+ ctx: Ctx;
178
+ locate(block: {
179
+ id: string;
180
+ type: string;
181
+ props: Record<string, unknown>;
182
+ }): BlockTarget<Ctx> | BlockTarget<Ctx>[] | null;
183
+ paths?: PathSyntax;
184
+ /** How a block is named in a report. Defaults to `"/slug → type"`. */
185
+ where?(pageSlug: string, block: {
186
+ id: string;
187
+ type: string;
188
+ }): string;
189
+ }): FieldDiff;
190
+ /** One line per refusal, in the editor's terms. For surfacing to a person. */
191
+ export declare function describeUnsupported(unsupported: UnsupportedChange[]): string[];
@@ -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 {};