@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.
- package/dist/block-manifest.d.ts +18 -0
- package/dist/block-manifest.js +8 -1
- package/dist/blocks/_registry.d.ts +73 -1
- package/dist/blocks/_registry.js +152 -8
- package/dist/blocks/banner.js +2 -2
- package/dist/blocks/card-grid.js +2 -2
- package/dist/blocks/card.js +2 -2
- package/dist/blocks/carousel.js +2 -2
- package/dist/blocks/cta.js +2 -2
- package/dist/blocks/embed.js +2 -2
- package/dist/blocks/faq-accordion.js +2 -2
- package/dist/blocks/feature-grid.js +2 -2
- package/dist/blocks/footer.js +2 -2
- package/dist/blocks/gallery.js +2 -2
- package/dist/blocks/hero.js +2 -2
- package/dist/blocks/quote.js +2 -2
- package/dist/blocks/rich-text.js +2 -2
- package/dist/blocks/site-header.js +2 -2
- package/dist/blocks/stats.js +2 -2
- package/dist/blocks/table.js +2 -2
- package/dist/blocks/tabs.js +2 -2
- package/dist/blocks/testimonials.js +2 -2
- package/dist/blocks/two-column.js +2 -2
- package/dist/blocks/video.js +2 -2
- package/dist/editable-coverage.js +36 -2
- package/dist/editor-block-meta.d.ts +70 -0
- package/dist/editor-block-meta.js +131 -0
- package/dist/index.d.ts +6 -2
- package/dist/index.js +4 -2
- package/dist/ops/builders.d.ts +34 -0
- package/dist/ops/builders.js +63 -0
- package/dist/panel-coverage.d.ts +105 -0
- package/dist/panel-coverage.js +395 -0
- package/package.json +2 -2
|
@@ -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.
|
|
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.
|
|
22
|
+
"@avocadostudio-ai/richtext": "^0.5.1"
|
|
23
23
|
},
|
|
24
24
|
"devDependencies": {
|
|
25
25
|
"tsx": "^4.21.0",
|