@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,153 +0,0 @@
1
- import { test, beforeEach, afterEach } from "node:test";
2
- import assert from "node:assert/strict";
3
- import { fetchEditorPage, fetchEditorSlugs, getOrchestratorUrl, resetDraftFetchWarnings } from "./draft-fetch.js";
4
- let calls = [];
5
- let warnings = [];
6
- const realFetch = globalThis.fetch;
7
- const realWarn = console.warn;
8
- /** Answer every request with `status`, recording what was asked and with what. */
9
- function stubFetch(status, body = {}) {
10
- globalThis.fetch = (async (input, init) => {
11
- const headers = new Headers(init?.headers ?? {});
12
- calls.push({ url: String(input), token: headers.get("x-access-token") });
13
- return new Response(JSON.stringify(body), {
14
- status,
15
- headers: { "content-type": "application/json" }
16
- });
17
- });
18
- }
19
- beforeEach(() => {
20
- calls = [];
21
- warnings = [];
22
- resetDraftFetchWarnings();
23
- console.warn = (msg) => { warnings.push(String(msg)); };
24
- delete process.env.ORCHESTRATOR_ACCESS_TOKEN;
25
- });
26
- afterEach(() => {
27
- globalThis.fetch = realFetch;
28
- console.warn = realWarn;
29
- delete process.env.ORCHESTRATOR_ACCESS_TOKEN;
30
- });
31
- test("the configured access token travels with the request", async () => {
32
- process.env.ORCHESTRATOR_ACCESS_TOKEN = "s3cret";
33
- stubFetch(404);
34
- await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://example.test" });
35
- assert.equal(calls.length, 1);
36
- assert.equal(calls[0].token, "s3cret");
37
- });
38
- test("an explicit token beats the environment", async () => {
39
- process.env.ORCHESTRATOR_ACCESS_TOKEN = "from-env";
40
- stubFetch(404);
41
- await fetchEditorPage("/about", "dev", "site", {
42
- orchestratorUrl: "http://example.test",
43
- accessToken: "explicit"
44
- });
45
- assert.equal(calls[0].token, "explicit");
46
- });
47
- test("no credential configured sends no header, rather than an empty one", async () => {
48
- stubFetch(404);
49
- await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://example.test" });
50
- assert.equal(calls[0].token, null);
51
- });
52
- test("a refusal stops the walk instead of retrying the same missing credential", async () => {
53
- // `localhost` yields a second candidate (127.0.0.1); a 404 tries it, a 401
54
- // must not — the credential that was missing is missing there too.
55
- stubFetch(401, { error: "unauthorized" });
56
- const page = await fetchEditorPage("/about", "dev", "site", {
57
- orchestratorUrl: "http://localhost:4200"
58
- });
59
- assert.equal(page, null);
60
- assert.equal(calls.length, 1, "a 401 must not be spent on the next candidate URL");
61
- });
62
- test("a refusal is reported, because otherwise the page just looks right", async () => {
63
- stubFetch(401, { error: "unauthorized" });
64
- await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://example.test" });
65
- assert.equal(warnings.length, 1);
66
- assert.match(warnings[0], /401/);
67
- assert.match(warnings[0], /ORCHESTRATOR_ACCESS_TOKEN/);
68
- assert.match(warnings[0], /published content/);
69
- });
70
- test("the same refusal is not reported once per rendered page", async () => {
71
- stubFetch(401);
72
- await fetchEditorPage("/a", "dev", "site", { orchestratorUrl: "http://example.test" });
73
- await fetchEditorPage("/b", "dev", "site", { orchestratorUrl: "http://example.test" });
74
- assert.equal(warnings.length, 1);
75
- });
76
- test("a 404 still falls through to the other candidate host", async () => {
77
- stubFetch(404);
78
- await fetchEditorPage("/about", "dev", "site", { orchestratorUrl: "http://localhost:4200" });
79
- assert.equal(calls.length, 2, "the localhost/127.0.0.1 fallback must survive this change");
80
- });
81
- test("fetchEditorSlugs refuses the same way, and returns an empty list", async () => {
82
- stubFetch(401);
83
- const slugs = await fetchEditorSlugs("dev", "site", { orchestratorUrl: "http://localhost:4200" });
84
- assert.deepEqual(slugs, []);
85
- assert.equal(calls.length, 1);
86
- assert.match(warnings[0], /fetchEditorSlugs/);
87
- });
88
- /*
89
- * VI-04. With `ORCHESTRATOR_URL` unset, a library-mode site used to read its
90
- * drafts from `127.0.0.1:4200` — the standalone orchestrator it does not run.
91
- * When nothing is listening there the preview silently serves published content
92
- * and every edit looks inert; when something is, the fetch succeeds and the
93
- * preview renders a foreign project's pages. A mounted `createOrchestrator`
94
- * now publishes its own address, and it must win.
95
- */
96
- test("a mounted library-mode orchestrator beats the :4200 fallback", () => {
97
- const prev = process.env.ORCHESTRATOR_URL;
98
- const g = globalThis;
99
- const prevMount = g["__avocado_library_mount__"];
100
- try {
101
- delete process.env.ORCHESTRATOR_URL;
102
- g["__avocado_library_mount__"] = { declared: "http://localhost:3001/api/avocado" };
103
- assert.equal(getOrchestratorUrl(), "http://localhost:3001/api/avocado");
104
- // A real request the handler served outranks what config declared.
105
- g["__avocado_library_mount__"] = {
106
- declared: "http://localhost:3001/api/avocado",
107
- observed: "https://villa.example.com/api/avocado"
108
- };
109
- assert.equal(getOrchestratorUrl(), "https://villa.example.com/api/avocado");
110
- // And an explicit env var still outranks both.
111
- process.env.ORCHESTRATOR_URL = "http://localhost:9999/api/avocado";
112
- assert.equal(getOrchestratorUrl(), "http://localhost:9999/api/avocado");
113
- }
114
- finally {
115
- if (prev === undefined)
116
- delete process.env.ORCHESTRATOR_URL;
117
- else
118
- process.env.ORCHESTRATOR_URL = prev;
119
- if (prevMount === undefined)
120
- delete g["__avocado_library_mount__"];
121
- else
122
- g["__avocado_library_mount__"] = prevMount;
123
- }
124
- });
125
- test("with nothing mounted the fallback still applies, and says so out loud", () => {
126
- const prev = process.env.ORCHESTRATOR_URL;
127
- const g = globalThis;
128
- const prevMount = g["__avocado_library_mount__"];
129
- const said = [];
130
- const prevWarn = console.warn;
131
- try {
132
- delete process.env.ORCHESTRATOR_URL;
133
- delete g["__avocado_library_mount__"];
134
- console.warn = (...args) => { said.push(args.join(" ")); };
135
- assert.equal(getOrchestratorUrl(), "http://127.0.0.1:4200");
136
- assert.equal(said.length, 1, "the fallback warns exactly once");
137
- assert.match(said[0], /ORCHESTRATOR_URL is not set/);
138
- assert.match(said[0], /createOrchestrator/);
139
- getOrchestratorUrl();
140
- assert.equal(said.length, 1, "and not on every subsequent read");
141
- }
142
- finally {
143
- console.warn = prevWarn;
144
- if (prev === undefined)
145
- delete process.env.ORCHESTRATOR_URL;
146
- else
147
- process.env.ORCHESTRATOR_URL = prev;
148
- if (prevMount === undefined)
149
- delete g["__avocado_library_mount__"];
150
- else
151
- g["__avocado_library_mount__"] = prevMount;
152
- }
153
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,73 +0,0 @@
1
- /*
2
- * `/api/editor/blocks` is the endpoint the editor actually reads, and it is
3
- * served by a different route module than the orchestrator.
4
- *
5
- * `createOrchestrator({ blockTypes })` declares the catalogue too, and because
6
- * the declaration lives on `globalThis` that looked like enough for both. It is
7
- * not: a Next route module is evaluated on the first request *to that route*,
8
- * so an editor that asks for the manifest before anything has touched
9
- * `/api/avocado/*` is answered by a process where `createOrchestrator` has never
10
- * run. What comes back is the site's own blocks plus all of Avocado's built-ins,
11
- * which the site has no renderer for — and an agent reading that list will
12
- * happily add a `Hero` that applies cleanly and draws nothing.
13
- *
14
- * These tests therefore never construct an orchestrator.
15
- */
16
- import { test } from "node:test";
17
- import assert from "node:assert/strict";
18
- import { z, registerBlock, declareBlockCatalogue } from "@avocadostudio-ai/shared";
19
- import { createEditorApiHandler } from "./editor-api-handler.js";
20
- registerBlock("site_ownHero", {
21
- schema: z.object({ headline: z.string() }).catchall(z.unknown()),
22
- meta: { displayName: "Own Hero", fields: { headline: { kind: "text" } } }
23
- });
24
- async function manifestTypes(config) {
25
- const { GET } = createEditorApiHandler(config);
26
- const res = await GET(new Request("http://site.test/api/editor/blocks"), {
27
- params: Promise.resolve({ path: ["blocks"] })
28
- });
29
- assert.equal(res.status, 200);
30
- const body = (await res.json());
31
- return body.blocks.map((b) => b.type);
32
- }
33
- test("without blockTypes the manifest still offers Avocado's built-ins", async () => {
34
- declareBlockCatalogue(null);
35
- const types = await manifestTypes({ getPages: () => [] });
36
- assert.ok(types.includes("site_ownHero"));
37
- assert.ok(types.includes("Hero"), "the built-ins are registered transitively and nothing has narrowed them");
38
- });
39
- test("blockTypes narrows /api/editor/blocks with no orchestrator in the process", async () => {
40
- declareBlockCatalogue(null);
41
- const types = await manifestTypes({ getPages: () => [], blockTypes: ["site_ownHero"] });
42
- assert.deepEqual(types, ["site_ownHero"]);
43
- });
44
- test("blockTypes composes with registerBlocks", async () => {
45
- declareBlockCatalogue(null);
46
- let registered = 0;
47
- const types = await manifestTypes({
48
- getPages: () => [],
49
- blockTypes: ["site_lateBlock"],
50
- registerBlocks: () => {
51
- registered += 1;
52
- registerBlock("site_lateBlock", {
53
- schema: z.object({ title: z.string() }).catchall(z.unknown()),
54
- meta: { displayName: "Late Block", fields: { title: { kind: "text" } } }
55
- });
56
- }
57
- });
58
- assert.equal(registered, 1);
59
- assert.deepEqual(types, ["site_lateBlock"]);
60
- });
61
- test("the declaration is re-made per request, so another copy cannot lift it", async () => {
62
- declareBlockCatalogue(null);
63
- const { GET } = createEditorApiHandler({ getPages: () => [], blockTypes: ["site_ownHero"] });
64
- const ask = async () => {
65
- const res = await GET(new Request("http://site.test/api/editor/blocks"), {
66
- params: Promise.resolve({ path: ["blocks"] })
67
- });
68
- return (await res.json()).blocks.map((b) => b.type);
69
- };
70
- assert.deepEqual(await ask(), ["site_ownHero"]);
71
- declareBlockCatalogue(null); // as if another module had lifted it
72
- assert.deepEqual(await ask(), ["site_ownHero"]);
73
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,85 +0,0 @@
1
- import assert from "node:assert/strict";
2
- import test from "node:test";
3
- import { applyEditorCors, getEditorCorsOrigins, resetEditorCorsOrigins } from "./editor-cors.js";
4
- function withEnv(env, fn) {
5
- const previous = {};
6
- for (const [key, value] of Object.entries(env)) {
7
- previous[key] = process.env[key];
8
- if (value === undefined)
9
- delete process.env[key];
10
- else
11
- process.env[key] = value;
12
- }
13
- resetEditorCorsOrigins();
14
- try {
15
- fn();
16
- }
17
- finally {
18
- for (const [key, value] of Object.entries(previous)) {
19
- if (value === undefined)
20
- delete process.env[key];
21
- else
22
- process.env[key] = value;
23
- }
24
- resetEditorCorsOrigins();
25
- }
26
- }
27
- const CLEAN = { EDITOR_CORS_ORIGINS: undefined, NEXT_PUBLIC_EDITOR_ORIGIN: undefined };
28
- test("the dev editor origin is allowed in development", () => {
29
- withEnv({ ...CLEAN, NODE_ENV: "development" }, () => {
30
- assert.ok(getEditorCorsOrigins().has("http://localhost:4100"));
31
- });
32
- });
33
- test("the dev editor origin is not allowed in production", () => {
34
- /*
35
- * The bug this replaces: a deployed site answered CORS-approved requests to
36
- * localhost:4100, so any page a visitor had open could read the site's drafts
37
- * out of that visitor's browser.
38
- */
39
- withEnv({ ...CLEAN, NODE_ENV: "production" }, () => {
40
- assert.equal(getEditorCorsOrigins().has("http://localhost:4100"), false);
41
- });
42
- });
43
- test("a production deployment allows exactly what it names", () => {
44
- withEnv({ ...CLEAN, NODE_ENV: "production", EDITOR_CORS_ORIGINS: "https://editor.example.com" }, () => {
45
- const origins = getEditorCorsOrigins();
46
- assert.ok(origins.has("https://editor.example.com"));
47
- assert.equal(origins.size, 1);
48
- });
49
- });
50
- test("the editor origin the app already advertises does not have to be named twice", () => {
51
- withEnv({ ...CLEAN, NODE_ENV: "production", NEXT_PUBLIC_EDITOR_ORIGIN: "https://editor.example.com/" }, () => {
52
- // Trailing slash stripped — an Origin header never has one, so a config
53
- // that carries one would silently match nothing.
54
- assert.ok(getEditorCorsOrigins().has("https://editor.example.com"));
55
- });
56
- });
57
- test("an allowed origin gets the CORS headers and a disallowed one does not", () => {
58
- withEnv({ ...CLEAN, NODE_ENV: "production", EDITOR_CORS_ORIGINS: "https://editor.example.com" }, () => {
59
- const allowed = applyEditorCors(new Response(null), "https://editor.example.com");
60
- assert.equal(allowed.headers.get("Access-Control-Allow-Origin"), "https://editor.example.com");
61
- const denied = applyEditorCors(new Response(null), "https://evil.example.com");
62
- assert.equal(denied.headers.get("Access-Control-Allow-Origin"), null);
63
- // Vary is set either way, or a cache would serve one origin's answer to another.
64
- assert.equal(denied.headers.get("Vary"), "Origin");
65
- });
66
- });
67
- /*
68
- * The CLI binds `127.0.0.1` and prints that URL; the docs all say `localhost`.
69
- * Origins compare as strings, so the editor our own CLI starts has to be
70
- * allowed under the spelling our own CLI printed.
71
- */
72
- test("development allows both spellings of the loopback editor origin", () => {
73
- withEnv({ ...CLEAN, NODE_ENV: "development" }, () => {
74
- const origins = getEditorCorsOrigins();
75
- assert.ok(origins.has("http://localhost:4100"), "the spelling the docs use");
76
- assert.ok(origins.has("http://127.0.0.1:4100"), "the spelling the CLI prints");
77
- });
78
- });
79
- test("production still allows neither by default", () => {
80
- withEnv({ ...CLEAN, NODE_ENV: "production" }, () => {
81
- const origins = getEditorCorsOrigins();
82
- assert.equal(origins.has("http://localhost:4100"), false);
83
- assert.equal(origins.has("http://127.0.0.1:4100"), false);
84
- });
85
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,44 +0,0 @@
1
- import { strict as assert } from "node:assert";
2
- import { test } from "node:test";
3
- import { EDITOR_RENDER_HEADER, isEditorRenderFrom } from "./editor-render.js";
4
- import { editorPreviewRewrite } from "./proxy.js";
5
- import { NextRequest } from "next/server";
6
- const noHeaders = () => null;
7
- test("a layout with neither the header nor draft mode is rendering the public page", () => {
8
- assert.equal(isEditorRenderFrom(noHeaders, false), false);
9
- });
10
- test("the header the proxy stamps is what a layout reads", () => {
11
- const get = (name) => (name === EDITOR_RENDER_HEADER ? "1" : null);
12
- assert.equal(isEditorRenderFrom(get, false), true);
13
- });
14
- /*
15
- * The editor renders the site in a cross-origin iframe, where the draft cookie
16
- * is frequently blocked outright — which is why the header exists at all. Draft
17
- * mode still counts, for a site whose preview reaches the route some other way.
18
- */
19
- test("draft mode alone is enough, for a preview that arrives without the proxy", () => {
20
- assert.equal(isEditorRenderFrom(noHeaders, true), true);
21
- });
22
- test("only the exact value counts, so a stray header does not blank the site's analytics", () => {
23
- assert.equal(isEditorRenderFrom(() => "true", false), false);
24
- assert.equal(isEditorRenderFrom(() => "", false), false);
25
- });
26
- /*
27
- * The two halves have to agree: a header the proxy does not set is a layout
28
- * that never learns, and the failure is silent — the preview simply keeps
29
- * loading the consent banner.
30
- */
31
- test("the preview rewrite stamps the header isEditorRender reads", () => {
32
- const response = editorPreviewRewrite(new NextRequest("https://site.test/about?__editor=1"));
33
- assert.ok(response, "an editor request must be rewritten");
34
- assert.equal(response.headers.get(`x-middleware-request-${EDITOR_RENDER_HEADER}`), "1");
35
- });
36
- test("a published request is not rewritten and carries no header", () => {
37
- assert.equal(editorPreviewRewrite(new NextRequest("https://site.test/about")), null);
38
- });
39
- test("a header the client sent is replaced by the proxy's own answer", () => {
40
- const response = editorPreviewRewrite(new NextRequest("https://site.test/about?__editor=1", {
41
- headers: { [EDITOR_RENDER_HEADER]: "0" }
42
- }));
43
- assert.equal(response?.headers.get(`x-middleware-request-${EDITOR_RENDER_HEADER}`), "1");
44
- });
@@ -1 +0,0 @@
1
- export {};
@@ -1,221 +0,0 @@
1
- import { strict as assert } from "node:assert";
2
- import { test } from "node:test";
3
- import { createLens } from "./create-lens.js";
4
- import { storyblokPrimitives, storyblokLocale } from "./storyblok.js";
5
- import { sanityPrimitives, sanityLocale } from "./sanity.js";
6
- // ---------------------------------------------------------------------------
7
- // Storyblok: a suffixed sibling key per language
8
- // ---------------------------------------------------------------------------
9
- const TABLE = {
10
- hero_section: {
11
- displayName: "Hero",
12
- topLevel: true,
13
- fields: {
14
- title: { kind: "text" },
15
- background_image: { kind: "image" },
16
- cta_link: { kind: "link" },
17
- anchor: { kind: "text", localized: false },
18
- buttons: { kind: "list", of: ["button"] }
19
- }
20
- },
21
- button: {
22
- displayName: "Button",
23
- topLevel: false,
24
- fields: {
25
- label: { kind: "text" },
26
- variant: { kind: "enum", options: ["solid", "outline"] }
27
- }
28
- }
29
- };
30
- const sbLocale = storyblokLocale("de", ["de", "fr"]);
31
- const sb = createLens({ table: TABLE, locale: sbLocale, primitives: storyblokPrimitives() });
32
- function story() {
33
- return {
34
- component: "hero_section",
35
- _uid: "u_hero",
36
- title: "Willkommen",
37
- title__i18n__fr: "Bienvenue",
38
- background_image: { fieldtype: "asset", id: 9, filename: "https://a.storyblok.com/f/1/a.jpg", alt: "Halle" },
39
- cta_link: { fieldtype: "multilink", linktype: "url", url: "https://book.example.com" },
40
- anchor: "top",
41
- // Declared by nobody. It has to survive every write below.
42
- layout_variant: "wide",
43
- buttons: [
44
- { component: "button", _uid: "u_b1", label: "Buchen", label__i18n__fr: "Réserver", variant: "solid" },
45
- { component: "spacer", _uid: "u_x1", height: 24 }
46
- ]
47
- };
48
- }
49
- test("a field the table does not declare is not projected", () => {
50
- const props = sb.project(story(), "hero_section", "de");
51
- assert.equal("layout_variant" in props, false);
52
- assert.equal(props.title, "Willkommen");
53
- });
54
- test("an image projects to two props, a URL and its alt", () => {
55
- const props = sb.project(story(), "hero_section", "de");
56
- assert.equal(props.background_image, "https://a.storyblok.com/f/1/a.jpg");
57
- assert.equal(props.background_image_alt, "Halle");
58
- });
59
- test("a language reads its own slot, and falls back to the default when absent", () => {
60
- assert.equal(sb.project(story(), "hero_section", "fr").title, "Bienvenue");
61
- const noFrench = { ...story(), title__i18n__fr: undefined };
62
- assert.equal(sb.project(noFrench, "hero_section", "fr").title, "Willkommen");
63
- });
64
- /*
65
- * The rule the whole file turns on. French has no value for `anchor` — it is
66
- * declared `localized: false` — and no value for the image alt, so the
67
- * projection carries German strings sitting in a French page. Merging that
68
- * projection back must write none of them: each one would become a real,
69
- * fabricated French translation, invisible on screen and permanent in the
70
- * document.
71
- */
72
- test("a projection merged straight back changes nothing, in either language", () => {
73
- for (const lang of ["de", "fr"]) {
74
- const result = sb.roundTrip(story(), "hero_section", lang);
75
- assert.equal(result.clean, true, `${lang}: ${result.fields.join(", ")}`);
76
- }
77
- });
78
- test("a real edit is written into the language being edited, and only there", () => {
79
- const props = sb.project(story(), "hero_section", "fr");
80
- const { doc, changed } = sb.merge(story(), { ...props, title: "Salut" }, "hero_section", "fr");
81
- assert.equal(changed, true);
82
- assert.equal(doc.title__i18n__fr, "Salut");
83
- assert.equal(doc.title, "Willkommen", "the German value must not move");
84
- });
85
- test("a field nobody declared survives a write", () => {
86
- const props = sb.project(story(), "hero_section", "de");
87
- const { doc } = sb.merge(story(), { ...props, title: "Neu" }, "hero_section", "de");
88
- assert.equal(doc.layout_variant, "wide");
89
- });
90
- test("a non-localised field writes to the bare key whatever page is edited", () => {
91
- const props = sb.project(story(), "hero_section", "fr");
92
- const { doc } = sb.merge(story(), { ...props, anchor: "hero" }, "hero_section", "fr");
93
- assert.equal(doc.anchor, "hero");
94
- assert.equal("anchor__i18n__fr" in doc, false);
95
- });
96
- /*
97
- * A story link is a reference rendered per-locale, so its href cannot be
98
- * written back — but an external URL is stored as itself and stays editable,
99
- * which is the case that matters (booking and shop links).
100
- */
101
- test("an external link is writable and a story link is refused with a reason", () => {
102
- const props = sb.project(story(), "hero_section", "de");
103
- const external = sb.merge(story(), { ...props, cta_link: "https://shop.example.com" }, "hero_section", "de");
104
- assert.equal(external.doc.cta_link.url, "https://shop.example.com");
105
- assert.equal(external.warnings.length, 0);
106
- const withStoryLink = { ...story(), cta_link: { fieldtype: "multilink", linktype: "story", cached_url: "faq" } };
107
- const refused = sb.merge(withStoryLink, { ...sb.project(withStoryLink, "hero_section", "de"), cta_link: "/anything" }, "hero_section", "de");
108
- assert.equal(refused.changed, false);
109
- assert.match(refused.warnings[0].reason, /points at a page in the CMS/);
110
- });
111
- test("a child row is matched by its own id, not by position", () => {
112
- const props = sb.project(story(), "hero_section", "de");
113
- const buttons = props.buttons;
114
- assert.equal(buttons.length, 1, "an undeclared child type is not projected");
115
- assert.equal(buttons[0]._uid, "u_b1");
116
- const reordered = { ...props, buttons: [{ ...buttons[0], label: "Jetzt buchen" }] };
117
- const { doc } = sb.merge(story(), reordered, "hero_section", "de");
118
- const merged = doc.buttons;
119
- assert.equal(merged.find((b) => b._uid === "u_b1").label, "Jetzt buchen");
120
- });
121
- /*
122
- * A list can hold types the table does not describe, and dropping them is how
123
- * an integration deletes content it was never asked about.
124
- */
125
- test("a child row of an undeclared type keeps its place through a list edit", () => {
126
- const props = sb.project(story(), "hero_section", "de");
127
- const buttons = props.buttons;
128
- const { doc } = sb.merge(story(), { ...props, buttons: [{ ...buttons[0], label: "X" }] }, "hero_section", "de");
129
- const merged = doc.buttons;
130
- assert.ok(merged.some((b) => b._uid === "u_x1"), "the spacer must survive");
131
- });
132
- test("a value outside a closed list is refused rather than stored", () => {
133
- const source = { component: "button", _uid: "u_b1", label: "Buchen", variant: "solid" };
134
- const result = sb.merge(source, { label: "Buchen", variant: "ghost" }, "button", "de");
135
- assert.equal(result.changed, false);
136
- assert.match(result.warnings[0].reason, /not one of solid, outline/);
137
- });
138
- // ---------------------------------------------------------------------------
139
- // Sanity: a per-locale object under the key itself
140
- // ---------------------------------------------------------------------------
141
- /*
142
- * The same table, the same lens, a CMS that disagrees in every particular — a
143
- * per-locale object rather than a suffixed key, a reference rather than a flat
144
- * asset, `_key` rather than `_uid`. If anything above `Primitives` had turned
145
- * out to be Storyblok-shaped, it would show up here.
146
- */
147
- const SANITY_TABLE = {
148
- heroSplit: {
149
- displayName: "Hero — split",
150
- fields: {
151
- heading: { kind: "text" },
152
- // Sanity localises only what the schema marks translatable, and an image
153
- // is not: it is one asset reference for every language, at the bare key
154
- // rather than inside a `{de, fr}` container. Declaring that is the
155
- // difference between reading the image and reading nothing.
156
- hero: { kind: "image", localized: false },
157
- // The list *container* is one array for every language; what is
158
- // translated are the fields on its rows. Storyblok and Sanity differ here
159
- // and the table is where the difference is stated.
160
- buttons: {
161
- kind: "list",
162
- localized: false,
163
- itemFields: { label: { kind: "text" }, variant: { kind: "enum", options: ["solid", "outline"] } }
164
- }
165
- }
166
- }
167
- };
168
- const sanity = createLens({
169
- table: SANITY_TABLE,
170
- locale: sanityLocale("de", ["de", "fr"]),
171
- primitives: sanityPrimitives()
172
- });
173
- function sanityDoc() {
174
- return {
175
- _type: "heroSplit",
176
- _key: "k_hero",
177
- heading: { de: "Willkommen", fr: "Bienvenue" },
178
- hero: { _type: "image", asset: { _ref: "image-abc", url: "https://cdn.sanity.io/a.jpg" }, alt: "Halle" },
179
- buttons: [{ _key: "k_b1", label: { de: "Buchen", fr: "Réserver" }, variant: "solid" }]
180
- };
181
- }
182
- test("the same lens reads a per-locale object", () => {
183
- assert.equal(sanity.project(sanityDoc(), "heroSplit", "de").heading, "Willkommen");
184
- assert.equal(sanity.project(sanityDoc(), "heroSplit", "fr").heading, "Bienvenue");
185
- });
186
- test("an image names its props the way the asset picker expects", () => {
187
- const props = sanity.project(sanityDoc(), "heroSplit", "de");
188
- assert.equal(props.heroUrl, "https://cdn.sanity.io/a.jpg");
189
- assert.equal(props.heroAlt, "Halle");
190
- });
191
- test("a Sanity projection merged back changes nothing either", () => {
192
- for (const lang of ["de", "fr"]) {
193
- const result = sanity.roundTrip(sanityDoc(), "heroSplit", lang);
194
- assert.equal(result.clean, true, `${lang}: ${result.fields.join(", ")}`);
195
- }
196
- });
197
- test("an edit lands in its own locale slot and leaves the other alone", () => {
198
- const props = sanity.project(sanityDoc(), "heroSplit", "fr");
199
- const { doc } = sanity.merge(sanityDoc(), { ...props, heading: "Salut" }, "heroSplit", "fr");
200
- assert.deepEqual(doc.heading, { de: "Willkommen", fr: "Salut" });
201
- });
202
- /*
203
- * The URL is a render of `asset._ref`, so writing it back would replace a
204
- * reference with a string. The alt text is content and stays editable — the
205
- * asymmetry is the point.
206
- */
207
- test("an image's alt is writable and its resolved URL is not", () => {
208
- const props = sanity.project(sanityDoc(), "heroSplit", "de");
209
- const { doc } = sanity.merge(sanityDoc(), { ...props, heroAlt: "Die Halle", heroUrl: "https://evil.example/x.jpg" }, "heroSplit", "de");
210
- const image = doc.hero;
211
- assert.equal(image.alt, "Die Halle");
212
- assert.deepEqual(image.asset, { _ref: "image-abc", url: "https://cdn.sanity.io/a.jpg" });
213
- });
214
- test("an inline-described row merges by _key", () => {
215
- const props = sanity.project(sanityDoc(), "heroSplit", "de");
216
- const buttons = props.buttons;
217
- assert.equal(buttons[0]._key, "k_b1");
218
- const { doc } = sanity.merge(sanityDoc(), { ...props, buttons: [{ ...buttons[0], label: "Jetzt" }] }, "heroSplit", "de");
219
- const merged = doc.buttons;
220
- assert.deepEqual(merged[0].label, { de: "Jetzt", fr: "Réserver" });
221
- });
@@ -1 +0,0 @@
1
- export {};