@avocadostudio-ai/site-sdk 0.7.0 → 0.9.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 (56) hide show
  1. package/README.md +1 -1
  2. package/dist/create-site-page.js +47 -84
  3. package/dist/draft-common.d.ts +1 -0
  4. package/dist/draft-common.js +13 -0
  5. package/dist/draft-context-core.d.ts +1 -1
  6. package/dist/draft-context-core.js +2 -3
  7. package/dist/draft-context.d.ts +1 -1
  8. package/dist/draft-context.js +1 -3
  9. package/dist/draft-routes.d.ts +7 -0
  10. package/dist/draft-routes.js +8 -3
  11. package/dist/editor-api-handler-core.d.ts +85 -0
  12. package/dist/editor-api-handler-core.js +101 -0
  13. package/dist/editor-api-handler.d.ts +7 -49
  14. package/dist/editor-api-handler.js +12 -66
  15. package/dist/editor-rewrite-core.d.ts +71 -0
  16. package/dist/editor-rewrite-core.js +37 -0
  17. package/dist/lens/register.d.ts +6 -8
  18. package/dist/lens/register.js +203 -29
  19. package/dist/lens/types.d.ts +36 -0
  20. package/dist/page-metadata.d.ts +21 -0
  21. package/dist/page-metadata.js +64 -0
  22. package/dist/proxy.d.ts +9 -30
  23. package/dist/proxy.js +16 -22
  24. package/dist/resolve-page-render.d.ts +94 -0
  25. package/dist/resolve-page-render.js +80 -0
  26. package/dist/routes-core.d.ts +12 -0
  27. package/dist/routes-core.js +24 -0
  28. package/package.json +25 -15
  29. package/dist/cli/register-notice.test.d.ts +0 -1
  30. package/dist/cli/register-notice.test.js +0 -21
  31. package/dist/draft-context-core.test.d.ts +0 -10
  32. package/dist/draft-context-core.test.js +0 -146
  33. package/dist/draft-fetch.test.d.ts +0 -1
  34. package/dist/draft-fetch.test.js +0 -153
  35. package/dist/editor-api-blocks-catalogue.test.d.ts +0 -1
  36. package/dist/editor-api-blocks-catalogue.test.js +0 -73
  37. package/dist/editor-cors.test.d.ts +0 -1
  38. package/dist/editor-cors.test.js +0 -85
  39. package/dist/editor-render.test.d.ts +0 -1
  40. package/dist/editor-render.test.js +0 -44
  41. package/dist/lens/lens.test.d.ts +0 -1
  42. package/dist/lens/lens.test.js +0 -221
  43. package/dist/lens/register.test.d.ts +0 -1
  44. package/dist/lens/register.test.js +0 -81
  45. package/dist/manifest-utils.test.d.ts +0 -1
  46. package/dist/manifest-utils.test.js +0 -72
  47. package/dist/markers.test.d.ts +0 -1
  48. package/dist/markers.test.js +0 -35
  49. package/dist/next-config.test.d.ts +0 -1
  50. package/dist/next-config.test.js +0 -393
  51. package/dist/page-metadata.test.d.ts +0 -1
  52. package/dist/page-metadata.test.js +0 -105
  53. package/dist/proxy.test.d.ts +0 -1
  54. package/dist/proxy.test.js +0 -182
  55. package/dist/publish/field-diff.test.d.ts +0 -1
  56. package/dist/publish/field-diff.test.js +0 -350
@@ -1,105 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { buildPageMetadata, derivePageDescription, derivePageTitle, stripMarkdown, truncateForMeta } from "./page-metadata.js";
4
- const basePage = {
5
- id: "p_test",
6
- slug: "/test",
7
- title: "Test Page",
8
- updatedAt: "2026-03-13T00:00:00.000Z",
9
- blocks: [],
10
- };
11
- test("derivePageDescription prefers an explicit description", () => {
12
- const page = { ...basePage, meta: { description: "Explicit description for search snippets." } };
13
- assert.equal(derivePageDescription(page), "Explicit description for search snippets.");
14
- });
15
- test("derivePageDescription falls back to the first block that reads like prose", () => {
16
- const page = {
17
- ...basePage,
18
- blocks: [
19
- { id: "b0", type: "Hero", props: { heading: "Avocados" } },
20
- { id: "b1", type: "Hero", props: { subheading: "Learn how to pick, store, and prepare avocados quickly." } },
21
- ],
22
- };
23
- assert.match(derivePageDescription(page), /pick, store, and prepare avocados quickly/i);
24
- });
25
- test("derivePageDescription skips block text too short to be a summary", () => {
26
- const page = {
27
- ...basePage,
28
- blocks: [{ id: "b1", type: "CTA", props: { text: "Buy now" } }],
29
- };
30
- // "Buy now" is a label, not a description — the generated fallback wins.
31
- assert.match(derivePageDescription(page), /^Test Page\./);
32
- });
33
- test("derivePageDescription strips markdown and caps length", () => {
34
- const page = {
35
- ...basePage,
36
- blocks: [
37
- {
38
- id: "b2",
39
- type: "RichText",
40
- props: {
41
- body: "# Title\n\nThis is **a long markdown description** with [a link](https://example.com) that should be normalized and capped to a search-friendly length without weird symbols lingering around.",
42
- },
43
- },
44
- ],
45
- };
46
- const description = derivePageDescription(page);
47
- assert.ok(description.length <= 161, `too long: ${description.length}`);
48
- assert.equal(description.includes("**"), false);
49
- assert.equal(description.includes("[a link]"), false);
50
- /*
51
- * The words have to survive, not just the brackets. Asserting only that
52
- * "[a link]" is gone passed while the replacement wrote a literal `$1` over
53
- * the link text, because the pattern it referred to had no capture group.
54
- */
55
- assert.ok(description.includes("a link"), `link text was dropped: ${description}`);
56
- assert.equal(description.includes("$1"), false, `replacement token leaked: ${description}`);
57
- });
58
- test("stripMarkdown keeps link text and drops the target", () => {
59
- assert.equal(stripMarkdown("Book a [guided tour](/tours) with our team."), "Book a guided tour with our team.");
60
- // An image has no text worth keeping — alt text describes the picture, not
61
- // the sentence — so it goes entirely rather than leaving its alt behind.
62
- assert.equal(stripMarkdown("Before ![a photo](/p.png) after."), "Before after.");
63
- });
64
- test("truncateForMeta ends in exactly one period", () => {
65
- const cutAfterASentence = `${"A".repeat(100)} end of the first sentence here. ${"B".repeat(80)}`;
66
- const out = truncateForMeta(cutAfterASentence);
67
- assert.equal(out.endsWith(".."), false, `doubled the period: ${JSON.stringify(out.slice(-20))}`);
68
- assert.equal(out.endsWith("."), true);
69
- assert.ok(out.length <= 161, `too long: ${out.length}`);
70
- });
71
- test("derivePageDescription never returns empty", () => {
72
- assert.ok(derivePageDescription(basePage).length > 0);
73
- });
74
- test("derivePageTitle prefers meta.title over the document title", () => {
75
- assert.equal(derivePageTitle(basePage), "Test Page");
76
- assert.equal(derivePageTitle({ ...basePage, meta: { title: "SEO Title" } }), "SEO Title");
77
- // An all-whitespace meta title is not a title.
78
- assert.equal(derivePageTitle({ ...basePage, meta: { title: " " } }), "Test Page");
79
- });
80
- test("buildPageMetadata emits title, description and Open Graph", () => {
81
- const meta = buildPageMetadata({ ...basePage, meta: { description: "A page about avocados and how to eat them." } });
82
- assert.equal(meta.title, "Test Page");
83
- assert.equal(meta.description, "A page about avocados and how to eat them.");
84
- assert.equal(meta.openGraph?.title, "Test Page");
85
- assert.equal(meta.openGraph?.description, "A page about avocados and how to eat them.");
86
- assert.equal(meta.openGraph?.type, "website");
87
- });
88
- test("buildPageMetadata only claims a large image card when there is an image", () => {
89
- const without = buildPageMetadata(basePage);
90
- assert.equal(without.openGraph?.images, undefined);
91
- assert.equal(without.twitter?.card, undefined);
92
- const with_ = buildPageMetadata({ ...basePage, meta: { ogImage: "https://cdn.example.com/og.png" } });
93
- assert.deepEqual(with_.openGraph?.images, ["https://cdn.example.com/og.png"]);
94
- assert.equal(with_.twitter?.card, "summary_large_image");
95
- assert.deepEqual(with_.twitter?.images, ["https://cdn.example.com/og.png"]);
96
- });
97
- test("buildPageMetadata carries siteName and canonical when given", () => {
98
- const meta = buildPageMetadata(basePage, { siteName: "Avocado Co", canonical: "https://example.com/test" });
99
- assert.equal(meta.openGraph?.siteName, "Avocado Co");
100
- assert.equal(meta.openGraph?.url, "https://example.com/test");
101
- assert.equal(meta.alternates?.canonical, "https://example.com/test");
102
- const bare = buildPageMetadata(basePage);
103
- assert.equal(bare.alternates, undefined);
104
- assert.equal(bare.openGraph?.siteName, undefined);
105
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,182 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { NextRequest, NextResponse } from "next/server";
4
- import { buildEditorMatcher, createEditorProxy, editorPreviewRewrite, trailingSlashRedirect, 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
- });
124
- /*
125
- * The two halves, on their own.
126
- *
127
- * A site that already has a `middleware.ts` cannot hand the whole file to
128
- * `createEditorProxy` and has to compose — which until now meant re-deriving
129
- * the trailing-slash redirect from the docs. One integration did, and wrote
130
- * `request.nextUrl.clone()`, because that is what every Next example uses for
131
- * a URL you intend to modify. A `NextURL` re-applies trailing-slash
132
- * normalisation when it serialises, so the redirect stripped the slash it had
133
- * just added and pointed at the URL it came from: every page on the site in an
134
- * infinite redirect, reproducing only in a browser. `curl` without `-L` sees
135
- * one perfectly ordinary 308, which is how it survived a session and a half.
136
- */
137
- test("the redirect is built from request.url, not from a NextURL that would undo it", () => {
138
- const response = trailingSlashRedirect(request("https://site.test/bern"));
139
- assert.equal(response?.status, 308);
140
- assert.equal(response?.headers.get("location"), "https://site.test/bern/", "a location without the slash is the loop: the browser asks again and gets the same answer");
141
- });
142
- test("an already-canonical URL, the root, and a file are left alone", () => {
143
- assert.equal(trailingSlashRedirect(request("https://site.test/bern/")), null);
144
- assert.equal(trailingSlashRedirect(request("https://site.test/")), null, "redirecting / never terminates");
145
- assert.equal(trailingSlashRedirect(request("https://site.test/logo.svg")), null);
146
- });
147
- test("the API routes are never redirected, whatever the caller's matcher says", () => {
148
- /*
149
- * The proxy's own matcher excludes these, but a hand-composed middleware
150
- * brings its own matcher and it is usually wider. A slash added here
151
- * re-creates by hand the exact 308 on `/api/editor/*` that
152
- * `skipTrailingSlashRedirect` was turned on to stop — and the symptom is a
153
- * CORS error that names nothing.
154
- */
155
- assert.equal(trailingSlashRedirect(request("https://site.test/api/editor/blocks")), null);
156
- assert.equal(trailingSlashRedirect(request("https://site.test/_next/static/chunk")), null);
157
- });
158
- test("the redirect keeps the query string when called directly too", () => {
159
- const response = trailingSlashRedirect(request("https://site.test/bern?__editor=1"));
160
- assert.equal(response?.headers.get("location"), "https://site.test/bern/?__editor=1");
161
- });
162
- test("editorPreviewRewrite answers null for a visitor, so the caller runs its own middleware", () => {
163
- assert.equal(editorPreviewRewrite(request("https://site.test/about")), null, "null is 'not mine', not 'pass this through' — a composing caller has its own work to do");
164
- assert.equal(rewriteOf(editorPreviewRewrite(request("https://site.test/about?__editor=1"))), "https://site.test/preview-draft/about?__editor=1");
165
- });
166
- test("the composed form behaves like the factory it was extracted from", () => {
167
- const { proxy } = createEditorProxy({ trailingSlash: true });
168
- const compose = (r) => trailingSlashRedirect(r) ?? editorPreviewRewrite(r) ?? NextResponse.next();
169
- for (const url of [
170
- "https://site.test/bern",
171
- "https://site.test/bern/",
172
- "https://site.test/bern?__editor=1",
173
- "https://site.test/bern/?__editor=1",
174
- "https://site.test/"
175
- ]) {
176
- const fromFactory = proxy(request(url));
177
- const fromParts = compose(request(url));
178
- assert.equal(fromParts.status, fromFactory.status, url);
179
- assert.equal(fromParts.headers.get("location"), fromFactory.headers.get("location"), url);
180
- assert.equal(rewriteOf(fromParts), rewriteOf(fromFactory), url);
181
- }
182
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,350 +0,0 @@
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
- paths: sanityPaths
19
- });
20
- assert.deepEqual(diff.patches, []);
21
- assert.deepEqual(diff.unsupported, []);
22
- });
23
- test("a changed field is set at the path that owns it", () => {
24
- const diff = diffFields({
25
- specs: { heading: plain("heading") },
26
- props: { heading: "After" },
27
- source: { heading: "Before" },
28
- ctx,
29
- documentId: "doc1",
30
- prefix: 'pageBuilder[_key=="b1"].',
31
- where: "/ → hero",
32
- paths: sanityPaths
33
- });
34
- assert.deepEqual(diff.patches, [
35
- { documentId: "doc1", path: 'pageBuilder[_key=="b1"].heading', value: "After" }
36
- ]);
37
- });
38
- test("cmsKey writes to the field the CMS actually has", () => {
39
- const diff = diffFields({
40
- specs: { heading: { ...plain("heading"), cmsKey: "headingOverride" } },
41
- props: { heading: "After" },
42
- // Keyed the way the CMS keys it. The version of this test that wrote
43
- // `{ heading: "Before" }` passed against the bug below, because a baseline
44
- // of `undefined` also produces a patch at the right path.
45
- source: { headingOverride: "Before" },
46
- ctx,
47
- documentId: "doc1",
48
- where: "/ → hero",
49
- paths: sanityPaths
50
- });
51
- assert.deepEqual(diff.patches, [
52
- { documentId: "doc1", path: "headingOverride", value: "After" }
53
- ]);
54
- });
55
- test("an unchanged cmsKey field emits nothing", () => {
56
- // The regression this file exists for. `diffFields` read the baseline from
57
- // the *props* key while pathing the patch from `cmsKey`, so every spec that
58
- // used the option compared its value against `undefined` and republished it
59
- // on every publish, edited or not.
60
- const diff = diffFields({
61
- specs: { heading: { ...plain("heading"), cmsKey: "headingOverride" } },
62
- props: { heading: "Same" },
63
- source: { headingOverride: "Same" },
64
- ctx,
65
- documentId: "doc1",
66
- where: "/ → hero",
67
- paths: sanityPaths
68
- });
69
- assert.deepEqual(diff.patches, []);
70
- });
71
- test("a cmsKey spec's rehydrate receives the value the CMS holds", () => {
72
- // Partial inversion is the whole point of passing `before`: return the stored
73
- // object with one key replaced and everything the projection dropped
74
- // survives. Hand `rehydrate` an `undefined` and it has nothing to merge into,
75
- // so the patch overwrites instead of editing.
76
- const seen = [];
77
- const diff = diffFields({
78
- specs: {
79
- alt: {
80
- cmsKey: "image",
81
- rehydrate: (props, before) => {
82
- seen.push(before);
83
- return { ...before, alt: props.alt };
84
- }
85
- }
86
- },
87
- props: { alt: "A team in action" },
88
- source: { image: { _type: "image", asset: { _ref: "image-abc" }, alt: "Old" } },
89
- ctx,
90
- documentId: "doc1",
91
- where: "/ → hero",
92
- paths: sanityPaths
93
- });
94
- assert.deepEqual(seen, [{ _type: "image", asset: { _ref: "image-abc" }, alt: "Old" }]);
95
- assert.deepEqual(diff.patches, [
96
- {
97
- documentId: "doc1",
98
- path: "image",
99
- value: { _type: "image", asset: { _ref: "image-abc" }, alt: "A team in action" }
100
- }
101
- ]);
102
- });
103
- test("rehydrate receives the stored value, so an inversion can be partial", () => {
104
- // The whole reason a snapshot contract cannot work: the editor saw
105
- // `image.url`, the CMS holds an asset reference beside it, and only the
106
- // integration knows how to put one back without destroying the other.
107
- const specs = {
108
- image: {
109
- rehydrate: (props, before) => ({
110
- ...before,
111
- alt: props.imageAlt ?? undefined
112
- })
113
- }
114
- };
115
- const diff = diffFields({
116
- specs,
117
- props: { imageAlt: "New alt" },
118
- source: { image: { _type: "image", asset: { _ref: "image-abc" }, alt: "Old alt" } },
119
- ctx,
120
- documentId: "doc1",
121
- where: "/ → hero",
122
- paths: sanityPaths
123
- });
124
- assert.deepEqual(diff.patches[0].value, {
125
- _type: "image",
126
- asset: { _ref: "image-abc" },
127
- alt: "New alt"
128
- });
129
- });
130
- // ---------------------------------------------------------------------------
131
- // The refusals
132
- // ---------------------------------------------------------------------------
133
- test("refusal 1 — a projection that cannot be inverted is reported, not guessed", () => {
134
- const specs = {
135
- image: {
136
- rehydrate: (props, before) => ({ ...before, url: props.imageUrl }),
137
- write: ({ before, after, emit, reject, path }) => {
138
- const was = before;
139
- const now = after;
140
- if (was?.alt !== now.alt)
141
- emit(`${path}.alt`, now.alt);
142
- if (was?.url !== now.url) {
143
- reject(`the image was replaced (${now.url})`, "The CMS stores an asset reference; the file has to be uploaded first.");
144
- }
145
- }
146
- }
147
- };
148
- const diff = diffFields({
149
- specs,
150
- props: { imageUrl: "https://example.com/new.png" },
151
- source: { image: { url: "https://cdn.example.com/old.png", alt: "Old" } },
152
- ctx,
153
- documentId: "doc1",
154
- where: "/ → hero",
155
- paths: sanityPaths
156
- });
157
- assert.deepEqual(diff.patches, [], "nothing may be written for a value that cannot be expressed");
158
- assert.equal(diff.unsupported.length, 1);
159
- assert.match(diff.unsupported[0].change, /image was replaced/);
160
- assert.match(String(diff.unsupported[0].remedy), /uploaded first/);
161
- });
162
- test("refusal 2 — adding or removing list items is a document-shaped change", () => {
163
- const specs = {
164
- cards: {
165
- rehydrate: (props) => props.cards,
166
- itemFields: { title: plain("title") }
167
- }
168
- };
169
- const diff = diffFields({
170
- specs,
171
- props: { cards: [{ _key: "k1", title: "One" }, { _key: "k2", title: "Two" }] },
172
- source: { cards: [{ _key: "k1", title: "One" }] },
173
- ctx,
174
- documentId: "doc1",
175
- where: "/ → cardGrid",
176
- paths: sanityPaths
177
- });
178
- assert.ok(diff.unsupported.some((u) => /added to or removed/.test(u.change)));
179
- });
180
- test("refusal 3 — an item the CMS has never seen has no path to patch", () => {
181
- const specs = {
182
- cards: {
183
- rehydrate: (props) => props.cards,
184
- itemFields: { title: plain("title") }
185
- }
186
- };
187
- const diff = diffFields({
188
- specs,
189
- props: { cards: [{ _key: "k1", title: "One" }, { title: "Brand new" }] },
190
- source: { cards: [{ _key: "k1", title: "One" }, { _key: "k2", title: "Two" }] },
191
- ctx,
192
- documentId: "doc1",
193
- where: "/ → cardGrid",
194
- paths: sanityPaths
195
- });
196
- assert.ok(diff.unsupported.some((u) => /new item/.test(u.change)));
197
- 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");
198
- });
199
- test("refusal 4 — a block with no upstream document is reported, never skipped", () => {
200
- const diff = diffPage({
201
- page: {
202
- slug: "/prices",
203
- blocks: [{ id: "b_new", type: "pba_pricingSection", props: { heading: "Hi" } }]
204
- },
205
- ctx,
206
- paths: sanityPaths,
207
- locate: () => null
208
- });
209
- assert.deepEqual(diff.patches, []);
210
- assert.equal(diff.unsupported.length, 1);
211
- assert.match(diff.unsupported[0].change, /new "pba_pricingSection" block/);
212
- assert.equal(diff.unsupported[0].where, "/prices → pba_pricingSection");
213
- });
214
- // ---------------------------------------------------------------------------
215
- // Lists, keys, and the reason index addressing is not the default
216
- // ---------------------------------------------------------------------------
217
- test("a list item is patched at its own key, not at its position", () => {
218
- const specs = {
219
- cards: {
220
- rehydrate: (props) => props.cards,
221
- itemFields: { title: plain("title") }
222
- }
223
- };
224
- // The upstream order is the reverse of the editor's. Index addressing would
225
- // write "Renamed" onto the wrong card.
226
- const diff = diffFields({
227
- specs,
228
- props: { cards: [{ _key: "k2", title: "Renamed" }, { _key: "k1", title: "One" }] },
229
- source: { cards: [{ _key: "k1", title: "One" }, { _key: "k2", title: "Two" }] },
230
- ctx,
231
- documentId: "doc1",
232
- where: "/ → cardGrid",
233
- paths: sanityPaths
234
- });
235
- assert.deepEqual(diff.patches, [
236
- { documentId: "doc1", path: 'cards[_key=="k2"].title', value: "Renamed" }
237
- ]);
238
- });
239
- test("index addressing is available and says what it costs", () => {
240
- const specs = {
241
- rows: {
242
- rehydrate: (props) => props.rows,
243
- itemFields: { label: plain("label") },
244
- itemKey: (item) => String(item.id)
245
- }
246
- };
247
- const diff = diffFields({
248
- specs,
249
- props: { rows: [{ id: "7", label: "After" }] },
250
- source: { rows: [{ id: "7", label: "Before" }] },
251
- ctx,
252
- documentId: "doc1",
253
- where: "/ → table",
254
- paths: indexPaths
255
- });
256
- assert.deepEqual(diff.patches, [{ documentId: "doc1", path: "rows[0].label", value: "After" }]);
257
- });
258
- test("a custom itemKey identifies items that have no _key", () => {
259
- const specs = {
260
- rows: {
261
- rehydrate: (props) => props.rows,
262
- itemFields: { label: plain("label") },
263
- itemKey: (item) => (typeof item.id === "string" ? item.id : undefined)
264
- }
265
- };
266
- const diff = diffFields({
267
- specs,
268
- props: { rows: [{ id: "r1", label: "After" }] },
269
- source: { rows: [{ id: "r1", label: "Before" }] },
270
- ctx,
271
- documentId: "doc1",
272
- where: "/ → table",
273
- paths: sanityPaths
274
- });
275
- assert.deepEqual(diff.patches, [
276
- { documentId: "doc1", path: 'rows[_key=="r1"].label', value: "After" }
277
- ]);
278
- });
279
- // ---------------------------------------------------------------------------
280
- // Routing across documents
281
- // ---------------------------------------------------------------------------
282
- test("one block can write to two documents", () => {
283
- // A block placed by a shared section: its heading override belongs to the
284
- // page, the rest of it belongs to the section every page shares.
285
- const diff = diffPage({
286
- page: {
287
- slug: "/",
288
- blocks: [{ id: "b1", type: "sharedSection", props: { heading: "Page heading", body: "Shared body" } }]
289
- },
290
- ctx,
291
- paths: sanityPaths,
292
- locate: () => [
293
- {
294
- documentId: "page-1",
295
- prefix: 'pageBuilder[_key=="b1"].',
296
- specs: { heading: { ...plain("heading"), cmsKey: "headingOverride" } },
297
- source: { headingOverride: "Old page heading" }
298
- },
299
- {
300
- documentId: "section-9",
301
- prefix: "content[0].",
302
- specs: { body: plain("body") },
303
- source: { body: "Old shared body" }
304
- }
305
- ]
306
- });
307
- assert.deepEqual(groupPatches(diff.patches), [
308
- { documentId: "page-1", set: { 'pageBuilder[_key=="b1"].headingOverride': "Page heading" } },
309
- { documentId: "section-9", set: { "content[0].body": "Shared body" } }
310
- ]);
311
- });
312
- test("a page with nothing changed issues no write at all", () => {
313
- const diff = diffPage({
314
- page: { slug: "/", blocks: [{ id: "b1", type: "hero", props: { heading: "Same" } }] },
315
- ctx,
316
- paths: sanityPaths,
317
- locate: () => ({
318
- documentId: "page-1",
319
- prefix: "",
320
- specs: { heading: plain("heading") },
321
- source: { heading: "Same" }
322
- })
323
- });
324
- assert.deepEqual(groupPatches(diff.patches), [], "an unchanged publish must not touch the CMS");
325
- });
326
- // ---------------------------------------------------------------------------
327
- // Helpers
328
- // ---------------------------------------------------------------------------
329
- test("deepEqual treats null and undefined as the same absence", () => {
330
- // A CMS that omits an empty field and an editor that sends `undefined` are
331
- // saying the same thing; treating them as a change rewrites every optional
332
- // field on every publish.
333
- assert.ok(deepEqual(null, undefined));
334
- assert.ok(deepEqual({ a: [1, { b: "c" }] }, { a: [1, { b: "c" }] }));
335
- assert.ok(!deepEqual({ a: 1 }, { a: 1, b: 2 }));
336
- assert.ok(!deepEqual([1, 2], { 0: 1, 1: 2 }));
337
- });
338
- test("mergeDiffs and describeUnsupported produce something a person can read", () => {
339
- const merged = mergeDiffs([
340
- { patches: [{ documentId: "d", path: "a", value: 1 }], unsupported: [] },
341
- {
342
- patches: [],
343
- unsupported: [{ where: "/ → hero", change: "the image was replaced", remedy: "Upload it first." }]
344
- }
345
- ]);
346
- assert.equal(merged.patches.length, 1);
347
- assert.deepEqual(describeUnsupported(merged.unsupported), [
348
- "/ → hero: the image was replaced — Upload it first."
349
- ]);
350
- });