@avocadostudio-ai/shared 0.5.0 → 0.6.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.
@@ -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,9 @@ 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>;
39
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
40
+ internal: z.ZodOptional<z.ZodBoolean>;
37
41
  inline: z.ZodOptional<z.ZodBoolean>;
38
42
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
39
43
  name: z.ZodString;
@@ -57,6 +61,7 @@ export declare const listFieldMetaSchema: z.ZodObject<{
57
61
  imageAlt: "imageAlt";
58
62
  color: "color";
59
63
  headingLevel: "headingLevel";
64
+ reference: "reference";
60
65
  }>;
61
66
  label: z.ZodOptional<z.ZodString>;
62
67
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -76,6 +81,9 @@ export declare const listFieldMetaSchema: z.ZodObject<{
76
81
  }>>;
77
82
  }, z.core.$strip>>;
78
83
  multiline: z.ZodOptional<z.ZodBoolean>;
84
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
85
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
86
+ internal: z.ZodOptional<z.ZodBoolean>;
79
87
  inline: z.ZodOptional<z.ZodBoolean>;
80
88
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
81
89
  name: z.ZodString;
@@ -98,6 +106,7 @@ export declare const listFieldMetaSchema: z.ZodObject<{
98
106
  imageAlt: "imageAlt";
99
107
  color: "color";
100
108
  headingLevel: "headingLevel";
109
+ reference: "reference";
101
110
  }>;
102
111
  label: z.ZodOptional<z.ZodString>;
103
112
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -117,6 +126,9 @@ export declare const listFieldMetaSchema: z.ZodObject<{
117
126
  }>>;
118
127
  }, z.core.$strip>>;
119
128
  multiline: z.ZodOptional<z.ZodBoolean>;
129
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
130
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
131
+ internal: z.ZodOptional<z.ZodBoolean>;
120
132
  inline: z.ZodOptional<z.ZodBoolean>;
121
133
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
122
134
  name: z.ZodString;
@@ -145,6 +157,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
145
157
  imageAlt: "imageAlt";
146
158
  color: "color";
147
159
  headingLevel: "headingLevel";
160
+ reference: "reference";
148
161
  }>;
149
162
  label: z.ZodOptional<z.ZodString>;
150
163
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -164,6 +177,9 @@ export declare const blockDefinitionSchema: z.ZodObject<{
164
177
  }>>;
165
178
  }, z.core.$strip>>;
166
179
  multiline: z.ZodOptional<z.ZodBoolean>;
180
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
181
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
182
+ internal: z.ZodOptional<z.ZodBoolean>;
167
183
  inline: z.ZodOptional<z.ZodBoolean>;
168
184
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
169
185
  name: z.ZodString;
@@ -187,6 +203,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
187
203
  imageAlt: "imageAlt";
188
204
  color: "color";
189
205
  headingLevel: "headingLevel";
206
+ reference: "reference";
190
207
  }>;
191
208
  label: z.ZodOptional<z.ZodString>;
192
209
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -206,6 +223,9 @@ export declare const blockDefinitionSchema: z.ZodObject<{
206
223
  }>>;
207
224
  }, z.core.$strip>>;
208
225
  multiline: z.ZodOptional<z.ZodBoolean>;
226
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
227
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
228
+ internal: z.ZodOptional<z.ZodBoolean>;
209
229
  inline: z.ZodOptional<z.ZodBoolean>;
210
230
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
211
231
  name: z.ZodString;
@@ -228,6 +248,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
228
248
  imageAlt: "imageAlt";
229
249
  color: "color";
230
250
  headingLevel: "headingLevel";
251
+ reference: "reference";
231
252
  }>;
232
253
  label: z.ZodOptional<z.ZodString>;
233
254
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -247,6 +268,9 @@ export declare const blockDefinitionSchema: z.ZodObject<{
247
268
  }>>;
248
269
  }, z.core.$strip>>;
249
270
  multiline: z.ZodOptional<z.ZodBoolean>;
271
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
272
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
273
+ internal: z.ZodOptional<z.ZodBoolean>;
250
274
  inline: z.ZodOptional<z.ZodBoolean>;
251
275
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
252
276
  name: z.ZodString;
@@ -281,6 +305,7 @@ export declare const blockManifestSchema: z.ZodObject<{
281
305
  imageAlt: "imageAlt";
282
306
  color: "color";
283
307
  headingLevel: "headingLevel";
308
+ reference: "reference";
284
309
  }>;
285
310
  label: z.ZodOptional<z.ZodString>;
286
311
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -300,6 +325,9 @@ export declare const blockManifestSchema: z.ZodObject<{
300
325
  }>>;
301
326
  }, z.core.$strip>>;
302
327
  multiline: z.ZodOptional<z.ZodBoolean>;
328
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
329
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
330
+ internal: z.ZodOptional<z.ZodBoolean>;
303
331
  inline: z.ZodOptional<z.ZodBoolean>;
304
332
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
305
333
  name: z.ZodString;
@@ -323,6 +351,7 @@ export declare const blockManifestSchema: z.ZodObject<{
323
351
  imageAlt: "imageAlt";
324
352
  color: "color";
325
353
  headingLevel: "headingLevel";
354
+ reference: "reference";
326
355
  }>;
327
356
  label: z.ZodOptional<z.ZodString>;
328
357
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -342,6 +371,9 @@ export declare const blockManifestSchema: z.ZodObject<{
342
371
  }>>;
343
372
  }, z.core.$strip>>;
344
373
  multiline: z.ZodOptional<z.ZodBoolean>;
374
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
375
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
376
+ internal: z.ZodOptional<z.ZodBoolean>;
345
377
  inline: z.ZodOptional<z.ZodBoolean>;
346
378
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
347
379
  name: z.ZodString;
@@ -364,6 +396,7 @@ export declare const blockManifestSchema: z.ZodObject<{
364
396
  imageAlt: "imageAlt";
365
397
  color: "color";
366
398
  headingLevel: "headingLevel";
399
+ reference: "reference";
367
400
  }>;
368
401
  label: z.ZodOptional<z.ZodString>;
369
402
  inlineEditable: z.ZodOptional<z.ZodBoolean>;
@@ -383,6 +416,9 @@ export declare const blockManifestSchema: z.ZodObject<{
383
416
  }>>;
384
417
  }, z.core.$strip>>;
385
418
  multiline: z.ZodOptional<z.ZodBoolean>;
419
+ referenceLabelKey: z.ZodOptional<z.ZodString>;
420
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
421
+ internal: z.ZodOptional<z.ZodBoolean>;
386
422
  inline: z.ZodOptional<z.ZodBoolean>;
387
423
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
388
424
  name: z.ZodString;
@@ -448,7 +484,11 @@ export declare function validateManifestDefaultProps(blocks: BlockDefinition[]):
448
484
  *
449
485
  * - `propsSchema` decides *which* props exist. A declared entry for a key the
450
486
  * schema does not expose is ignored, not added — otherwise a stale manifest
451
- * would put a control on a prop the block cannot store.
487
+ * would put a control on a prop the block cannot store. The one exception is
488
+ * `internal: true`, which adds no control by definition: it is how a site
489
+ * names a prop the derivation skipped for being structural (a Contentful
490
+ * `sys`, a `__source` snapshot) so the coverage checkers stop reporting it as
491
+ * undescribed. Nothing else can be conjured this way.
452
492
  * - A declared entry is merged *over* the derived one, key by key, so a site
453
493
  * can supply just `multiline` and keep the label and options that were
454
494
  * inferred for it.
@@ -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,22 @@ 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(),
64
+ /*
65
+ * Both of these decide whether a surface *mentions* a field, and every one
66
+ * of those surfaces is on the far side of this boundary — the panel, the
67
+ * planner's schema summary, the coverage checkers. A key this schema omits
68
+ * is stripped rather than rejected, so an undeclared flag here means the
69
+ * declaration silently does nothing where it was supposed to act.
70
+ */
71
+ panelOnly: z.boolean().optional(),
72
+ internal: z.boolean().optional(),
57
73
  inline: z.boolean().optional(),
58
74
  decorators: z
59
75
  .array(z.object({ name: z.string().min(1), title: z.string().optional() }))
@@ -250,6 +266,29 @@ const FILE_MEDIA_TYPE_RE = /^application\/(?!json\b)|^text\/(?:csv|calendar)\b/i
250
266
  function isInternalPropKey(key) {
251
267
  return key.startsWith("_");
252
268
  }
269
+ /**
270
+ * The same statement the paragraph above makes, as a field rather than as an
271
+ * absence.
272
+ *
273
+ * These props used to be skipped outright, which kept them out of the panel
274
+ * and away from the planner — correct, and invisible to everything that reads
275
+ * the resolved metadata afterwards. `panelCoverage` is the one that noticed:
276
+ * a prop in the content and in no field set is an `orphan_prop`, and one
277
+ * Storyblok integration's `_uid` produced **~800 of them**, burying every real
278
+ * finding under a wall of noise that could not be closed. There was no way to
279
+ * close it, because "skipped" and "never declared" were the same thing.
280
+ *
281
+ * Emitting `internal: true` says it out loud instead. Every surface that must
282
+ * not mention the prop checks that flag, so nothing about the promise above
283
+ * changes; what changes is that the checkers can tell a known non-content prop
284
+ * from an undescribed one.
285
+ *
286
+ * `kind` is a formality here — nothing renders the field — so it does not
287
+ * matter that a `_key` and a `sys` object both come out as `text`.
288
+ */
289
+ function internalFieldMeta(key) {
290
+ return { kind: "text", label: labelFromKey(key), internal: true };
291
+ }
253
292
  function labelFromKey(key) {
254
293
  // ctaText → "CTA text", imageUrl → "Image", logoAlt → "Logo alt",
255
294
  // background_image → "Background image", video_url → "Video", src → "Src"
@@ -341,7 +380,11 @@ function deriveItemFields(itemSchema) {
341
380
  for (const [key, propSchema] of Object.entries(props)) {
342
381
  if (!isObject(propSchema))
343
382
  continue;
344
- if (isInternalPropKey(key) || isItemIdentityKey(key))
383
+ if (isInternalPropKey(key)) {
384
+ fields[key] = internalFieldMeta(key);
385
+ continue;
386
+ }
387
+ if (isItemIdentityKey(key))
345
388
  continue;
346
389
  // Skip opaque object/array props (they'd render as "[object Object]"), but
347
390
  // keep richtext docs — those self-identify and get the richtext editor.
@@ -436,8 +479,10 @@ export function deriveFieldMetaFromSchema(propsSchema) {
436
479
  for (const [key, propSchema] of Object.entries(props)) {
437
480
  if (!isObject(propSchema))
438
481
  continue;
439
- if (isInternalPropKey(key))
482
+ if (isInternalPropKey(key)) {
483
+ fields[key] = internalFieldMeta(key);
440
484
  continue;
485
+ }
441
486
  const type = typeof propSchema.type === "string" ? propSchema.type : undefined;
442
487
  // Array of a discriminated union (oneOf/anyOf of object branches) →
443
488
  // polymorphic list field: per-branch fields keyed by the discriminant value.
@@ -497,7 +542,11 @@ export function validateManifestDefaultProps(blocks) {
497
542
  *
498
543
  * - `propsSchema` decides *which* props exist. A declared entry for a key the
499
544
  * schema does not expose is ignored, not added — otherwise a stale manifest
500
- * would put a control on a prop the block cannot store.
545
+ * would put a control on a prop the block cannot store. The one exception is
546
+ * `internal: true`, which adds no control by definition: it is how a site
547
+ * names a prop the derivation skipped for being structural (a Contentful
548
+ * `sys`, a `__source` snapshot) so the coverage checkers stop reporting it as
549
+ * undescribed. Nothing else can be conjured this way.
501
550
  * - A declared entry is merged *over* the derived one, key by key, so a site
502
551
  * can supply just `multiline` and keep the label and options that were
503
552
  * inferred for it.
@@ -508,7 +557,20 @@ export function resolveManifestFieldMeta(definition) {
508
557
  const resolvedFields = {};
509
558
  for (const [key, derived] of Object.entries(fields)) {
510
559
  const declared = declaredFields[key];
511
- resolvedFields[key] = isObject(declared) ? { ...derived, ...declared } : derived;
560
+ const merged = isObject(declared) ? { ...derived, ...declared } : derived;
561
+ // The underscore convention is not negotiable by declaration: a site may
562
+ // label `_key` or change its kind, and it still may not hand it to a
563
+ // person, because the value typed there is the address a field-level
564
+ // publish patches by.
565
+ resolvedFields[key] = isInternalPropKey(key) ? { ...merged, internal: true } : merged;
566
+ }
567
+ for (const [key, declared] of Object.entries(declaredFields)) {
568
+ if (key in resolvedFields)
569
+ continue;
570
+ if (!isObject(declared) || declared.internal !== true)
571
+ continue;
572
+ const fm = declared;
573
+ resolvedFields[key] = { label: labelFromKey(key), ...fm, kind: fm.kind ?? "text" };
512
574
  }
513
575
  const declaredLists = isObject(definition.listFields) ? definition.listFields : {};
514
576
  const resolvedListFields = {};
@@ -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,46 @@ export type FieldMeta = {
97
116
  * Use this one when there is no element at all.
98
117
  */
99
118
  panelOnly?: boolean;
119
+ /**
120
+ * This prop belongs to the system that stores the content, not to anyone
121
+ * editing it.
122
+ *
123
+ * Storyblok's `_uid`, Contentful's `sys.id`, a `__source` snapshot, a
124
+ * revision stamp: the publisher needs them, a person must never edit them,
125
+ * and the planner must never be told they exist. Until this flag there was
126
+ * no way to say so, so they rode along undeclared — which is not the same as
127
+ * absent. An undeclared prop is an `orphan_prop` to `panelCoverage`, and one
128
+ * integration's `_uid` produced **~800 of them**, burying every real finding
129
+ * under a wall of noise that could not be closed.
130
+ *
131
+ * Declaring the prop internal says the opposite of what leaving it out says:
132
+ * "this is known, it is not content, and nothing that talks to a human or a
133
+ * model should mention it." Concretely it is hidden from the property panel,
134
+ * absent from the schema summary the planner reads, not expected to carry a
135
+ * preview marker, never offered for translation, and not a coverage finding
136
+ * in either direction — neither an orphan nor a phantom.
137
+ *
138
+ * It is not access control. The value still rides in `props`, still
139
+ * round-trips through the draft, and is still what the adapter writes back;
140
+ * an operation that names one is refused the way any undeclared prop is,
141
+ * because the planner was never told about it.
142
+ *
143
+ * `panelOnly` is the weaker neighbour and means almost the reverse: *a
144
+ * person does edit this, just not on the page.* Reach for that one when the
145
+ * field has no element; reach for this one when the field has no audience.
146
+ */
147
+ internal?: boolean;
148
+ /**
149
+ * For `kind: "reference"`: the key inside the value holding something a
150
+ * person can read.
151
+ *
152
+ * Optional, because the common spellings are tried anyway — Storyblok's
153
+ * `cached_url`, a `slug`, a `title`, a `name`. Set it when the reference
154
+ * carries a readable key under a name nothing would guess, or when it carries
155
+ * several and the wrong one wins. Purely a display concern: it never selects
156
+ * what is written, because a reference is not written here at all.
157
+ */
158
+ referenceLabelKey?: string;
100
159
  };
101
160
  /** Metadata for list-type props (features, items, cards). */
102
161
  export type ListFieldMeta = {
@@ -137,6 +196,14 @@ export type BlockRegistration = {
137
196
  };
138
197
  /** Test seam: forget what has already been warned about. */
139
198
  export declare function resetListFieldWarnings(): void;
199
+ /** Whether `type` is currently backed by Avocado's own definition. */
200
+ export declare function isBuiltinBlock(type: string): boolean;
201
+ /**
202
+ * Register one of Avocado's own blocks. Internal — a site uses `registerBlock`,
203
+ * and registering over a built-in name deliberately drops the built-in flag
204
+ * with the built-in schema.
205
+ */
206
+ export declare function registerBuiltinBlock(type: string, config: BlockRegistration): void;
140
207
  export declare function registerBlock(type: string, config: BlockRegistration): void;
141
208
  /** Get metadata for a registered block type, or undefined. */
142
209
  export declare function getBlockMeta(type: string): BlockMeta | undefined;
@@ -226,6 +293,19 @@ export declare function isInBlockCatalogue(type: string): boolean;
226
293
  export declare function catalogueBlockTypes(): string[];
227
294
  /** Declared types with no registration behind them — a typo, or a missing import. */
228
295
  export declare function undeclaredBlockTypes(): string[];
296
+ /**
297
+ * Something a person can read out of a reference value.
298
+ *
299
+ * A reference is opaque by design — the panel shows it and never rewrites it —
300
+ * but "opaque" must not mean `[object Object]` in the one place a person looks
301
+ * to check which document a CTA points at. Storyblok spells the readable half
302
+ * `cached_url`, Contentful puts a `slug` or `title` on the resolved entry,
303
+ * Sanity hands over a bare `_ref`. Falls back to the id, and then to nothing,
304
+ * which the panel renders as "not set" rather than as a broken label.
305
+ *
306
+ * `referenceLabelKey` wins when the meta declares it.
307
+ */
308
+ export declare function referenceLabel(value: unknown, labelKey?: string): string;
229
309
  export declare function getPropDisplayName(blockType: string | undefined, propKey: string): string;
230
310
  export declare function defaultListItemForBlock(type: BlockType, listKey: string): Record<string, unknown> | null;
231
311
  /** 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(),
@@ -49,6 +49,10 @@ const DRAWN_KINDS = new Set(["text", "richtext", "image"]);
49
49
  * button, and the panel offering one is the site saying the image is editable.
50
50
  */
51
51
  function needsMarker(meta) {
52
+ // Not content at all — a CMS's `_uid`, a revision stamp. Nobody edits it
53
+ // anywhere, so there is nothing on the page for it to be missing from.
54
+ if (meta.internal)
55
+ return false;
52
56
  // The site has said outright that nothing on the page draws this — a
53
57
  // `sectionId`, a `<video poster>`, an input's `placeholder`. See `panelOnly`.
54
58
  if (meta.panelOnly)
@@ -73,10 +77,19 @@ const VOID_ELEMENTS = new Set([
73
77
  "link", "meta", "param", "source", "track", "wbr",
74
78
  ]);
75
79
  /** One element: its tag name, and the raw attribute text, quotes respected. */
76
- const TAG_RE = /<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
80
+ const TAG_RE = /<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
77
81
  const BLOCK_ID_RE = /\bdata-block-id="([^"]*)"/;
78
82
  const BLOCK_TYPE_RE = /\bdata-block-type="([^"]*)"/;
79
83
  const TARGET_RE = /\bdata-editable-target="([^"]*)"/;
84
+ const SCOPE_RE = /\bdata-editable-scope="([^"]*)"/;
85
+ /** `items[3]` + `question` → `items[3].question`; `cards` + `[0].title` → `cards[0].title`. */
86
+ function joinPath(scope, path) {
87
+ if (scope === "")
88
+ return path;
89
+ if (path === "")
90
+ return scope;
91
+ return path.startsWith("[") ? `${scope}${path}` : `${scope}.${path}`;
92
+ }
80
93
  /**
81
94
  * Read the marked blocks out of a rendered page's HTML.
82
95
  *
@@ -90,9 +103,35 @@ const TARGET_RE = /\bdata-editable-target="([^"]*)"/;
90
103
  export function extractMarkedBlocks(html) {
91
104
  const blocks = [];
92
105
  let current = null;
106
+ /*
107
+ * The open elements that carry a `data-editable-scope`, innermost last, each
108
+ * remembered with the tag that will close it. A component rendering a list
109
+ * row marks a bare `question` and its parent supplies `items[3]`, so the
110
+ * path this file compares against the manifest exists only as the join —
111
+ * see `editableScopeProps`. Without the stack a scoped site's every field
112
+ * reads as missing, and the check that exists to catch a lost marker becomes
113
+ * the reason nobody runs it.
114
+ */
115
+ const scopes = [];
116
+ const depths = new Map();
93
117
  for (const match of html.matchAll(TAG_RE)) {
94
- const tag = match[1].toLowerCase();
95
- const attrs = match[2] ?? "";
118
+ const closing = match[1] === "/";
119
+ const tag = match[2].toLowerCase();
120
+ const attrs = match[3] ?? "";
121
+ const selfClosing = attrs.trimEnd().endsWith("/");
122
+ const isVoid = VOID_ELEMENTS.has(tag);
123
+ if (closing) {
124
+ const depth = (depths.get(tag) ?? 0) - 1;
125
+ depths.set(tag, depth);
126
+ while (scopes.length > 0) {
127
+ const top = scopes[scopes.length - 1];
128
+ if (top.tag === tag && top.depth === depth)
129
+ scopes.pop();
130
+ else
131
+ break;
132
+ }
133
+ continue;
134
+ }
96
135
  const blockId = BLOCK_ID_RE.exec(attrs)?.[1];
97
136
  const blockType = BLOCK_TYPE_RE.exec(attrs)?.[1];
98
137
  if (blockId !== undefined || blockType !== undefined) {
@@ -102,13 +141,29 @@ export function extractMarkedBlocks(html) {
102
141
  paths: [],
103
142
  };
104
143
  blocks.push(current);
144
+ // A path is scoped from the block down, so a scope opened outside this
145
+ // block must not reach into it.
146
+ scopes.length = 0;
105
147
  }
106
148
  const target = TARGET_RE.exec(attrs)?.[1];
107
149
  if (target !== undefined && current) {
108
- current.paths.push(target);
109
- if (VOID_ELEMENTS.has(tag))
110
- (current.voidPaths ??= []).push(target);
150
+ const path = scopes.reduce((acc, s) => joinPath(acc, s.scope), "");
151
+ const full = joinPath(path, target);
152
+ current.paths.push(full);
153
+ if (isVoid)
154
+ (current.voidPaths ??= []).push(full);
111
155
  }
156
+ if (isVoid || selfClosing)
157
+ continue;
158
+ const depth = depths.get(tag) ?? 0;
159
+ depths.set(tag, depth + 1);
160
+ const scope = SCOPE_RE.exec(attrs)?.[1];
161
+ // The scoping element's own marker is not inside its scope — a component
162
+ // that both scopes its children and marks a field of its own would
163
+ // otherwise prefix its own path with the scope it sets for them. That is
164
+ // why this comes after the target is recorded.
165
+ if (scope !== undefined && scope !== "")
166
+ scopes.push({ tag, scope, depth });
112
167
  }
113
168
  return blocks;
114
169
  }
@@ -314,7 +369,15 @@ export function editableCoverage(manifest, blocks) {
314
369
  for (const [itemKey, itemMeta] of drawnItemFields) {
315
370
  const path = `${listKey}[].${itemKey}`;
316
371
  expected += 1;
317
- if (!paths.has(path)) {
372
+ /*
373
+ * `isCovered`, not `paths.has`, for the same reason the top-level scan
374
+ * uses it: a row field can itself be a list. `buttons` is marked as
375
+ * `left[].buttons[].label` — the label is the only part of a button a
376
+ * person edits in place — and an exact match called that unmarked,
377
+ * reporting a gap whose only remedy would be a second marker on an
378
+ * element that draws nothing.
379
+ */
380
+ if (!isCovered(path, paths)) {
318
381
  missingItemFields.push(path);
319
382
  }
320
383
  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.6.0",
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.6.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "tsx": "^4.21.0",