@avocadostudio-ai/site-sdk 0.6.0 → 0.8.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 (64) hide show
  1. package/dist/create-site-page.js +47 -84
  2. package/dist/draft-common.d.ts +1 -0
  3. package/dist/draft-common.js +13 -0
  4. package/dist/draft-context-core.d.ts +1 -1
  5. package/dist/draft-context-core.js +2 -3
  6. package/dist/draft-context.d.ts +1 -1
  7. package/dist/draft-context.js +1 -3
  8. package/dist/draft-routes.d.ts +7 -0
  9. package/dist/draft-routes.js +8 -3
  10. package/dist/draft.d.ts +1 -0
  11. package/dist/draft.js +3 -0
  12. package/dist/editor-api-handler-core.d.ts +85 -0
  13. package/dist/editor-api-handler-core.js +101 -0
  14. package/dist/editor-api-handler.d.ts +7 -49
  15. package/dist/editor-api-handler.js +12 -66
  16. package/dist/editor-render.d.ts +43 -0
  17. package/dist/editor-render.js +71 -0
  18. package/dist/editor-rewrite-core.d.ts +71 -0
  19. package/dist/editor-rewrite-core.js +37 -0
  20. package/dist/lens/create-lens.d.ts +48 -0
  21. package/dist/lens/create-lens.js +349 -0
  22. package/dist/lens/index.d.ts +7 -0
  23. package/dist/lens/index.js +25 -0
  24. package/dist/lens/register.d.ts +32 -0
  25. package/dist/lens/register.js +322 -0
  26. package/dist/lens/sanity.d.ts +28 -0
  27. package/dist/lens/sanity.js +187 -0
  28. package/dist/lens/scalar-codecs.d.ts +26 -0
  29. package/dist/lens/scalar-codecs.js +92 -0
  30. package/dist/lens/storyblok.d.ts +28 -0
  31. package/dist/lens/storyblok.js +232 -0
  32. package/dist/lens/types.d.ts +279 -0
  33. package/dist/lens/types.js +28 -0
  34. package/dist/markers.d.ts +37 -1
  35. package/dist/markers.js +32 -2
  36. package/dist/page-metadata.d.ts +21 -0
  37. package/dist/page-metadata.js +64 -0
  38. package/dist/proxy.d.ts +9 -30
  39. package/dist/proxy.js +18 -8
  40. package/dist/resolve-page-render.d.ts +94 -0
  41. package/dist/resolve-page-render.js +80 -0
  42. package/dist/routes-core.d.ts +12 -0
  43. package/dist/routes-core.js +24 -0
  44. package/package.json +35 -14
  45. package/dist/cli/register-notice.test.d.ts +0 -1
  46. package/dist/cli/register-notice.test.js +0 -21
  47. package/dist/draft-context-core.test.d.ts +0 -10
  48. package/dist/draft-context-core.test.js +0 -146
  49. package/dist/draft-fetch.test.d.ts +0 -1
  50. package/dist/draft-fetch.test.js +0 -153
  51. package/dist/editor-api-blocks-catalogue.test.d.ts +0 -1
  52. package/dist/editor-api-blocks-catalogue.test.js +0 -73
  53. package/dist/editor-cors.test.d.ts +0 -1
  54. package/dist/editor-cors.test.js +0 -85
  55. package/dist/manifest-utils.test.d.ts +0 -1
  56. package/dist/manifest-utils.test.js +0 -72
  57. package/dist/next-config.test.d.ts +0 -1
  58. package/dist/next-config.test.js +0 -393
  59. package/dist/page-metadata.test.d.ts +0 -1
  60. package/dist/page-metadata.test.js +0 -105
  61. package/dist/proxy.test.d.ts +0 -1
  62. package/dist/proxy.test.js +0 -182
  63. package/dist/publish/field-diff.test.d.ts +0 -1
  64. package/dist/publish/field-diff.test.js +0 -350
@@ -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 };
@@ -0,0 +1,279 @@
1
+ import type { FieldKind, ImageSpec } from "@avocadostudio-ai/shared";
2
+ /** Carried by every field kind. */
3
+ type FieldCommon = {
4
+ /** What the property panel calls it. Defaults to a humanised key. */
5
+ label?: string;
6
+ /**
7
+ * The publisher needs it, the planner must never see it, nobody may edit it.
8
+ *
9
+ * A CMS row identity — `_uid`, `_key`, `_id` — is content-shaped and is not
10
+ * content. Declaring it keeps the merge able to match rows by it while
11
+ * keeping it out of the panel and out of the planner's view.
12
+ */
13
+ internal?: boolean;
14
+ /**
15
+ * A person edits it, but never on the page.
16
+ *
17
+ * A section anchor, a video poster, a layout switch: real content with no
18
+ * element that could carry an editable marker, so the property panel is the
19
+ * whole of its UI. `editableCoverage` reads this as "no marker expected",
20
+ * which is what makes the number reachable — without it a correct
21
+ * integration reports 88% and the integrator gating on coverage has to guess
22
+ * a threshold, at which point a real regression hides behind the guess.
23
+ *
24
+ * The weaker neighbour of `internal`: that one says the field has no
25
+ * audience, this one says it has no element.
26
+ */
27
+ panelOnly?: boolean;
28
+ /**
29
+ * `false` for a field with one value for every language.
30
+ *
31
+ * The default is `true`, which is right for a field-level-i18n CMS: most
32
+ * things are translated and the exceptions are declared.
33
+ *
34
+ * **It means the bare key, which is not the same as the default language.**
35
+ * The two coincide on a CMS that localises into a suffixed sibling
36
+ * (`title` and `title__i18n__fr`) and diverge on one that localises into an
37
+ * object under the key, where the default language lives at `title.de` and a
38
+ * non-localised field lives at `title` with no container at all.
39
+ *
40
+ * On the second kind of CMS this is not optional decoration. An image is one
41
+ * asset reference for every language and a list is one array; leave them
42
+ * declared as localised and the projection looks inside a container that is
43
+ * not there, so the image reads as empty and the list as having no rows.
44
+ * There is deliberately no implicit fallback to the bare key, because a read
45
+ * that fell back would pair with a write that did not — and reading one place
46
+ * while writing another is how a lens corrupts a document.
47
+ */
48
+ localized?: boolean;
49
+ };
50
+ /**
51
+ * How one CMS field becomes one or more Avocado props, and back.
52
+ *
53
+ * The kinds are the union of what two real integrations needed, mapped onto the
54
+ * `FieldKind` vocabulary the property panel already speaks — so a table entry
55
+ * is also the panel metadata, with no second mapping to keep in step.
56
+ */
57
+ export type FieldSpec =
58
+ /** Plain string. `multiline` changes the control, not the storage. */
59
+ (FieldCommon & {
60
+ kind: "text";
61
+ multiline?: boolean;
62
+ inlineEditable?: boolean;
63
+ })
64
+ /** A rich-text document, edited as a document rather than flattened. */
65
+ | (FieldCommon & {
66
+ kind: "richtext";
67
+ inline?: boolean;
68
+ })
69
+ /**
70
+ * A string of HTML — a prop the template renders with `set:html`,
71
+ * `dangerouslySetInnerHTML` or `v-html`.
72
+ *
73
+ * Stored as the string it is, edited as a document. Declaring one of these
74
+ * `richtext` is the mistake it exists to stop: `richtext` means a document,
75
+ * so the panel renders the markup literally and a person editing it writes
76
+ * broken markup back into the site's own source file. Tags outside the
77
+ * converter's vocabulary are preserved rather than dropped — see
78
+ * `@avocadostudio-ai/richtext`'s `fromHtml`.
79
+ */
80
+ | (FieldCommon & {
81
+ kind: "html";
82
+ inline?: boolean;
83
+ inlineEditable?: boolean;
84
+ })
85
+ /** An image. Projects to a URL string plus a companion alt prop. */
86
+ | (FieldCommon & {
87
+ kind: "image";
88
+ imageSpec?: ImageSpec;
89
+ })
90
+ /** A document asset — a menu PDF. Projects to a URL string, no alt. */
91
+ | (FieldCommon & {
92
+ kind: "file";
93
+ })
94
+ /** A link. Projects to an href string. */
95
+ | (FieldCommon & {
96
+ kind: "link";
97
+ })
98
+ /**
99
+ * A pointer to another document in the CMS.
100
+ *
101
+ * Distinct from `link` because the href is a *render* of it: the same stored
102
+ * reference serves `/faq` and `/fr/faq`, so a projection can never be the
103
+ * inverse of the source, and writing the rendered href back replaces the
104
+ * reference with a hard-coded URL that stops following renames.
105
+ */
106
+ | (FieldCommon & {
107
+ kind: "reference";
108
+ }) | (FieldCommon & {
109
+ kind: "enum";
110
+ options: readonly string[];
111
+ }) | (FieldCommon & {
112
+ kind: "boolean";
113
+ }) | (FieldCommon & {
114
+ kind: "number";
115
+ }) | (FieldCommon & {
116
+ kind: "headingLevel";
117
+ })
118
+ /** An array of plain strings — bullets. */
119
+ | (FieldCommon & {
120
+ kind: "stringList";
121
+ })
122
+ /** An array of bare images, not of documents — a logo strip, a gallery. */
123
+ | (FieldCommon & {
124
+ kind: "imageList";
125
+ })
126
+ /**
127
+ * An array of child rows.
128
+ *
129
+ * `of` names the child types admitted, for a CMS where each row is its own
130
+ * document and carries its own type; `itemFields` describes the row inline,
131
+ * for a CMS where the rows are plain objects. Exactly one of the two.
132
+ *
133
+ * Every type named in `of` must itself be in the table, and the pack must
134
+ * supply a `rowTypeKey`: the list's schema is a discriminated union over that
135
+ * key, with one branch per named type. A type the table does not describe is
136
+ * left out of the union rather than admitted as an empty shape, because
137
+ * `createLens` cannot project or merge a row of it either.
138
+ */
139
+ | (FieldCommon & {
140
+ kind: "list";
141
+ of: readonly string[];
142
+ }) | (FieldCommon & {
143
+ kind: "list";
144
+ itemFields: Record<string, FieldSpec>;
145
+ });
146
+ /** One block type, as the site's own CMS names it. */
147
+ export type BlockSpec = {
148
+ /** What the block is called in the editor. */
149
+ displayName: string;
150
+ /** Blocks that may appear directly in a page body. Child rows are not. */
151
+ topLevel?: boolean;
152
+ category?: "content" | "media" | "navigation" | "conversion" | "layout";
153
+ fields: Record<string, FieldSpec>;
154
+ };
155
+ /**
156
+ * The table: every type Avocado may edit on this site, keyed by the CMS's own
157
+ * name for it.
158
+ *
159
+ * Use the CMS's spelling — `hero_section`, not `SiteHeroSection`. `BlockType`
160
+ * is a free string, and keeping the name identical in the CMS, in the manifest
161
+ * and in a publish diff makes the adapter an identity map on the type instead
162
+ * of a translation nobody can grep for.
163
+ */
164
+ export type FieldTable = Record<string, BlockSpec>;
165
+ /**
166
+ * Where one language's value for a key is stored.
167
+ *
168
+ * Field-level i18n comes in two shapes and both fit here: a suffixed sibling
169
+ * key (`title__i18n__fr`, Storyblok) and a per-locale object under the key
170
+ * itself (`{de, fr, en}`, Sanity and Contentful) — the second by giving `path`
171
+ * a second segment.
172
+ */
173
+ export type LocaleLens<L extends string = string> = {
174
+ /** The language whose value lives in the bare key. */
175
+ default: L;
176
+ /** Every language this site publishes. */
177
+ languages: readonly L[];
178
+ /**
179
+ * The path at which `lang`'s value for `key` is stored, as one or two
180
+ * segments: `["title__i18n__fr"]` or `["title", "fr"]`.
181
+ */
182
+ path(key: string, lang: L): readonly [string] | readonly [string, string];
183
+ /**
184
+ * `false` when the CMS itself says this field is not translatable.
185
+ *
186
+ * Storyblok leaves old translations in the document after a field stops being
187
+ * marked translatable, and the Delivery API ignores them — so a projection
188
+ * that reads them shows text the site has not rendered in years, and a merge
189
+ * that writes them produces a publish nobody can explain. Defaults to `true`.
190
+ */
191
+ translatable?(type: string, key: string, lang: L): boolean;
192
+ };
193
+ /** What a codec is told beyond the value itself. */
194
+ export type CodecContext = {
195
+ /** The field's own declaration, for `options`, `of`, `itemFields`. */
196
+ spec: FieldSpec;
197
+ /** The language being read or written. */
198
+ lang: string;
199
+ /** The block type this field sits on, for messages. */
200
+ type: string;
201
+ /** A path like `/preise > hero_section > title`, for messages. */
202
+ where: string;
203
+ };
204
+ /**
205
+ * The outcome of merging one edited value back.
206
+ *
207
+ * `null` means *unchanged*, and it is the most important of the three. A CMS
208
+ * with per-language fallback resolves a missing translation to the default
209
+ * language, which is correct on screen and a lie in storage: merge a projection
210
+ * back wholesale and every one of those fallbacks becomes a real, fabricated
211
+ * translation — dozens per publish, each identical to what the page already
212
+ * showed, so nothing looks wrong. A codec returns a value only when the value
213
+ * really differs from what the source holds.
214
+ */
215
+ export type MergeOutcome = null | {
216
+ value: unknown;
217
+ }
218
+ /** The edit cannot be stored, and the editor is owed the reason. */
219
+ | {
220
+ warning: string;
221
+ };
222
+ /**
223
+ * How one CMS reads and writes one kind of field.
224
+ *
225
+ * `project` returns the props this field contributes — more than one for a kind
226
+ * with a companion, like an image and its alt text — so the 1-to-2 case needs
227
+ * no special handling anywhere else.
228
+ */
229
+ export type FieldCodec = {
230
+ project(key: string, raw: unknown, ctx: CodecContext): Record<string, unknown>;
231
+ merge(key: string, props: Record<string, unknown>, before: unknown, ctx: CodecContext): MergeOutcome;
232
+ /** The panel metadata for this kind, when it is not simply `kind`. */
233
+ fieldKind?: FieldKind;
234
+ };
235
+ /**
236
+ * What an image field's two props are called.
237
+ *
238
+ * An image contributes a URL and an alt text, and the names are load-bearing in
239
+ * a place nothing checks: the editor's asset picker keys on the `*imageUrl` /
240
+ * `*Image` name pattern rather than on any declaration. Two integrations chose
241
+ * differently — `image` + `image_alt` against `imageUrl` + `imageAlt` — and
242
+ * both were right for their site.
243
+ *
244
+ * It lives on `Primitives` so the projection and the panel registration read
245
+ * the same answer. Held separately they are two things that must agree with
246
+ * nothing checking that they do, which is the defect class this file exists to
247
+ * delete rather than reproduce.
248
+ */
249
+ export type ImageNaming = {
250
+ url(key: string): string;
251
+ alt(key: string): string;
252
+ };
253
+ /** `suffixNaming("", "_alt")` gives `image` + `image_alt`. */
254
+ export declare function suffixNaming(urlSuffix: string, altSuffix: string): ImageNaming;
255
+ /**
256
+ * A CMS's answers, keyed by field kind.
257
+ *
258
+ * The scalar kinds have CMS-independent defaults, so a pack supplies only what
259
+ * its CMS actually shapes differently — in practice `image`, `file`, `link`,
260
+ * `reference`, `richtext`, `imageList` and `list`.
261
+ */
262
+ export type Primitives = {
263
+ codecs: Partial<Record<FieldSpec["kind"], FieldCodec>>;
264
+ /**
265
+ * The key under which a child row carries its own identity (`_uid`, `_key`).
266
+ *
267
+ * The merge matches rows by it, which is what keeps a list edit a merge
268
+ * rather than a replace — and it is why row identity has to survive the round
269
+ * trip rather than being stripped as "not content".
270
+ */
271
+ rowIdKey: string;
272
+ /** The key under which a child row carries its own type, for `of` lists. */
273
+ rowTypeKey?: string;
274
+ /** A fresh row identity, for a row the editor added. */
275
+ newRowId(): string;
276
+ /** What an image field's URL and alt props are called. */
277
+ imageNaming: ImageNaming;
278
+ };
279
+ export {};
@@ -0,0 +1,28 @@
1
+ /*
2
+ * One table describing the fields a CMS-backed site lets Avocado edit — and
3
+ * the four things derived from it.
4
+ *
5
+ * Two integrations reached this design independently, for different CMSes, and
6
+ * each opened its own file by explaining it in near-identical words: four
7
+ * things have to agree about every block — the Zod schema the operations engine
8
+ * validates against, the field metadata the property panel draws, the
9
+ * projection that turns a CMS document into props, and the merge that writes
10
+ * edited props back — and if they are written four times they disagree within a
11
+ * week.
12
+ *
13
+ * Both were right about the remedy and both had to build it themselves, in
14
+ * mutually unreadable vocabularies (`{ t: "asset" }` against
15
+ * `{ kind: 'image' }`), so nothing carried from the first integration to the
16
+ * second. The derivation is the same program either way: what differs between
17
+ * two CMSes is how one value of a given kind is read and written, which is the
18
+ * `FieldCodec` below, and where a language's value is stored, which is the
19
+ * `LocaleLens`.
20
+ *
21
+ * What stays site-specific is the table. It is the content model, it is
22
+ * irreducible, and it is the whole of what an adopter on a CMS we already know
23
+ * should have to write.
24
+ */
25
+ /** `suffixNaming("", "_alt")` gives `image` + `image_alt`. */
26
+ export function suffixNaming(urlSuffix, altSuffix) {
27
+ return { url: (key) => `${key}${urlSuffix}`, alt: (key) => `${key}${altSuffix}` };
28
+ }
package/dist/markers.d.ts CHANGED
@@ -104,7 +104,43 @@ export declare function editableProps(path: string, options?: {
104
104
  * `sections[0]` scope makes a child's `text` into `sections[0].left[1].text`.
105
105
  * A block boundary ends the composition, so a scope outside a block never
106
106
  * reaches into it.
107
+ *
108
+ * **The scope needs an element, and the element needs to not be there.** A
109
+ * scope is a DOM attribute, so it wants an ancestor to sit on — and a list
110
+ * whose rows map straight into a flex or grid container has none to offer.
111
+ * Adding a plain wrapper around each row gives the scope its element and makes
112
+ * the wrapper the flex item, so the layout the rows had is now the layout of a
113
+ * column of wrappers: gaps land in different places, `align-items` applies to
114
+ * the wrong box, and a grid's rows stop being the grid's children at all.
115
+ *
116
+ * `display: contents` is the whole answer — the element stays in the tree for
117
+ * anything walking it, and lays out as if it were not there, so the rows go on
118
+ * being their parent's children. Pass `{ display: "contents" }` and the helper
119
+ * writes it:
120
+ *
121
+ * ```tsx
122
+ * <div className="flex flex-col gap-6">
123
+ * {props.items.map((item, i) => (
124
+ * <div key={item.id} {...editableScopeProps(`items[${i}]`, { display: "contents" })}>
125
+ * <FaqRow item={item} />
126
+ * </div>
127
+ * ))}
128
+ * </div>
129
+ * ```
130
+ *
131
+ * Leave it off when the wrapper is one you were going to render anyway — a
132
+ * row that already has a `<li>` or a card `<div>` around it should carry the
133
+ * scope on that, not gain a second element to hold it.
107
134
  */
108
- export declare function editableScopeProps(scope: string): {
135
+ export declare function editableScopeProps(scope: string, options?: {
136
+ /**
137
+ * `"contents"` adds `style={{ display: "contents" }}`, for a wrapper that
138
+ * exists only to carry the scope and must not become a box.
139
+ */
140
+ display?: "contents";
141
+ }): {
142
+ readonly style?: {
143
+ display: "contents";
144
+ } | undefined;
109
145
  readonly "data-editable-scope": string;
110
146
  };
package/dist/markers.js CHANGED
@@ -113,7 +113,37 @@ export function editableProps(path, options) {
113
113
  * `sections[0]` scope makes a child's `text` into `sections[0].left[1].text`.
114
114
  * A block boundary ends the composition, so a scope outside a block never
115
115
  * reaches into it.
116
+ *
117
+ * **The scope needs an element, and the element needs to not be there.** A
118
+ * scope is a DOM attribute, so it wants an ancestor to sit on — and a list
119
+ * whose rows map straight into a flex or grid container has none to offer.
120
+ * Adding a plain wrapper around each row gives the scope its element and makes
121
+ * the wrapper the flex item, so the layout the rows had is now the layout of a
122
+ * column of wrappers: gaps land in different places, `align-items` applies to
123
+ * the wrong box, and a grid's rows stop being the grid's children at all.
124
+ *
125
+ * `display: contents` is the whole answer — the element stays in the tree for
126
+ * anything walking it, and lays out as if it were not there, so the rows go on
127
+ * being their parent's children. Pass `{ display: "contents" }` and the helper
128
+ * writes it:
129
+ *
130
+ * ```tsx
131
+ * <div className="flex flex-col gap-6">
132
+ * {props.items.map((item, i) => (
133
+ * <div key={item.id} {...editableScopeProps(`items[${i}]`, { display: "contents" })}>
134
+ * <FaqRow item={item} />
135
+ * </div>
136
+ * ))}
137
+ * </div>
138
+ * ```
139
+ *
140
+ * Leave it off when the wrapper is one you were going to render anyway — a
141
+ * row that already has a `<li>` or a card `<div>` around it should carry the
142
+ * scope on that, not gain a second element to hold it.
116
143
  */
117
- export function editableScopeProps(scope) {
118
- return { "data-editable-scope": scope };
144
+ export function editableScopeProps(scope, options) {
145
+ return {
146
+ "data-editable-scope": scope,
147
+ ...(options?.display === "contents" ? { style: { display: "contents" } } : {})
148
+ };
119
149
  }
@@ -64,3 +64,24 @@ export type BuildPageMetadataOptions = {
64
64
  * image to put in it.
65
65
  */
66
66
  export declare function buildPageMetadata(page: Pick<PageDoc, "title" | "meta" | "blocks">, options?: BuildPageMetadataOptions): PageMetadata;
67
+ /**
68
+ * Render {@link PageMetadata} as the head tags it describes.
69
+ *
70
+ * Next consumes the object directly — a route exports `metadata` and the
71
+ * framework emits the tags — so nothing in this repo needed this until now.
72
+ * Every other host writes its own `<head>`, and an integrator left to do that
73
+ * by hand reproduces a subset: the title and description usually, Open Graph
74
+ * sometimes, `twitter:card` almost never, and the canonical link not at all.
75
+ * That is how `test:build` came to exist — three of those omissions had already
76
+ * shipped from the one route that built its head by hand.
77
+ *
78
+ * Returns a string because the alternative is a shape per templating language.
79
+ * The values are attribute-escaped; the caller inserts it as raw HTML
80
+ * (`set:html` in Astro, `v-html` in Vue, `{@html}` in Svelte).
81
+ *
82
+ * `og:image` and `twitter:image` are emitted exactly as given. A relative path
83
+ * is legal in the document but most crawlers will not resolve it, so pass
84
+ * absolute URLs — the same rule that applies to `buildPageMetadata`'s
85
+ * `canonical`.
86
+ */
87
+ export declare function renderPageMetadata(metadata: PageMetadata): string;