@avocadostudio-ai/shared 0.5.0 → 0.5.1

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.
@@ -15,6 +15,7 @@ export declare const fieldMetaSchema: z.ZodObject<{
15
15
  imageAlt: "imageAlt";
16
16
  color: "color";
17
17
  headingLevel: "headingLevel";
18
+ reference: "reference";
18
19
  }>;
19
20
  label: z.ZodOptional<z.ZodString>;
20
21
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -34,6 +35,7 @@ export declare const fieldMetaSchema: z.ZodObject<{
34
35
  }>>;
35
36
  }, z.core.$strip>>;
36
37
  multiline: z.ZodOptional<z.ZodBoolean>;
38
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
37
39
  inline: z.ZodOptional<z.ZodBoolean>;
38
40
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
39
41
  name: z.ZodString;
@@ -57,6 +59,7 @@ export declare const listFieldMetaSchema: z.ZodObject<{
57
59
  imageAlt: "imageAlt";
58
60
  color: "color";
59
61
  headingLevel: "headingLevel";
62
+ reference: "reference";
60
63
  }>;
61
64
  label: z.ZodOptional<z.ZodString>;
62
65
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -76,6 +79,7 @@ export declare const listFieldMetaSchema: z.ZodObject<{
76
79
  }>>;
77
80
  }, z.core.$strip>>;
78
81
  multiline: z.ZodOptional<z.ZodBoolean>;
82
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
79
83
  inline: z.ZodOptional<z.ZodBoolean>;
80
84
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
81
85
  name: z.ZodString;
@@ -98,6 +102,7 @@ export declare const listFieldMetaSchema: z.ZodObject<{
98
102
  imageAlt: "imageAlt";
99
103
  color: "color";
100
104
  headingLevel: "headingLevel";
105
+ reference: "reference";
101
106
  }>;
102
107
  label: z.ZodOptional<z.ZodString>;
103
108
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -117,6 +122,7 @@ export declare const listFieldMetaSchema: z.ZodObject<{
117
122
  }>>;
118
123
  }, z.core.$strip>>;
119
124
  multiline: z.ZodOptional<z.ZodBoolean>;
125
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
120
126
  inline: z.ZodOptional<z.ZodBoolean>;
121
127
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
122
128
  name: z.ZodString;
@@ -145,6 +151,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
145
151
  imageAlt: "imageAlt";
146
152
  color: "color";
147
153
  headingLevel: "headingLevel";
154
+ reference: "reference";
148
155
  }>;
149
156
  label: z.ZodOptional<z.ZodString>;
150
157
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -164,6 +171,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
164
171
  }>>;
165
172
  }, z.core.$strip>>;
166
173
  multiline: z.ZodOptional<z.ZodBoolean>;
174
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
167
175
  inline: z.ZodOptional<z.ZodBoolean>;
168
176
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
169
177
  name: z.ZodString;
@@ -187,6 +195,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
187
195
  imageAlt: "imageAlt";
188
196
  color: "color";
189
197
  headingLevel: "headingLevel";
198
+ reference: "reference";
190
199
  }>;
191
200
  label: z.ZodOptional<z.ZodString>;
192
201
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -206,6 +215,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
206
215
  }>>;
207
216
  }, z.core.$strip>>;
208
217
  multiline: z.ZodOptional<z.ZodBoolean>;
218
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
209
219
  inline: z.ZodOptional<z.ZodBoolean>;
210
220
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
211
221
  name: z.ZodString;
@@ -228,6 +238,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
228
238
  imageAlt: "imageAlt";
229
239
  color: "color";
230
240
  headingLevel: "headingLevel";
241
+ reference: "reference";
231
242
  }>;
232
243
  label: z.ZodOptional<z.ZodString>;
233
244
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -247,6 +258,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
247
258
  }>>;
248
259
  }, z.core.$strip>>;
249
260
  multiline: z.ZodOptional<z.ZodBoolean>;
261
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
250
262
  inline: z.ZodOptional<z.ZodBoolean>;
251
263
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
252
264
  name: z.ZodString;
@@ -281,6 +293,7 @@ export declare const blockManifestSchema: z.ZodObject<{
281
293
  imageAlt: "imageAlt";
282
294
  color: "color";
283
295
  headingLevel: "headingLevel";
296
+ reference: "reference";
284
297
  }>;
285
298
  label: z.ZodOptional<z.ZodString>;
286
299
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -300,6 +313,7 @@ export declare const blockManifestSchema: z.ZodObject<{
300
313
  }>>;
301
314
  }, z.core.$strip>>;
302
315
  multiline: z.ZodOptional<z.ZodBoolean>;
316
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
303
317
  inline: z.ZodOptional<z.ZodBoolean>;
304
318
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
305
319
  name: z.ZodString;
@@ -323,6 +337,7 @@ export declare const blockManifestSchema: z.ZodObject<{
323
337
  imageAlt: "imageAlt";
324
338
  color: "color";
325
339
  headingLevel: "headingLevel";
340
+ reference: "reference";
326
341
  }>;
327
342
  label: z.ZodOptional<z.ZodString>;
328
343
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -342,6 +357,7 @@ export declare const blockManifestSchema: z.ZodObject<{
342
357
  }>>;
343
358
  }, z.core.$strip>>;
344
359
  multiline: z.ZodOptional<z.ZodBoolean>;
360
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
345
361
  inline: z.ZodOptional<z.ZodBoolean>;
346
362
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
347
363
  name: z.ZodString;
@@ -364,6 +380,7 @@ export declare const blockManifestSchema: z.ZodObject<{
364
380
  imageAlt: "imageAlt";
365
381
  color: "color";
366
382
  headingLevel: "headingLevel";
383
+ reference: "reference";
367
384
  }>;
368
385
  label: z.ZodOptional<z.ZodString>;
369
386
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -383,6 +400,7 @@ export declare const blockManifestSchema: z.ZodObject<{
383
400
  }>>;
384
401
  }, z.core.$strip>>;
385
402
  multiline: z.ZodOptional<z.ZodBoolean>;
403
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
386
404
  inline: z.ZodOptional<z.ZodBoolean>;
387
405
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
388
406
  name: z.ZodString;
@@ -36,7 +36,7 @@ export const jsonSchemaLikeSchema = z.lazy(() => z.object({
36
36
  */
37
37
  const FIELD_KINDS = [
38
38
  "text", "richtext", "url", "link", "file", "image", "imageAlt",
39
- "enum", "color", "number", "boolean", "headingLevel"
39
+ "enum", "color", "number", "boolean", "headingLevel", "reference"
40
40
  ];
41
41
  const _everyKindListed = true;
42
42
  void _everyKindListed;
@@ -54,6 +54,13 @@ export const fieldMetaSchema = z.object({
54
54
  })
55
55
  .optional(),
56
56
  multiline: z.boolean().optional(),
57
+ /*
58
+ * Travels with the manifest because the panel is what reads it, and the panel
59
+ * is in a different process. A key this schema omits is stripped here, not
60
+ * rejected — so a reference would have arrived in the editor with nothing to
61
+ * label it and no sign that anything had been declared.
62
+ */
63
+ referenceLabelKey: z.string().optional(),
57
64
  inline: z.boolean().optional(),
58
65
  decorators: z
59
66
  .array(z.object({ name: z.string().min(1), title: z.string().optional() }))
@@ -18,8 +18,27 @@ export declare function isImagePlaceholder(url: string | undefined | null): bool
18
18
  * against nothing at all; a `file` can be checked against what the site's asset
19
19
  * store actually holds, which is the only way anyone finds out that a menu link
20
20
  * has been pointing at a filename with a typo in it since August.
21
+ *
22
+ * `reference` is a *pointer the CMS owns*, and it is not a flavour of `link`.
23
+ * A Storyblok story link is `{ linktype: "story", id: <uuid>, cached_url: "faq" }`
24
+ * and the Delivery API renders it per language — `/faq` on the German page,
25
+ * `/fr/faq` on the French one. Neither string is what the document holds.
26
+ * Contentful entry links and Sanity references have the same shape and the same
27
+ * problem. Declare one as `link` and the projection stops being invertible in
28
+ * both directions at once: the diff reports every reference on every page as
29
+ * changed because the rendered href never equals the stored object, and writing
30
+ * the href back replaces the reference with a hard-coded URL, which silently
31
+ * stops following renames — the one thing the reference was for.
32
+ *
33
+ * So the panel shows it and does not edit it. The value is carried through
34
+ * untouched, the way `avocadoUnknownBlock` carries a rich-text node the pivot
35
+ * cannot model, and `referenceLabelKey` says where to find something a person
36
+ * can read. Re-pointing a reference needs a picker that knows the CMS's own
37
+ * document ids, which only the integration has; until it exists, showing the
38
+ * target and refusing the edit is the honest answer and the corrupting one is
39
+ * a text input.
21
40
  */
22
- export type FieldKind = "text" | "richtext" | "url" | "link" | "file" | "image" | "imageAlt" | "enum" | "color" | "number" | "boolean" | "headingLevel";
41
+ export type FieldKind = "text" | "richtext" | "url" | "link" | "file" | "image" | "imageAlt" | "enum" | "color" | "number" | "boolean" | "headingLevel" | "reference";
23
42
  /** Recommended image dimensions for an image field. */
24
43
  export type ImageSpec = {
25
44
  aspectRatio: "landscape" | "square" | "portrait";
@@ -97,6 +116,17 @@ export type FieldMeta = {
97
116
  * Use this one when there is no element at all.
98
117
  */
99
118
  panelOnly?: boolean;
119
+ /**
120
+ * For `kind: "reference"`: the key inside the value holding something a
121
+ * person can read.
122
+ *
123
+ * Optional, because the common spellings are tried anyway — Storyblok's
124
+ * `cached_url`, a `slug`, a `title`, a `name`. Set it when the reference
125
+ * carries a readable key under a name nothing would guess, or when it carries
126
+ * several and the wrong one wins. Purely a display concern: it never selects
127
+ * what is written, because a reference is not written here at all.
128
+ */
129
+ referenceLabelKey?: string;
100
130
  };
101
131
  /** Metadata for list-type props (features, items, cards). */
102
132
  export type ListFieldMeta = {
@@ -137,6 +167,14 @@ export type BlockRegistration = {
137
167
  };
138
168
  /** Test seam: forget what has already been warned about. */
139
169
  export declare function resetListFieldWarnings(): void;
170
+ /** Whether `type` is currently backed by Avocado's own definition. */
171
+ export declare function isBuiltinBlock(type: string): boolean;
172
+ /**
173
+ * Register one of Avocado's own blocks. Internal — a site uses `registerBlock`,
174
+ * and registering over a built-in name deliberately drops the built-in flag
175
+ * with the built-in schema.
176
+ */
177
+ export declare function registerBuiltinBlock(type: string, config: BlockRegistration): void;
140
178
  export declare function registerBlock(type: string, config: BlockRegistration): void;
141
179
  /** Get metadata for a registered block type, or undefined. */
142
180
  export declare function getBlockMeta(type: string): BlockMeta | undefined;
@@ -226,6 +264,19 @@ export declare function isInBlockCatalogue(type: string): boolean;
226
264
  export declare function catalogueBlockTypes(): string[];
227
265
  /** Declared types with no registration behind them — a typo, or a missing import. */
228
266
  export declare function undeclaredBlockTypes(): string[];
267
+ /**
268
+ * Something a person can read out of a reference value.
269
+ *
270
+ * A reference is opaque by design — the panel shows it and never rewrites it —
271
+ * but "opaque" must not mean `[object Object]` in the one place a person looks
272
+ * to check which document a CTA points at. Storyblok spells the readable half
273
+ * `cached_url`, Contentful puts a `slug` or `title` on the resolved entry,
274
+ * Sanity hands over a bare `_ref`. Falls back to the id, and then to nothing,
275
+ * which the panel renders as "not set" rather than as a broken label.
276
+ *
277
+ * `referenceLabelKey` wins when the meta declares it.
278
+ */
279
+ export declare function referenceLabel(value: unknown, labelKey?: string): string;
229
280
  export declare function getPropDisplayName(blockType: string | undefined, propKey: string): string;
230
281
  export declare function defaultListItemForBlock(type: BlockType, listKey: string): Record<string, unknown> | null;
231
282
  /** Base schema — accepts any block type. Used for ingesting external site content with custom blocks. */
@@ -68,6 +68,72 @@ function unwrapZod(schema) {
68
68
  }
69
69
  return current;
70
70
  }
71
+ /**
72
+ * The object shapes a list's rows can take.
73
+ *
74
+ * A monomorphic list has one. A polymorphic list — `z.discriminatedUnion`, the
75
+ * shape our own docs tell an integrator to use when rows differ — has one per
76
+ * branch and no `.shape` of its own. Reading `.element.shape` and stopping there
77
+ * declared every correct polymorphic list unbacked, warned about it at startup,
78
+ * and prescribed `z.array(z.object({ … }))`: the one edit that really would
79
+ * break it, because collapsing the union is what stops each row narrowing to its
80
+ * own field set. The panel never used this path — it reads the JSON Schema,
81
+ * where the branches survive as `anyOf` — so the lists worked exactly as
82
+ * declared while their author was being told they could not.
83
+ */
84
+ function listElementBranches(element) {
85
+ const unwrapped = unwrapZod(element);
86
+ if (!unwrapped)
87
+ return undefined;
88
+ const ownShape = unwrapped.shape;
89
+ if (ownShape)
90
+ return [{ discriminantValues: [], shape: ownShape }];
91
+ const def = unwrapped.def ?? unwrapped._def;
92
+ const options = def?.options;
93
+ if (!Array.isArray(options) || options.length === 0)
94
+ return undefined;
95
+ const discriminator = def?.discriminator;
96
+ const branches = [];
97
+ for (const option of options) {
98
+ const optionShape = unwrapZod(option)?.shape;
99
+ if (!optionShape)
100
+ continue;
101
+ branches.push({
102
+ discriminantValues: discriminator ? literalValuesOf(optionShape[discriminator]) : [],
103
+ shape: optionShape
104
+ });
105
+ }
106
+ return branches.length > 0 ? branches : undefined;
107
+ }
108
+ /** The values a discriminant field admits, spelled as the strings a row carries. */
109
+ function literalValuesOf(schema) {
110
+ const def = unwrapZod(schema)?.def ?? unwrapZod(schema)?._def;
111
+ if (!def)
112
+ return [];
113
+ if (Array.isArray(def.values))
114
+ return def.values.map(String); // z.literal in Zod 4
115
+ if (def.value !== undefined)
116
+ return [String(def.value)]; // z.literal in Zod 3
117
+ if (def.entries)
118
+ return Object.values(def.entries).map(String); // z.enum
119
+ return [];
120
+ }
121
+ /**
122
+ * Fill in `required` from the Zod shapes a field set can be backed by.
123
+ *
124
+ * More than one shape means the set spans branches, and a key only one branch
125
+ * declares cannot be required of a row that might be another branch.
126
+ */
127
+ function deriveRequired(fields, shapes) {
128
+ for (const [key, field] of Object.entries(fields)) {
129
+ if (field.required !== undefined)
130
+ continue;
131
+ const backing = shapes.filter((s) => s[key]);
132
+ if (backing.length === 0)
133
+ continue;
134
+ field.required = backing.length === shapes.length && backing.every((s) => !s[key].isOptional());
135
+ }
136
+ }
71
137
  const warnedListFields = new Set();
72
138
  function warnUnbackedListField(type, listKey, present) {
73
139
  const key = `${type}.${listKey}`;
@@ -91,8 +157,35 @@ function warnUnbackedListField(type, listKey, present) {
91
157
  export function resetListFieldWarnings() {
92
158
  warnedListFields.clear();
93
159
  }
160
+ /**
161
+ * The block types Avocado itself defines, as opposed to a site's own.
162
+ *
163
+ * A site that renders its own `Hero` registers it under that name, and from
164
+ * then on the name is all the two shapes have in common. Anything that reasons
165
+ * about a block by hard-coded key — the demo-content migrations in
166
+ * `session-state`, which stamp `imageUrl` and a `left`/`right` pair onto
167
+ * whatever is called `Hero` or `TwoColumn` — is reasoning about *our* schema,
168
+ * and must not run against a schema it has never seen. Registration order
169
+ * settles it: whoever registered last owns the name, so the flag is written on
170
+ * every call rather than only on the first.
171
+ */
172
+ const _builtinBlockTypes = G.__ase_builtinBlockTypes ?? (G.__ase_builtinBlockTypes = new Set());
173
+ /** Whether `type` is currently backed by Avocado's own definition. */
174
+ export function isBuiltinBlock(type) {
175
+ return _builtinBlockTypes.has(type);
176
+ }
177
+ /**
178
+ * Register one of Avocado's own blocks. Internal — a site uses `registerBlock`,
179
+ * and registering over a built-in name deliberately drops the built-in flag
180
+ * with the built-in schema.
181
+ */
182
+ export function registerBuiltinBlock(type, config) {
183
+ registerBlock(type, config);
184
+ _builtinBlockTypes.add(type);
185
+ }
94
186
  export function registerBlock(type, config) {
95
187
  _blockSchemas[type] = config.schema;
188
+ _builtinBlockTypes.delete(type);
96
189
  // Auto-derive `required` on each FieldMeta from the Zod schema shape
97
190
  const shape = config.schema.shape;
98
191
  if (shape) {
@@ -112,18 +205,20 @@ export function registerBlock(type, config) {
112
205
  // Without the outer unwrap, `z.array(...).optional()` — which is how a
113
206
  // list should be declared — looked like no array at all, so every
114
207
  // optional list silently skipped its own `required` derivation.
115
- const elementShape = unwrapZod(listZod)?.element?.shape;
116
- if (!elementShape) {
208
+ const branches = listElementBranches(unwrapZod(listZod)?.element);
209
+ if (!branches) {
117
210
  warnUnbackedListField(type, listKey, listZod !== undefined);
118
211
  continue;
119
212
  }
120
- for (const [itemKey, itemField] of Object.entries(listMeta.itemFields)) {
121
- if (itemField.required !== undefined)
213
+ // `itemFields` is the union of every branch — the set a row is edited
214
+ // against when its discriminant matches nothing — so a key is required
215
+ // there only if every branch has it and no branch makes it optional.
216
+ deriveRequired(listMeta.itemFields, branches.map((b) => b.shape));
217
+ for (const [value, branchFields] of Object.entries(listMeta.itemFieldsByType ?? {})) {
218
+ const matching = branches.filter((b) => b.discriminantValues.includes(value));
219
+ if (matching.length === 0)
122
220
  continue;
123
- const zodItem = elementShape[itemKey];
124
- if (zodItem) {
125
- itemField.required = !zodItem.isOptional();
126
- }
221
+ deriveRequired(branchFields, matching.map((b) => b.shape));
127
222
  }
128
223
  }
129
224
  }
@@ -391,6 +486,47 @@ export function undeclaredBlockTypes() {
391
486
  return [];
392
487
  return [...declared].filter((t) => !(t in _blockSchemas));
393
488
  }
489
+ /**
490
+ * Something a person can read out of a reference value.
491
+ *
492
+ * A reference is opaque by design — the panel shows it and never rewrites it —
493
+ * but "opaque" must not mean `[object Object]` in the one place a person looks
494
+ * to check which document a CTA points at. Storyblok spells the readable half
495
+ * `cached_url`, Contentful puts a `slug` or `title` on the resolved entry,
496
+ * Sanity hands over a bare `_ref`. Falls back to the id, and then to nothing,
497
+ * which the panel renders as "not set" rather than as a broken label.
498
+ *
499
+ * `referenceLabelKey` wins when the meta declares it.
500
+ */
501
+ export function referenceLabel(value, labelKey) {
502
+ if (value === null || value === undefined)
503
+ return "";
504
+ if (typeof value === "string" || typeof value === "number")
505
+ return String(value).trim();
506
+ if (typeof value !== "object" || Array.isArray(value))
507
+ return "";
508
+ const rec = value;
509
+ const readable = (key) => {
510
+ const candidate = rec[key];
511
+ return typeof candidate === "string" || typeof candidate === "number" ? String(candidate).trim() : "";
512
+ };
513
+ if (labelKey) {
514
+ const declared = readable(labelKey);
515
+ if (declared !== "")
516
+ return declared;
517
+ }
518
+ for (const key of ["cached_url", "slug", "url", "href", "title", "name", "label"]) {
519
+ const found = readable(key);
520
+ if (found !== "")
521
+ return found;
522
+ }
523
+ for (const key of ["_ref", "id", "_id", "uuid", "entryId"]) {
524
+ const found = readable(key);
525
+ if (found !== "")
526
+ return found;
527
+ }
528
+ return "";
529
+ }
394
530
  export function getPropDisplayName(blockType, propKey) {
395
531
  if (!blockType)
396
532
  return propKey;
@@ -429,6 +565,14 @@ function defaultScalarForField(field, fieldKey) {
429
565
  return false;
430
566
  if (field.kind === "enum")
431
567
  return Array.isArray(field.options) && field.options.length > 0 ? field.options[0] : "";
568
+ /*
569
+ * A reference points at a document in the CMS. There is no default one, and
570
+ * `"New Story link"` in a field the site resolves to a URL is a broken link
571
+ * dressed as content. An added row simply has no reference until somebody
572
+ * sets one upstream.
573
+ */
574
+ if (field.kind === "reference")
575
+ return null;
432
576
  return `New ${label}`;
433
577
  }
434
578
  export function defaultListItemForBlock(type, listKey) {
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Banner", {
4
+ registerBuiltinBlock("Banner", {
5
5
  schema: z.object({
6
6
  text: z.string().min(1),
7
7
  variant: z.enum(["info", "success", "warning"]).default("info").catch("info"),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("CardGrid", {
4
+ registerBuiltinBlock("CardGrid", {
5
5
  schema: z.object({
6
6
  title: z.string().min(1),
7
7
  subtitle: z.string().optional(),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Card", {
4
+ registerBuiltinBlock("Card", {
5
5
  schema: z.object({
6
6
  title: z.string().min(1),
7
7
  description: z.string().min(1),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock, IMAGE_PLACEHOLDER } from "./_registry.js";
2
+ import { registerBuiltinBlock, IMAGE_PLACEHOLDER } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Carousel", {
4
+ registerBuiltinBlock("Carousel", {
5
5
  schema: z.object({
6
6
  items: z.array(z.object({
7
7
  id: z.string().optional(),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("CTA", {
4
+ registerBuiltinBlock("CTA", {
5
5
  schema: z.object({
6
6
  title: z.string().min(1),
7
7
  description: z.string().min(1),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Embed", {
4
+ registerBuiltinBlock("Embed", {
5
5
  schema: z.object({
6
6
  embedType: z.enum(["map", "social", "custom"]).default("map").catch("map"),
7
7
  url: z.string().min(1),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("FAQAccordion", {
4
+ registerBuiltinBlock("FAQAccordion", {
5
5
  schema: z.object({
6
6
  title: z.string().min(1),
7
7
  items: z.array(z.object({ id: z.string().optional(), q: z.string().min(1), a: z.string().min(1) })).min(1)
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("FeatureGrid", {
4
+ registerBuiltinBlock("FeatureGrid", {
5
5
  schema: z.object({
6
6
  title: z.string().min(1),
7
7
  columns: z.enum(["2", "3", "4"]).default("3").catch("3"),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Footer", {
4
+ registerBuiltinBlock("Footer", {
5
5
  schema: z.object({
6
6
  copyright: z.string().min(1),
7
7
  columns: z.array(z.object({ id: z.string().optional(), title: z.string().min(1), links: z.string().min(1) })).min(1)
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock, IMAGE_PLACEHOLDER } from "./_registry.js";
2
+ import { registerBuiltinBlock, IMAGE_PLACEHOLDER } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Gallery", {
4
+ registerBuiltinBlock("Gallery", {
5
5
  schema: z.object({
6
6
  title: z.string().optional(),
7
7
  headingLevel: z.string().optional(),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock, IMAGE_PLACEHOLDER } from "./_registry.js";
2
+ import { registerBuiltinBlock, IMAGE_PLACEHOLDER } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Hero", {
4
+ registerBuiltinBlock("Hero", {
5
5
  schema: z.object({
6
6
  heading: z.string().min(1),
7
7
  subheading: z.string().min(1),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Quote", {
4
+ registerBuiltinBlock("Quote", {
5
5
  schema: z.object({
6
6
  quote: z.string().min(1),
7
7
  author: z.string().optional(),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("RichText", {
4
+ registerBuiltinBlock("RichText", {
5
5
  schema: z.object({
6
6
  title: z.string(),
7
7
  body: z.string().min(1)
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
4
  const navLinkLeaf = z.object({
5
5
  id: z.string().optional(),
@@ -7,7 +7,7 @@ const navLinkLeaf = z.object({
7
7
  href: z.string().min(1),
8
8
  newTab: z.boolean().optional(),
9
9
  });
10
- registerBlock("SiteHeader", {
10
+ registerBuiltinBlock("SiteHeader", {
11
11
  schema: z.object({
12
12
  siteName: z.string().min(1),
13
13
  logoUrl: z.string().min(1),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Stats", {
4
+ registerBuiltinBlock("Stats", {
5
5
  schema: z.object({
6
6
  title: z.string().optional(),
7
7
  stats: z.array(z.object({
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Table", {
4
+ registerBuiltinBlock("Table", {
5
5
  schema: z.object({
6
6
  title: z.string().optional(),
7
7
  headingLevel: z.string().optional(),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Tabs", {
4
+ registerBuiltinBlock("Tabs", {
5
5
  schema: z.object({
6
6
  title: z.string().optional(),
7
7
  tabs: z.array(z.object({
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Testimonials", {
4
+ registerBuiltinBlock("Testimonials", {
5
5
  schema: z.object({
6
6
  title: z.string().min(1),
7
7
  items: z.array(z.object({
@@ -1,5 +1,5 @@
1
1
  import { z } from "zod";
2
- import { registerBlock, IMAGE_PLACEHOLDER } from "./_registry.js";
2
+ import { registerBuiltinBlock, IMAGE_PLACEHOLDER } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
4
  const twoColumnChild = z.object({
5
5
  id: z.string().optional(),
@@ -62,7 +62,7 @@ const twoColumnItemFieldsByType = {
62
62
  poster: f.image("Video poster image", { aspectRatio: "landscape", width: 768, height: 512 }),
63
63
  },
64
64
  };
65
- registerBlock("TwoColumn", {
65
+ registerBuiltinBlock("TwoColumn", {
66
66
  schema: z.object({
67
67
  variant: z.enum(["default", "accent"]).default("default").catch("default"),
68
68
  left: z.array(twoColumnChild).min(1),
@@ -1,7 +1,7 @@
1
1
  import { z } from "zod";
2
- import { registerBlock } from "./_registry.js";
2
+ import { registerBuiltinBlock } from "./_registry.js";
3
3
  import { f } from "./_helpers.js";
4
- registerBlock("Video", {
4
+ registerBuiltinBlock("Video", {
5
5
  schema: z.object({
6
6
  src: z.string().min(1),
7
7
  title: z.string().optional(),
@@ -314,7 +314,15 @@ export function editableCoverage(manifest, blocks) {
314
314
  for (const [itemKey, itemMeta] of drawnItemFields) {
315
315
  const path = `${listKey}[].${itemKey}`;
316
316
  expected += 1;
317
- if (!paths.has(path)) {
317
+ /*
318
+ * `isCovered`, not `paths.has`, for the same reason the top-level scan
319
+ * uses it: a row field can itself be a list. `buttons` is marked as
320
+ * `left[].buttons[].label` — the label is the only part of a button a
321
+ * person edits in place — and an exact match called that unmarked,
322
+ * reporting a gap whose only remedy would be a second marker on an
323
+ * element that draws nothing.
324
+ */
325
+ if (!isCovered(path, paths)) {
318
326
  missingItemFields.push(path);
319
327
  }
320
328
  else if (itemMeta.kind === "image" && voids.has(path)) {
package/dist/index.d.ts CHANGED
@@ -12,10 +12,10 @@ export { parseLink, resolveLink, normalizeLinkPath, isKnownRoute, internalPathFo
12
12
  export { parseInline, parseRichText, parseRichTextBlocks, normalizeRichTextBody, resolveRichTextHeadingLevel, clampMarkdownHeadings, unescapeMarkdownText, isRichTextDoc, fromMarkdown, toMarkdown, mergeRichTextDoc, NODE, MARK, type InlineToken, type RichTextBlock, type RichTextList, type RichTextListItem, type RichTextDoc, type RichTextNode, type RichTextMark } from "@avocadostudio-ai/richtext";
13
13
  export { blockDefinitionSchema, blockManifestSchema, buildBlockManifest, jsonSchemaLikeSchema, validateByJsonSchemaLike, findManifestSchemaIssue, type ManifestSchemaIssue, validateManifestDefaultProps, deriveFieldMetaFromSchema, resolveManifestFieldMeta, isProseMirrorDocSchema, type BlockDefinition, type BlockManifest } from "./block-manifest.ts";
14
14
  export { z } from "zod";
15
- export { type FieldKind, type ImageSpec, type FieldMeta, type ListFieldMeta, type BlockMeta, type BlockType, type BlockInstance, type BlockRegistration, IMAGE_PLACEHOLDER, isImagePlaceholder, registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, blockAcceptsProp, blockListItemAcceptsKey, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes, blockSchemas, allowedBlockTypes, declareBlockCatalogue, getBlockCatalogue, isInBlockCatalogue, catalogueBlockTypes, undeclaredBlockTypes, getPropDisplayName, defaultListItemForBlock, blockInstanceSchema, blockInstanceSchemaLenient, validateBlockProps, getBlockJsonSchema, } from "./blocks/_registry.ts";
15
+ export { type FieldKind, type ImageSpec, type FieldMeta, type ListFieldMeta, type BlockMeta, type BlockType, type BlockInstance, type BlockRegistration, IMAGE_PLACEHOLDER, isImagePlaceholder, registerBlock, isBuiltinBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, blockAcceptsProp, blockListItemAcceptsKey, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, referenceLabel, getImageSpec, isChrome, getChromeTypes, blockSchemas, allowedBlockTypes, declareBlockCatalogue, getBlockCatalogue, isInBlockCatalogue, catalogueBlockTypes, undeclaredBlockTypes, getPropDisplayName, defaultListItemForBlock, blockInstanceSchema, blockInstanceSchemaLenient, validateBlockProps, getBlockJsonSchema, } from "./blocks/_registry.ts";
16
16
  export { defaultPropsForType, declaredDefaultPropsForType, resolveHeadingTag, resolveItemHeadingTag, DEFAULT_HEADING_LEVELS, } from "./blocks/index.ts";
17
17
  export { blockTypeToCamel, camelToBlockType, blockTypeToLower, lowerToBlockType, } from "./block-names.ts";
18
- export { makeAddBlock, generateBlockId, makeAddItem, generateItemId, ensureItemIds, type AddBlockOp, type MakeAddBlockOptions, type AddItemOp, type MakeAddItemOptions, } from "./ops/builders.ts";
18
+ export { makeAddBlock, generateBlockId, makeAddItem, generateItemId, ensureItemIds, withoutGeneratedItemIds, isGeneratedItemId, type AddBlockOp, type MakeAddBlockOptions, type AddItemOp, type MakeAddItemOptions, } from "./ops/builders.ts";
19
19
  export { THEME_TOKEN_TO_CSS_VARS, themeTokenKeys, semanticThemeTokensSchema, mapSemanticThemeTokens, type ThemeTokenKey, type SemanticThemeTokens, } from "./ops/theme-tokens.ts";
20
20
  export { chatStreamEventSchema, parseChatStreamFrame, type ChatStreamEvent, type ChatStreamEventType, type ChatStreamFrame, } from "./chat-events.ts";
21
21
  export { type PageMeta, type PageDoc, type SiteConfig, type Operation, type EditPlan, type PatchRejectReason, type ApplyPatchMessage, type PatchAckMessage, type ResetToServerMessage, pageMetaSchema, pageDocSchema, pageDocSchemaLenient, siteConfigSchema, operationSchema, editPlanSchema, demoPublishedPages, demoSiteConfig, } from "./schemas.ts";
package/dist/index.js CHANGED
@@ -37,7 +37,7 @@ export {
37
37
  // Constants & helpers
38
38
  IMAGE_PLACEHOLDER, isImagePlaceholder,
39
39
  // Registry functions
40
- registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, blockAcceptsProp, blockListItemAcceptsKey, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes,
40
+ registerBlock, isBuiltinBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, blockAcceptsProp, blockListItemAcceptsKey, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, referenceLabel, getImageSpec, isChrome, getChromeTypes,
41
41
  // Backwards-compatible exports
42
42
  blockSchemas, allowedBlockTypes,
43
43
  // The catalogue a site actually renders — see `declareBlockCatalogue`
@@ -48,7 +48,7 @@ getPropDisplayName, defaultListItemForBlock,
48
48
  blockInstanceSchema, blockInstanceSchemaLenient, validateBlockProps, getBlockJsonSchema, } from "./blocks/_registry.js";
49
49
  export { defaultPropsForType, declaredDefaultPropsForType, resolveHeadingTag, resolveItemHeadingTag, DEFAULT_HEADING_LEVELS, } from "./blocks/index.js";
50
50
  export { blockTypeToCamel, camelToBlockType, blockTypeToLower, lowerToBlockType, } from "./block-names.js";
51
- export { makeAddBlock, generateBlockId, makeAddItem, generateItemId, ensureItemIds, } from "./ops/builders.js";
51
+ export { makeAddBlock, generateBlockId, makeAddItem, generateItemId, ensureItemIds, withoutGeneratedItemIds, isGeneratedItemId, } from "./ops/builders.js";
52
52
  export { THEME_TOKEN_TO_CSS_VARS, themeTokenKeys, semanticThemeTokensSchema, mapSemanticThemeTokens, } from "./ops/theme-tokens.js";
53
53
  export { chatStreamEventSchema, parseChatStreamFrame, } from "./chat-events.js";
54
54
  export {
@@ -65,3 +65,37 @@ export declare function ensureItemIds(blocks: Array<{
65
65
  type: string;
66
66
  props: unknown;
67
67
  }>): boolean;
68
+ /**
69
+ * Whether `value` is an id this module generated, rather than one the content
70
+ * arrived with.
71
+ *
72
+ * The shape is the whole point: eight lowercase hex characters behind `i_`. A
73
+ * CMS's own row key — a Sanity `_key`, a Contentful `sys.id`, a Storyblok
74
+ * `_uid` flattened to `id` — does not match, and must not be stripped by
75
+ * anything below.
76
+ */
77
+ export declare function isGeneratedItemId(value: unknown): boolean;
78
+ /**
79
+ * The same blocks with Avocado's own list-row stamps removed.
80
+ *
81
+ * `ensureItemIds` writes an `id` onto every row of every declared list so the
82
+ * panel can keep rows stable under reordering and a planner can address one by
83
+ * name. It goes into `props`, because `props` is the only thing persisted — and
84
+ * it comes back out of `/draft/pages` indistinguishable from content.
85
+ *
86
+ * An adapter that compares its draft against freshly-read CMS content therefore
87
+ * sees **every block with a list** as changed, permanently and from the first
88
+ * load. On the integration that found this, a one-field edit to one page
89
+ * produced a publish that wanted to rewrite 117 stories; the same publish
90
+ * touches one after this. Diagnosing it took a field-level diff of two JSON
91
+ * blobs, which is why the keys are named here in code rather than described in
92
+ * a document.
93
+ *
94
+ * Returns a deep copy — the draft keeps its ids, which every op still needs.
95
+ * Only ids this module generated are removed, so a row that carries the CMS's
96
+ * own `id` keeps it.
97
+ */
98
+ export declare function withoutGeneratedItemIds<T extends {
99
+ type: string;
100
+ props: unknown;
101
+ }>(blocks: T[]): T[];
@@ -108,6 +108,69 @@ export function ensureItemIds(blocks) {
108
108
  }
109
109
  return changed;
110
110
  }
111
+ /**
112
+ * Whether `value` is an id this module generated, rather than one the content
113
+ * arrived with.
114
+ *
115
+ * The shape is the whole point: eight lowercase hex characters behind `i_`. A
116
+ * CMS's own row key — a Sanity `_key`, a Contentful `sys.id`, a Storyblok
117
+ * `_uid` flattened to `id` — does not match, and must not be stripped by
118
+ * anything below.
119
+ */
120
+ export function isGeneratedItemId(value) {
121
+ return typeof value === "string" && /^i_[0-9a-f]{8}$/.test(value);
122
+ }
123
+ /**
124
+ * The same blocks with Avocado's own list-row stamps removed.
125
+ *
126
+ * `ensureItemIds` writes an `id` onto every row of every declared list so the
127
+ * panel can keep rows stable under reordering and a planner can address one by
128
+ * name. It goes into `props`, because `props` is the only thing persisted — and
129
+ * it comes back out of `/draft/pages` indistinguishable from content.
130
+ *
131
+ * An adapter that compares its draft against freshly-read CMS content therefore
132
+ * sees **every block with a list** as changed, permanently and from the first
133
+ * load. On the integration that found this, a one-field edit to one page
134
+ * produced a publish that wanted to rewrite 117 stories; the same publish
135
+ * touches one after this. Diagnosing it took a field-level diff of two JSON
136
+ * blobs, which is why the keys are named here in code rather than described in
137
+ * a document.
138
+ *
139
+ * Returns a deep copy — the draft keeps its ids, which every op still needs.
140
+ * Only ids this module generated are removed, so a row that carries the CMS's
141
+ * own `id` keeps it.
142
+ */
143
+ export function withoutGeneratedItemIds(blocks) {
144
+ return blocks.map((block) => {
145
+ const listFields = getBlockMeta(block.type)?.listFields;
146
+ const props = block.props;
147
+ if (!listFields || !props || typeof props !== "object" || Array.isArray(props))
148
+ return block;
149
+ const nextProps = { ...props };
150
+ let changed = false;
151
+ for (const listKey of Object.keys(listFields)) {
152
+ const list = nextProps[listKey];
153
+ if (!Array.isArray(list))
154
+ continue;
155
+ let listChanged = false;
156
+ const rows = list.map((item) => {
157
+ if (!item || typeof item !== "object" || Array.isArray(item))
158
+ return item;
159
+ const rec = item;
160
+ if (!isGeneratedItemId(rec.id))
161
+ return item;
162
+ const { id: _dropped, ...rest } = rec;
163
+ listChanged = true;
164
+ return rest;
165
+ });
166
+ if (listChanged) {
167
+ nextProps[listKey] = rows;
168
+ changed = true;
169
+ }
170
+ }
171
+ return changed ? { ...block, props: nextProps } : block;
172
+ });
173
+ }
111
174
  function randomSuffix() {
112
175
  const c = globalThis.crypto;
113
176
  if (c?.randomUUID)
@@ -37,7 +37,7 @@ export type PanelFindingCode =
37
37
  | "phantom_field"
38
38
  /** The type name exists in the editor's own registry with a different shape. */
39
39
  | "colliding_type"
40
- /** An image row labelled by its filename while a populated alt field sits beside it. */
40
+ /** An image row labelled by its filename because the alt field beside it is empty. */
41
41
  | "filename_row_label";
42
42
  export type PanelFinding = {
43
43
  code: PanelFindingCode;
@@ -73,14 +73,29 @@ type ManifestLike = {
73
73
  * The label the panel puts on a collapsed list row.
74
74
  *
75
75
  * Mirrors `PropertyPanel`'s own derivation exactly, including its fallbacks: the
76
- * first text-ish field with a value, else the filename of the first image, else
77
- * `Item N`. Kept here so the two cannot drift — the panel imports this.
76
+ * first text-ish field with a value, else that image's alt text, else the
77
+ * filename of the first image, else `Item N`. Kept here so the two cannot drift
78
+ * — the panel imports this.
79
+ *
80
+ * "With a value" is load-bearing and was, for a while, only true of the comment.
81
+ * The code took the first *declared* candidate and read whatever it held, so a
82
+ * field set listing an empty `title` ahead of a populated `text` labelled the row
83
+ * `Item 4` with the answer sitting one key further along. Each step below scans
84
+ * for content instead of stopping at the first key of the right kind.
85
+ *
86
+ * Alt text outranks the filename because it is the only one of the two a person
87
+ * wrote on purpose. A column of `20250904_075546.webp`, `20250904_081233.webp`
88
+ * tells a reader which row is which no better than `Item 4` did, while the alt
89
+ * beside it already says "Pool bei Sonnenuntergang". The first version of this
90
+ * ranked the filename higher and the check then reported the mismatch as a
91
+ * finding — which told a site its alt text was the better label while the panel
92
+ * had no way to use it. A finding with no remedy gets switched off.
78
93
  */
79
94
  export declare function deriveRowLabel(fields: Record<string, FieldMeta>, item: Record<string, unknown>, index: number, options?: {
80
95
  discriminator?: string;
81
96
  }): {
82
97
  label: string;
83
- source: "text" | "filename" | "fallback";
98
+ source: "text" | "alt" | "filename" | "fallback";
84
99
  };
85
100
  export declare function panelCoverage(manifest: ManifestLike, pages: PageLike[], options?: {
86
101
  builtinTypes?: Record<string, BlockMeta>;
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/shared",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -19,7 +19,7 @@
19
19
  ],
20
20
  "dependencies": {
21
21
  "zod": "^4.3.6",
22
- "@avocadostudio-ai/richtext": "^0.5.0"
22
+ "@avocadostudio-ai/richtext": "^0.5.1"
23
23
  },
24
24
  "devDependencies": {
25
25
  "tsx": "^4.21.0",