@avocadostudio-ai/site-sdk 0.5.1 → 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.
Files changed (41) hide show
  1. package/dist/cli/register-notice.d.ts +20 -0
  2. package/dist/cli/register-notice.js +34 -0
  3. package/dist/cli/register-notice.test.d.ts +1 -0
  4. package/dist/cli/register-notice.test.js +21 -0
  5. package/dist/cli/register.js +7 -2
  6. package/dist/draft.d.ts +1 -0
  7. package/dist/draft.js +3 -0
  8. package/dist/editor-render.d.ts +43 -0
  9. package/dist/editor-render.js +71 -0
  10. package/dist/editor-render.test.d.ts +1 -0
  11. package/dist/editor-render.test.js +44 -0
  12. package/dist/editor.d.ts +1 -1
  13. package/dist/editor.js +1 -1
  14. package/dist/lens/create-lens.d.ts +48 -0
  15. package/dist/lens/create-lens.js +349 -0
  16. package/dist/lens/index.d.ts +7 -0
  17. package/dist/lens/index.js +25 -0
  18. package/dist/lens/lens.test.d.ts +1 -0
  19. package/dist/lens/lens.test.js +221 -0
  20. package/dist/lens/register.d.ts +34 -0
  21. package/dist/lens/register.js +148 -0
  22. package/dist/lens/register.test.d.ts +1 -0
  23. package/dist/lens/register.test.js +81 -0
  24. package/dist/lens/sanity.d.ts +28 -0
  25. package/dist/lens/sanity.js +187 -0
  26. package/dist/lens/scalar-codecs.d.ts +26 -0
  27. package/dist/lens/scalar-codecs.js +92 -0
  28. package/dist/lens/storyblok.d.ts +28 -0
  29. package/dist/lens/storyblok.js +232 -0
  30. package/dist/lens/types.d.ts +243 -0
  31. package/dist/lens/types.js +28 -0
  32. package/dist/markers.d.ts +68 -0
  33. package/dist/markers.js +62 -0
  34. package/dist/markers.test.d.ts +1 -0
  35. package/dist/markers.test.js +35 -0
  36. package/dist/middleware.d.ts +1 -0
  37. package/dist/middleware.js +6 -0
  38. package/dist/proxy.d.ts +26 -0
  39. package/dist/proxy.js +88 -26
  40. package/dist/proxy.test.js +61 -2
  41. package/package.json +21 -5
@@ -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,243 @@
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
+ * `false` for a field with one value for every language.
16
+ *
17
+ * The default is `true`, which is right for a field-level-i18n CMS: most
18
+ * things are translated and the exceptions are declared.
19
+ *
20
+ * **It means the bare key, which is not the same as the default language.**
21
+ * The two coincide on a CMS that localises into a suffixed sibling
22
+ * (`title` and `title__i18n__fr`) and diverge on one that localises into an
23
+ * object under the key, where the default language lives at `title.de` and a
24
+ * non-localised field lives at `title` with no container at all.
25
+ *
26
+ * On the second kind of CMS this is not optional decoration. An image is one
27
+ * asset reference for every language and a list is one array; leave them
28
+ * declared as localised and the projection looks inside a container that is
29
+ * not there, so the image reads as empty and the list as having no rows.
30
+ * There is deliberately no implicit fallback to the bare key, because a read
31
+ * that fell back would pair with a write that did not — and reading one place
32
+ * while writing another is how a lens corrupts a document.
33
+ */
34
+ localized?: boolean;
35
+ };
36
+ /**
37
+ * How one CMS field becomes one or more Avocado props, and back.
38
+ *
39
+ * The kinds are the union of what two real integrations needed, mapped onto the
40
+ * `FieldKind` vocabulary the property panel already speaks — so a table entry
41
+ * is also the panel metadata, with no second mapping to keep in step.
42
+ */
43
+ export type FieldSpec =
44
+ /** Plain string. `multiline` changes the control, not the storage. */
45
+ (FieldCommon & {
46
+ kind: "text";
47
+ multiline?: boolean;
48
+ inlineEditable?: boolean;
49
+ })
50
+ /** A rich-text document, edited as a document rather than flattened. */
51
+ | (FieldCommon & {
52
+ kind: "richtext";
53
+ inline?: boolean;
54
+ })
55
+ /** An image. Projects to a URL string plus a companion alt prop. */
56
+ | (FieldCommon & {
57
+ kind: "image";
58
+ imageSpec?: ImageSpec;
59
+ })
60
+ /** A document asset — a menu PDF. Projects to a URL string, no alt. */
61
+ | (FieldCommon & {
62
+ kind: "file";
63
+ })
64
+ /** A link. Projects to an href string. */
65
+ | (FieldCommon & {
66
+ kind: "link";
67
+ })
68
+ /**
69
+ * A pointer to another document in the CMS.
70
+ *
71
+ * Distinct from `link` because the href is a *render* of it: the same stored
72
+ * reference serves `/faq` and `/fr/faq`, so a projection can never be the
73
+ * inverse of the source, and writing the rendered href back replaces the
74
+ * reference with a hard-coded URL that stops following renames.
75
+ */
76
+ | (FieldCommon & {
77
+ kind: "reference";
78
+ }) | (FieldCommon & {
79
+ kind: "enum";
80
+ options: readonly string[];
81
+ }) | (FieldCommon & {
82
+ kind: "boolean";
83
+ }) | (FieldCommon & {
84
+ kind: "number";
85
+ }) | (FieldCommon & {
86
+ kind: "headingLevel";
87
+ })
88
+ /** An array of plain strings — bullets. */
89
+ | (FieldCommon & {
90
+ kind: "stringList";
91
+ })
92
+ /** An array of bare images, not of documents — a logo strip, a gallery. */
93
+ | (FieldCommon & {
94
+ kind: "imageList";
95
+ })
96
+ /**
97
+ * An array of child rows.
98
+ *
99
+ * `of` names the child types admitted, for a CMS where each row is its own
100
+ * document and carries its own type; `itemFields` describes the row inline,
101
+ * for a CMS where the rows are plain objects. Exactly one of the two.
102
+ */
103
+ | (FieldCommon & {
104
+ kind: "list";
105
+ of: readonly string[];
106
+ }) | (FieldCommon & {
107
+ kind: "list";
108
+ itemFields: Record<string, FieldSpec>;
109
+ });
110
+ /** One block type, as the site's own CMS names it. */
111
+ export type BlockSpec = {
112
+ /** What the block is called in the editor. */
113
+ displayName: string;
114
+ /** Blocks that may appear directly in a page body. Child rows are not. */
115
+ topLevel?: boolean;
116
+ category?: "content" | "media" | "navigation" | "conversion" | "layout";
117
+ fields: Record<string, FieldSpec>;
118
+ };
119
+ /**
120
+ * The table: every type Avocado may edit on this site, keyed by the CMS's own
121
+ * name for it.
122
+ *
123
+ * Use the CMS's spelling — `hero_section`, not `SiteHeroSection`. `BlockType`
124
+ * is a free string, and keeping the name identical in the CMS, in the manifest
125
+ * and in a publish diff makes the adapter an identity map on the type instead
126
+ * of a translation nobody can grep for.
127
+ */
128
+ export type FieldTable = Record<string, BlockSpec>;
129
+ /**
130
+ * Where one language's value for a key is stored.
131
+ *
132
+ * Field-level i18n comes in two shapes and both fit here: a suffixed sibling
133
+ * key (`title__i18n__fr`, Storyblok) and a per-locale object under the key
134
+ * itself (`{de, fr, en}`, Sanity and Contentful) — the second by giving `path`
135
+ * a second segment.
136
+ */
137
+ export type LocaleLens<L extends string = string> = {
138
+ /** The language whose value lives in the bare key. */
139
+ default: L;
140
+ /** Every language this site publishes. */
141
+ languages: readonly L[];
142
+ /**
143
+ * The path at which `lang`'s value for `key` is stored, as one or two
144
+ * segments: `["title__i18n__fr"]` or `["title", "fr"]`.
145
+ */
146
+ path(key: string, lang: L): readonly [string] | readonly [string, string];
147
+ /**
148
+ * `false` when the CMS itself says this field is not translatable.
149
+ *
150
+ * Storyblok leaves old translations in the document after a field stops being
151
+ * marked translatable, and the Delivery API ignores them — so a projection
152
+ * that reads them shows text the site has not rendered in years, and a merge
153
+ * that writes them produces a publish nobody can explain. Defaults to `true`.
154
+ */
155
+ translatable?(type: string, key: string, lang: L): boolean;
156
+ };
157
+ /** What a codec is told beyond the value itself. */
158
+ export type CodecContext = {
159
+ /** The field's own declaration, for `options`, `of`, `itemFields`. */
160
+ spec: FieldSpec;
161
+ /** The language being read or written. */
162
+ lang: string;
163
+ /** The block type this field sits on, for messages. */
164
+ type: string;
165
+ /** A path like `/preise > hero_section > title`, for messages. */
166
+ where: string;
167
+ };
168
+ /**
169
+ * The outcome of merging one edited value back.
170
+ *
171
+ * `null` means *unchanged*, and it is the most important of the three. A CMS
172
+ * with per-language fallback resolves a missing translation to the default
173
+ * language, which is correct on screen and a lie in storage: merge a projection
174
+ * back wholesale and every one of those fallbacks becomes a real, fabricated
175
+ * translation — dozens per publish, each identical to what the page already
176
+ * showed, so nothing looks wrong. A codec returns a value only when the value
177
+ * really differs from what the source holds.
178
+ */
179
+ export type MergeOutcome = null | {
180
+ value: unknown;
181
+ }
182
+ /** The edit cannot be stored, and the editor is owed the reason. */
183
+ | {
184
+ warning: string;
185
+ };
186
+ /**
187
+ * How one CMS reads and writes one kind of field.
188
+ *
189
+ * `project` returns the props this field contributes — more than one for a kind
190
+ * with a companion, like an image and its alt text — so the 1-to-2 case needs
191
+ * no special handling anywhere else.
192
+ */
193
+ export type FieldCodec = {
194
+ project(key: string, raw: unknown, ctx: CodecContext): Record<string, unknown>;
195
+ merge(key: string, props: Record<string, unknown>, before: unknown, ctx: CodecContext): MergeOutcome;
196
+ /** The panel metadata for this kind, when it is not simply `kind`. */
197
+ fieldKind?: FieldKind;
198
+ };
199
+ /**
200
+ * What an image field's two props are called.
201
+ *
202
+ * An image contributes a URL and an alt text, and the names are load-bearing in
203
+ * a place nothing checks: the editor's asset picker keys on the `*imageUrl` /
204
+ * `*Image` name pattern rather than on any declaration. Two integrations chose
205
+ * differently — `image` + `image_alt` against `imageUrl` + `imageAlt` — and
206
+ * both were right for their site.
207
+ *
208
+ * It lives on `Primitives` so the projection and the panel registration read
209
+ * the same answer. Held separately they are two things that must agree with
210
+ * nothing checking that they do, which is the defect class this file exists to
211
+ * delete rather than reproduce.
212
+ */
213
+ export type ImageNaming = {
214
+ url(key: string): string;
215
+ alt(key: string): string;
216
+ };
217
+ /** `suffixNaming("", "_alt")` gives `image` + `image_alt`. */
218
+ export declare function suffixNaming(urlSuffix: string, altSuffix: string): ImageNaming;
219
+ /**
220
+ * A CMS's answers, keyed by field kind.
221
+ *
222
+ * The scalar kinds have CMS-independent defaults, so a pack supplies only what
223
+ * its CMS actually shapes differently — in practice `image`, `file`, `link`,
224
+ * `reference`, `richtext`, `imageList` and `list`.
225
+ */
226
+ export type Primitives = {
227
+ codecs: Partial<Record<FieldSpec["kind"], FieldCodec>>;
228
+ /**
229
+ * The key under which a child row carries its own identity (`_uid`, `_key`).
230
+ *
231
+ * The merge matches rows by it, which is what keeps a list edit a merge
232
+ * rather than a replace — and it is why row identity has to survive the round
233
+ * trip rather than being stripped as "not content".
234
+ */
235
+ rowIdKey: string;
236
+ /** The key under which a child row carries its own type, for `of` lists. */
237
+ rowTypeKey?: string;
238
+ /** A fresh row identity, for a row the editor added. */
239
+ newRowId(): string;
240
+ /** What an image field's URL and alt props are called. */
241
+ imageNaming: ImageNaming;
242
+ };
243
+ 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
@@ -76,3 +76,71 @@ export declare function editableProps(path: string, options?: {
76
76
  readonly "data-editable-target": string;
77
77
  readonly "data-editable-target-label": string;
78
78
  };
79
+ /**
80
+ * Mark an element as the scope its marked descendants sit inside.
81
+ *
82
+ * The field path is scoped from the block down — `items[3].question` — which
83
+ * a renderer can only write if it knows where it sits. That holds while one
84
+ * component draws the whole block, and stops holding the moment a list row is
85
+ * drawn by a component of its own: the child knows it has a `question` and
86
+ * cannot know it is `items[3]`. Without this, every component that can appear
87
+ * inside a list takes a prefix prop from its parent, and every parent passes
88
+ * one.
89
+ *
90
+ * Forgetting to is silent and *wrong*, not silent and absent. The child marks
91
+ * a bare `question`, the overlay resolves it against the enclosing block, and
92
+ * an edit to a headline inside a column patches a prop the section does not
93
+ * have.
94
+ *
95
+ * ```tsx
96
+ * {props.items.map((item, i) => (
97
+ * <div key={item.id} {...editableScopeProps(`items[${i}]`)}>
98
+ * <FaqRow item={item} /> // marks a bare "question"; needs no prefix
99
+ * </div>
100
+ * ))}
101
+ * ```
102
+ *
103
+ * Scopes nest, and compose outermost first — a `left[1]` scope inside a
104
+ * `sections[0]` scope makes a child's `text` into `sections[0].left[1].text`.
105
+ * A block boundary ends the composition, so a scope outside a block never
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.
134
+ */
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;
145
+ readonly "data-editable-scope": string;
146
+ };
package/dist/markers.js CHANGED
@@ -85,3 +85,65 @@ export function editableProps(path, options) {
85
85
  ...(options?.kind ? { "data-editable-kind": options.kind } : {})
86
86
  };
87
87
  }
88
+ /**
89
+ * Mark an element as the scope its marked descendants sit inside.
90
+ *
91
+ * The field path is scoped from the block down — `items[3].question` — which
92
+ * a renderer can only write if it knows where it sits. That holds while one
93
+ * component draws the whole block, and stops holding the moment a list row is
94
+ * drawn by a component of its own: the child knows it has a `question` and
95
+ * cannot know it is `items[3]`. Without this, every component that can appear
96
+ * inside a list takes a prefix prop from its parent, and every parent passes
97
+ * one.
98
+ *
99
+ * Forgetting to is silent and *wrong*, not silent and absent. The child marks
100
+ * a bare `question`, the overlay resolves it against the enclosing block, and
101
+ * an edit to a headline inside a column patches a prop the section does not
102
+ * have.
103
+ *
104
+ * ```tsx
105
+ * {props.items.map((item, i) => (
106
+ * <div key={item.id} {...editableScopeProps(`items[${i}]`)}>
107
+ * <FaqRow item={item} /> // marks a bare "question"; needs no prefix
108
+ * </div>
109
+ * ))}
110
+ * ```
111
+ *
112
+ * Scopes nest, and compose outermost first — a `left[1]` scope inside a
113
+ * `sections[0]` scope makes a child's `text` into `sections[0].left[1].text`.
114
+ * A block boundary ends the composition, so a scope outside a block never
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.
143
+ */
144
+ export function editableScopeProps(scope, options) {
145
+ return {
146
+ "data-editable-scope": scope,
147
+ ...(options?.display === "contents" ? { style: { display: "contents" } } : {})
148
+ };
149
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,35 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { editableProps, editableScopeProps, getPreviewWrapperProps } from "./markers.js";
4
+ test("editableProps defaults the label to the path and omits kind when unset", () => {
5
+ assert.deepEqual(editableProps("cards[0].title"), {
6
+ "data-editable-target": "cards[0].title",
7
+ "data-editable-target-label": "cards[0].title"
8
+ });
9
+ });
10
+ test("editableProps carries an explicit label and kind", () => {
11
+ assert.deepEqual(editableProps("photoUrl", { label: "Photo", kind: "image" }), {
12
+ "data-editable-target": "photoUrl",
13
+ "data-editable-target-label": "Photo",
14
+ "data-editable-kind": "image"
15
+ });
16
+ });
17
+ test("editableScopeProps writes only the scope by default", () => {
18
+ assert.deepEqual(editableScopeProps("items[3]"), { "data-editable-scope": "items[3]" });
19
+ });
20
+ /*
21
+ * A scope wants a DOM ancestor, and a list that maps its rows straight into a
22
+ * flex container has none — so the wrapper you add to hold it becomes the flex
23
+ * item and the layout moves. `display: contents` keeps the element in the tree
24
+ * the overlay walks and out of the box tree that lays the rows out.
25
+ */
26
+ test("editableScopeProps can make its wrapper vanish from layout", () => {
27
+ assert.deepEqual(editableScopeProps("items[3]", { display: "contents" }), {
28
+ "data-editable-scope": "items[3]",
29
+ style: { display: "contents" }
30
+ });
31
+ });
32
+ test("getPreviewWrapperProps renders nothing outside editor mode", () => {
33
+ assert.deepEqual(getPreviewWrapperProps(false, "b1", "Hero"), {});
34
+ assert.equal(getPreviewWrapperProps(true, "b1", "Hero")["data-block-id"], "b1");
35
+ });
@@ -1,4 +1,5 @@
1
1
  import { type EditorProxyOptions } from "./proxy.ts";
2
+ export { trailingSlashRedirect, editorPreviewRewrite } from "./proxy.ts";
2
3
  /**
3
4
  * Options for the editor middleware factory.
4
5
  *