@avocadostudio-ai/site-sdk 0.6.0 → 0.7.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.
- package/dist/draft.d.ts +1 -0
- package/dist/draft.js +3 -0
- package/dist/editor-render.d.ts +43 -0
- package/dist/editor-render.js +71 -0
- package/dist/editor-render.test.d.ts +1 -0
- package/dist/editor-render.test.js +44 -0
- package/dist/lens/create-lens.d.ts +48 -0
- package/dist/lens/create-lens.js +349 -0
- package/dist/lens/index.d.ts +7 -0
- package/dist/lens/index.js +25 -0
- package/dist/lens/lens.test.d.ts +1 -0
- package/dist/lens/lens.test.js +221 -0
- package/dist/lens/register.d.ts +34 -0
- package/dist/lens/register.js +148 -0
- package/dist/lens/register.test.d.ts +1 -0
- package/dist/lens/register.test.js +81 -0
- package/dist/lens/sanity.d.ts +28 -0
- package/dist/lens/sanity.js +187 -0
- package/dist/lens/scalar-codecs.d.ts +26 -0
- package/dist/lens/scalar-codecs.js +92 -0
- package/dist/lens/storyblok.d.ts +28 -0
- package/dist/lens/storyblok.js +232 -0
- package/dist/lens/types.d.ts +243 -0
- package/dist/lens/types.js +28 -0
- package/dist/markers.d.ts +37 -1
- package/dist/markers.js +32 -2
- package/dist/markers.test.d.ts +1 -0
- package/dist/markers.test.js +35 -0
- package/dist/proxy.js +17 -1
- package/package.json +21 -5
package/dist/draft.d.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
1
|
export { resolveEditorContext, single } from "./draft-context.ts";
|
|
2
|
+
export { isEditorRender, isEditorRenderFrom, EDITOR_RENDER_HEADER } from "./editor-render.ts";
|
|
2
3
|
export { getOrchestratorUrl, fetchEditorPage, fetchEditorSlugs, fetchEditorSiteConfig } from "./draft-fetch.ts";
|
|
3
4
|
export { DRAFT_SESSION_COOKIE, DRAFT_SITE_COOKIE, EDITOR_ORIGIN_COOKIE } from "./draft-common.ts";
|
package/dist/draft.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
// Editor context resolution
|
|
2
2
|
export { resolveEditorContext, single } from "./draft-context.js";
|
|
3
|
+
// Whether this render is the editor's preview — answerable from a layout,
|
|
4
|
+
// where `resolveEditorContext` cannot reach because layouts get no searchParams.
|
|
5
|
+
export { isEditorRender, isEditorRenderFrom, EDITOR_RENDER_HEADER } from "./editor-render.js";
|
|
3
6
|
// Editor content fetching
|
|
4
7
|
export { getOrchestratorUrl, fetchEditorPage, fetchEditorSlugs, fetchEditorSiteConfig } from "./draft-fetch.js";
|
|
5
8
|
// Draft cookie constants
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
declare const EDITOR_RENDER_HEADER = "x-avocado-editor-render";
|
|
2
|
+
export { EDITOR_RENDER_HEADER };
|
|
3
|
+
/**
|
|
4
|
+
* The pure half, so the decision is testable without a Next request.
|
|
5
|
+
*
|
|
6
|
+
* `getHeader` is whatever `headers()` gives you; `isDraftMode` is
|
|
7
|
+
* `draftMode().isEnabled`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function isEditorRenderFrom(getHeader: (name: string) => string | null | undefined, isDraftMode: boolean): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* `true` when this render is being drawn inside the Avocado editor.
|
|
12
|
+
*
|
|
13
|
+
* Call it in the root layout and gate everything a preview should not carry:
|
|
14
|
+
*
|
|
15
|
+
* ```tsx
|
|
16
|
+
* export default async function RootLayout({ children }) {
|
|
17
|
+
* const inEditor = await isEditorRender()
|
|
18
|
+
* return (
|
|
19
|
+
* <html lang="de">
|
|
20
|
+
* <body>
|
|
21
|
+
* {children}
|
|
22
|
+
* {!inEditor && <CookieConsent />}
|
|
23
|
+
* {!inEditor && <Analytics />}
|
|
24
|
+
* </body>
|
|
25
|
+
* </html>
|
|
26
|
+
* )
|
|
27
|
+
* }
|
|
28
|
+
* ```
|
|
29
|
+
*
|
|
30
|
+
* **It answers a rendering question, not an authorization one.** What may see
|
|
31
|
+
* unpublished content is decided by `resolveEditorContext`, which requires draft
|
|
32
|
+
* mode or a valid secret; this only decides whether to mount third-party
|
|
33
|
+
* scripts. The header can be sent by anyone, and the worst a forged one does is
|
|
34
|
+
* opt that visitor out of the site's own analytics and consent banner — which
|
|
35
|
+
* is self-consistent, because the tracking those scripts set up does not happen
|
|
36
|
+
* either. Never gate content or credentials on it.
|
|
37
|
+
*
|
|
38
|
+
* Reading `headers()` opts the calling segment into dynamic rendering. That is
|
|
39
|
+
* already true of any layout that reads cookies for a session, and it is the
|
|
40
|
+
* price of a layout knowing anything about the request at all — but it is worth
|
|
41
|
+
* knowing before adding the call to a fully static layout.
|
|
42
|
+
*/
|
|
43
|
+
export declare function isEditorRender(): Promise<boolean>;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Whether this render is the editor's preview, answerable from a layout.
|
|
3
|
+
*
|
|
4
|
+
* `resolveEditorContext()` already tells a *page* it is being previewed, and
|
|
5
|
+
* that is the wrong half of the tree for the thing sites keep getting wrong.
|
|
6
|
+
* Consent banners, analytics, tag managers and other visual editors' bridges
|
|
7
|
+
* are mounted in the root layout, and a Next layout receives no `searchParams`
|
|
8
|
+
* — so the natural implementation renders the real layout and brings all of
|
|
9
|
+
* them into the iframe.
|
|
10
|
+
*
|
|
11
|
+
* Measured on one integration, per preview render: three uncaught cross-origin
|
|
12
|
+
* errors from the consent platform reaching for `parent.location`, a cookie
|
|
13
|
+
* banner covering the page being edited, and a `page_view` written into the
|
|
14
|
+
* site's own analytics for every block an editor clicked through — twenty
|
|
15
|
+
* blocks, twenty pageviews, attributed to whoever was editing. Nobody would
|
|
16
|
+
* choose that, and nobody was asked.
|
|
17
|
+
*
|
|
18
|
+
* The signal is a request header the proxy sets when it rewrites to the preview
|
|
19
|
+
* route, because that is the one thing available to a layout regardless of
|
|
20
|
+
* where the site put its own state. Draft mode is checked too, for a site whose
|
|
21
|
+
* preview reaches the route some other way.
|
|
22
|
+
*/
|
|
23
|
+
const EDITOR_RENDER_HEADER = "x-avocado-editor-render";
|
|
24
|
+
export { EDITOR_RENDER_HEADER };
|
|
25
|
+
/**
|
|
26
|
+
* The pure half, so the decision is testable without a Next request.
|
|
27
|
+
*
|
|
28
|
+
* `getHeader` is whatever `headers()` gives you; `isDraftMode` is
|
|
29
|
+
* `draftMode().isEnabled`.
|
|
30
|
+
*/
|
|
31
|
+
export function isEditorRenderFrom(getHeader, isDraftMode) {
|
|
32
|
+
return getHeader(EDITOR_RENDER_HEADER) === "1" || isDraftMode;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* `true` when this render is being drawn inside the Avocado editor.
|
|
36
|
+
*
|
|
37
|
+
* Call it in the root layout and gate everything a preview should not carry:
|
|
38
|
+
*
|
|
39
|
+
* ```tsx
|
|
40
|
+
* export default async function RootLayout({ children }) {
|
|
41
|
+
* const inEditor = await isEditorRender()
|
|
42
|
+
* return (
|
|
43
|
+
* <html lang="de">
|
|
44
|
+
* <body>
|
|
45
|
+
* {children}
|
|
46
|
+
* {!inEditor && <CookieConsent />}
|
|
47
|
+
* {!inEditor && <Analytics />}
|
|
48
|
+
* </body>
|
|
49
|
+
* </html>
|
|
50
|
+
* )
|
|
51
|
+
* }
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* **It answers a rendering question, not an authorization one.** What may see
|
|
55
|
+
* unpublished content is decided by `resolveEditorContext`, which requires draft
|
|
56
|
+
* mode or a valid secret; this only decides whether to mount third-party
|
|
57
|
+
* scripts. The header can be sent by anyone, and the worst a forged one does is
|
|
58
|
+
* opt that visitor out of the site's own analytics and consent banner — which
|
|
59
|
+
* is self-consistent, because the tracking those scripts set up does not happen
|
|
60
|
+
* either. Never gate content or credentials on it.
|
|
61
|
+
*
|
|
62
|
+
* Reading `headers()` opts the calling segment into dynamic rendering. That is
|
|
63
|
+
* already true of any layout that reads cookies for a session, and it is the
|
|
64
|
+
* price of a layout knowing anything about the request at all — but it is worth
|
|
65
|
+
* knowing before adding the call to a fully static layout.
|
|
66
|
+
*/
|
|
67
|
+
export async function isEditorRender() {
|
|
68
|
+
const { headers, draftMode } = await import("next/headers");
|
|
69
|
+
const [headerList, draft] = await Promise.all([headers(), draftMode()]);
|
|
70
|
+
return isEditorRenderFrom((name) => headerList.get(name), draft.isEnabled);
|
|
71
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,44 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { FieldTable, LocaleLens, Primitives } from "./types.ts";
|
|
2
|
+
export type LensOptions<L extends string = string> = {
|
|
3
|
+
table: FieldTable;
|
|
4
|
+
locale: LocaleLens<L>;
|
|
5
|
+
primitives: Primitives;
|
|
6
|
+
};
|
|
7
|
+
/** Where a write was refused or degraded, and why. */
|
|
8
|
+
export type MergeWarning = {
|
|
9
|
+
/** `/preise > hero_section > title` — where it happened. */
|
|
10
|
+
where: string;
|
|
11
|
+
reason: string;
|
|
12
|
+
};
|
|
13
|
+
export type MergeResult<D> = {
|
|
14
|
+
doc: D;
|
|
15
|
+
changed: boolean;
|
|
16
|
+
warnings: MergeWarning[];
|
|
17
|
+
};
|
|
18
|
+
export type ProjectOptions = {
|
|
19
|
+
/**
|
|
20
|
+
* `true` when the document already came out of the CMS with a language
|
|
21
|
+
* applied, so the bare keys hold the right values and the localised slots
|
|
22
|
+
* must not be consulted.
|
|
23
|
+
*
|
|
24
|
+
* Delivery APIs resolve; management APIs do not. Getting this wrong is
|
|
25
|
+
* silent in one direction — a resolved document read as raw finds nothing in
|
|
26
|
+
* the suffixed keys and falls back to the same values it already had.
|
|
27
|
+
*/
|
|
28
|
+
resolved?: boolean;
|
|
29
|
+
};
|
|
30
|
+
type Doc = Record<string, unknown>;
|
|
31
|
+
declare function humanise(key: string): string;
|
|
32
|
+
export declare function createLens<L extends string = string>(options: LensOptions<L>): {
|
|
33
|
+
project: (doc: Doc, type: string, lang: L, opts?: ProjectOptions) => Doc;
|
|
34
|
+
merge: (source: Doc, props: Doc, type: string, lang: L, where?: string, opts?: {
|
|
35
|
+
checkLang?: L;
|
|
36
|
+
}) => MergeResult<Doc>;
|
|
37
|
+
roundTrip: (doc: Doc, type: string, lang: L, opts?: ProjectOptions) => {
|
|
38
|
+
clean: boolean;
|
|
39
|
+
fields: string[];
|
|
40
|
+
warnings: MergeWarning[];
|
|
41
|
+
};
|
|
42
|
+
table: FieldTable;
|
|
43
|
+
locale: LocaleLens<L>;
|
|
44
|
+
primitives: Primitives;
|
|
45
|
+
label: typeof humanise;
|
|
46
|
+
};
|
|
47
|
+
export type Lens<L extends string = string> = ReturnType<typeof createLens<L>>;
|
|
48
|
+
export {};
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The derivation: one field table, read as props and written back.
|
|
3
|
+
*
|
|
4
|
+
* `project` is the getter and `merge` is the setter, and the discipline of the
|
|
5
|
+
* whole thing is that they are inverses — a projection fed back through the
|
|
6
|
+
* merge must produce no change at all. That is not a property you reason out
|
|
7
|
+
* from the shapes; it is one you run against real content, which is why
|
|
8
|
+
* `roundTrip` below is part of the API rather than a script each integration
|
|
9
|
+
* writes for itself.
|
|
10
|
+
*
|
|
11
|
+
* Two rules govern every write, and both came out of running a projection
|
|
12
|
+
* through its own inverse over a real dataset rather than out of thinking about
|
|
13
|
+
* it:
|
|
14
|
+
*
|
|
15
|
+
* 1. **Unchanged means untouched.** A CMS with per-language fallback resolves
|
|
16
|
+
* a missing translation to the default language, which is correct on screen
|
|
17
|
+
* and a lie in storage. Merging a projection back wholesale materialises
|
|
18
|
+
* every one of those fallbacks as a real translation — dozens per publish,
|
|
19
|
+
* each identical to what the page already showed, so nothing looks wrong.
|
|
20
|
+
* Compare against the *projection* of the source, never against a rebuilt
|
|
21
|
+
* object: key order and synthetic row ids both make a string comparison
|
|
22
|
+
* report changes nobody made.
|
|
23
|
+
* 2. **Empty means absent.** Writing `""` into a slot that had no value for
|
|
24
|
+
* this language is a no-op on screen and a diff in the document.
|
|
25
|
+
*
|
|
26
|
+
* A field the table does not declare is invisible to Avocado and untouched by
|
|
27
|
+
* it: it does not reach the planner, does not appear in the panel, and survives
|
|
28
|
+
* every publish, because the merge patches the source document rather than
|
|
29
|
+
* replacing it. That is the lever for scope — declare what an editor should be
|
|
30
|
+
* able to change and leave the layout and behaviour switches out.
|
|
31
|
+
*/
|
|
32
|
+
import { SCALAR_CODECS } from "./scalar-codecs.js";
|
|
33
|
+
function isRecord(v) {
|
|
34
|
+
return v != null && typeof v === "object" && !Array.isArray(v);
|
|
35
|
+
}
|
|
36
|
+
function humanise(key) {
|
|
37
|
+
return key
|
|
38
|
+
.replace(/[_-]+/g, " ")
|
|
39
|
+
.replace(/([a-z\d])([A-Z])/g, "$1 $2")
|
|
40
|
+
.replace(/^./, (c) => c.toUpperCase());
|
|
41
|
+
}
|
|
42
|
+
export function createLens(options) {
|
|
43
|
+
const { table, locale, primitives } = options;
|
|
44
|
+
const codecs = { ...SCALAR_CODECS, ...primitives.codecs };
|
|
45
|
+
function codecFor(spec) {
|
|
46
|
+
if (spec.kind === "list")
|
|
47
|
+
return listCodec;
|
|
48
|
+
const codec = codecs[spec.kind];
|
|
49
|
+
if (!codec) {
|
|
50
|
+
throw new Error(`No codec for field kind "${spec.kind}". The CMS primitive pack must supply one, ` +
|
|
51
|
+
`or the table should use a kind it does supply.`);
|
|
52
|
+
}
|
|
53
|
+
return codec;
|
|
54
|
+
}
|
|
55
|
+
function at(rec, path) {
|
|
56
|
+
if (path.length === 1)
|
|
57
|
+
return rec[path[0]];
|
|
58
|
+
const container = rec[path[0]];
|
|
59
|
+
return isRecord(container) ? container[path[1]] : undefined;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Where this language's value lives.
|
|
63
|
+
*
|
|
64
|
+
* Three cases, and the third is the one that cost a rewrite. A localised
|
|
65
|
+
* field is wherever `locale.path` says — for *every* language including the
|
|
66
|
+
* default, because a CMS that localises into an object under the key has no
|
|
67
|
+
* bare value at all, and short-circuiting the default to `doc[key]` reads
|
|
68
|
+
* that object back as `[object Object]`.
|
|
69
|
+
*
|
|
70
|
+
* A field with one value for every language is at the bare key, which is
|
|
71
|
+
* **not** the same as the default language's path: those coincide in a CMS
|
|
72
|
+
* that localises into a suffixed sibling and diverge in one that localises
|
|
73
|
+
* into an object. A field the CMS has stopped marking translatable is the
|
|
74
|
+
* same case — its old translations are still in the document and the delivery
|
|
75
|
+
* API ignores them, so this must too, or the publisher believes a field
|
|
76
|
+
* changed on every publish.
|
|
77
|
+
*/
|
|
78
|
+
function slot(key, lang, spec, type) {
|
|
79
|
+
if (spec.localized === false)
|
|
80
|
+
return [key];
|
|
81
|
+
if (locale.translatable && !locale.translatable(type, key, lang))
|
|
82
|
+
return [key];
|
|
83
|
+
return locale.path(key, lang);
|
|
84
|
+
}
|
|
85
|
+
function isBlank(v) {
|
|
86
|
+
return v === undefined || v === null || v === "";
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Read `key`'s value for `lang`, falling back to the default language.
|
|
90
|
+
*
|
|
91
|
+
* The default language is the fallback when the localised slot is absent *or*
|
|
92
|
+
* empty — an empty string in a translation slot is how a CMS spells "not
|
|
93
|
+
* translated yet", and reading it literally blanks the page.
|
|
94
|
+
*/
|
|
95
|
+
function readField(rec, key, lang, spec, type) {
|
|
96
|
+
const target = slot(key, lang, spec, type);
|
|
97
|
+
const value = at(rec, target);
|
|
98
|
+
if (!isBlank(value))
|
|
99
|
+
return value;
|
|
100
|
+
const fallbackTarget = locale.path(key, locale.default);
|
|
101
|
+
if (target.length === fallbackTarget.length && target.every((seg, i) => seg === fallbackTarget[i]))
|
|
102
|
+
return value;
|
|
103
|
+
if (spec.localized === false)
|
|
104
|
+
return value;
|
|
105
|
+
const fallback = at(rec, fallbackTarget);
|
|
106
|
+
return isBlank(fallback) ? value : fallback;
|
|
107
|
+
}
|
|
108
|
+
function writeField(rec, key, lang, spec, type, value) {
|
|
109
|
+
const target = slot(key, lang, spec, type);
|
|
110
|
+
if (target.length === 1) {
|
|
111
|
+
rec[target[0]] = value;
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const container = isRecord(rec[target[0]]) ? { ...rec[target[0]] } : {};
|
|
115
|
+
container[target[1]] = value;
|
|
116
|
+
rec[target[0]] = container;
|
|
117
|
+
}
|
|
118
|
+
function specFor(type) {
|
|
119
|
+
return table[type];
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* A list of child rows, matched by identity rather than by position.
|
|
123
|
+
*
|
|
124
|
+
* Position is the wrong key and it fails quietly: reorder a list, or delete
|
|
125
|
+
* the second of five rows, and every row after the change is merged onto the
|
|
126
|
+
* wrong source — the edit lands, the page looks plausible, and four rows have
|
|
127
|
+
* quietly swapped their untouched fields. Matching on the CMS's own row id is
|
|
128
|
+
* what keeps a list edit a merge.
|
|
129
|
+
*
|
|
130
|
+
* **A row Avocado never saw stays where it was.** The editor's order is taken
|
|
131
|
+
* for the rows it knows; anything else is re-inserted at its original index.
|
|
132
|
+
* A list can hold types the table does not declare, and dropping them is how
|
|
133
|
+
* an integration deletes content it was never asked about.
|
|
134
|
+
*
|
|
135
|
+
* Built here rather than in a primitive pack because the walk is the same
|
|
136
|
+
* everywhere — what differs is the key a row carries its identity and type
|
|
137
|
+
* under, and both are already declared.
|
|
138
|
+
*/
|
|
139
|
+
const listCodec = {
|
|
140
|
+
project(key, raw, ctx) {
|
|
141
|
+
const rows = Array.isArray(raw) ? raw : [];
|
|
142
|
+
const spec = ctx.spec;
|
|
143
|
+
if (spec.kind !== "list")
|
|
144
|
+
return { [key]: [] };
|
|
145
|
+
if ("itemFields" in spec) {
|
|
146
|
+
// Rows described inline: no type of their own, so project field by field.
|
|
147
|
+
const inline = spec.itemFields;
|
|
148
|
+
return {
|
|
149
|
+
[key]: rows.filter(isRecord).map((row) => {
|
|
150
|
+
const out = {};
|
|
151
|
+
if (primitives.rowIdKey in row)
|
|
152
|
+
out[primitives.rowIdKey] = row[primitives.rowIdKey];
|
|
153
|
+
for (const [itemKey, itemSpec] of Object.entries(inline)) {
|
|
154
|
+
const itemCtx = { spec: itemSpec, lang: ctx.lang, type: ctx.type, where: `${ctx.where}[] > ${itemKey}` };
|
|
155
|
+
// A row's fields are localised exactly as a block's are — the
|
|
156
|
+
// per-locale container sits on the row, not on the page.
|
|
157
|
+
const raw = readField(row, itemKey, ctx.lang, itemSpec, ctx.type);
|
|
158
|
+
Object.assign(out, codecFor(itemSpec).project(itemKey, raw, itemCtx));
|
|
159
|
+
}
|
|
160
|
+
return out;
|
|
161
|
+
})
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
const typeKey = primitives.rowTypeKey;
|
|
165
|
+
return {
|
|
166
|
+
[key]: rows
|
|
167
|
+
.filter((row) => isRecord(row) && Boolean(typeKey) && specFor(String(row[typeKey])) !== undefined)
|
|
168
|
+
.map((row) => ({
|
|
169
|
+
[typeKey]: row[typeKey],
|
|
170
|
+
[primitives.rowIdKey]: row[primitives.rowIdKey],
|
|
171
|
+
...project(row, String(row[typeKey]), ctx.lang)
|
|
172
|
+
}))
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
merge(key, props, before, ctx) {
|
|
176
|
+
if (!(key in props))
|
|
177
|
+
return null;
|
|
178
|
+
const spec = ctx.spec;
|
|
179
|
+
if (spec.kind !== "list")
|
|
180
|
+
return null;
|
|
181
|
+
const items = Array.isArray(props[key]) ? props[key].filter(isRecord) : [];
|
|
182
|
+
const sourceRows = (Array.isArray(before) ? before : []).filter(isRecord);
|
|
183
|
+
const byId = new Map(sourceRows.map((row) => [row[primitives.rowIdKey], row]));
|
|
184
|
+
const seen = new Set();
|
|
185
|
+
const merged = [];
|
|
186
|
+
for (const item of items) {
|
|
187
|
+
const rowId = item[primitives.rowIdKey];
|
|
188
|
+
const source = rowId === undefined ? undefined : byId.get(rowId);
|
|
189
|
+
if (source)
|
|
190
|
+
seen.add(rowId);
|
|
191
|
+
if ("itemFields" in spec) {
|
|
192
|
+
const base = source ? { ...source } : { [primitives.rowIdKey]: primitives.newRowId() };
|
|
193
|
+
let rowChanged = !source;
|
|
194
|
+
for (const [itemKey, itemSpec] of Object.entries(spec.itemFields)) {
|
|
195
|
+
const itemCtx = { spec: itemSpec, lang: ctx.lang, type: ctx.type, where: `${ctx.where}[] > ${itemKey}` };
|
|
196
|
+
const before = source ? readField(source, itemKey, ctx.lang, itemSpec, ctx.type) : undefined;
|
|
197
|
+
const outcome = codecFor(itemSpec).merge(itemKey, item, before, itemCtx);
|
|
198
|
+
if (!outcome || "warning" in outcome)
|
|
199
|
+
continue;
|
|
200
|
+
writeField(base, itemKey, ctx.lang, itemSpec, ctx.type, outcome.value);
|
|
201
|
+
rowChanged = true;
|
|
202
|
+
}
|
|
203
|
+
void rowChanged;
|
|
204
|
+
merged.push(base);
|
|
205
|
+
continue;
|
|
206
|
+
}
|
|
207
|
+
const typeKey = primitives.rowTypeKey;
|
|
208
|
+
const rowType = String(item[typeKey] ?? (source ? source[typeKey] : ""));
|
|
209
|
+
if (!rowType || !specFor(rowType)) {
|
|
210
|
+
/*
|
|
211
|
+
* A row that names no type is a row no CMS storing rows as documents
|
|
212
|
+
* can construct, and guessing one writes content nobody asked for.
|
|
213
|
+
* The op validator rejects this at the moment of the mistake; a row
|
|
214
|
+
* that still reaches here is dropped from the merge rather than
|
|
215
|
+
* fabricated, and the source list keeps whatever it had.
|
|
216
|
+
*/
|
|
217
|
+
if (source)
|
|
218
|
+
merged.push(source);
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const result = merge(source ?? { [typeKey]: rowType, [primitives.rowIdKey]: primitives.newRowId() }, item, rowType, ctx.lang, `${ctx.where}[]`);
|
|
222
|
+
merged.push(result.doc);
|
|
223
|
+
}
|
|
224
|
+
// Rows the table does not describe keep their place rather than vanishing.
|
|
225
|
+
for (const [index, row] of sourceRows.entries()) {
|
|
226
|
+
const rowId = row[primitives.rowIdKey];
|
|
227
|
+
if (seen.has(rowId))
|
|
228
|
+
continue;
|
|
229
|
+
const typeKey = primitives.rowTypeKey;
|
|
230
|
+
const described = typeKey ? specFor(String(row[typeKey])) !== undefined : true;
|
|
231
|
+
if (described)
|
|
232
|
+
continue;
|
|
233
|
+
merged.splice(Math.min(index, merged.length), 0, row);
|
|
234
|
+
}
|
|
235
|
+
/*
|
|
236
|
+
* Compared structurally, and deliberately not by counting rows.
|
|
237
|
+
*
|
|
238
|
+
* The projection omits every row whose type the table does not describe,
|
|
239
|
+
* so a list of five that Avocado can edit two of arrives back with two —
|
|
240
|
+
* and a length check reads that as three deletions on a list nobody
|
|
241
|
+
* touched. Comparing the rebuilt list to the source catches a real
|
|
242
|
+
* reorder, add and delete, and says nothing about a list that only looks
|
|
243
|
+
* shorter from Avocado's side.
|
|
244
|
+
*/
|
|
245
|
+
return JSON.stringify(merged) === JSON.stringify(sourceRows) ? null : { value: merged };
|
|
246
|
+
}
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* One CMS document as Avocado props, in one language.
|
|
250
|
+
*
|
|
251
|
+
* A type the table does not declare projects to nothing, rather than to a
|
|
252
|
+
* partial guess — an undeclared type is one nobody said Avocado may edit.
|
|
253
|
+
*/
|
|
254
|
+
function project(doc, type, lang, opts) {
|
|
255
|
+
const blockSpec = specFor(type);
|
|
256
|
+
if (!blockSpec)
|
|
257
|
+
return {};
|
|
258
|
+
const props = {};
|
|
259
|
+
for (const [key, spec] of Object.entries(blockSpec.fields)) {
|
|
260
|
+
const raw = opts?.resolved ? doc[key] : readField(doc, key, lang, spec, type);
|
|
261
|
+
const ctx = { spec, lang, type, where: `${type} > ${key}` };
|
|
262
|
+
Object.assign(props, codecFor(spec).project(key, raw, ctx));
|
|
263
|
+
}
|
|
264
|
+
return props;
|
|
265
|
+
}
|
|
266
|
+
/**
|
|
267
|
+
* Edited props back onto their source document, for one language.
|
|
268
|
+
*
|
|
269
|
+
* The source is the live CMS document, not a snapshot Avocado holds, so every
|
|
270
|
+
* field the table does not declare survives untouched by construction — and
|
|
271
|
+
* that is the difference between a publish that patches and a publish that
|
|
272
|
+
* silently deletes forty fields this integration never learned about.
|
|
273
|
+
*/
|
|
274
|
+
function merge(source, props, type, lang, where = type, opts) {
|
|
275
|
+
const blockSpec = specFor(type);
|
|
276
|
+
if (!blockSpec)
|
|
277
|
+
return { doc: source, changed: false, warnings: [] };
|
|
278
|
+
/*
|
|
279
|
+
* The language the *page* is in, which is not always the language being
|
|
280
|
+
* written: a live preview merges into the bare keys of an already-resolved
|
|
281
|
+
* document — writing as if it were the default language — while the page it
|
|
282
|
+
* draws may be another. Translatability is a question about the page;
|
|
283
|
+
* where to store the value is a question about the write. Conflating them
|
|
284
|
+
* lets the preview show an edit the publish then refuses, which is worse
|
|
285
|
+
* than refusing it in both places.
|
|
286
|
+
*/
|
|
287
|
+
const checkLang = opts?.checkLang ?? lang;
|
|
288
|
+
const out = { ...source };
|
|
289
|
+
const warnings = [];
|
|
290
|
+
let changed = false;
|
|
291
|
+
for (const [key, spec] of Object.entries(blockSpec.fields)) {
|
|
292
|
+
const before = readField(source, key, lang, spec, type);
|
|
293
|
+
const ctx = { spec, lang, type, where: `${where} > ${key}` };
|
|
294
|
+
const outcome = codecFor(spec).merge(key, props, before, ctx);
|
|
295
|
+
if (!outcome)
|
|
296
|
+
continue;
|
|
297
|
+
if ("warning" in outcome) {
|
|
298
|
+
warnings.push({ where: ctx.where, reason: outcome.warning });
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
/*
|
|
302
|
+
* Asked here, about a real change, and deliberately not earlier. The
|
|
303
|
+
* first version of this re-projected the source and compared JSON before
|
|
304
|
+
* deciding — and reported fields nobody had touched as refused
|
|
305
|
+
* translations, because a rebuilt object's key order does not match the
|
|
306
|
+
* source's and because list rows carry a synthetic id. Both are
|
|
307
|
+
* invisible differences that a string comparison calls a change.
|
|
308
|
+
*/
|
|
309
|
+
if (checkLang !== locale.default &&
|
|
310
|
+
spec.localized !== false &&
|
|
311
|
+
locale.translatable &&
|
|
312
|
+
!locale.translatable(type, key, checkLang)) {
|
|
313
|
+
warnings.push({
|
|
314
|
+
where: ctx.where,
|
|
315
|
+
reason: `"${key}" is not translatable on ${type} — it has one value for every language, ` +
|
|
316
|
+
`so this ${String(checkLang).toUpperCase()} edit cannot be stored. ` +
|
|
317
|
+
`Edit it on the ${String(locale.default).toUpperCase()} page.`
|
|
318
|
+
});
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
writeField(out, key, lang, spec, type, outcome.value);
|
|
322
|
+
changed = true;
|
|
323
|
+
}
|
|
324
|
+
return { doc: out, changed, warnings };
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* Project, merge the projection straight back, and report what moved.
|
|
328
|
+
*
|
|
329
|
+
* Nothing should. A non-empty result is the signal that a codec is not the
|
|
330
|
+
* inverse of itself for some value in this dataset — the class of defect that
|
|
331
|
+
* is invisible in the editor, harmless in the preview, and shows up as a
|
|
332
|
+
* publish wanting to rewrite documents nobody opened.
|
|
333
|
+
*
|
|
334
|
+
* Run it over real content, not fixtures. Every rule in this file exists
|
|
335
|
+
* because a fixture round-tripped and a dataset did not.
|
|
336
|
+
*/
|
|
337
|
+
function roundTrip(doc, type, lang, opts) {
|
|
338
|
+
const props = project(doc, type, lang, opts);
|
|
339
|
+
const result = merge(doc, props, type, lang);
|
|
340
|
+
const fields = Object.keys(specFor(type)?.fields ?? {}).filter((key) => {
|
|
341
|
+
const spec = specFor(type).fields[key];
|
|
342
|
+
const before = readField(doc, key, lang, spec, type);
|
|
343
|
+
const after = readField(result.doc, key, lang, spec, type);
|
|
344
|
+
return JSON.stringify(before ?? null) !== JSON.stringify(after ?? null);
|
|
345
|
+
});
|
|
346
|
+
return { clean: !result.changed && fields.length === 0, fields, warnings: result.warnings };
|
|
347
|
+
}
|
|
348
|
+
return { project, merge, roundTrip, table, locale, primitives, label: humanise };
|
|
349
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export { createLens } from "./create-lens.ts";
|
|
2
|
+
export type { Lens, LensOptions, MergeResult, MergeWarning, ProjectOptions } from "./create-lens.ts";
|
|
3
|
+
export { registerFieldTable, topLevelTypes } from "./register.ts";
|
|
4
|
+
export type { RegisterOptions } from "./register.ts";
|
|
5
|
+
export { suffixNaming } from "./types.ts";
|
|
6
|
+
export type { BlockSpec, CodecContext, FieldCodec, FieldSpec, FieldTable, ImageNaming, LocaleLens, MergeOutcome, Primitives } from "./types.ts";
|
|
7
|
+
export { changed } from "./scalar-codecs.ts";
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The field table, and the four things derived from it.
|
|
3
|
+
*
|
|
4
|
+
* ```ts
|
|
5
|
+
* import { createLens, registerFieldTable } from "@avocadostudio-ai/site-sdk/lens"
|
|
6
|
+
* import { storyblokPrimitives } from "@avocadostudio-ai/site-sdk/lens/storyblok"
|
|
7
|
+
*
|
|
8
|
+
* const primitives = storyblokPrimitives()
|
|
9
|
+
*
|
|
10
|
+
* registerFieldTable(TABLE, { primitives }) // schemas + panel metadata
|
|
11
|
+
* export const lens = createLens({ // projection + merge
|
|
12
|
+
* table: TABLE,
|
|
13
|
+
* locale: { default: "de", languages: ["de", "en", "fr"], path: (k, l) => [`${k}__i18n__${l}`] },
|
|
14
|
+
* primitives,
|
|
15
|
+
* })
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* The two are separate on purpose: registration writes to a global registry and
|
|
19
|
+
* `createLens` returns a value, so folding one into the other would make an
|
|
20
|
+
* object you cannot build twice and make the order of two imports matter.
|
|
21
|
+
*/
|
|
22
|
+
export { createLens } from "./create-lens.js";
|
|
23
|
+
export { registerFieldTable, topLevelTypes } from "./register.js";
|
|
24
|
+
export { suffixNaming } from "./types.js";
|
|
25
|
+
export { changed } from "./scalar-codecs.js";
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|