@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
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Sanity's answers to the same six questions.
|
|
3
|
+
*
|
|
4
|
+
* This pack exists as much to prove the abstraction as to serve Sanity. A codec
|
|
5
|
+
* interface derived from one CMS is a description of that CMS wearing a general
|
|
6
|
+
* name; the two that matter here disagree in every particular — a Storyblok
|
|
7
|
+
* asset is a flat object with a `filename`, a Sanity image is a reference to an
|
|
8
|
+
* asset document; Storyblok's rich text is the pivot itself, Sanity's is
|
|
9
|
+
* Portable Text; Storyblok localises into a suffixed sibling key, Sanity into a
|
|
10
|
+
* per-locale object under the key. Nothing above this file changes between the
|
|
11
|
+
* two, which is the claim.
|
|
12
|
+
*/
|
|
13
|
+
import { fromPortableText, toPortableText } from "@avocadostudio-ai/richtext";
|
|
14
|
+
import { changed } from "./scalar-codecs.js";
|
|
15
|
+
import { suffixNaming } from "./types.js";
|
|
16
|
+
const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
17
|
+
const str = (v) => (v == null ? "" : String(v));
|
|
18
|
+
/**
|
|
19
|
+
* A resolved image URL, which is a render of a reference and not the reference.
|
|
20
|
+
*
|
|
21
|
+
* This is why only the alt text below is writable. The URL a projection carries
|
|
22
|
+
* was built by the query that resolved `asset._ref` through the CDN; writing it
|
|
23
|
+
* back replaces a reference to an asset document with a string, which is
|
|
24
|
+
* precisely the overwrite a field-level publish diff exists to prevent.
|
|
25
|
+
*/
|
|
26
|
+
export function imageUrl(image) {
|
|
27
|
+
if (!image)
|
|
28
|
+
return "";
|
|
29
|
+
if (typeof image === "string")
|
|
30
|
+
return image;
|
|
31
|
+
const img = image;
|
|
32
|
+
if (typeof img.url === "string")
|
|
33
|
+
return img.url;
|
|
34
|
+
const asset = isObj(img.asset) ? img.asset : undefined;
|
|
35
|
+
return str(asset?.url);
|
|
36
|
+
}
|
|
37
|
+
export function imageAlt(image) {
|
|
38
|
+
return isObj(image) ? str(image.alt) : "";
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* An image whose alt text is editable and whose asset is not.
|
|
42
|
+
*
|
|
43
|
+
* The asymmetry is deliberate and it is the honest shape: an editor changing
|
|
44
|
+
* alt text is editing content, and an editor "changing" the URL is proposing to
|
|
45
|
+
* repoint a reference at something the CMS has no record of.
|
|
46
|
+
*/
|
|
47
|
+
const imageCodec = (naming) => ({
|
|
48
|
+
project(key, raw) {
|
|
49
|
+
return { [naming.url(key)]: imageUrl(raw), [naming.alt(key)]: imageAlt(raw) };
|
|
50
|
+
},
|
|
51
|
+
merge(key, props, before) {
|
|
52
|
+
const altKey = naming.alt(key);
|
|
53
|
+
if (!(altKey in props))
|
|
54
|
+
return null;
|
|
55
|
+
const nextAlt = str(props[altKey]);
|
|
56
|
+
if (!changed(nextAlt, imageAlt(before)))
|
|
57
|
+
return null;
|
|
58
|
+
if (!isObj(before))
|
|
59
|
+
return null;
|
|
60
|
+
return { value: { ...before, alt: nextAlt } };
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
const fileCodec = {
|
|
64
|
+
project(key, raw) {
|
|
65
|
+
return { [key]: imageUrl(raw) };
|
|
66
|
+
},
|
|
67
|
+
merge() {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
/**
|
|
72
|
+
* A reference, which is what Sanity stores every internal link as.
|
|
73
|
+
*
|
|
74
|
+
* Read as the href the query resolved; never written, for the same reason the
|
|
75
|
+
* image URL is never written — the stored value is a `_ref` and the href is one
|
|
76
|
+
* rendering of it, per locale.
|
|
77
|
+
*/
|
|
78
|
+
const referenceCodec = {
|
|
79
|
+
project(key, raw) {
|
|
80
|
+
if (typeof raw === "string")
|
|
81
|
+
return { [key]: raw };
|
|
82
|
+
const ref = isObj(raw) ? raw : undefined;
|
|
83
|
+
return { [key]: str(ref?.href ?? ref?.slug ?? "") };
|
|
84
|
+
},
|
|
85
|
+
merge(key, props, before) {
|
|
86
|
+
if (!(key in props))
|
|
87
|
+
return null;
|
|
88
|
+
const current = typeof before === "string" ? before : str(isObj(before) ? (before.href ?? before.slug) : "");
|
|
89
|
+
if (str(props[key]) === current)
|
|
90
|
+
return null;
|
|
91
|
+
return {
|
|
92
|
+
warning: `"${key}" points at a document in the CMS, not at a URL. Its address is derived from ` +
|
|
93
|
+
`that document — move or rename it in Sanity instead.`
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
fieldKind: "reference"
|
|
97
|
+
};
|
|
98
|
+
const richTextCodec = {
|
|
99
|
+
project(key, raw) {
|
|
100
|
+
return { [key]: fromPortableText(Array.isArray(raw) ? raw : []) };
|
|
101
|
+
},
|
|
102
|
+
merge(key, props, before) {
|
|
103
|
+
if (!(key in props))
|
|
104
|
+
return null;
|
|
105
|
+
/*
|
|
106
|
+
* Key-preserving on purpose. Portable Text blocks carry a `_key` that
|
|
107
|
+
* Sanity uses to address them, and a converter that mints fresh keys turns
|
|
108
|
+
* every edit into a full replacement of the array — which is a patch that
|
|
109
|
+
* conflicts with every other editor's concurrent patch, rather than one
|
|
110
|
+
* that merges with it.
|
|
111
|
+
*/
|
|
112
|
+
const nextBlocks = toPortableText(props[key], { previous: Array.isArray(before) ? before : [] });
|
|
113
|
+
if (JSON.stringify(nextBlocks) === JSON.stringify(before ?? []))
|
|
114
|
+
return null;
|
|
115
|
+
const bothEmpty = nextBlocks.length === 0 && (!Array.isArray(before) || before.length === 0);
|
|
116
|
+
if (bothEmpty)
|
|
117
|
+
return null;
|
|
118
|
+
return { value: nextBlocks };
|
|
119
|
+
}
|
|
120
|
+
};
|
|
121
|
+
const imageListCodec = () => ({
|
|
122
|
+
project(key, raw) {
|
|
123
|
+
return {
|
|
124
|
+
[key]: (Array.isArray(raw) ? raw : []).map((img) => ({
|
|
125
|
+
image: imageUrl(img),
|
|
126
|
+
alt: imageAlt(img),
|
|
127
|
+
_key: isObj(img) ? img._key : undefined
|
|
128
|
+
}))
|
|
129
|
+
};
|
|
130
|
+
},
|
|
131
|
+
merge(key, props, before) {
|
|
132
|
+
if (!(key in props))
|
|
133
|
+
return null;
|
|
134
|
+
const items = Array.isArray(props[key]) ? props[key] : [];
|
|
135
|
+
const sourceItems = (Array.isArray(before) ? before : []);
|
|
136
|
+
const byKey = new Map(sourceItems.filter(isObj).map((row) => [row._key, row]));
|
|
137
|
+
const merged = items.map((item, i) => {
|
|
138
|
+
const src = (item._key !== undefined ? byKey.get(item._key) : undefined) ?? sourceItems[i] ?? {};
|
|
139
|
+
const alt = str(item.alt);
|
|
140
|
+
if (!isObj(src) || alt === imageAlt(src))
|
|
141
|
+
return src;
|
|
142
|
+
return { ...src, alt };
|
|
143
|
+
});
|
|
144
|
+
if (JSON.stringify(merged) === JSON.stringify(sourceItems))
|
|
145
|
+
return null;
|
|
146
|
+
return { value: merged };
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
/**
|
|
150
|
+
* The locale lens for a site whose translatable fields are `{de, fr, en}`.
|
|
151
|
+
*
|
|
152
|
+
* One Sanity document becomes one Avocado page *per language*, because an
|
|
153
|
+
* Avocado prop is one value and this content model has three. `/preise` and
|
|
154
|
+
* `/fr/prix` are two pages backed by the same document.
|
|
155
|
+
*/
|
|
156
|
+
export function sanityLocale(defaultLang, languages) {
|
|
157
|
+
return {
|
|
158
|
+
default: defaultLang,
|
|
159
|
+
languages,
|
|
160
|
+
path: (key, lang) => [key, lang]
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* The default naming is the one a tri-lingual Sanity site on Next 15 arrived
|
|
165
|
+
* at, and it is not a style choice: the asset picker recognises a field as an
|
|
166
|
+
* image by the `*imageUrl` / `*Image` name pattern, so an image field `hero`
|
|
167
|
+
* has to project to `heroUrl` for the picker to appear at all.
|
|
168
|
+
*/
|
|
169
|
+
export function sanityPrimitives(options) {
|
|
170
|
+
const imageNaming = options?.imageNaming ?? suffixNaming("Url", "Alt");
|
|
171
|
+
return {
|
|
172
|
+
imageNaming,
|
|
173
|
+
rowIdKey: "_key",
|
|
174
|
+
rowTypeKey: "_type",
|
|
175
|
+
newRowId: () => typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
176
|
+
? crypto.randomUUID().replace(/-/g, "").slice(0, 12)
|
|
177
|
+
: Math.random().toString(36).slice(2, 14),
|
|
178
|
+
codecs: {
|
|
179
|
+
image: imageCodec(imageNaming),
|
|
180
|
+
file: fileCodec,
|
|
181
|
+
link: referenceCodec,
|
|
182
|
+
reference: referenceCodec,
|
|
183
|
+
richtext: richTextCodec,
|
|
184
|
+
imageList: imageListCodec()
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { CodecContext, FieldCodec } from "./types.ts";
|
|
2
|
+
/**
|
|
3
|
+
* Unchanged is not written, and empty is not written over absent.
|
|
4
|
+
*
|
|
5
|
+
* The second rule is the one that is easy to miss and expensive to discover.
|
|
6
|
+
* Writing `""` into a slot that had no value for this language looks like a
|
|
7
|
+
* no-op — the page rendered the fallback before and renders it after — and it
|
|
8
|
+
* is a real diff in the document, so a publish that touched one field reports
|
|
9
|
+
* every page as modified.
|
|
10
|
+
*/
|
|
11
|
+
export declare function changed(next: unknown, before: unknown): boolean;
|
|
12
|
+
export declare const textCodec: FieldCodec;
|
|
13
|
+
export declare const numberCodec: FieldCodec;
|
|
14
|
+
export declare const booleanCodec: FieldCodec;
|
|
15
|
+
/**
|
|
16
|
+
* A closed list, and a value outside it is refused rather than stored.
|
|
17
|
+
*
|
|
18
|
+
* The panel offers only the declared options, so an out-of-range value can only
|
|
19
|
+
* arrive from a planner — and storing one produces a page that renders its
|
|
20
|
+
* fallback branch with no error anywhere, which is the failure this whole file
|
|
21
|
+
* exists to stop being silent.
|
|
22
|
+
*/
|
|
23
|
+
export declare const enumCodec: FieldCodec;
|
|
24
|
+
export declare const stringListCodec: FieldCodec;
|
|
25
|
+
export declare const SCALAR_CODECS: Record<string, FieldCodec>;
|
|
26
|
+
export type { CodecContext };
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The kinds no CMS shapes differently.
|
|
3
|
+
*
|
|
4
|
+
* A string is a string in Storyblok, Sanity, Contentful and Strapi; so is a
|
|
5
|
+
* number, a boolean, an enum value and a heading level. These carry no
|
|
6
|
+
* per-CMS answer, so a primitive pack that supplied them would be four copies
|
|
7
|
+
* of the same eight lines — and the eighth line, the one that decides whether a
|
|
8
|
+
* value counts as changed, is the one it is worst to get subtly different
|
|
9
|
+
* between two integrations.
|
|
10
|
+
*/
|
|
11
|
+
/**
|
|
12
|
+
* Unchanged is not written, and empty is not written over absent.
|
|
13
|
+
*
|
|
14
|
+
* The second rule is the one that is easy to miss and expensive to discover.
|
|
15
|
+
* Writing `""` into a slot that had no value for this language looks like a
|
|
16
|
+
* no-op — the page rendered the fallback before and renders it after — and it
|
|
17
|
+
* is a real diff in the document, so a publish that touched one field reports
|
|
18
|
+
* every page as modified.
|
|
19
|
+
*/
|
|
20
|
+
export function changed(next, before) {
|
|
21
|
+
if (next === before)
|
|
22
|
+
return false;
|
|
23
|
+
const nextEmpty = next === "" || next === undefined || next === null;
|
|
24
|
+
const beforeEmpty = before === "" || before === undefined || before === null;
|
|
25
|
+
return !(nextEmpty && beforeEmpty);
|
|
26
|
+
}
|
|
27
|
+
function scalar(read, write) {
|
|
28
|
+
return {
|
|
29
|
+
project(key, raw) {
|
|
30
|
+
return { [key]: read(raw) };
|
|
31
|
+
},
|
|
32
|
+
merge(key, props, before) {
|
|
33
|
+
if (!(key in props))
|
|
34
|
+
return null;
|
|
35
|
+
const next = write(props[key]);
|
|
36
|
+
return changed(next, read(before)) ? { value: next } : null;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
const asText = (raw) => (raw == null ? "" : String(raw));
|
|
41
|
+
export const textCodec = scalar(asText, asText);
|
|
42
|
+
export const numberCodec = scalar((raw) => (typeof raw === "number" ? raw : Number(raw ?? 0) || 0), (next) => (typeof next === "number" ? next : Number(next ?? 0) || 0));
|
|
43
|
+
export const booleanCodec = scalar((raw) => raw === true, (next) => next === true);
|
|
44
|
+
/**
|
|
45
|
+
* A closed list, and a value outside it is refused rather than stored.
|
|
46
|
+
*
|
|
47
|
+
* The panel offers only the declared options, so an out-of-range value can only
|
|
48
|
+
* arrive from a planner — and storing one produces a page that renders its
|
|
49
|
+
* fallback branch with no error anywhere, which is the failure this whole file
|
|
50
|
+
* exists to stop being silent.
|
|
51
|
+
*/
|
|
52
|
+
export const enumCodec = {
|
|
53
|
+
project(key, raw) {
|
|
54
|
+
return { [key]: asText(raw) };
|
|
55
|
+
},
|
|
56
|
+
merge(key, props, before, ctx) {
|
|
57
|
+
if (!(key in props))
|
|
58
|
+
return null;
|
|
59
|
+
const next = asText(props[key]);
|
|
60
|
+
if (!changed(next, asText(before)))
|
|
61
|
+
return null;
|
|
62
|
+
const options = ctx.spec.kind === "enum" ? ctx.spec.options : [];
|
|
63
|
+
if (next !== "" && !options.includes(next)) {
|
|
64
|
+
return { warning: `"${next}" is not one of ${options.join(", ")}` };
|
|
65
|
+
}
|
|
66
|
+
return { value: next };
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
export const stringListCodec = {
|
|
70
|
+
project(key, raw) {
|
|
71
|
+
return { [key]: (Array.isArray(raw) ? raw : []).map(asText) };
|
|
72
|
+
},
|
|
73
|
+
merge(key, props, before) {
|
|
74
|
+
if (!(key in props))
|
|
75
|
+
return null;
|
|
76
|
+
const next = (Array.isArray(props[key]) ? props[key] : []).map(asText);
|
|
77
|
+
const prev = (Array.isArray(before) ? before : []).map(asText);
|
|
78
|
+
if (next.length === prev.length && next.every((v, i) => v === prev[i]))
|
|
79
|
+
return null;
|
|
80
|
+
if (next.length === 0 && prev.length === 0)
|
|
81
|
+
return null;
|
|
82
|
+
return { value: next };
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
export const SCALAR_CODECS = {
|
|
86
|
+
text: textCodec,
|
|
87
|
+
number: numberCodec,
|
|
88
|
+
boolean: booleanCodec,
|
|
89
|
+
enum: enumCodec,
|
|
90
|
+
headingLevel: textCodec,
|
|
91
|
+
stringList: stringListCodec
|
|
92
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { changed } from "./scalar-codecs.ts";
|
|
2
|
+
import type { ImageNaming, LocaleLens, Primitives } from "./types.ts";
|
|
3
|
+
export declare function assetUrl(asset: unknown): string;
|
|
4
|
+
export declare function assetAlt(asset: unknown): string;
|
|
5
|
+
export declare function linkHref(link: unknown): string;
|
|
6
|
+
/** A link that points at a story is a reference, whatever it renders as. */
|
|
7
|
+
export declare function isStoryLink(link: unknown): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* The locale lens for Storyblok's field-level i18n.
|
|
10
|
+
*
|
|
11
|
+
* The default language lives in the bare key and every other language in
|
|
12
|
+
* `<key>__i18n__<lang>` beside it. Supplied rather than left to the integrator
|
|
13
|
+
* because the conditional is one line and getting it wrong is silent: a lens
|
|
14
|
+
* that reads `title__i18n__de` finds nothing, falls back to `title`, and looks
|
|
15
|
+
* correct until somebody edits the German page.
|
|
16
|
+
*/
|
|
17
|
+
export declare function storyblokLocale<L extends string>(defaultLang: L, languages: readonly L[]): LocaleLens<L>;
|
|
18
|
+
/**
|
|
19
|
+
* Storyblok's answers, ready to hand to `createLens` and `registerFieldTable`.
|
|
20
|
+
*
|
|
21
|
+
* The default naming is the one a tri-lingual Storyblok site on Next 16
|
|
22
|
+
* arrived at: an image field `background_image` projects to
|
|
23
|
+
* `background_image` and `background_image_alt`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function storyblokPrimitives(options?: {
|
|
26
|
+
imageNaming?: ImageNaming;
|
|
27
|
+
}): Primitives;
|
|
28
|
+
export { changed };
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Storyblok's answers to the six questions a lens asks.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is a fact about Storyblok rather than about a site: an asset
|
|
5
|
+
* is `{ fieldtype, filename, alt }`, a link is a multilink whose `linktype`
|
|
6
|
+
* decides whether its href is editable at all, a child row is a blok carrying
|
|
7
|
+
* `_uid` and `component`, and rich text is a ProseMirror document — the pivot
|
|
8
|
+
* itself, which is why the converter is a rename rather than a translation.
|
|
9
|
+
*/
|
|
10
|
+
import { fromStoryblok, toStoryblok, isEmptyRichText } from "@avocadostudio-ai/richtext";
|
|
11
|
+
import { changed } from "./scalar-codecs.js";
|
|
12
|
+
import { suffixNaming } from "./types.js";
|
|
13
|
+
const isObj = (v) => v != null && typeof v === "object" && !Array.isArray(v);
|
|
14
|
+
const str = (v) => (v == null ? "" : String(v));
|
|
15
|
+
export function assetUrl(asset) {
|
|
16
|
+
if (!asset)
|
|
17
|
+
return "";
|
|
18
|
+
if (typeof asset === "string")
|
|
19
|
+
return asset;
|
|
20
|
+
return str(asset.filename);
|
|
21
|
+
}
|
|
22
|
+
export function assetAlt(asset) {
|
|
23
|
+
if (!isObj(asset))
|
|
24
|
+
return "";
|
|
25
|
+
return str(asset.alt);
|
|
26
|
+
}
|
|
27
|
+
export function linkHref(link) {
|
|
28
|
+
if (!link)
|
|
29
|
+
return "";
|
|
30
|
+
if (typeof link === "string")
|
|
31
|
+
return link;
|
|
32
|
+
const l = link;
|
|
33
|
+
return str(l.url || l.cached_url || l.href);
|
|
34
|
+
}
|
|
35
|
+
/** A link that points at a story is a reference, whatever it renders as. */
|
|
36
|
+
export function isStoryLink(link) {
|
|
37
|
+
return isObj(link) && link.linktype === "story";
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* An asset object built from a URL, keeping whatever the source knew.
|
|
41
|
+
*
|
|
42
|
+
* The id must not be carried over when the URL did not come from Storyblok's
|
|
43
|
+
* asset service: there is no asset record behind it, and an id pointing at the
|
|
44
|
+
* image it replaced is worse than no id at all.
|
|
45
|
+
*/
|
|
46
|
+
function assetFrom(url, alt, before) {
|
|
47
|
+
const base = isObj(before) ? before : {};
|
|
48
|
+
return {
|
|
49
|
+
fieldtype: "asset",
|
|
50
|
+
...base,
|
|
51
|
+
id: url.includes("a.storyblok.com") ? (base.id ?? null) : null,
|
|
52
|
+
filename: url,
|
|
53
|
+
...(alt === undefined ? {} : { alt })
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
const imageCodec = (naming) => ({
|
|
57
|
+
project(key, raw) {
|
|
58
|
+
return { [naming.url(key)]: assetUrl(raw), [naming.alt(key)]: assetAlt(raw) };
|
|
59
|
+
},
|
|
60
|
+
merge(key, props, before) {
|
|
61
|
+
const urlKey = naming.url(key);
|
|
62
|
+
const altKey = naming.alt(key);
|
|
63
|
+
if (!(urlKey in props) && !(altKey in props))
|
|
64
|
+
return null;
|
|
65
|
+
const nextUrl = str(props[urlKey] ?? assetUrl(before));
|
|
66
|
+
const nextAlt = str(props[altKey] ?? assetAlt(before));
|
|
67
|
+
if (nextUrl === assetUrl(before) && nextAlt === assetAlt(before))
|
|
68
|
+
return null;
|
|
69
|
+
// Rule 2: clearing a field that was never set for this language is a diff
|
|
70
|
+
// with no visible effect. Clearing one that was set is real.
|
|
71
|
+
if (!nextUrl)
|
|
72
|
+
return assetUrl(before) ? { value: null } : null;
|
|
73
|
+
return { value: assetFrom(nextUrl, nextAlt, before) };
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
const fileCodec = {
|
|
77
|
+
project(key, raw) {
|
|
78
|
+
return { [key]: assetUrl(raw) };
|
|
79
|
+
},
|
|
80
|
+
merge(key, props, before) {
|
|
81
|
+
if (!(key in props))
|
|
82
|
+
return null;
|
|
83
|
+
const nextUrl = str(props[key]);
|
|
84
|
+
if (nextUrl === assetUrl(before))
|
|
85
|
+
return null;
|
|
86
|
+
if (!nextUrl && !assetUrl(before))
|
|
87
|
+
return null;
|
|
88
|
+
return { value: nextUrl ? assetFrom(nextUrl, undefined, before) : null };
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
/**
|
|
92
|
+
* A link, and the one shape of it that cannot be written.
|
|
93
|
+
*
|
|
94
|
+
* Storyblok stores a story link as `faq` and serves `/fr/faq` on the French
|
|
95
|
+
* page and `/faq` on the German one — the same stored value, two different
|
|
96
|
+
* strings, neither of them what is in the document. Flattening it to an href
|
|
97
|
+
* therefore cannot round-trip: the projection never equals the source, so every
|
|
98
|
+
* publish of a page nobody edited wants to rewrite every link on it, and
|
|
99
|
+
* writing the rendered href back replaces the reference with a hard-coded URL
|
|
100
|
+
* that stops following renames — the one thing the reference was for.
|
|
101
|
+
*
|
|
102
|
+
* External links have no such problem, because the stored value *is* the href.
|
|
103
|
+
* Those stay editable, which covers the case that actually matters: booking and
|
|
104
|
+
* shop URLs.
|
|
105
|
+
*/
|
|
106
|
+
const linkCodec = {
|
|
107
|
+
project(key, raw) {
|
|
108
|
+
return { [key]: linkHref(raw) };
|
|
109
|
+
},
|
|
110
|
+
merge(key, props, before, ctx) {
|
|
111
|
+
if (!(key in props))
|
|
112
|
+
return null;
|
|
113
|
+
const nextHref = str(props[key]);
|
|
114
|
+
if (nextHref === linkHref(before))
|
|
115
|
+
return null;
|
|
116
|
+
if (!nextHref && !linkHref(before))
|
|
117
|
+
return null;
|
|
118
|
+
if (isStoryLink(before)) {
|
|
119
|
+
return {
|
|
120
|
+
warning: `"${key}" points at a page in the CMS, not at a URL. Its address is derived from ` +
|
|
121
|
+
`that page and cannot be edited here — move or rename the page in Storyblok instead.`
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
const base = isObj(before) ? before : {};
|
|
125
|
+
return { value: { fieldtype: "multilink", ...base, linktype: "url", url: nextHref, cached_url: nextHref } };
|
|
126
|
+
},
|
|
127
|
+
fieldKind: "link"
|
|
128
|
+
};
|
|
129
|
+
/** A reference is never written from a rendered href. It is read-only here. */
|
|
130
|
+
const referenceCodec = {
|
|
131
|
+
project(key, raw) {
|
|
132
|
+
return { [key]: linkHref(raw) };
|
|
133
|
+
},
|
|
134
|
+
merge(key, props, before) {
|
|
135
|
+
if (!(key in props))
|
|
136
|
+
return null;
|
|
137
|
+
if (str(props[key]) === linkHref(before))
|
|
138
|
+
return null;
|
|
139
|
+
return {
|
|
140
|
+
warning: `"${key}" is a reference to another document. Its address is derived from that ` +
|
|
141
|
+
`document and cannot be edited here.`
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
fieldKind: "reference"
|
|
145
|
+
};
|
|
146
|
+
const richTextCodec = {
|
|
147
|
+
project(key, raw) {
|
|
148
|
+
return { [key]: fromStoryblok(raw) };
|
|
149
|
+
},
|
|
150
|
+
merge(key, props, before) {
|
|
151
|
+
if (!(key in props))
|
|
152
|
+
return null;
|
|
153
|
+
const nextDoc = toStoryblok(props[key]);
|
|
154
|
+
if (JSON.stringify(nextDoc) === JSON.stringify(before ?? {}))
|
|
155
|
+
return null;
|
|
156
|
+
// Two different spellings of "nothing" are not a change.
|
|
157
|
+
if (isEmptyRichText(nextDoc) && isEmptyRichText(before))
|
|
158
|
+
return null;
|
|
159
|
+
return { value: nextDoc };
|
|
160
|
+
}
|
|
161
|
+
};
|
|
162
|
+
const imageListCodec = {
|
|
163
|
+
project(key, raw) {
|
|
164
|
+
return {
|
|
165
|
+
[key]: (Array.isArray(raw) ? raw : []).map((a) => ({
|
|
166
|
+
image: assetUrl(a),
|
|
167
|
+
alt: assetAlt(a),
|
|
168
|
+
_uid: isObj(a) && a.id != null ? String(a.id) : undefined
|
|
169
|
+
}))
|
|
170
|
+
};
|
|
171
|
+
},
|
|
172
|
+
merge(key, props, before) {
|
|
173
|
+
if (!(key in props))
|
|
174
|
+
return null;
|
|
175
|
+
const items = Array.isArray(props[key]) ? props[key] : [];
|
|
176
|
+
const sourceItems = Array.isArray(before) ? before : [];
|
|
177
|
+
const merged = items.map((item, i) => {
|
|
178
|
+
const src = sourceItems[i] ?? {};
|
|
179
|
+
const url = str(item?.image);
|
|
180
|
+
const alt = str(item?.alt);
|
|
181
|
+
if (url === assetUrl(src) && alt === assetAlt(src))
|
|
182
|
+
return src;
|
|
183
|
+
return assetFrom(url, alt, src);
|
|
184
|
+
});
|
|
185
|
+
if (JSON.stringify(merged) === JSON.stringify(sourceItems))
|
|
186
|
+
return null;
|
|
187
|
+
return { value: merged };
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
/**
|
|
191
|
+
* The locale lens for Storyblok's field-level i18n.
|
|
192
|
+
*
|
|
193
|
+
* The default language lives in the bare key and every other language in
|
|
194
|
+
* `<key>__i18n__<lang>` beside it. Supplied rather than left to the integrator
|
|
195
|
+
* because the conditional is one line and getting it wrong is silent: a lens
|
|
196
|
+
* that reads `title__i18n__de` finds nothing, falls back to `title`, and looks
|
|
197
|
+
* correct until somebody edits the German page.
|
|
198
|
+
*/
|
|
199
|
+
export function storyblokLocale(defaultLang, languages) {
|
|
200
|
+
return {
|
|
201
|
+
default: defaultLang,
|
|
202
|
+
languages,
|
|
203
|
+
path: (key, lang) => (lang === defaultLang ? [key] : [`${key}__i18n__${lang}`])
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
/**
|
|
207
|
+
* Storyblok's answers, ready to hand to `createLens` and `registerFieldTable`.
|
|
208
|
+
*
|
|
209
|
+
* The default naming is the one a tri-lingual Storyblok site on Next 16
|
|
210
|
+
* arrived at: an image field `background_image` projects to
|
|
211
|
+
* `background_image` and `background_image_alt`.
|
|
212
|
+
*/
|
|
213
|
+
export function storyblokPrimitives(options) {
|
|
214
|
+
const imageNaming = options?.imageNaming ?? suffixNaming("", "_alt");
|
|
215
|
+
return {
|
|
216
|
+
imageNaming,
|
|
217
|
+
rowIdKey: "_uid",
|
|
218
|
+
rowTypeKey: "component",
|
|
219
|
+
newRowId: () => typeof crypto !== "undefined" && "randomUUID" in crypto
|
|
220
|
+
? crypto.randomUUID()
|
|
221
|
+
: `uid_${Math.random().toString(36).slice(2, 12)}`,
|
|
222
|
+
codecs: {
|
|
223
|
+
image: imageCodec(imageNaming),
|
|
224
|
+
file: fileCodec,
|
|
225
|
+
link: linkCodec,
|
|
226
|
+
reference: referenceCodec,
|
|
227
|
+
richtext: richTextCodec,
|
|
228
|
+
imageList: imageListCodec
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
}
|
|
232
|
+
export { changed };
|