@avocadostudio-ai/shared 0.3.2 → 0.4.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.
@@ -52,6 +52,11 @@ export declare const chatStartResponseSchema: z.ZodObject<{
52
52
  }, z.core.$loose>;
53
53
  export declare const slugsResponseSchema: z.ZodObject<{
54
54
  slugs: z.ZodOptional<z.ZodArray<z.ZodString>>;
55
+ pages: z.ZodOptional<z.ZodArray<z.ZodObject<{
56
+ slug: z.ZodString;
57
+ path: z.ZodOptional<z.ZodString>;
58
+ title: z.ZodOptional<z.ZodString>;
59
+ }, z.core.$loose>>>;
55
60
  }, z.core.$loose>;
56
61
  export declare const bootstrapResponseSchema: z.ZodObject<{
57
62
  slugs: z.ZodOptional<z.ZodArray<z.ZodString>>;
@@ -58,6 +58,18 @@ export const chatStartResponseSchema = z.object({
58
58
  }).passthrough();
59
59
  export const slugsResponseSchema = z.object({
60
60
  slugs: z.array(z.string()).optional(),
61
+ /*
62
+ * The same pages the `slugs` array names, with the title and the URL that
63
+ * `/draft/slugs` has always returned beside them. The editor parsed this
64
+ * response and kept only the bare slugs, so the link picker had nothing to
65
+ * label a page with; `path` is what a locale-prefixed site navigates to when
66
+ * it differs from the slug (see docs/ideas/page-identity-punch-list.md).
67
+ */
68
+ pages: z.array(z.object({
69
+ slug: z.string(),
70
+ path: z.string().optional(),
71
+ title: z.string().optional(),
72
+ }).passthrough()).optional(),
61
73
  }).passthrough();
62
74
  export const bootstrapResponseSchema = z.object({
63
75
  slugs: z.array(z.string()).optional(),
@@ -1,24 +1,16 @@
1
1
  import { z } from "zod";
2
2
  import type { FieldMeta, ListFieldMeta } from "./blocks/_registry.ts";
3
3
  export declare const jsonSchemaLikeSchema: z.ZodType<Record<string, unknown>>;
4
- /**
5
- * Field metadata a site may declare alongside the JSON schema.
6
- *
7
- * JSON Schema describes what a value *is*; this describes how a person edits
8
- * it, and the two are not the same question. A one-word headline and a six
9
- * sentence body are both `{"type":"string"}`, so a manifest carrying only the
10
- * schema gives them the same one-line input — which is exactly what happened to
11
- * every integration with a real prose field. `kind` and `multiline` have no
12
- * JSON Schema spelling, so they have to travel beside it.
13
- */
14
4
  export declare const fieldMetaSchema: z.ZodObject<{
15
5
  kind: z.ZodEnum<{
16
6
  number: "number";
17
7
  boolean: "boolean";
8
+ file: "file";
18
9
  enum: "enum";
19
10
  text: "text";
20
11
  richtext: "richtext";
21
12
  url: "url";
13
+ link: "link";
22
14
  image: "image";
23
15
  imageAlt: "imageAlt";
24
16
  color: "color";
@@ -55,10 +47,12 @@ export declare const listFieldMetaSchema: z.ZodObject<{
55
47
  kind: z.ZodEnum<{
56
48
  number: "number";
57
49
  boolean: "boolean";
50
+ file: "file";
58
51
  enum: "enum";
59
52
  text: "text";
60
53
  richtext: "richtext";
61
54
  url: "url";
55
+ link: "link";
62
56
  image: "image";
63
57
  imageAlt: "imageAlt";
64
58
  color: "color";
@@ -94,10 +88,12 @@ export declare const listFieldMetaSchema: z.ZodObject<{
94
88
  kind: z.ZodEnum<{
95
89
  number: "number";
96
90
  boolean: "boolean";
91
+ file: "file";
97
92
  enum: "enum";
98
93
  text: "text";
99
94
  richtext: "richtext";
100
95
  url: "url";
96
+ link: "link";
101
97
  image: "image";
102
98
  imageAlt: "imageAlt";
103
99
  color: "color";
@@ -139,10 +135,12 @@ export declare const blockDefinitionSchema: z.ZodObject<{
139
135
  kind: z.ZodEnum<{
140
136
  number: "number";
141
137
  boolean: "boolean";
138
+ file: "file";
142
139
  enum: "enum";
143
140
  text: "text";
144
141
  richtext: "richtext";
145
142
  url: "url";
143
+ link: "link";
146
144
  image: "image";
147
145
  imageAlt: "imageAlt";
148
146
  color: "color";
@@ -179,10 +177,12 @@ export declare const blockDefinitionSchema: z.ZodObject<{
179
177
  kind: z.ZodEnum<{
180
178
  number: "number";
181
179
  boolean: "boolean";
180
+ file: "file";
182
181
  enum: "enum";
183
182
  text: "text";
184
183
  richtext: "richtext";
185
184
  url: "url";
185
+ link: "link";
186
186
  image: "image";
187
187
  imageAlt: "imageAlt";
188
188
  color: "color";
@@ -218,10 +218,12 @@ export declare const blockDefinitionSchema: z.ZodObject<{
218
218
  kind: z.ZodEnum<{
219
219
  number: "number";
220
220
  boolean: "boolean";
221
+ file: "file";
221
222
  enum: "enum";
222
223
  text: "text";
223
224
  richtext: "richtext";
224
225
  url: "url";
226
+ link: "link";
225
227
  image: "image";
226
228
  imageAlt: "imageAlt";
227
229
  color: "color";
@@ -269,10 +271,12 @@ export declare const blockManifestSchema: z.ZodObject<{
269
271
  kind: z.ZodEnum<{
270
272
  number: "number";
271
273
  boolean: "boolean";
274
+ file: "file";
272
275
  enum: "enum";
273
276
  text: "text";
274
277
  richtext: "richtext";
275
278
  url: "url";
279
+ link: "link";
276
280
  image: "image";
277
281
  imageAlt: "imageAlt";
278
282
  color: "color";
@@ -309,10 +313,12 @@ export declare const blockManifestSchema: z.ZodObject<{
309
313
  kind: z.ZodEnum<{
310
314
  number: "number";
311
315
  boolean: "boolean";
316
+ file: "file";
312
317
  enum: "enum";
313
318
  text: "text";
314
319
  richtext: "richtext";
315
320
  url: "url";
321
+ link: "link";
316
322
  image: "image";
317
323
  imageAlt: "imageAlt";
318
324
  color: "color";
@@ -348,10 +354,12 @@ export declare const blockManifestSchema: z.ZodObject<{
348
354
  kind: z.ZodEnum<{
349
355
  number: "number";
350
356
  boolean: "boolean";
357
+ file: "file";
351
358
  enum: "enum";
352
359
  text: "text";
353
360
  richtext: "richtext";
354
361
  url: "url";
362
+ link: "link";
355
363
  image: "image";
356
364
  imageAlt: "imageAlt";
357
365
  color: "color";
@@ -23,8 +23,25 @@ export const jsonSchemaLikeSchema = z.lazy(() => z.object({
23
23
  * every integration with a real prose field. `kind` and `multiline` have no
24
24
  * JSON Schema spelling, so they have to travel beside it.
25
25
  */
26
+ /*
27
+ * The same list as `FieldKind`, and pinned to it.
28
+ *
29
+ * A Zod enum cannot be derived from a union type, so this is the second place
30
+ * the kinds are written — and it drifted the moment a kind was added: `file`
31
+ * went into `FieldKind`, every derivation started emitting it, and this schema
32
+ * went on rejecting it, which surfaced as a type error three packages away in
33
+ * the MCP server rather than as anything about field kinds. The
34
+ * `satisfies`-flavoured assignment below makes the compiler check the two lists
35
+ * against each other here, where the mismatch is.
36
+ */
37
+ const FIELD_KINDS = [
38
+ "text", "richtext", "url", "link", "file", "image", "imageAlt",
39
+ "enum", "color", "number", "boolean", "headingLevel"
40
+ ];
41
+ const _everyKindListed = true;
42
+ void _everyKindListed;
26
43
  export const fieldMetaSchema = z.object({
27
- kind: z.enum(["text", "richtext", "url", "image", "imageAlt", "enum", "color", "number", "boolean", "headingLevel"]),
44
+ kind: z.enum(FIELD_KINDS),
28
45
  label: z.string().optional(),
29
46
  inlineEditable: z.boolean().optional(),
30
47
  options: z.array(z.string()).optional(),
@@ -184,6 +201,55 @@ export function validateByJsonSchemaLike(schema, value) {
184
201
  // ---------------------------------------------------------------------------
185
202
  const IMAGE_KEY_RE = /(?:image|img)(?:Url|Src)?$|^(?:src|imageUrl|logoUrl|heroImage)$/i;
186
203
  const IMAGE_ALT_KEY_RE = /(?:Alt)$/;
204
+ const LINK_KEY_RE = /(?:href|link)$/i;
205
+ /*
206
+ * Prop names that hold a document rather than a route.
207
+ *
208
+ * Deliberately narrow. `download`, `pdf`, `file`, `doc`, `attachment`,
209
+ * `brochure`, `datasheet` and `menu` are the words sites use, each optionally
210
+ * followed by Url/Href/Link/Path — so `menuPdf`, `downloadUrl` and
211
+ * `brochureHref` all land here, and `menuLabel` does not. A name this misses
212
+ * simply stays a `link`, which is where it already was; the cost of guessing
213
+ * wrong in the other direction is a page picker offered for a PDF.
214
+ */
215
+ const FILE_KEY_RE = /(?:^|[a-z])(?:download|pdf|file|doc|document|attachment|brochure|datasheet|menu)(?:Url|Href|Link|Path)?$/i;
216
+ /*
217
+ * A `contentMediaType` that means "this is a file to download".
218
+ *
219
+ * Not simply "anything that is not an image": `text/html`, `text/plain` and
220
+ * `text/markdown` are prose a block renders inline, and treating them as
221
+ * documents would put a file picker on a body field. `application/json` is
222
+ * excluded for the same reason — a config blob is data the block reads, not
223
+ * something a visitor downloads. What is left is `application/*` (pdf, msword,
224
+ * vnd.*, zip) plus the two text types that really are files.
225
+ */
226
+ const FILE_MEDIA_TYPE_RE = /^application\/(?!json\b)|^text\/(?:csv|calendar)\b/i;
227
+ /**
228
+ * A prop the CMS owns, which no editor may offer as a control.
229
+ *
230
+ * A leading underscore is the near-universal marker for a document's own
231
+ * bookkeeping — Sanity's `_key`, `_type`, `_id`, `_ref`, `_rev`; GraphQL's
232
+ * `__typename`. These reach a block's schema legitimately: a site that projects
233
+ * CMS array members has to carry `_key` through, because `_key` is the *address*
234
+ * a field-level publish patches by (`pageBuilder[_key=="home-hero"].heading.de`).
235
+ * Declaring it keeps the ops engine from stripping it off an edited item.
236
+ *
237
+ * But "the block can store it" was being read as "a person may type it". The
238
+ * property panel derives its controls from the schema, so `_key` rendered as an
239
+ * empty text box — empty because the projection hands the editor its own `id`,
240
+ * not the CMS key — labelled " key", sitting directly under a CTA's Label. That
241
+ * is not merely a stray control: the value a user typed into it would become the
242
+ * item's `_key`, which is the thing the publish diff addresses the array member
243
+ * by. Editing it re-points or orphans the patch, and the same derivation feeds
244
+ * the planner's block contracts, so the model was being shown `_key` as a prop
245
+ * it could set.
246
+ *
247
+ * Underscore-prefixed props are therefore stored, projected and published as
248
+ * before, and never editable by anyone.
249
+ */
250
+ function isInternalPropKey(key) {
251
+ return key.startsWith("_");
252
+ }
187
253
  function labelFromKey(key) {
188
254
  // ctaText → "CTA text", imageUrl → "Image", logoAlt → "Logo alt",
189
255
  // background_image → "Background image", video_url → "Video", src → "Src"
@@ -224,14 +290,59 @@ function inferFieldKind(key, schema) {
224
290
  return "imageAlt";
225
291
  if (IMAGE_KEY_RE.test(key))
226
292
  return "image";
293
+ /*
294
+ * A document prop, named as one. `menuPdf`, `brochureFile`, `datasheetDoc`,
295
+ * `downloadUrl` — all of them hold a path to a file rather than a route, and
296
+ * inferring them as `link` puts them in front of the page picker and the
297
+ * dead-route warning, which are both the wrong question. Checked before the
298
+ * link pattern precisely because `downloadUrl` and `fileHref` match both.
299
+ */
300
+ if (FILE_KEY_RE.test(key))
301
+ return "file";
302
+ /*
303
+ * A schema can also say so outright, and a `contentMediaType` of anything but
304
+ * an image means a document — the same standard vocabulary the markdown case
305
+ * above uses, so an integrator needs no proprietary annotation to get the
306
+ * right control.
307
+ */
308
+ if (typeof schema.contentMediaType === "string" && FILE_MEDIA_TYPE_RE.test(schema.contentMediaType)) {
309
+ return "file";
310
+ }
311
+ /*
312
+ * A custom block's `ctaHref` is a link, and inferring it as plain text gave
313
+ * an integrator's editors a bare box where the built-in blocks get a page
314
+ * picker. Checked after the image patterns so `imageUrl` and `logoUrl` still
315
+ * resolve to images — those end in "Url" too.
316
+ */
317
+ if (LINK_KEY_RE.test(key))
318
+ return "link";
227
319
  return "text";
228
320
  }
321
+ /**
322
+ * A list item's `id` is identity, not content.
323
+ *
324
+ * The same argument as `_key` above, one convention over: every one of this
325
+ * package's own list blocks carries an optional `id` on each item, used as the
326
+ * React key and as the handle an operation addresses the item by. Derived from
327
+ * the schema it looks like any other optional string, so the panel drew a text
328
+ * box for it and the planner was shown it as a prop it could set — and a value
329
+ * typed there re-points or orphans the very patch that would save it.
330
+ *
331
+ * Scoped to list items deliberately. A top-level `id` prop on a block is a
332
+ * perfectly ordinary content field (an anchor, a slug, an embed's video id) and
333
+ * is left alone.
334
+ */
335
+ function isItemIdentityKey(key) {
336
+ return key === "id";
337
+ }
229
338
  function deriveItemFields(itemSchema) {
230
339
  const fields = {};
231
340
  const props = isObject(itemSchema.properties) ? itemSchema.properties : {};
232
341
  for (const [key, propSchema] of Object.entries(props)) {
233
342
  if (!isObject(propSchema))
234
343
  continue;
344
+ if (isInternalPropKey(key) || isItemIdentityKey(key))
345
+ continue;
235
346
  // Skip opaque object/array props (they'd render as "[object Object]"), but
236
347
  // keep richtext docs — those self-identify and get the richtext editor.
237
348
  const t = typeof propSchema.type === "string" ? propSchema.type : undefined;
@@ -325,6 +436,8 @@ export function deriveFieldMetaFromSchema(propsSchema) {
325
436
  for (const [key, propSchema] of Object.entries(props)) {
326
437
  if (!isObject(propSchema))
327
438
  continue;
439
+ if (isInternalPropKey(key))
440
+ continue;
328
441
  const type = typeof propSchema.type === "string" ? propSchema.type : undefined;
329
442
  // Array of a discriminated union (oneOf/anyOf of object branches) →
330
443
  // polymorphic list field: per-branch fields keyed by the discriminant value.
@@ -3,8 +3,29 @@ export declare const f: {
3
3
  readonly text: (label?: string) => FieldMeta;
4
4
  readonly longtext: (label?: string) => FieldMeta;
5
5
  readonly richtext: (label?: string) => FieldMeta;
6
+ /** A resource address — an iframe src, a video file. Never an internal page. */
6
7
  readonly url: (label?: string) => FieldMeta;
8
+ /** A navigation target: an internal page, or an external URL / mailto / tel / anchor. */
9
+ readonly link: (label?: string) => FieldMeta;
10
+ /**
11
+ * The "open in new tab" companion for a link prop. Named by convention
12
+ * (`newTabKeyFor`) so the property panel renders it inside the link control
13
+ * rather than as a row of its own — the same pairing as `image`/`imageAlt`.
14
+ */
15
+ readonly newTab: (label?: string) => FieldMeta;
7
16
  readonly image: (label?: string, imageSpec?: ImageSpec) => FieldMeta;
17
+ /**
18
+ * A single emoji or symbol glyph, or the URL of a small image.
19
+ *
20
+ * Text in the panel, and deliberately *not* editable on the page. Whatever a
21
+ * person types into an icon goes through `renderBlockIcon`, which draws only a
22
+ * glyph or an image and renders **nothing** for anything else — so an inline
23
+ * edit that typed a word ("rocket", the name the planner reaches for) would
24
+ * make the icon silently disappear with the value stored and invisible. The
25
+ * panel's labelled box is the affordance that fits a value from a constrained
26
+ * vocabulary; a cursor blinking inside the emoji is not.
27
+ */
28
+ readonly icon: (label?: string) => FieldMeta;
8
29
  readonly imageAlt: (label?: string) => FieldMeta;
9
30
  readonly headingLevel: () => FieldMeta;
10
31
  };
@@ -2,8 +2,29 @@ export const f = {
2
2
  text: (label) => ({ kind: "text", label }),
3
3
  longtext: (label) => ({ kind: "text", label, multiline: true }),
4
4
  richtext: (label) => ({ kind: "richtext", label }),
5
+ /** A resource address — an iframe src, a video file. Never an internal page. */
5
6
  url: (label) => ({ kind: "url", label, inlineEditable: false }),
7
+ /** A navigation target: an internal page, or an external URL / mailto / tel / anchor. */
8
+ link: (label) => ({ kind: "link", label, inlineEditable: false }),
9
+ /**
10
+ * The "open in new tab" companion for a link prop. Named by convention
11
+ * (`newTabKeyFor`) so the property panel renders it inside the link control
12
+ * rather than as a row of its own — the same pairing as `image`/`imageAlt`.
13
+ */
14
+ newTab: (label = "Open in new tab") => ({ kind: "boolean", label, inlineEditable: false }),
6
15
  image: (label, imageSpec) => ({ kind: "image", label, inlineEditable: false, ...(imageSpec ? { imageSpec } : {}) }),
16
+ /**
17
+ * A single emoji or symbol glyph, or the URL of a small image.
18
+ *
19
+ * Text in the panel, and deliberately *not* editable on the page. Whatever a
20
+ * person types into an icon goes through `renderBlockIcon`, which draws only a
21
+ * glyph or an image and renders **nothing** for anything else — so an inline
22
+ * edit that typed a word ("rocket", the name the planner reaches for) would
23
+ * make the icon silently disappear with the value stored and invisible. The
24
+ * panel's labelled box is the affordance that fits a value from a constrained
25
+ * vocabulary; a cursor blinking inside the emoji is not.
26
+ */
27
+ icon: (label = "Icon (single emoji)") => ({ kind: "text", label, inlineEditable: false }),
7
28
  imageAlt: (label) => ({ kind: "imageAlt", label }),
8
29
  headingLevel: () => ({ kind: "headingLevel", label: "Heading type", inlineEditable: false }),
9
30
  };
@@ -1,8 +1,25 @@
1
1
  import { z } from "zod";
2
2
  export declare const IMAGE_PLACEHOLDER = "/hero-generated.svg";
3
3
  export declare function isImagePlaceholder(url: string | undefined | null): boolean;
4
- /** Semantic kind for a block prop field. */
5
- export type FieldKind = "text" | "richtext" | "url" | "image" | "imageAlt" | "enum" | "color" | "number" | "boolean" | "headingLevel";
4
+ /**
5
+ * Semantic kind for a block prop field.
6
+ *
7
+ * `link` and `url` are both href-valued strings and deliberately distinct.
8
+ * `link` is a *navigation target* — it may address a page on this site, so it
9
+ * gets the page picker, the dead-route warning, and the rename rewriter.
10
+ * `url` is a *resource address* — an embed's iframe src, a video file, an OG
11
+ * image — which is never an internal page, and where offering a list of pages
12
+ * would be noise.
13
+ *
14
+ * `file` is a *document*: a menu PDF, a price list, a consent form. It is a
15
+ * third thing and not a flavour of either, because the question you ask of it
16
+ * is different. A `link` is checked against the site's pages; a `file` must not
17
+ * be, or every document on the site reports as a dead link. A `url` is checked
18
+ * against nothing at all; a `file` can be checked against what the site's asset
19
+ * store actually holds, which is the only way anyone finds out that a menu link
20
+ * has been pointing at a filename with a typo in it since August.
21
+ */
22
+ export type FieldKind = "text" | "richtext" | "url" | "link" | "file" | "image" | "imageAlt" | "enum" | "color" | "number" | "boolean" | "headingLevel";
6
23
  /** Recommended image dimensions for an image field. */
7
24
  export type ImageSpec = {
8
25
  aspectRatio: "landscape" | "square" | "portrait";
@@ -104,10 +121,52 @@ export declare function registerBlock(type: string, config: BlockRegistration):
104
121
  export declare function getBlockMeta(type: string): BlockMeta | undefined;
105
122
  /** Get all registered block metadata. */
106
123
  export declare function getAllBlockMeta(): Readonly<Record<string, BlockMeta>>;
124
+ /**
125
+ * Does this block type declare this prop?
126
+ *
127
+ * The only honest answer to "is `heading` a real prop" is the one the block's
128
+ * own schema gives, and it is the question two very different callers need: the
129
+ * plan normalizer, deciding whether a prop name the model emitted needs
130
+ * repairing, and the planner prompt, deciding whether a prop-name correction is
131
+ * even true of this site's catalogue. Both used to answer it by comparing the
132
+ * block type against a literal from Avocado's own catalogue, which is a fact
133
+ * about our names rather than about the site's schema — so a site that brings
134
+ * its own blocks got a confident wrong answer. Asking here means they cannot
135
+ * drift apart again.
136
+ *
137
+ * Manifest-registered blocks may carry a schema richer than their derived meta,
138
+ * so the schema gets the second look rather than the first refusal. An
139
+ * unregistered type answers `false`, which callers must read as "no evidence",
140
+ * not as "no".
141
+ */
142
+ export declare function blockAcceptsProp(blockType: string, prop: string): boolean;
143
+ /**
144
+ * Does this block type's list `listKey` declare an item field named `itemKey`?
145
+ * The list-shaped counterpart of `blockAcceptsProp`, with the same contract: a
146
+ * block with no declared list metadata answers `false` for every key, meaning
147
+ * "no evidence".
148
+ */
149
+ export declare function blockListItemAcceptsKey(blockType: string, listKey: string, itemKey: string): boolean;
107
150
  /** Get the set of prop keys that are image fields for a block type. */
108
151
  export declare function getImageFields(blockType: string): Set<string>;
109
152
  /** Get list props that contain image fields: Map<listKey, Set<imageFieldKey>>. */
110
153
  export declare function getListImageFields(blockType: string): Map<string, Set<string>>;
154
+ /**
155
+ * Every prop key on a block type that addresses a *resource* rather than a page
156
+ * — images, iframe sources, video files — at any depth, scalar and list-item
157
+ * props flattened into one set.
158
+ *
159
+ * Renaming a page rewrites the links that point at it, and the rewriter cannot
160
+ * tell a link from any other string: it walks the props and remaps every value
161
+ * that starts with the old slug. That silently rewrote `/pricing/demo.mp4` into
162
+ * `/plans/demo.mp4` on a rename — an asset path is not a route, and renaming a
163
+ * page does not move the file. This set is what the walker skips.
164
+ *
165
+ * It is an exclusion rather than a `kind: "link"` allow-list on purpose: a
166
+ * custom block that ships no field metadata still has its links rewritten, the
167
+ * way it always did.
168
+ */
169
+ export declare function getMediaFields(blockType: string): Set<string>;
111
170
  /** Check if a block type is a chrome block (structurally pinned). */
112
171
  export declare function isChrome(type: string): boolean;
113
172
  /** Get all registered chrome block type names. */
@@ -141,6 +141,45 @@ export function getBlockMeta(type) {
141
141
  export function getAllBlockMeta() {
142
142
  return _blockMeta;
143
143
  }
144
+ /**
145
+ * Does this block type declare this prop?
146
+ *
147
+ * The only honest answer to "is `heading` a real prop" is the one the block's
148
+ * own schema gives, and it is the question two very different callers need: the
149
+ * plan normalizer, deciding whether a prop name the model emitted needs
150
+ * repairing, and the planner prompt, deciding whether a prop-name correction is
151
+ * even true of this site's catalogue. Both used to answer it by comparing the
152
+ * block type against a literal from Avocado's own catalogue, which is a fact
153
+ * about our names rather than about the site's schema — so a site that brings
154
+ * its own blocks got a confident wrong answer. Asking here means they cannot
155
+ * drift apart again.
156
+ *
157
+ * Manifest-registered blocks may carry a schema richer than their derived meta,
158
+ * so the schema gets the second look rather than the first refusal. An
159
+ * unregistered type answers `false`, which callers must read as "no evidence",
160
+ * not as "no".
161
+ */
162
+ export function blockAcceptsProp(blockType, prop) {
163
+ if (!blockType || !prop)
164
+ return false;
165
+ const meta = _blockMeta[blockType];
166
+ if (meta?.fields && prop in meta.fields)
167
+ return true;
168
+ const shape = _blockSchemas[blockType]?.shape;
169
+ return Boolean(shape && prop in shape);
170
+ }
171
+ /**
172
+ * Does this block type's list `listKey` declare an item field named `itemKey`?
173
+ * The list-shaped counterpart of `blockAcceptsProp`, with the same contract: a
174
+ * block with no declared list metadata answers `false` for every key, meaning
175
+ * "no evidence".
176
+ */
177
+ export function blockListItemAcceptsKey(blockType, listKey, itemKey) {
178
+ if (!blockType || !listKey || !itemKey)
179
+ return false;
180
+ const itemFields = _blockMeta[blockType]?.listFields?.[listKey]?.itemFields;
181
+ return Boolean(itemFields && itemKey in itemFields);
182
+ }
144
183
  /** Cache for getImageFields results. */
145
184
  const _imageFieldsCache = new Map();
146
185
  /** Get the set of prop keys that are image fields for a block type. */
@@ -182,6 +221,46 @@ export function getListImageFields(blockType) {
182
221
  _listImageFieldsCache.set(blockType, result);
183
222
  return result;
184
223
  }
224
+ /** Cache for getMediaFields results. */
225
+ const _mediaFieldsCache = new Map();
226
+ /**
227
+ * Every prop key on a block type that addresses a *resource* rather than a page
228
+ * — images, iframe sources, video files — at any depth, scalar and list-item
229
+ * props flattened into one set.
230
+ *
231
+ * Renaming a page rewrites the links that point at it, and the rewriter cannot
232
+ * tell a link from any other string: it walks the props and remaps every value
233
+ * that starts with the old slug. That silently rewrote `/pricing/demo.mp4` into
234
+ * `/plans/demo.mp4` on a rename — an asset path is not a route, and renaming a
235
+ * page does not move the file. This set is what the walker skips.
236
+ *
237
+ * It is an exclusion rather than a `kind: "link"` allow-list on purpose: a
238
+ * custom block that ships no field metadata still has its links rewritten, the
239
+ * way it always did.
240
+ */
241
+ export function getMediaFields(blockType) {
242
+ const cached = _mediaFieldsCache.get(blockType);
243
+ if (cached)
244
+ return cached;
245
+ const meta = _blockMeta[blockType];
246
+ const result = new Set();
247
+ const collect = (fields) => {
248
+ for (const [key, fm] of Object.entries(fields)) {
249
+ if (fm.kind === "image" || fm.kind === "url")
250
+ result.add(key);
251
+ }
252
+ };
253
+ if (meta) {
254
+ collect(meta.fields);
255
+ for (const listMeta of Object.values(meta.listFields ?? {})) {
256
+ collect(listMeta.itemFields);
257
+ for (const branch of Object.values(listMeta.itemFieldsByType ?? {}))
258
+ collect(branch);
259
+ }
260
+ }
261
+ _mediaFieldsCache.set(blockType, result);
262
+ return result;
263
+ }
185
264
  /** Check if a block type is a chrome block (structurally pinned). */
186
265
  export function isChrome(type) {
187
266
  return _blockMeta[type]?.chrome === true;
@@ -332,7 +411,7 @@ function defaultScalarForField(field, fieldKey) {
332
411
  const label = field.label?.trim() || fieldKey;
333
412
  if (field.kind === "text" || field.kind === "richtext" || field.kind === "imageAlt")
334
413
  return `New ${label}`;
335
- if (field.kind === "url")
414
+ if (field.kind === "url" || field.kind === "link")
336
415
  return "/";
337
416
  if (field.kind === "image")
338
417
  return IMAGE_PLACEHOLDER;
@@ -340,6 +419,14 @@ function defaultScalarForField(field, fieldKey) {
340
419
  return "#0f766e";
341
420
  if (field.kind === "number")
342
421
  return 0;
422
+ /*
423
+ * Without this a boolean item field seeded a *string* ("New Open in new tab")
424
+ * into a new list item, which the block's own zod schema then rejected — the
425
+ * add-item op failed on a value this function produced. Latent until link
426
+ * fields introduced the first boolean inside a list.
427
+ */
428
+ if (field.kind === "boolean")
429
+ return false;
343
430
  if (field.kind === "enum")
344
431
  return Array.isArray(field.options) && field.options.length > 0 ? field.options[0] : "";
345
432
  return `New ${label}`;
@@ -7,6 +7,7 @@ registerBlock("Banner", {
7
7
  variant: z.enum(["info", "success", "warning"]).default("info").catch("info"),
8
8
  ctaText: z.string().optional(),
9
9
  ctaHref: z.string().optional(),
10
+ ctaNewTab: z.boolean().optional(),
10
11
  backgroundColor: z.string().optional(),
11
12
  textColor: z.string().optional(),
12
13
  }),
@@ -18,7 +19,8 @@ registerBlock("Banner", {
18
19
  text: f.text("Banner text"),
19
20
  variant: { kind: "enum", label: "Variant", options: ["info", "success", "warning"], inlineEditable: false },
20
21
  ctaText: f.text("Button label"),
21
- ctaHref: f.url("Button link"),
22
+ ctaHref: f.link("Button link"),
23
+ ctaNewTab: f.newTab(),
22
24
  backgroundColor: { kind: "color", label: "Background color", inlineEditable: false },
23
25
  textColor: { kind: "color", label: "Text color", inlineEditable: false },
24
26
  },
@@ -14,6 +14,7 @@ registerBlock("CardGrid", {
14
14
  description: z.string().min(1),
15
15
  ctaText: z.string().min(1),
16
16
  ctaHref: z.string().min(1),
17
+ ctaNewTab: z.boolean().optional(),
17
18
  imageUrl: z.string().min(1).optional(),
18
19
  imageAlt: z.string().min(1).optional()
19
20
  }))
@@ -37,7 +38,8 @@ registerBlock("CardGrid", {
37
38
  title: f.text("Card title"),
38
39
  description: f.longtext("Card description"),
39
40
  ctaText: f.text("Button text"),
40
- ctaHref: f.url("Button link"),
41
+ ctaHref: f.link("Button link"),
42
+ ctaNewTab: f.newTab(),
41
43
  imageUrl: f.image("Card image", { aspectRatio: "landscape", width: 768, height: 512 }),
42
44
  imageAlt: f.imageAlt("Card image alt text"),
43
45
  }
@@ -7,6 +7,7 @@ registerBlock("Card", {
7
7
  description: z.string().min(1),
8
8
  ctaText: z.string().min(1),
9
9
  ctaHref: z.string().min(1),
10
+ ctaNewTab: z.boolean().optional(),
10
11
  imageUrl: z.string().min(1).optional(),
11
12
  imageAlt: z.string().min(1).optional(),
12
13
  variant: z.enum(["default", "full-bleed"]).default("default").catch("default"),
@@ -19,7 +20,8 @@ registerBlock("Card", {
19
20
  title: f.text("Card title"),
20
21
  description: f.longtext("Card description"),
21
22
  ctaText: f.text("Button text"),
22
- ctaHref: f.url("Button link"),
23
+ ctaHref: f.link("Button link"),
24
+ ctaNewTab: f.newTab(),
23
25
  imageUrl: f.image("Card image", { aspectRatio: "landscape", width: 768, height: 512 }),
24
26
  imageAlt: f.imageAlt("Card image alt text"),
25
27
  variant: { kind: "enum", label: "Variant", options: ["default", "full-bleed"], inlineEditable: false },
@@ -11,6 +11,7 @@ registerBlock("Carousel", {
11
11
  description: z.string().optional(),
12
12
  ctaText: z.string().optional(),
13
13
  ctaHref: z.string().optional(),
14
+ ctaNewTab: z.boolean().optional(),
14
15
  })).min(1),
15
16
  autoplay: z.enum(["true", "false"]).default("false").catch("false"),
16
17
  interval: z.number().optional(),
@@ -32,7 +33,8 @@ registerBlock("Carousel", {
32
33
  heading: f.text("Heading"),
33
34
  description: f.longtext("Description"),
34
35
  ctaText: f.text("Button label"),
35
- ctaHref: f.url("Button link"),
36
+ ctaHref: f.link("Button link"),
37
+ ctaNewTab: f.newTab(),
36
38
  }
37
39
  }
38
40
  }