@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
package/dist/proxy.d.ts
ADDED
|
@@ -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[];
|