@avocadostudio-ai/shared 0.3.3 → 0.5.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.
@@ -0,0 +1,368 @@
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 { resolveManifestFieldMeta } from "./block-manifest.js";
24
+ /**
25
+ * The field kinds a marker is expected for: the ones something on the page
26
+ * *draws*.
27
+ *
28
+ * A renderer emits `data-editable-target` for an element, and only these three
29
+ * are elements. `enum`, `boolean`, `number`, `color` and `headingLevel` are
30
+ * settings — they change how something looks rather than being a thing on the
31
+ * page. `url`, `link` and `file` are attributes on an anchor whose *label* is
32
+ * the text field next to them, and `imageAlt` is an attribute on the image. A
33
+ * checker that demanded markers for those would report gaps that cannot be
34
+ * closed, which is the fastest way to get a checker ignored.
35
+ */
36
+ const DRAWN_KINDS = new Set(["text", "richtext", "image"]);
37
+ /**
38
+ * Does this field need an element marked for it?
39
+ *
40
+ * `inlineEditable: false` on a text field is the site saying the string is not
41
+ * something anybody edits on the page — an anchor id, a slug fragment, a
42
+ * machine value that happens to be typed as text. Honouring it is what keeps
43
+ * this checker worth reading: PBA declares seven of those, and counting them as
44
+ * gaps would have made the first report 16% instead of 19% and every one of the
45
+ * extra findings unfixable.
46
+ *
47
+ * It is not consulted for images. There, "inline editable" means typing into
48
+ * it, which nobody does to a photo; the marker is what carries the Change
49
+ * button, and the panel offering one is the site saying the image is editable.
50
+ */
51
+ function needsMarker(meta) {
52
+ // The site has said outright that nothing on the page draws this — a
53
+ // `sectionId`, a `<video poster>`, an input's `placeholder`. See `panelOnly`.
54
+ if (meta.panelOnly)
55
+ return false;
56
+ if (!DRAWN_KINDS.has(meta.kind))
57
+ return false;
58
+ if (meta.kind === "image")
59
+ return true;
60
+ return meta.inlineEditable !== false;
61
+ }
62
+ /**
63
+ * Elements that cannot contain anything.
64
+ *
65
+ * The overlay mounts its image Change button by *appending it into* the marked
66
+ * element, so a marker on an `<img>` is inert — the attribute is in the HTML,
67
+ * the editor finds the field, and no button can ever appear. It looks
68
+ * instrumented from every angle except the one that matters, and this package's
69
+ * own Card and Hero renderers both did it on their full-bleed variants.
70
+ */
71
+ const VOID_ELEMENTS = new Set([
72
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
73
+ "link", "meta", "param", "source", "track", "wbr",
74
+ ]);
75
+ /** One element: its tag name, and the raw attribute text, quotes respected. */
76
+ const TAG_RE = /<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
77
+ const BLOCK_ID_RE = /\bdata-block-id="([^"]*)"/;
78
+ const BLOCK_TYPE_RE = /\bdata-block-type="([^"]*)"/;
79
+ const TARGET_RE = /\bdata-editable-target="([^"]*)"/;
80
+ /**
81
+ * Read the marked blocks out of a rendered page's HTML.
82
+ *
83
+ * A linear scan: each `data-editable-target` belongs to the most recently seen
84
+ * block. That is exact when block wrappers are siblings, which is the contract
85
+ * `getPreviewWrapperProps` describes — one wrapper per block, not nested. A
86
+ * site that nests blocks inside blocks will see inner fields attributed to the
87
+ * outer one; it would also confuse the overlay's own `closest()` walk, so it is
88
+ * out of contract on both ends rather than a limitation of this function.
89
+ */
90
+ export function extractMarkedBlocks(html) {
91
+ const blocks = [];
92
+ let current = null;
93
+ for (const match of html.matchAll(TAG_RE)) {
94
+ const tag = match[1].toLowerCase();
95
+ const attrs = match[2] ?? "";
96
+ const blockId = BLOCK_ID_RE.exec(attrs)?.[1];
97
+ const blockType = BLOCK_TYPE_RE.exec(attrs)?.[1];
98
+ if (blockId !== undefined || blockType !== undefined) {
99
+ current = {
100
+ blockType: blockType ?? "",
101
+ ...(blockId !== undefined ? { blockId } : {}),
102
+ paths: [],
103
+ };
104
+ blocks.push(current);
105
+ }
106
+ const target = TARGET_RE.exec(attrs)?.[1];
107
+ if (target !== undefined && current) {
108
+ current.paths.push(target);
109
+ if (VOID_ELEMENTS.has(tag))
110
+ (current.voidPaths ??= []).push(target);
111
+ }
112
+ }
113
+ return blocks;
114
+ }
115
+ /** Does this prop hold anything a renderer would draw? */
116
+ function hasContent(value) {
117
+ if (value === null || value === undefined)
118
+ return false;
119
+ if (typeof value === "string")
120
+ return value.trim() !== "";
121
+ if (Array.isArray(value))
122
+ return value.length > 0;
123
+ return true;
124
+ }
125
+ /** `cards[2].title` → `cards[].title`; anything else unchanged. */
126
+ function generalizeIndex(path) {
127
+ return path.replace(/\[\d+\]/g, "[]");
128
+ }
129
+ /**
130
+ * Is this field marked — at its own path, or at a finer one inside it?
131
+ *
132
+ * A prop the manifest calls `buttons` is not always drawn as one element. A
133
+ * renderer that maps over it and marks `buttons[].label` on each button has
134
+ * instrumented that field *better* than a single marker on the container would,
135
+ * and reporting it as missing is the kind of unclosable finding that gets a
136
+ * checker ignored. Villa hit exactly this, and it was one of the six gaps that
137
+ * kept its report off 100%.
138
+ *
139
+ * The prefix test is on `key[` and `key.` rather than on `key`, so `title` is
140
+ * not counted as covering `titleColor`.
141
+ */
142
+ function isCovered(key, paths) {
143
+ if (paths.has(key))
144
+ return true;
145
+ for (const path of paths) {
146
+ if (path.startsWith(`${key}[`) || path.startsWith(`${key}.`))
147
+ return true;
148
+ }
149
+ return false;
150
+ }
151
+ /**
152
+ * The item fields a list is expected to have markers for, given what is in it.
153
+ *
154
+ * Two refinements of "every field the manifest declares for this list", both
155
+ * for the same reason the top-level fields already consult `props`: an element
156
+ * that was never drawn cannot carry a marker, and reporting it is a finding
157
+ * nobody can act on.
158
+ *
159
+ * 1. A field no item has a value for is not expected. `imageUrl` is optional on
160
+ * every card block here, and a page whose cards are all text would otherwise
161
+ * report a missing image marker on every one of them.
162
+ *
163
+ * 2. When the list is polymorphic — `discriminator` plus `itemFieldsByType`,
164
+ * the shape a `oneOf` schema derives and a permissive one has to declare —
165
+ * each item is measured against *its own* branch. TwoColumn is the case: a
166
+ * `type: "image"` child has `src` and `alt` and no `label`, and asking it for
167
+ * a button label is asking the renderer to draw a field the item does not
168
+ * have.
169
+ *
170
+ * With no props in hand there is nothing to narrow by, so every declared field
171
+ * is expected — the behaviour before this existed.
172
+ */
173
+ function expectedItemFields(listMeta, items) {
174
+ /*
175
+ * The discriminator is never content. It decides which shape the item is —
176
+ * `derivePolymorphicListField` deletes it from every branch it builds for
177
+ * exactly that reason — and a list whose metadata was declared rather than
178
+ * derived has nothing doing the same, so a `type` typed as a plain string
179
+ * arrives here looking like an ordinary text field nobody marked.
180
+ */
181
+ const isContent = ([key, meta]) => key !== listMeta.discriminator && needsMarker(meta);
182
+ const declared = Object.entries(listMeta.itemFields ?? {}).filter(isContent);
183
+ if (!items)
184
+ return declared;
185
+ const expected = new Map();
186
+ for (const raw of items) {
187
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
188
+ continue;
189
+ const item = raw;
190
+ const branchKey = listMeta.discriminator ? String(item[listMeta.discriminator] ?? "") : "";
191
+ const fields = listMeta.itemFieldsByType?.[branchKey] ?? listMeta.itemFields ?? {};
192
+ for (const entry of Object.entries(fields)) {
193
+ if (!isContent(entry))
194
+ continue;
195
+ if (!hasContent(item[entry[0]]))
196
+ continue;
197
+ expected.set(entry[0], entry[1]);
198
+ }
199
+ }
200
+ return [...expected];
201
+ }
202
+ export function editableCoverage(manifest, blocks) {
203
+ const definitions = new Map(manifest.blocks.map((block) => [block.type, block]));
204
+ // Aggregate by type: a missing marker is a property of the component, not of
205
+ // the one instance that happened to render first.
206
+ const pathsByType = new Map();
207
+ const exampleIdByType = new Map();
208
+ const unknownBlockTypes = new Set();
209
+ /*
210
+ * Props that at least one instance of a type actually had. Only consulted
211
+ * when some instance supplied props at all — a caller that passes none gets
212
+ * the old behaviour, where every declared field is expected.
213
+ */
214
+ const filledByType = new Map();
215
+ const sawPropsForType = new Set();
216
+ const voidByType = new Map();
217
+ /** Every list item seen for a type, by list key — what `expectedItemFields` narrows by. */
218
+ const listItemsByType = new Map();
219
+ for (const block of blocks) {
220
+ if (!block.blockType)
221
+ continue;
222
+ if (!definitions.has(block.blockType)) {
223
+ unknownBlockTypes.add(block.blockType);
224
+ continue;
225
+ }
226
+ let paths = pathsByType.get(block.blockType);
227
+ if (!paths) {
228
+ paths = new Set();
229
+ pathsByType.set(block.blockType, paths);
230
+ if (block.blockId)
231
+ exampleIdByType.set(block.blockType, block.blockId);
232
+ }
233
+ for (const path of block.paths)
234
+ paths.add(generalizeIndex(path));
235
+ if (block.voidPaths?.length) {
236
+ let voids = voidByType.get(block.blockType);
237
+ if (!voids) {
238
+ voids = new Set();
239
+ voidByType.set(block.blockType, voids);
240
+ }
241
+ for (const path of block.voidPaths)
242
+ voids.add(generalizeIndex(path));
243
+ }
244
+ if (!block.props)
245
+ continue;
246
+ sawPropsForType.add(block.blockType);
247
+ let filled = filledByType.get(block.blockType);
248
+ if (!filled) {
249
+ filled = new Set();
250
+ filledByType.set(block.blockType, filled);
251
+ }
252
+ let lists = listItemsByType.get(block.blockType);
253
+ if (!lists) {
254
+ lists = new Map();
255
+ listItemsByType.set(block.blockType, lists);
256
+ }
257
+ for (const [key, value] of Object.entries(block.props)) {
258
+ if (hasContent(value))
259
+ filled.add(key);
260
+ if (Array.isArray(value)) {
261
+ const seen = lists.get(key);
262
+ if (seen)
263
+ seen.push(...value);
264
+ else
265
+ lists.set(key, [...value]);
266
+ }
267
+ }
268
+ }
269
+ let expected = 0;
270
+ let marked = 0;
271
+ const gaps = [];
272
+ for (const [blockType, paths] of pathsByType) {
273
+ const definition = definitions.get(blockType);
274
+ const { fields, listFields } = resolveManifestFieldMeta(definition);
275
+ const missing = [];
276
+ const missingItemFields = [];
277
+ const unmarkedLists = [];
278
+ const voids = voidByType.get(blockType) ?? new Set();
279
+ const markedOnVoidElement = [];
280
+ const knowsContent = sawPropsForType.has(blockType);
281
+ const filled = filledByType.get(blockType) ?? new Set();
282
+ for (const [key, meta] of Object.entries(fields)) {
283
+ if (!needsMarker(meta))
284
+ continue;
285
+ // Nothing drew it, so nothing could have marked it.
286
+ if (knowsContent && !filled.has(key))
287
+ continue;
288
+ expected += 1;
289
+ if (!isCovered(key, paths)) {
290
+ missing.push(key);
291
+ }
292
+ else if (meta.kind === "image" && voids.has(key)) {
293
+ // Marked, found by the editor, and unable to hold the button.
294
+ markedOnVoidElement.push(key);
295
+ }
296
+ else {
297
+ marked += 1;
298
+ }
299
+ }
300
+ for (const [listKey, listMeta] of Object.entries(listFields)) {
301
+ const listPresent = [...paths].some((path) => path.startsWith(`${listKey}[]`));
302
+ const drawnItemFields = expectedItemFields(listMeta, knowsContent ? (listItemsByType.get(blockType)?.get(listKey) ?? []) : undefined);
303
+ if (drawnItemFields.length === 0)
304
+ continue;
305
+ // An empty list on every page is not an instrumentation question either,
306
+ // and with props in hand we can say so instead of guessing.
307
+ if (knowsContent && !filled.has(listKey))
308
+ continue;
309
+ if (!listPresent) {
310
+ // Could be an empty list on this page. Not counted against coverage.
311
+ unmarkedLists.push(listKey);
312
+ continue;
313
+ }
314
+ for (const [itemKey, itemMeta] of drawnItemFields) {
315
+ const path = `${listKey}[].${itemKey}`;
316
+ expected += 1;
317
+ if (!paths.has(path)) {
318
+ missingItemFields.push(path);
319
+ }
320
+ else if (itemMeta.kind === "image" && voids.has(path)) {
321
+ markedOnVoidElement.push(path);
322
+ }
323
+ else {
324
+ marked += 1;
325
+ }
326
+ }
327
+ }
328
+ if (missing.length > 0 ||
329
+ missingItemFields.length > 0 ||
330
+ unmarkedLists.length > 0 ||
331
+ markedOnVoidElement.length > 0) {
332
+ const exampleBlockId = exampleIdByType.get(blockType);
333
+ gaps.push({
334
+ blockType,
335
+ ...(exampleBlockId ? { exampleBlockId } : {}),
336
+ missing,
337
+ missingItemFields,
338
+ unmarkedLists,
339
+ markedOnVoidElement,
340
+ });
341
+ }
342
+ }
343
+ return { expected, marked, gaps, unknownBlockTypes: [...unknownBlockTypes] };
344
+ }
345
+ /** A report a person can read in a terminal. */
346
+ export function formatEditableCoverage(report) {
347
+ const lines = [];
348
+ const pct = report.expected === 0 ? 100 : Math.round((report.marked / report.expected) * 100);
349
+ lines.push(`editable fields marked: ${report.marked}/${report.expected} (${pct}%)`);
350
+ for (const gap of report.gaps) {
351
+ const where = gap.exampleBlockId ? ` (e.g. ${gap.exampleBlockId})` : "";
352
+ lines.push(` ${gap.blockType}${where}`);
353
+ for (const key of gap.missing)
354
+ lines.push(` unmarked ${key}`);
355
+ for (const key of gap.missingItemFields)
356
+ lines.push(` unmarked ${key}`);
357
+ for (const key of gap.markedOnVoidElement) {
358
+ lines.push(` marked on a void element (no button can mount) ${key}`);
359
+ }
360
+ for (const key of gap.unmarkedLists) {
361
+ lines.push(` no marker for any item of ${key}[] — empty on this page, or not instrumented`);
362
+ }
363
+ }
364
+ if (report.unknownBlockTypes.length > 0) {
365
+ lines.push(` not in the manifest, skipped: ${report.unknownBlockTypes.join(", ")}`);
366
+ }
367
+ return lines.join("\n");
368
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * What the property panel should render for one block type.
3
+ *
4
+ * There are two sources and they disagree. The **manifest** is what the site
5
+ * serves over HTTP: its own schema, its own labels, its own list metadata. The
6
+ * **registry** is whatever is registered in the process doing the rendering —
7
+ * and in the editor that is its *own* bundled copy of `@avocadostudio-ai/blocks`,
8
+ * which registers the built-in types on import. A site that calls
9
+ * `registerBlocks` fills the registry inside the *orchestrator*; the editor's
10
+ * in-browser registry is a third source of truth that nobody registered into.
11
+ *
12
+ * This used to be inline in `PropertyPanel`, with the rule "the manifest decides
13
+ * *which* fields exist; the registry supplies richer metadata for any field in
14
+ * both". That is right when the site's `Hero` really is Avocado's `Hero`, and
15
+ * exactly wrong when the site re-registered that name with its own shape — which
16
+ * is the common case, because these are the names blocks *have*. A real
17
+ * integration collided on seven of its eight types and got a property panel
18
+ * describing Avocado's blocks: its `Left column` list was labelled
19
+ * `Left column items`, its `Variant` became `Style variant`.
20
+ *
21
+ * So the rule is now about *who said it*, not *who has it*:
22
+ *
23
+ * - The manifest **declared** the field (it is in `definition.fields`, not merely
24
+ * derived from the JSON schema) → the site said this out loud, and wins.
25
+ * - The manifest only **derived** it → the site said nothing beyond the schema,
26
+ * and the registry's entry is a better answer than an inference, because it
27
+ * carries `imageSpec`, `options`, `inlineEditable` and a human label.
28
+ *
29
+ * It also carries `discriminator` and `itemFieldsByType` through, which the old
30
+ * merge dropped by constructing a two-key object. That was pure data loss: the
31
+ * manifest carries both, and without them the panel narrows nothing, so every
32
+ * row of a polymorphic list is measured against the merged union of all
33
+ * branches. Rows whose branch has no text field then fall back to `Item 4`,
34
+ * `Item 5` — in the case that found this, a bullet list and a pair of CTAs, both
35
+ * fully described in the manifest the panel had in hand.
36
+ */
37
+ import type { BlockMeta, FieldMeta, ListFieldMeta } from "./blocks/_registry.ts";
38
+ /** The shape this needs from a manifest entry — a structural subset of `BlockDefinition`. */
39
+ export type EditorBlockDefinition = {
40
+ type: string;
41
+ displayName?: string;
42
+ propsSchema: Record<string, unknown>;
43
+ fields?: Record<string, unknown>;
44
+ listFields?: Record<string, unknown>;
45
+ };
46
+ /**
47
+ * Merge one manifest block definition with whatever the local registry knows
48
+ * about the same type.
49
+ *
50
+ * `registryMeta` is `undefined` for a type the registry has never heard of — a
51
+ * net-new block — and then the manifest is simply the answer.
52
+ */
53
+ export declare function resolveEditorBlockMeta(definition: EditorBlockDefinition, registryMeta: BlockMeta | undefined): BlockMeta;
54
+ /**
55
+ * The fields one list row is actually edited with.
56
+ *
57
+ * Polymorphic lists narrow to the branch named by the row's own discriminant
58
+ * value; anything else — no discriminator, or a value with no branch — falls
59
+ * back to the union in `itemFields`, which is the pre-narrowing behaviour and
60
+ * the right answer when there is nothing to narrow by.
61
+ *
62
+ * Exported because the panel and the editing-surface check must agree exactly.
63
+ * A checker that approximates this reports gaps the panel does not have, and
64
+ * misses the ones it does.
65
+ */
66
+ export declare function resolveListItemFields(listField: ListFieldMeta, item: Record<string, unknown>): {
67
+ fields: Record<string, FieldMeta>;
68
+ discriminantValue: string;
69
+ matchedBranch: boolean;
70
+ };
@@ -0,0 +1,131 @@
1
+ /**
2
+ * What the property panel should render for one block type.
3
+ *
4
+ * There are two sources and they disagree. The **manifest** is what the site
5
+ * serves over HTTP: its own schema, its own labels, its own list metadata. The
6
+ * **registry** is whatever is registered in the process doing the rendering —
7
+ * and in the editor that is its *own* bundled copy of `@avocadostudio-ai/blocks`,
8
+ * which registers the built-in types on import. A site that calls
9
+ * `registerBlocks` fills the registry inside the *orchestrator*; the editor's
10
+ * in-browser registry is a third source of truth that nobody registered into.
11
+ *
12
+ * This used to be inline in `PropertyPanel`, with the rule "the manifest decides
13
+ * *which* fields exist; the registry supplies richer metadata for any field in
14
+ * both". That is right when the site's `Hero` really is Avocado's `Hero`, and
15
+ * exactly wrong when the site re-registered that name with its own shape — which
16
+ * is the common case, because these are the names blocks *have*. A real
17
+ * integration collided on seven of its eight types and got a property panel
18
+ * describing Avocado's blocks: its `Left column` list was labelled
19
+ * `Left column items`, its `Variant` became `Style variant`.
20
+ *
21
+ * So the rule is now about *who said it*, not *who has it*:
22
+ *
23
+ * - The manifest **declared** the field (it is in `definition.fields`, not merely
24
+ * derived from the JSON schema) → the site said this out loud, and wins.
25
+ * - The manifest only **derived** it → the site said nothing beyond the schema,
26
+ * and the registry's entry is a better answer than an inference, because it
27
+ * carries `imageSpec`, `options`, `inlineEditable` and a human label.
28
+ *
29
+ * It also carries `discriminator` and `itemFieldsByType` through, which the old
30
+ * merge dropped by constructing a two-key object. That was pure data loss: the
31
+ * manifest carries both, and without them the panel narrows nothing, so every
32
+ * row of a polymorphic list is measured against the merged union of all
33
+ * branches. Rows whose branch has no text field then fall back to `Item 4`,
34
+ * `Item 5` — in the case that found this, a bullet list and a pair of CTAs, both
35
+ * fully described in the manifest the panel had in hand.
36
+ */
37
+ import { resolveManifestFieldMeta } from "./block-manifest.js";
38
+ function isRecord(value) {
39
+ return typeof value === "object" && value !== null && !Array.isArray(value);
40
+ }
41
+ /** Keys the definition stated for itself, as opposed to keys inferred from its schema. */
42
+ function declaredKeys(source) {
43
+ return isRecord(source) ? new Set(Object.keys(source)) : new Set();
44
+ }
45
+ /**
46
+ * Merge one manifest block definition with whatever the local registry knows
47
+ * about the same type.
48
+ *
49
+ * `registryMeta` is `undefined` for a type the registry has never heard of — a
50
+ * net-new block — and then the manifest is simply the answer.
51
+ */
52
+ export function resolveEditorBlockMeta(definition, registryMeta) {
53
+ const derived = resolveManifestFieldMeta(definition);
54
+ if (!registryMeta) {
55
+ return {
56
+ displayName: definition.displayName ?? definition.type,
57
+ fields: derived.fields,
58
+ ...(Object.keys(derived.listFields).length > 0 ? { listFields: derived.listFields } : {})
59
+ };
60
+ }
61
+ const declaredFields = declaredKeys(definition.fields);
62
+ const fields = {};
63
+ for (const [key, fromManifest] of Object.entries(derived.fields)) {
64
+ const fromRegistry = registryMeta.fields[key];
65
+ // The site said it out loud, or the registry has nothing to add.
66
+ fields[key] = declaredFields.has(key) || !fromRegistry ? fromManifest : fromRegistry;
67
+ }
68
+ const declaredLists = declaredKeys(definition.listFields);
69
+ const listFields = {};
70
+ for (const [key, fromManifest] of Object.entries(derived.listFields)) {
71
+ const fromRegistry = registryMeta.listFields?.[key];
72
+ if (!fromRegistry || declaredLists.has(key)) {
73
+ listFields[key] = fromManifest;
74
+ continue;
75
+ }
76
+ const declaredItems = declaredKeys(isRecord(definition.listFields?.[key])
77
+ ? definition.listFields[key].itemFields
78
+ : undefined);
79
+ const itemFields = {};
80
+ for (const [itemKey, itemFromManifest] of Object.entries(fromManifest.itemFields)) {
81
+ const itemFromRegistry = fromRegistry.itemFields[itemKey];
82
+ itemFields[itemKey] =
83
+ declaredItems.has(itemKey) || !itemFromRegistry ? itemFromManifest : itemFromRegistry;
84
+ }
85
+ listFields[key] = {
86
+ ...fromManifest,
87
+ ...(fromManifest.label ?? fromRegistry.label
88
+ ? { label: fromManifest.label ?? fromRegistry.label }
89
+ : {}),
90
+ itemFields,
91
+ // Never synthesised, never dropped: whichever source has a branch map, keep it.
92
+ ...(fromManifest.discriminator ?? fromRegistry.discriminator
93
+ ? { discriminator: fromManifest.discriminator ?? fromRegistry.discriminator }
94
+ : {}),
95
+ ...(fromManifest.itemFieldsByType ?? fromRegistry.itemFieldsByType
96
+ ? { itemFieldsByType: fromManifest.itemFieldsByType ?? fromRegistry.itemFieldsByType }
97
+ : {})
98
+ };
99
+ }
100
+ return {
101
+ ...registryMeta,
102
+ displayName: definition.displayName ?? registryMeta.displayName,
103
+ fields,
104
+ ...(Object.keys(listFields).length > 0 ? { listFields } : {})
105
+ };
106
+ }
107
+ /**
108
+ * The fields one list row is actually edited with.
109
+ *
110
+ * Polymorphic lists narrow to the branch named by the row's own discriminant
111
+ * value; anything else — no discriminator, or a value with no branch — falls
112
+ * back to the union in `itemFields`, which is the pre-narrowing behaviour and
113
+ * the right answer when there is nothing to narrow by.
114
+ *
115
+ * Exported because the panel and the editing-surface check must agree exactly.
116
+ * A checker that approximates this reports gaps the panel does not have, and
117
+ * misses the ones it does.
118
+ */
119
+ export function resolveListItemFields(listField, item) {
120
+ if (!listField.discriminator) {
121
+ return { fields: listField.itemFields, discriminantValue: "", matchedBranch: false };
122
+ }
123
+ const raw = item[listField.discriminator];
124
+ const discriminantValue = raw === undefined || raw === null ? "" : String(raw);
125
+ const branch = listField.itemFieldsByType?.[discriminantValue];
126
+ return {
127
+ fields: branch ?? listField.itemFields,
128
+ discriminantValue,
129
+ matchedBranch: Boolean(branch)
130
+ };
131
+ }
package/dist/index.d.ts CHANGED
@@ -2,11 +2,17 @@ export { EDITOR_PROTOCOL_VERSION } from "./protocol.ts";
2
2
  export { getConfiguredDraftSecret, getSafeInternalRedirectPath, validateDraftSecret, type DraftSecretValidationResult } from "./draft-mode.ts";
3
3
  export type { FieldDiffKind, FieldDiff, BlockDiffStatus, BlockDiff, PageDiffStatus, PageDiff, PublishDiff, SiteConfigFieldDiff, SiteConfigDiff, } from "./publish-diff.ts";
4
4
  export { isImagePath, toAltPath, isAltPath, toImagePath, setPropAtPath } from "./editable-path.ts";
5
- export { parseLink, resolveLink, normalizeLinkPath, isKnownRoute, internalPathForUrl, rankLinkTargets, scoreLinkCandidate, suggestLinkTarget, newTabKeyFor, linkAttrs, type LinkKind, type ParsedLink, type ResolvedLink, type LinkPageOption, } from "./links.ts";
5
+ export { extractMarkedBlocks, editableCoverage, formatEditableCoverage } from "./editable-coverage.ts";
6
+ export type { MarkedBlock, BlockCoverageGap, EditableCoverage } from "./editable-coverage.ts";
7
+ export { panelCoverage, formatPanelCoverage, deriveRowLabel } from "./panel-coverage.ts";
8
+ export type { PanelCoverage, PanelFinding, PanelFindingCode } from "./panel-coverage.ts";
9
+ export { resolveEditorBlockMeta, resolveListItemFields } from "./editor-block-meta.ts";
10
+ export type { EditorBlockDefinition } from "./editor-block-meta.ts";
11
+ export { parseLink, resolveLink, normalizeLinkPath, isKnownRoute, internalPathForUrl, rankLinkTargets, rankFileTargets, suggestLinkTargets, scoreLinkCandidate, suggestLinkTarget, newTabKeyFor, linkAttrs, isFilePath, knownFileExtensions, linksInRichText, type LinkKind, type ParsedLink, type ResolvedLink, type LinkPageOption, type LinkFileOption, type LinkSuggestions, } from "./links.ts";
6
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";
7
13
  export { blockDefinitionSchema, blockManifestSchema, buildBlockManifest, jsonSchemaLikeSchema, validateByJsonSchemaLike, findManifestSchemaIssue, type ManifestSchemaIssue, validateManifestDefaultProps, deriveFieldMetaFromSchema, resolveManifestFieldMeta, isProseMirrorDocSchema, type BlockDefinition, type BlockManifest } from "./block-manifest.ts";
8
14
  export { z } from "zod";
9
- export { type FieldKind, type ImageSpec, type FieldMeta, type ListFieldMeta, type BlockMeta, type BlockType, type BlockInstance, type BlockRegistration, IMAGE_PLACEHOLDER, isImagePlaceholder, registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, 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, 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";
10
16
  export { defaultPropsForType, declaredDefaultPropsForType, resolveHeadingTag, resolveItemHeadingTag, DEFAULT_HEADING_LEVELS, } from "./blocks/index.ts";
11
17
  export { blockTypeToCamel, camelToBlockType, blockTypeToLower, lowerToBlockType, } from "./block-names.ts";
12
18
  export { makeAddBlock, generateBlockId, makeAddItem, generateItemId, ensureItemIds, type AddBlockOp, type MakeAddBlockOptions, type AddItemOp, type MakeAddItemOptions, } from "./ops/builders.ts";
package/dist/index.js CHANGED
@@ -1,7 +1,10 @@
1
1
  export { EDITOR_PROTOCOL_VERSION } from "./protocol.js";
2
2
  export { getConfiguredDraftSecret, getSafeInternalRedirectPath, validateDraftSecret } from "./draft-mode.js";
3
3
  export { isImagePath, toAltPath, isAltPath, toImagePath, setPropAtPath } from "./editable-path.js";
4
- export { parseLink, resolveLink, normalizeLinkPath, isKnownRoute, internalPathForUrl, rankLinkTargets, scoreLinkCandidate, suggestLinkTarget, newTabKeyFor, linkAttrs, } from "./links.js";
4
+ export { extractMarkedBlocks, editableCoverage, formatEditableCoverage } from "./editable-coverage.js";
5
+ export { panelCoverage, formatPanelCoverage, deriveRowLabel } from "./panel-coverage.js";
6
+ export { resolveEditorBlockMeta, resolveListItemFields } from "./editor-block-meta.js";
7
+ export { parseLink, resolveLink, normalizeLinkPath, isKnownRoute, internalPathForUrl, rankLinkTargets, rankFileTargets, suggestLinkTargets, scoreLinkCandidate, suggestLinkTarget, newTabKeyFor, linkAttrs, isFilePath, knownFileExtensions, linksInRichText, } from "./links.js";
5
8
  /*
6
9
  * The rich-text grammar lives in `@avocadostudio-ai/richtext`, which owns the
7
10
  * parser and every CMS converter and has no dependencies of its own. It is
@@ -34,7 +37,7 @@ export {
34
37
  // Constants & helpers
35
38
  IMAGE_PLACEHOLDER, isImagePlaceholder,
36
39
  // Registry functions
37
- registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes,
40
+ registerBlock, resetListFieldWarnings, getBlockMeta, getAllBlockMeta, blockAcceptsProp, blockListItemAcceptsKey, getImageFields, getListImageFields, getMediaFields, isFieldInlineEditable, getImageSpec, isChrome, getChromeTypes,
38
41
  // Backwards-compatible exports
39
42
  blockSchemas, allowedBlockTypes,
40
43
  // The catalogue a site actually renders — see `declareBlockCatalogue`