@avocadostudio-ai/shared 0.3.3 → 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.
- package/dist/block-manifest.d.ts +9 -10
- package/dist/block-manifest.js +105 -1
- package/dist/blocks/_helpers.d.ts +12 -0
- package/dist/blocks/_helpers.js +12 -0
- package/dist/blocks/_registry.d.ts +35 -1
- package/dist/blocks/_registry.js +39 -0
- package/dist/blocks/feature-grid.js +1 -1
- package/dist/blocks/stats.js +1 -1
- package/dist/blocks/testimonials.js +1 -1
- package/dist/blocks/two-column.js +46 -2
- package/dist/editable-coverage.d.ts +85 -0
- package/dist/editable-coverage.js +342 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +3 -2
- package/dist/links.d.ts +126 -5
- package/dist/links.js +256 -5
- package/package.json +2 -2
package/dist/block-manifest.d.ts
CHANGED
|
@@ -1,20 +1,11 @@
|
|
|
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";
|
|
@@ -56,6 +47,7 @@ export declare const listFieldMetaSchema: z.ZodObject<{
|
|
|
56
47
|
kind: z.ZodEnum<{
|
|
57
48
|
number: "number";
|
|
58
49
|
boolean: "boolean";
|
|
50
|
+
file: "file";
|
|
59
51
|
enum: "enum";
|
|
60
52
|
text: "text";
|
|
61
53
|
richtext: "richtext";
|
|
@@ -96,6 +88,7 @@ export declare const listFieldMetaSchema: z.ZodObject<{
|
|
|
96
88
|
kind: z.ZodEnum<{
|
|
97
89
|
number: "number";
|
|
98
90
|
boolean: "boolean";
|
|
91
|
+
file: "file";
|
|
99
92
|
enum: "enum";
|
|
100
93
|
text: "text";
|
|
101
94
|
richtext: "richtext";
|
|
@@ -142,6 +135,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
|
|
|
142
135
|
kind: z.ZodEnum<{
|
|
143
136
|
number: "number";
|
|
144
137
|
boolean: "boolean";
|
|
138
|
+
file: "file";
|
|
145
139
|
enum: "enum";
|
|
146
140
|
text: "text";
|
|
147
141
|
richtext: "richtext";
|
|
@@ -183,6 +177,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
|
|
|
183
177
|
kind: z.ZodEnum<{
|
|
184
178
|
number: "number";
|
|
185
179
|
boolean: "boolean";
|
|
180
|
+
file: "file";
|
|
186
181
|
enum: "enum";
|
|
187
182
|
text: "text";
|
|
188
183
|
richtext: "richtext";
|
|
@@ -223,6 +218,7 @@ export declare const blockDefinitionSchema: z.ZodObject<{
|
|
|
223
218
|
kind: z.ZodEnum<{
|
|
224
219
|
number: "number";
|
|
225
220
|
boolean: "boolean";
|
|
221
|
+
file: "file";
|
|
226
222
|
enum: "enum";
|
|
227
223
|
text: "text";
|
|
228
224
|
richtext: "richtext";
|
|
@@ -275,6 +271,7 @@ export declare const blockManifestSchema: z.ZodObject<{
|
|
|
275
271
|
kind: z.ZodEnum<{
|
|
276
272
|
number: "number";
|
|
277
273
|
boolean: "boolean";
|
|
274
|
+
file: "file";
|
|
278
275
|
enum: "enum";
|
|
279
276
|
text: "text";
|
|
280
277
|
richtext: "richtext";
|
|
@@ -316,6 +313,7 @@ export declare const blockManifestSchema: z.ZodObject<{
|
|
|
316
313
|
kind: z.ZodEnum<{
|
|
317
314
|
number: "number";
|
|
318
315
|
boolean: "boolean";
|
|
316
|
+
file: "file";
|
|
319
317
|
enum: "enum";
|
|
320
318
|
text: "text";
|
|
321
319
|
richtext: "richtext";
|
|
@@ -356,6 +354,7 @@ export declare const blockManifestSchema: z.ZodObject<{
|
|
|
356
354
|
kind: z.ZodEnum<{
|
|
357
355
|
number: "number";
|
|
358
356
|
boolean: "boolean";
|
|
357
|
+
file: "file";
|
|
359
358
|
enum: "enum";
|
|
360
359
|
text: "text";
|
|
361
360
|
richtext: "richtext";
|
package/dist/block-manifest.js
CHANGED
|
@@ -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(
|
|
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(),
|
|
@@ -185,6 +202,54 @@ export function validateByJsonSchemaLike(schema, value) {
|
|
|
185
202
|
const IMAGE_KEY_RE = /(?:image|img)(?:Url|Src)?$|^(?:src|imageUrl|logoUrl|heroImage)$/i;
|
|
186
203
|
const IMAGE_ALT_KEY_RE = /(?:Alt)$/;
|
|
187
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
|
+
}
|
|
188
253
|
function labelFromKey(key) {
|
|
189
254
|
// ctaText → "CTA text", imageUrl → "Image", logoAlt → "Logo alt",
|
|
190
255
|
// background_image → "Background image", video_url → "Video", src → "Src"
|
|
@@ -225,6 +290,24 @@ function inferFieldKind(key, schema) {
|
|
|
225
290
|
return "imageAlt";
|
|
226
291
|
if (IMAGE_KEY_RE.test(key))
|
|
227
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
|
+
}
|
|
228
311
|
/*
|
|
229
312
|
* A custom block's `ctaHref` is a link, and inferring it as plain text gave
|
|
230
313
|
* an integrator's editors a bare box where the built-in blocks get a page
|
|
@@ -235,12 +318,31 @@ function inferFieldKind(key, schema) {
|
|
|
235
318
|
return "link";
|
|
236
319
|
return "text";
|
|
237
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
|
+
}
|
|
238
338
|
function deriveItemFields(itemSchema) {
|
|
239
339
|
const fields = {};
|
|
240
340
|
const props = isObject(itemSchema.properties) ? itemSchema.properties : {};
|
|
241
341
|
for (const [key, propSchema] of Object.entries(props)) {
|
|
242
342
|
if (!isObject(propSchema))
|
|
243
343
|
continue;
|
|
344
|
+
if (isInternalPropKey(key) || isItemIdentityKey(key))
|
|
345
|
+
continue;
|
|
244
346
|
// Skip opaque object/array props (they'd render as "[object Object]"), but
|
|
245
347
|
// keep richtext docs — those self-identify and get the richtext editor.
|
|
246
348
|
const t = typeof propSchema.type === "string" ? propSchema.type : undefined;
|
|
@@ -334,6 +436,8 @@ export function deriveFieldMetaFromSchema(propsSchema) {
|
|
|
334
436
|
for (const [key, propSchema] of Object.entries(props)) {
|
|
335
437
|
if (!isObject(propSchema))
|
|
336
438
|
continue;
|
|
439
|
+
if (isInternalPropKey(key))
|
|
440
|
+
continue;
|
|
337
441
|
const type = typeof propSchema.type === "string" ? propSchema.type : undefined;
|
|
338
442
|
// Array of a discriminated union (oneOf/anyOf of object branches) →
|
|
339
443
|
// polymorphic list field: per-branch fields keyed by the discriminant value.
|
|
@@ -14,6 +14,18 @@ export declare const f: {
|
|
|
14
14
|
*/
|
|
15
15
|
readonly newTab: (label?: string) => FieldMeta;
|
|
16
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;
|
|
17
29
|
readonly imageAlt: (label?: string) => FieldMeta;
|
|
18
30
|
readonly headingLevel: () => FieldMeta;
|
|
19
31
|
};
|
package/dist/blocks/_helpers.js
CHANGED
|
@@ -13,6 +13,18 @@ export const f = {
|
|
|
13
13
|
*/
|
|
14
14
|
newTab: (label = "Open in new tab") => ({ kind: "boolean", label, inlineEditable: false }),
|
|
15
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 }),
|
|
16
28
|
imageAlt: (label) => ({ kind: "imageAlt", label }),
|
|
17
29
|
headingLevel: () => ({ kind: "headingLevel", label: "Heading type", inlineEditable: false }),
|
|
18
30
|
};
|
|
@@ -10,8 +10,16 @@ export declare function isImagePlaceholder(url: string | undefined | null): bool
|
|
|
10
10
|
* `url` is a *resource address* — an embed's iframe src, a video file, an OG
|
|
11
11
|
* image — which is never an internal page, and where offering a list of pages
|
|
12
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.
|
|
13
21
|
*/
|
|
14
|
-
export type FieldKind = "text" | "richtext" | "url" | "link" | "image" | "imageAlt" | "enum" | "color" | "number" | "boolean" | "headingLevel";
|
|
22
|
+
export type FieldKind = "text" | "richtext" | "url" | "link" | "file" | "image" | "imageAlt" | "enum" | "color" | "number" | "boolean" | "headingLevel";
|
|
15
23
|
/** Recommended image dimensions for an image field. */
|
|
16
24
|
export type ImageSpec = {
|
|
17
25
|
aspectRatio: "landscape" | "square" | "portrait";
|
|
@@ -113,6 +121,32 @@ export declare function registerBlock(type: string, config: BlockRegistration):
|
|
|
113
121
|
export declare function getBlockMeta(type: string): BlockMeta | undefined;
|
|
114
122
|
/** Get all registered block metadata. */
|
|
115
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;
|
|
116
150
|
/** Get the set of prop keys that are image fields for a block type. */
|
|
117
151
|
export declare function getImageFields(blockType: string): Set<string>;
|
|
118
152
|
/** Get list props that contain image fields: Map<listKey, Set<imageFieldKey>>. */
|
package/dist/blocks/_registry.js
CHANGED
|
@@ -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. */
|
package/dist/blocks/stats.js
CHANGED
|
@@ -28,6 +28,40 @@ const twoColumnItemFields = {
|
|
|
28
28
|
alt: f.imageAlt("Alt text"),
|
|
29
29
|
poster: f.image("Video poster image", { aspectRatio: "landscape", width: 768, height: 512 }),
|
|
30
30
|
};
|
|
31
|
+
/**
|
|
32
|
+
* Which of those fields a child actually has, by its `type`.
|
|
33
|
+
*
|
|
34
|
+
* The union above is the merged fallback and it is a poor description of any
|
|
35
|
+
* single child: a heading has `text` and nothing else, and the panel was
|
|
36
|
+
* offering it a button label, a link, a media source, alt text and a video
|
|
37
|
+
* poster — nine controls for a one-field component, on every child of every
|
|
38
|
+
* TwoColumn. The renderer reads exactly one shape per `type` and ignores the
|
|
39
|
+
* rest, so a value typed into any of the others is stored and never drawn.
|
|
40
|
+
*
|
|
41
|
+
* `discriminator` + `itemFieldsByType` is the vocabulary the manifest already
|
|
42
|
+
* has for this; the derivation builds it automatically for a schema written as
|
|
43
|
+
* a `oneOf` of branches, and this block's schema is one permissive object with
|
|
44
|
+
* a `type` enum, so it has to be said out loud. The property panel narrows to
|
|
45
|
+
* the branch, the coverage check expects markers only for fields the child can
|
|
46
|
+
* draw, and `variant` — a styling hint the renderer never reads — stops being
|
|
47
|
+
* offered as content at all.
|
|
48
|
+
*/
|
|
49
|
+
const twoColumnItemFieldsByType = {
|
|
50
|
+
heading: { text: f.longtext("Heading text") },
|
|
51
|
+
subheading: { text: f.longtext("Subheading text") },
|
|
52
|
+
paragraph: { text: f.richtext("Body text") },
|
|
53
|
+
list: { text: f.longtext("Text content") },
|
|
54
|
+
cta: { label: f.text("Button label"), href: f.link("Link URL"), newTab: f.newTab() },
|
|
55
|
+
ctas: { label: f.text("Button label"), href: f.link("Link URL"), newTab: f.newTab() },
|
|
56
|
+
image: {
|
|
57
|
+
src: f.image("Image", { aspectRatio: "portrait", width: 768, height: 1024 }),
|
|
58
|
+
alt: f.imageAlt("Alt text"),
|
|
59
|
+
},
|
|
60
|
+
video: {
|
|
61
|
+
src: f.url("Video file URL"),
|
|
62
|
+
poster: f.image("Video poster image", { aspectRatio: "landscape", width: 768, height: 512 }),
|
|
63
|
+
},
|
|
64
|
+
};
|
|
31
65
|
registerBlock("TwoColumn", {
|
|
32
66
|
schema: z.object({
|
|
33
67
|
variant: z.enum(["default", "accent"]).default("default").catch("default"),
|
|
@@ -43,8 +77,18 @@ registerBlock("TwoColumn", {
|
|
|
43
77
|
headingLevel: f.headingLevel(),
|
|
44
78
|
},
|
|
45
79
|
listFields: {
|
|
46
|
-
left: {
|
|
47
|
-
|
|
80
|
+
left: {
|
|
81
|
+
label: "Left column items",
|
|
82
|
+
itemFields: twoColumnItemFields,
|
|
83
|
+
discriminator: "type",
|
|
84
|
+
itemFieldsByType: twoColumnItemFieldsByType
|
|
85
|
+
},
|
|
86
|
+
right: {
|
|
87
|
+
label: "Right column items",
|
|
88
|
+
itemFields: twoColumnItemFields,
|
|
89
|
+
discriminator: "type",
|
|
90
|
+
itemFieldsByType: twoColumnItemFieldsByType
|
|
91
|
+
}
|
|
48
92
|
}
|
|
49
93
|
}
|
|
50
94
|
});
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which editable fields does a rendered preview actually offer?
|
|
3
|
+
*
|
|
4
|
+
* The overlay finds every job it does by walking `[data-editable-target]`, so a
|
|
5
|
+
* field the site never marked is a field with no inline editing, no hover pill
|
|
6
|
+
* and — for an image — no Change button. Nothing about that is visible: the
|
|
7
|
+
* property panel is built from this same manifest and never looks at the page,
|
|
8
|
+
* so an unmarked field still appears there, still edits, and still saves. The
|
|
9
|
+
* preview simply offers less than the panel does, quietly and forever.
|
|
10
|
+
*
|
|
11
|
+
* `missingEditableTargetsWarning` in preview-adapter catches the all-or-nothing
|
|
12
|
+
* case at runtime. This is the graded one, and it is the case that actually
|
|
13
|
+
* recurs: instrumentation is per-component work spread over a dozen files, so
|
|
14
|
+
* it gets done on one branch, not merged, and re-lost on the next. The symptom
|
|
15
|
+
* is always a single missing button, reported as a bug in the button.
|
|
16
|
+
*
|
|
17
|
+
* The manifest already knows every editable field of every block type, and the
|
|
18
|
+
* rendered page already carries the block type on each wrapper. Nothing further
|
|
19
|
+
* is needed to answer the question exactly — only somebody asking it, which is
|
|
20
|
+
* what this is for: an integrator asserts on it in their own test suite, and a
|
|
21
|
+
* branch that drops the markers goes red instead of going quiet.
|
|
22
|
+
*/
|
|
23
|
+
import { type BlockManifest } from "./block-manifest.ts";
|
|
24
|
+
/** One block as it appears in a rendered page: its type, and the paths marked inside it. */
|
|
25
|
+
export type MarkedBlock = {
|
|
26
|
+
blockType: string;
|
|
27
|
+
blockId?: string;
|
|
28
|
+
paths: string[];
|
|
29
|
+
/** Paths whose marker sits on a void element — see `VOID_ELEMENTS`. */
|
|
30
|
+
voidPaths?: string[];
|
|
31
|
+
/**
|
|
32
|
+
* The block's props, when the caller has them.
|
|
33
|
+
*
|
|
34
|
+
* Optional, and worth supplying. A field with no value draws nothing, and an
|
|
35
|
+
* element that was never drawn cannot carry a marker — so without this, a
|
|
36
|
+
* prop that happens to be empty on every page of the site is indistinguishable
|
|
37
|
+
* from one nobody instrumented, and gets reported as a gap that cannot be
|
|
38
|
+
* closed. PBA had two: `ctaSection.body` and `heroImage.subheading`, neither
|
|
39
|
+
* of which has content anywhere on the site.
|
|
40
|
+
*/
|
|
41
|
+
props?: Record<string, unknown>;
|
|
42
|
+
};
|
|
43
|
+
export type BlockCoverageGap = {
|
|
44
|
+
blockType: string;
|
|
45
|
+
/** One block id that has the gap, so the report points somewhere. */
|
|
46
|
+
exampleBlockId?: string;
|
|
47
|
+
/** Top-level fields that draw something and carry no marker. */
|
|
48
|
+
missing: string[];
|
|
49
|
+
/** Item fields of a list that is present in the page but under-marked, as `cards[].title`. */
|
|
50
|
+
missingItemFields: string[];
|
|
51
|
+
/**
|
|
52
|
+
* Image fields marked on an element that cannot hold the button — an `<img>`
|
|
53
|
+
* most of the time. Present in the HTML, and as useless as absent.
|
|
54
|
+
*/
|
|
55
|
+
markedOnVoidElement: string[];
|
|
56
|
+
/**
|
|
57
|
+
* Lists with no marked path at all. Weaker evidence than the other two: an
|
|
58
|
+
* empty list and an uninstrumented list look identical from outside, so this
|
|
59
|
+
* is reported separately rather than counted as a gap.
|
|
60
|
+
*/
|
|
61
|
+
unmarkedLists: string[];
|
|
62
|
+
};
|
|
63
|
+
export type EditableCoverage = {
|
|
64
|
+
/** Fields that should carry a marker, summed over the block types on the page. */
|
|
65
|
+
expected: number;
|
|
66
|
+
/** How many of those do. */
|
|
67
|
+
marked: number;
|
|
68
|
+
gaps: BlockCoverageGap[];
|
|
69
|
+
/** Block types on the page that the manifest does not describe — not our business, but worth saying. */
|
|
70
|
+
unknownBlockTypes: string[];
|
|
71
|
+
};
|
|
72
|
+
/**
|
|
73
|
+
* Read the marked blocks out of a rendered page's HTML.
|
|
74
|
+
*
|
|
75
|
+
* A linear scan: each `data-editable-target` belongs to the most recently seen
|
|
76
|
+
* block. That is exact when block wrappers are siblings, which is the contract
|
|
77
|
+
* `getPreviewWrapperProps` describes — one wrapper per block, not nested. A
|
|
78
|
+
* site that nests blocks inside blocks will see inner fields attributed to the
|
|
79
|
+
* outer one; it would also confuse the overlay's own `closest()` walk, so it is
|
|
80
|
+
* out of contract on both ends rather than a limitation of this function.
|
|
81
|
+
*/
|
|
82
|
+
export declare function extractMarkedBlocks(html: string): MarkedBlock[];
|
|
83
|
+
export declare function editableCoverage(manifest: BlockManifest, blocks: MarkedBlock[]): EditableCoverage;
|
|
84
|
+
/** A report a person can read in a terminal. */
|
|
85
|
+
export declare function formatEditableCoverage(report: EditableCoverage): string;
|