@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.
Files changed (50) hide show
  1. package/README.md +212 -2
  2. package/dist/cli/register.js +23 -1
  3. package/dist/create-site-page.d.ts +38 -8
  4. package/dist/create-site-page.js +59 -8
  5. package/dist/draft-common.d.ts +32 -0
  6. package/dist/draft-common.js +58 -0
  7. package/dist/draft-context-core.js +39 -6
  8. package/dist/draft-context-core.test.d.ts +10 -0
  9. package/dist/draft-context-core.test.js +146 -0
  10. package/dist/draft-fetch.d.ts +9 -10
  11. package/dist/draft-fetch.js +71 -5
  12. package/dist/draft-fetch.test.d.ts +1 -0
  13. package/dist/draft-fetch.test.js +87 -0
  14. package/dist/editor-cors.d.ts +12 -0
  15. package/dist/editor-cors.js +31 -6
  16. package/dist/editor-cors.test.d.ts +1 -0
  17. package/dist/editor-cors.test.js +66 -0
  18. package/dist/editor-manifest.d.ts +2 -3
  19. package/dist/editor-manifest.js +12 -64
  20. package/dist/editor-matcher.d.ts +27 -0
  21. package/dist/editor-matcher.js +34 -0
  22. package/dist/editor-query.js +7 -1
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.js +2 -0
  25. package/dist/integration-check.js +11 -1
  26. package/dist/manifest-utils.d.ts +13 -0
  27. package/dist/manifest-utils.js +30 -3
  28. package/dist/manifest-utils.test.d.ts +1 -0
  29. package/dist/manifest-utils.test.js +72 -0
  30. package/dist/middleware.d.ts +21 -19
  31. package/dist/middleware.js +19 -22
  32. package/dist/next-config.test.d.ts +1 -0
  33. package/dist/next-config.test.js +355 -0
  34. package/dist/page-metadata.d.ts +66 -0
  35. package/dist/page-metadata.js +110 -0
  36. package/dist/page-metadata.test.d.ts +1 -0
  37. package/dist/page-metadata.test.js +105 -0
  38. package/dist/proxy.d.ts +95 -0
  39. package/dist/proxy.js +76 -0
  40. package/dist/proxy.test.d.ts +1 -0
  41. package/dist/proxy.test.js +123 -0
  42. package/dist/publish/field-diff.d.ts +191 -0
  43. package/dist/publish/field-diff.js +252 -0
  44. package/dist/publish/field-diff.test.d.ts +1 -0
  45. package/dist/publish/field-diff.test.js +286 -0
  46. package/dist/server/orchestrator.d.ts +1 -117
  47. package/dist/server/orchestrator.js +14 -733
  48. package/next-config.d.ts +78 -0
  49. package/next-config.mjs +468 -0
  50. package/package.json +63 -19
@@ -0,0 +1,95 @@
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, or `false` to ignore cookies
19
+ * entirely and key the rewrite on {@link EditorProxyOptions.editorParam} alone.
20
+ *
21
+ * The cookie is how navigation *inside* the editor iframe stays in draft mode:
22
+ * a link click carries no `__editor=1`, so without it the second page a user
23
+ * visits renders published content.
24
+ *
25
+ * But the cookie is Next's own, and it is not Avocado's to claim. Any other
26
+ * feature that calls `draftMode().enable()` sets the same
27
+ * `__prerender_bypass` — Sanity's Presentation tool and Contentful's live
28
+ * preview both do — and every one of *their* preview requests then lands on
29
+ * Avocado's preview route. On a site that already had Draft Mode before it had
30
+ * Avocado, pass `draftCookie: false` and the two stop fighting over it.
31
+ *
32
+ * @default "__prerender_bypass"
33
+ */
34
+ draftCookie?: string | false;
35
+ /**
36
+ * Set this to `true` on a site whose `next.config` sets `trailingSlash: true`.
37
+ *
38
+ * Such a site cannot talk to the editor until it *stops* letting Next issue
39
+ * the trailing-slash redirect. Next applies that 308 to `/api/*` as well, so
40
+ * `/api/editor/blocks` answers `308 → /api/editor/blocks/` — and while `fetch`
41
+ * follows a 308, a browser does **not** follow a redirect on a CORS preflight.
42
+ * The editor calls those routes from its own origin, so every editor API call
43
+ * fails before the request is made. A middleware rewrite cannot repair it
44
+ * either: Next's trailing-slash redirect runs *before* middleware.
45
+ *
46
+ * The fix is `skipTrailingSlashRedirect: true` — which `withAvocado` sets for
47
+ * you as soon as it sees `trailingSlash: true` — plus re-issuing the redirect
48
+ * by hand for everything that is not an API route. This flag is that second
49
+ * half, and the two must be turned on together: the config half alone stops a
50
+ * site redirecting to its canonical URLs.
51
+ *
52
+ * Only page paths are affected. The proxy's matcher already excludes `/api`,
53
+ * `_next` and anything with a file extension, which is exactly the set that
54
+ * should never have gained a trailing slash to begin with.
55
+ *
56
+ * @default false
57
+ */
58
+ trailingSlash?: boolean;
59
+ };
60
+ /**
61
+ * Create a Next.js proxy function that rewrites editor/draft requests
62
+ * to a dynamic preview route, keeping the main page route fully static.
63
+ *
64
+ * Next.js 16 renamed the `middleware` file convention to `proxy`. Usage in
65
+ * `proxy.ts` — note that `config` must be written as a literal, because Next 16
66
+ * rejects both `export const { proxy, config } = createEditorProxy()` and
67
+ * `export const config = editor.config` with "Next.js can't recognize the
68
+ * exported `config` field in route. It needs to be a static object":
69
+ *
70
+ * ```ts
71
+ * import { createEditorProxy } from "@avocadostudio-ai/site-sdk/proxy"
72
+ *
73
+ * export const proxy = createEditorProxy().proxy
74
+ *
75
+ * export const config = {
76
+ * matcher: ["/((?!_next|preview-draft|api|favicon\\.ico|icon\\.svg|logos/|generated-images/|.*\\.).*)"],
77
+ * }
78
+ * ```
79
+ *
80
+ * On Next.js 15 the file is still called `middleware.ts` and the destructured
81
+ * form works — see `createEditorMiddleware` in
82
+ * `@avocadostudio-ai/site-sdk/middleware`.
83
+ *
84
+ * The returned `config` is kept for that Next 15 path and for tests; on Next 16
85
+ * inline the literal instead. {@link buildEditorMatcher} produces the string.
86
+ *
87
+ * The rewrite only reads the URL and cookies, so it runs unchanged on the
88
+ * Node.js runtime that `proxy` mandates.
89
+ */
90
+ export declare function createEditorProxy(options?: EditorProxyOptions): {
91
+ proxy: (request: NextRequest) => NextResponse<unknown>;
92
+ config: {
93
+ matcher: string[];
94
+ };
95
+ };
package/dist/proxy.js ADDED
@@ -0,0 +1,76 @@
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 === undefined ? "__prerender_bypass" : options.draftCookie;
38
+ const trailingSlash = options?.trailingSlash ?? false;
39
+ function proxy(request) {
40
+ /*
41
+ * Before anything else, and deliberately: this stands in for a redirect
42
+ * Next would have issued before middleware ran, so a request that should
43
+ * never have been served at this URL must not be served at it here either.
44
+ * Rewriting first would answer `/about?__editor=1` with content the site
45
+ * publishes only at `/about/`.
46
+ */
47
+ if (trailingSlash) {
48
+ /*
49
+ * Built from `request.url`, not from `request.nextUrl.clone()`. NextURL
50
+ * normalises a trailing slash back *off* when it stringifies, so a
51
+ * redirect built from a clone points at the URL it is trying to leave —
52
+ * a redirect loop, and one that only a browser would ever have shown us.
53
+ * A plain URL does no normalising. It also keeps `basePath`, which
54
+ * `nextUrl.pathname` has already stripped.
55
+ */
56
+ const url = new URL(request.url);
57
+ if (url.pathname.length > 1 && !url.pathname.endsWith("/")) {
58
+ url.pathname = `${url.pathname}/`;
59
+ // 308, not 307: the method is preserved *and* the redirect is
60
+ // permanent, which is what Next's own trailing-slash redirect sends
61
+ // and what the site's existing search rankings were built on.
62
+ return NextResponse.redirect(url, 308);
63
+ }
64
+ }
65
+ const isEditor = request.nextUrl.searchParams.get(editorParam) === "1";
66
+ const hasDraftCookie = draftCookie !== false && request.cookies.has(draftCookie);
67
+ if (isEditor || hasDraftCookie) {
68
+ const url = request.nextUrl.clone();
69
+ url.pathname = `${previewRoute}${url.pathname}`;
70
+ return NextResponse.rewrite(url);
71
+ }
72
+ return NextResponse.next();
73
+ }
74
+ const config = { matcher: [buildEditorMatcher(previewRoute)] };
75
+ return { proxy, config };
76
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,123 @@
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
+ });
73
+ /*
74
+ * `trailingSlash: true` and the editor could not coexist. Next applies its 308
75
+ * to `/api/*` too, and a browser will not follow a redirect on a CORS preflight,
76
+ * so every editor API call failed before it was sent. `withAvocado` turns the
77
+ * redirect off; these pin the half that puts it back.
78
+ */
79
+ const locationOf = (response) => response.headers.get("location");
80
+ test("a trailing-slash site gets back the redirect the config turned off", () => {
81
+ const { proxy } = createEditorProxy({ trailingSlash: true });
82
+ const response = proxy(request("https://site.test/about"));
83
+ assert.equal(response.status, 308, "308, like Next's own — the site's rankings were built on a permanent redirect");
84
+ assert.equal(locationOf(response), "https://site.test/about/");
85
+ });
86
+ test("the redirect keeps the query string, or the editor loses its own parameter", () => {
87
+ const { proxy } = createEditorProxy({ trailingSlash: true });
88
+ assert.equal(locationOf(proxy(request("https://site.test/about?__editor=1"))), "https://site.test/about/?__editor=1");
89
+ });
90
+ test("an already-canonical path is rewritten, not redirected into a loop", () => {
91
+ const { proxy } = createEditorProxy({ trailingSlash: true });
92
+ const response = proxy(request("https://site.test/about/?__editor=1"));
93
+ assert.equal(response.status, 200);
94
+ assert.equal(rewriteOf(response), "https://site.test/preview-draft/about/?__editor=1");
95
+ });
96
+ test("the root is already canonical — redirecting it would never terminate", () => {
97
+ const { proxy } = createEditorProxy({ trailingSlash: true });
98
+ const response = proxy(request("https://site.test/?__editor=1"));
99
+ assert.equal(response.status, 200, "`/` already ends in a slash; redirecting it is a loop");
100
+ // Asserted as a prefix: the rewrite target goes through NextURL, which
101
+ // normalises the trailing slash according to the app's own config.
102
+ assert.match(rewriteOf(response) ?? "", /^https:\/\/site\.test\/preview-draft/);
103
+ });
104
+ test("the redirect comes before the rewrite, so no page is served at a URL the site does not publish", () => {
105
+ const { proxy } = createEditorProxy({ trailingSlash: true });
106
+ const response = proxy(request("https://site.test/about?__editor=1"));
107
+ assert.equal(rewriteOf(response), null, "an unslashed editor URL must redirect first, not render");
108
+ });
109
+ test("a site that never asked for trailing slashes is never redirected", () => {
110
+ const { proxy } = createEditorProxy();
111
+ assert.equal(proxy(request("https://site.test/about")).status, 200);
112
+ assert.equal(locationOf(proxy(request("https://site.test/about"))), null);
113
+ });
114
+ /*
115
+ * `__prerender_bypass` is Next's cookie, not Avocado's. A site that already used
116
+ * Draft Mode for its CMS's own preview sent every one of those requests into
117
+ * Avocado's preview route.
118
+ */
119
+ test("draftCookie: false leaves Next's draft cookie to whoever else is using it", () => {
120
+ const { proxy } = createEditorProxy({ draftCookie: false });
121
+ assert.equal(rewriteOf(proxy(request("https://site.test/about", "__prerender_bypass=abc"))), null, "a Sanity or Contentful preview must not be hijacked into Avocado's route");
122
+ assert.equal(rewriteOf(proxy(request("https://site.test/about?__editor=1"))), "https://site.test/preview-draft/about?__editor=1", "the explicit editor parameter still works — that is the whole point of the opt-out");
123
+ });
@@ -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[];