@avocadostudio-ai/shared 0.4.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,395 @@
1
+ /**
2
+ * Can a human actually edit this site in the property panel?
3
+ *
4
+ * `editableCoverage` answers the same question for the *preview*: which fields
5
+ * carry a marker the overlay can find. This is the panel's half, and it was the
6
+ * half with no check at all — which is why a real integration shipped a panel
7
+ * whose list rows read `Item 4`, `Item 5`, whose labels came from somebody
8
+ * else's block, and whose polymorphic branches were never narrowed. Every one of
9
+ * those was visible in the data the whole time. Nobody was asking.
10
+ *
11
+ * The check needs no browser, no screenshot and no model. It has the manifest
12
+ * (what the panel will render) and the site's own pages (what the rows really
13
+ * contain), and every finding below is a disagreement between the two.
14
+ *
15
+ * It resolves metadata through `resolveEditorBlockMeta` and rows through
16
+ * `resolveListItemFields` — the same functions the panel itself uses — so its
17
+ * findings are the panel's behaviour rather than a model of it. A checker that
18
+ * approximates the panel reports gaps the panel does not have and misses the
19
+ * ones it does, and gets switched off within a week.
20
+ *
21
+ * Pass `builtinTypes` (the registry the *editor* will run with) to get the
22
+ * collision findings. Without it, collisions are simply not reported — an
23
+ * absent input is never evidence.
24
+ */
25
+ import { resolveEditorBlockMeta, resolveListItemFields } from "./editor-block-meta.js";
26
+ function isRecord(value) {
27
+ return typeof value === "object" && value !== null && !Array.isArray(value);
28
+ }
29
+ function hasContent(value) {
30
+ if (value === null || value === undefined)
31
+ return false;
32
+ if (typeof value === "string")
33
+ return value.trim() !== "";
34
+ if (Array.isArray(value))
35
+ return value.length > 0;
36
+ return true;
37
+ }
38
+ /**
39
+ * The label the panel puts on a collapsed list row.
40
+ *
41
+ * Mirrors `PropertyPanel`'s own derivation exactly, including its fallbacks: the
42
+ * first text-ish field with a value, else that image's alt text, else the
43
+ * filename of the first image, else `Item N`. Kept here so the two cannot drift
44
+ * — the panel imports this.
45
+ *
46
+ * "With a value" is load-bearing and was, for a while, only true of the comment.
47
+ * The code took the first *declared* candidate and read whatever it held, so a
48
+ * field set listing an empty `title` ahead of a populated `text` labelled the row
49
+ * `Item 4` with the answer sitting one key further along. Each step below scans
50
+ * for content instead of stopping at the first key of the right kind.
51
+ *
52
+ * Alt text outranks the filename because it is the only one of the two a person
53
+ * wrote on purpose. A column of `20250904_075546.webp`, `20250904_081233.webp`
54
+ * tells a reader which row is which no better than `Item 4` did, while the alt
55
+ * beside it already says "Pool bei Sonnenuntergang". The first version of this
56
+ * ranked the filename higher and the check then reported the mismatch as a
57
+ * finding — which told a site its alt text was the better label while the panel
58
+ * had no way to use it. A finding with no remedy gets switched off.
59
+ */
60
+ export function deriveRowLabel(fields, item, index, options) {
61
+ const candidates = Object.keys(fields).filter((k) => k !== options?.discriminator);
62
+ for (const key of candidates) {
63
+ if (fields[key].kind !== "text" && fields[key].kind !== "richtext")
64
+ continue;
65
+ const text = firstText(item[key]);
66
+ if (text !== "")
67
+ return { label: text, source: "text" };
68
+ }
69
+ for (const key of candidates) {
70
+ if (fields[key].kind !== "imageAlt")
71
+ continue;
72
+ const alt = firstText(item[key]);
73
+ if (alt !== "")
74
+ return { label: alt, source: "alt" };
75
+ }
76
+ for (const key of candidates) {
77
+ if (fields[key].kind !== "image")
78
+ continue;
79
+ const src = imageSrcOf(item[key]);
80
+ if (src === "")
81
+ continue;
82
+ const name = src.split("?")[0].split("/").filter(Boolean).pop() ?? src;
83
+ return { label: name, source: "filename" };
84
+ }
85
+ return { label: `Item ${index + 1}`, source: "fallback" };
86
+ }
87
+ /**
88
+ * The first run of readable text in a value, whatever shape the value is in.
89
+ *
90
+ * A `richtext` field is only sometimes a string. Storyblok hands over a
91
+ * ProseMirror document, Sanity a Portable Text array, Contentful its own node
92
+ * tree — and `String(…)` of any of them is `"[object Object]"`, which is neither
93
+ * empty nor a label. This used to call exactly that, so a site whose rows carry
94
+ * nothing but rich text was told 48 of them could not be labelled, in a message
95
+ * that names `richtext` among the kinds it accepts. Every one of those rows
96
+ * opened with a heading.
97
+ *
98
+ * `text` covers ProseMirror and Strapi text nodes, `value` covers Contentful's;
99
+ * `content` and `children` are the two spellings of "the nodes below this one".
100
+ * `label` and `title` are for the other object a text-ish field turns out to
101
+ * hold: a row of CTAs, whose value is `[{ label, href }]`. That one used to
102
+ * stringify to `"[object Object]"`, which is not empty, so the row counted as
103
+ * labelled and the panel printed it.
104
+ */
105
+ function firstText(value, depth = 0) {
106
+ if (depth > 8)
107
+ return "";
108
+ if (typeof value === "string")
109
+ return collapseWhitespace(value);
110
+ if (typeof value === "number")
111
+ return String(value);
112
+ if (Array.isArray(value)) {
113
+ for (const entry of value) {
114
+ const found = firstText(entry, depth + 1);
115
+ if (found !== "")
116
+ return found;
117
+ }
118
+ return "";
119
+ }
120
+ if (!isRecord(value))
121
+ return "";
122
+ for (const key of ["text", "value", "label", "title"]) {
123
+ const direct = value[key];
124
+ if (typeof direct === "string") {
125
+ const collapsed = collapseWhitespace(direct);
126
+ if (collapsed !== "")
127
+ return collapsed;
128
+ }
129
+ }
130
+ for (const key of ["content", "children"]) {
131
+ const found = firstText(value[key], depth + 1);
132
+ if (found !== "")
133
+ return found;
134
+ }
135
+ return "";
136
+ }
137
+ /** An image field holds a URL string, or an asset object that has one inside. */
138
+ function imageSrcOf(value) {
139
+ if (typeof value === "string")
140
+ return value.trim();
141
+ if (!isRecord(value))
142
+ return "";
143
+ for (const key of ["url", "src", "filename"]) {
144
+ const candidate = value[key];
145
+ if (typeof candidate === "string" && candidate.trim() !== "")
146
+ return candidate.trim();
147
+ }
148
+ return "";
149
+ }
150
+ function collapseWhitespace(value) {
151
+ return value.replace(/\s+/g, " ").trim();
152
+ }
153
+ /** Does the registry's version of this type describe a different block? */
154
+ function collisionDetail(definition, registryMeta) {
155
+ const schemaProps = isRecord(definition.propsSchema.properties)
156
+ ? new Set(Object.keys(definition.propsSchema.properties))
157
+ : new Set();
158
+ const registryProps = new Set([
159
+ ...Object.keys(registryMeta.fields),
160
+ ...Object.keys(registryMeta.listFields ?? {})
161
+ ]);
162
+ const onlySite = [...schemaProps].filter((p) => !registryProps.has(p));
163
+ const onlyRegistry = [...registryProps].filter((p) => !schemaProps.has(p));
164
+ if (onlySite.length === 0 && onlyRegistry.length === 0)
165
+ return null;
166
+ const parts = [];
167
+ if (onlySite.length > 0)
168
+ parts.push(`only in yours: ${onlySite.join(", ")}`);
169
+ if (onlyRegistry.length > 0)
170
+ parts.push(`only in the built-in: ${onlyRegistry.join(", ")}`);
171
+ return parts.join("; ");
172
+ }
173
+ export function panelCoverage(manifest, pages, options) {
174
+ const definitions = new Map(manifest.blocks.map((b) => [b.type, b]));
175
+ const builtins = options?.builtinTypes;
176
+ const unknownBlockTypes = new Set();
177
+ /*
178
+ * Findings are aggregated by (code, blockType, path). A missing row label is a
179
+ * property of the component, not of the one row that happened to render first
180
+ * — and a report with forty rows of the same sentence is a report nobody
181
+ * finishes reading.
182
+ */
183
+ const byKey = new Map();
184
+ const note = (f) => {
185
+ const key = `${f.code}\u0000${f.blockType}\u0000${f.path ?? ""}`;
186
+ const existing = byKey.get(key);
187
+ if (existing) {
188
+ existing.count += 1;
189
+ return;
190
+ }
191
+ byKey.set(key, { ...f, count: 1 });
192
+ };
193
+ // ── static checks, once per declared type ──────────────────────────────
194
+ for (const definition of manifest.blocks) {
195
+ const registryMeta = builtins?.[definition.type];
196
+ if (registryMeta) {
197
+ const detail = collisionDetail(definition, registryMeta);
198
+ if (detail) {
199
+ note({
200
+ code: "colliding_type",
201
+ blockType: definition.type,
202
+ detail: `the editor also ships a built-in "${definition.type}" with a different shape (${detail}). ` +
203
+ `Anything your manifest does not declare explicitly is taken from the built-in.`
204
+ });
205
+ }
206
+ }
207
+ const meta = resolveEditorBlockMeta(definition, registryMeta);
208
+ for (const [listKey, listField] of Object.entries(meta.listFields ?? {})) {
209
+ const hasDiscriminator = Boolean(listField.discriminator);
210
+ const hasBranches = Object.keys(listField.itemFieldsByType ?? {}).length > 0;
211
+ if (hasDiscriminator !== hasBranches) {
212
+ note({
213
+ code: "incomplete_polymorphism",
214
+ blockType: definition.type,
215
+ path: listKey,
216
+ detail: hasDiscriminator
217
+ ? `declares discriminator "${listField.discriminator}" and no itemFieldsByType, so nothing narrows`
218
+ : `declares itemFieldsByType and no discriminator, so no branch is ever selected`
219
+ });
220
+ }
221
+ }
222
+ }
223
+ // ── content-driven checks ──────────────────────────────────────────────
224
+ let rowsExamined = 0;
225
+ let rowsLabelled = 0;
226
+ /** Item field keys a branch declared, against keys any row of that branch actually had. */
227
+ const seenItemKeys = new Map();
228
+ const declaredItemKeys = new Map();
229
+ for (const page of pages) {
230
+ const slug = page.slug ?? "(unknown)";
231
+ for (const block of page.blocks ?? []) {
232
+ if (!block?.type)
233
+ continue;
234
+ const definition = definitions.get(block.type);
235
+ if (!definition) {
236
+ unknownBlockTypes.add(block.type);
237
+ continue;
238
+ }
239
+ const meta = resolveEditorBlockMeta(definition, builtins?.[block.type]);
240
+ const props = isRecord(block.props) ? block.props : {};
241
+ const listFields = meta.listFields ?? {};
242
+ for (const [propKey, value] of Object.entries(props)) {
243
+ if (!hasContent(value))
244
+ continue;
245
+ // `id` and `_key`-style identity keys are never panel content.
246
+ if (propKey === "id" || propKey === "_key" || propKey === "_type")
247
+ continue;
248
+ if (propKey in meta.fields || propKey in listFields)
249
+ continue;
250
+ note({
251
+ code: "orphan_prop",
252
+ blockType: block.type,
253
+ path: propKey,
254
+ exampleSlug: slug,
255
+ detail: `held in content, described by neither fields nor listFields — the panel cannot show or edit it`
256
+ });
257
+ }
258
+ for (const [listKey, listField] of Object.entries(listFields)) {
259
+ const items = props[listKey];
260
+ if (!Array.isArray(items))
261
+ continue;
262
+ for (const [index, raw] of items.entries()) {
263
+ if (!isRecord(raw))
264
+ continue;
265
+ rowsExamined += 1;
266
+ const { fields, discriminantValue, matchedBranch } = resolveListItemFields(listField, raw);
267
+ if (listField.discriminator && discriminantValue !== "" && !matchedBranch) {
268
+ note({
269
+ code: "unmatched_branch",
270
+ blockType: block.type,
271
+ path: `${listKey}[].${listField.discriminator}=${discriminantValue}`,
272
+ exampleSlug: slug,
273
+ detail: `no itemFieldsByType entry for "${discriminantValue}", so this row is edited against ` +
274
+ `the union of every branch`
275
+ });
276
+ }
277
+ const { source } = deriveRowLabel(fields, raw, index, {
278
+ ...(listField.discriminator ? { discriminator: listField.discriminator } : {})
279
+ });
280
+ if (source === "fallback") {
281
+ note({
282
+ code: "unlabelled_row",
283
+ blockType: block.type,
284
+ path: `${listKey}[]${discriminantValue ? `.${listField.discriminator}=${discriminantValue}` : ""}`,
285
+ exampleSlug: slug,
286
+ detail: `the panel labels this row "Item N" — no text, rich text, alt text or image field on it ` +
287
+ `holds a value, so a person scanning the list cannot tell which row is which`
288
+ });
289
+ }
290
+ else {
291
+ rowsLabelled += 1;
292
+ if (source === "filename") {
293
+ /*
294
+ * The row fell through to a filename, which means its alt is
295
+ * empty — `deriveRowLabel` would have used it otherwise. So this
296
+ * is one finding covering two costs: the panel row is named after
297
+ * a camera, and the image ships to readers with no description.
298
+ * Both are fixed by the same edit.
299
+ */
300
+ const altKey = Object.keys(fields).find((k) => fields[k].kind === "imageAlt");
301
+ if (altKey && !hasContent(raw[altKey])) {
302
+ note({
303
+ code: "filename_row_label",
304
+ blockType: block.type,
305
+ path: `${listKey}[].${altKey}`,
306
+ exampleSlug: slug,
307
+ detail: `labelled by image filename because "${altKey}" is empty — writing the alt text ` +
308
+ `names the row and describes the image to a screen reader in one edit`
309
+ });
310
+ }
311
+ }
312
+ }
313
+ const branchKey = `${block.type}\u0000${listKey}\u0000${discriminantValue}`;
314
+ let declared = declaredItemKeys.get(branchKey);
315
+ if (!declared)
316
+ declaredItemKeys.set(branchKey, (declared = new Set(Object.keys(fields))));
317
+ let seen = seenItemKeys.get(branchKey);
318
+ if (!seen)
319
+ seenItemKeys.set(branchKey, (seen = new Set()));
320
+ for (const [itemKey, itemValue] of Object.entries(raw)) {
321
+ if (itemKey === "id" || itemKey === "_key" || itemKey === listField.discriminator)
322
+ continue;
323
+ if (!hasContent(itemValue))
324
+ continue;
325
+ seen.add(itemKey);
326
+ if (!(itemKey in fields)) {
327
+ note({
328
+ code: "orphan_prop",
329
+ blockType: block.type,
330
+ path: `${listKey}[]${discriminantValue ? `.${listField.discriminator}=${discriminantValue}` : ""}.${itemKey}`,
331
+ exampleSlug: slug,
332
+ detail: `held in content, described by no field on this row's field set`
333
+ });
334
+ }
335
+ }
336
+ }
337
+ }
338
+ }
339
+ }
340
+ for (const [branchKey, declared] of declaredItemKeys) {
341
+ const seen = seenItemKeys.get(branchKey) ?? new Set();
342
+ const [blockType, listKey, discriminantValue] = branchKey.split("\u0000");
343
+ const never = [...declared].filter((k) => !seen.has(k));
344
+ if (never.length === 0)
345
+ continue;
346
+ note({
347
+ code: "phantom_field",
348
+ blockType,
349
+ path: `${listKey}[]${discriminantValue ? `.${discriminantValue}` : ""}`,
350
+ detail: `declared for these rows and never present in one: ${never.join(", ")}`
351
+ });
352
+ }
353
+ /*
354
+ * Most-actionable first. A colliding type explains several of the rows below
355
+ * it, so reading in this order means fixing one cause rather than six
356
+ * symptoms.
357
+ */
358
+ const order = [
359
+ "colliding_type",
360
+ "incomplete_polymorphism",
361
+ "unmatched_branch",
362
+ "unlabelled_row",
363
+ "orphan_prop",
364
+ "filename_row_label",
365
+ "phantom_field"
366
+ ];
367
+ const findings = [...byKey.values()].sort((a, b) => order.indexOf(a.code) - order.indexOf(b.code) ||
368
+ a.blockType.localeCompare(b.blockType) ||
369
+ (a.path ?? "").localeCompare(b.path ?? ""));
370
+ return { rowsExamined, rowsLabelled, findings, unknownBlockTypes: [...unknownBlockTypes] };
371
+ }
372
+ /** A human-readable report, in the shape `formatEditableCoverage` uses. */
373
+ export function formatPanelCoverage(report) {
374
+ const pct = report.rowsExamined === 0 ? 100 : Math.round((report.rowsLabelled / report.rowsExamined) * 100);
375
+ const lines = [`list rows the panel can label: ${report.rowsLabelled}/${report.rowsExamined} (${pct}%)`];
376
+ if (report.findings.length === 0 && report.unknownBlockTypes.length === 0) {
377
+ lines.push("no editing-surface findings");
378
+ return lines.join("\n");
379
+ }
380
+ let lastCode = null;
381
+ for (const finding of report.findings) {
382
+ if (finding.code !== lastCode) {
383
+ lines.push("", `${finding.code}:`);
384
+ lastCode = finding.code;
385
+ }
386
+ const where = finding.path ? `${finding.blockType}.${finding.path}` : finding.blockType;
387
+ const times = finding.count > 1 ? ` ×${finding.count}` : "";
388
+ const on = finding.exampleSlug ? ` (e.g. ${finding.exampleSlug})` : "";
389
+ lines.push(` ${where}${times}${on} — ${finding.detail}`);
390
+ }
391
+ if (report.unknownBlockTypes.length > 0) {
392
+ lines.push("", `block types on a page with no manifest entry: ${report.unknownBlockTypes.join(", ")}`);
393
+ }
394
+ return lines.join("\n");
395
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/shared",
3
- "version": "0.4.0",
3
+ "version": "0.5.1",
4
4
  "type": "module",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -19,7 +19,7 @@
19
19
  ],
20
20
  "dependencies": {
21
21
  "zod": "^4.3.6",
22
- "@avocadostudio-ai/richtext": "^0.4.0"
22
+ "@avocadostudio-ai/richtext": "^0.5.1"
23
23
  },
24
24
  "devDependencies": {
25
25
  "tsx": "^4.21.0",