@avocadostudio-ai/shared 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,8 +7,10 @@ registerBlock("CTA", {
7
7
  description: z.string().min(1),
8
8
  ctaText: z.string().min(1),
9
9
  ctaHref: z.string().min(1),
10
+ ctaNewTab: z.boolean().optional(),
10
11
  secondaryCtaText: z.string().optional(),
11
- secondaryCtaHref: z.string().optional()
12
+ secondaryCtaHref: z.string().optional(),
13
+ secondaryCtaNewTab: z.boolean().optional()
12
14
  }),
13
15
  meta: {
14
16
  displayName: "Call to Action",
@@ -18,9 +20,11 @@ registerBlock("CTA", {
18
20
  title: f.text("Headline"),
19
21
  description: f.longtext("Description"),
20
22
  ctaText: f.text("Button text"),
21
- ctaHref: f.url("Button link"),
23
+ ctaHref: f.link("Button link"),
24
+ ctaNewTab: f.newTab(),
22
25
  secondaryCtaText: f.text("Secondary button text"),
23
- secondaryCtaHref: f.url("Secondary button link"),
26
+ secondaryCtaHref: f.link("Secondary button link"),
27
+ secondaryCtaNewTab: f.newTab(),
24
28
  headingLevel: f.headingLevel(),
25
29
  }
26
30
  }
@@ -25,7 +25,7 @@ registerBlock("FeatureGrid", {
25
25
  features: {
26
26
  label: "Features",
27
27
  itemFields: {
28
- icon: f.text("Icon (single emoji)"),
28
+ icon: f.icon(),
29
29
  title: f.text("Feature title"),
30
30
  description: f.longtext("Feature description"),
31
31
  }
@@ -7,13 +7,15 @@ registerBlock("Hero", {
7
7
  subheading: z.string().min(1),
8
8
  ctaText: z.string().min(1),
9
9
  ctaHref: z.string().min(1),
10
+ ctaNewTab: z.boolean().optional(),
10
11
  imageUrl: z.string().min(1),
11
12
  imageAlt: z.string().min(1),
12
13
  imagePosition: z.enum(["left", "right", "full"]).default("right").catch("right"),
13
14
  textAlign: z.enum(["left", "center"]).default("left").catch("left"),
14
15
  eyebrow: z.string().optional(),
15
16
  secondaryCtaText: z.string().optional(),
16
- secondaryCtaHref: z.string().optional()
17
+ secondaryCtaHref: z.string().optional(),
18
+ secondaryCtaNewTab: z.boolean().optional()
17
19
  }),
18
20
  meta: {
19
21
  displayName: "Hero",
@@ -23,14 +25,16 @@ registerBlock("Hero", {
23
25
  heading: f.text("Heading"),
24
26
  subheading: f.text("Subheading"),
25
27
  ctaText: f.text("CTA button text"),
26
- ctaHref: f.url("CTA link"),
28
+ ctaHref: f.link("CTA link"),
29
+ ctaNewTab: f.newTab(),
27
30
  imageUrl: f.image("Hero image", { aspectRatio: "landscape", width: 1536, height: 1024 }),
28
31
  imageAlt: f.imageAlt("Hero image alt text"),
29
32
  imagePosition: { kind: "enum", label: "Image position", options: ["left", "right", "full"], inlineEditable: false },
30
33
  textAlign: { kind: "enum", label: "Text alignment", options: ["left", "center"], inlineEditable: false },
31
34
  eyebrow: f.text("Eyebrow text"),
32
35
  secondaryCtaText: f.text("Secondary CTA text"),
33
- secondaryCtaHref: f.url("Secondary CTA link"),
36
+ secondaryCtaHref: f.link("Secondary CTA link"),
37
+ secondaryCtaNewTab: f.newTab(),
34
38
  headingLevel: f.headingLevel(),
35
39
  }
36
40
  }
@@ -5,6 +5,7 @@ const navLinkLeaf = z.object({
5
5
  id: z.string().optional(),
6
6
  label: z.string().min(1),
7
7
  href: z.string().min(1),
8
+ newTab: z.boolean().optional(),
8
9
  });
9
10
  registerBlock("SiteHeader", {
10
11
  schema: z.object({
@@ -29,7 +30,8 @@ registerBlock("SiteHeader", {
29
30
  label: "Nav links",
30
31
  itemFields: {
31
32
  label: f.text("Link label"),
32
- href: f.url("Link URL"),
33
+ href: f.link("Link URL"),
34
+ newTab: f.newTab(),
33
35
  },
34
36
  },
35
37
  },
@@ -21,7 +21,7 @@ registerBlock("Stats", {
21
21
  stats: {
22
22
  label: "Stats",
23
23
  itemFields: {
24
- icon: f.text("Icon (single emoji)"),
24
+ icon: f.icon(),
25
25
  value: f.text("Value"),
26
26
  label: f.text("Label"),
27
27
  description: f.text("Description"),
@@ -16,8 +16,11 @@ registerBlock("Table", {
16
16
  fields: {
17
17
  title: f.text("Section title"),
18
18
  headingLevel: f.headingLevel(),
19
- headers: f.text("Column headers"),
20
- rows: f.text("Table rows"),
19
+ // Both are lists of scalars, which `FieldKind` cannot name — the panel
20
+ // recognises the value's shape instead: `headers` edits one per line,
21
+ // `rows` (string[][]) is shown there and edited cell-by-cell in the page.
22
+ headers: f.text("Column headers (one per line)"),
23
+ rows: f.text("Table rows (edit cells in the page)"),
21
24
  striped: { kind: "enum", label: "Striped rows", options: ["true", "false"], inlineEditable: false },
22
25
  },
23
26
  }
@@ -23,7 +23,7 @@ registerBlock("Testimonials", {
23
23
  items: {
24
24
  label: "Testimonials",
25
25
  itemFields: {
26
- icon: f.text("Icon (single emoji)"),
26
+ icon: f.icon(),
27
27
  quote: f.longtext("Quote"),
28
28
  author: f.text("Author"),
29
29
  role: f.text("Role / Company"),
@@ -10,6 +10,7 @@ const twoColumnChild = z.object({
10
10
  text: z.string().optional(),
11
11
  label: z.string().optional(),
12
12
  href: z.string().optional(),
13
+ newTab: z.boolean().optional(),
13
14
  src: z.string().optional(),
14
15
  alt: z.string().optional(),
15
16
  poster: z.string().optional(),
@@ -21,11 +22,46 @@ const twoColumnItemFields = {
21
22
  type: { kind: "enum", label: "Component type", options: ["heading", "subheading", "paragraph", "list", "cta", "ctas", "image", "video"] },
22
23
  text: f.longtext("Text content"),
23
24
  label: f.text("Button label"),
24
- href: f.url("Link URL"),
25
+ href: f.link("Link URL"),
26
+ newTab: f.newTab(),
25
27
  src: f.image("Media source", { aspectRatio: "portrait", width: 768, height: 1024 }),
26
28
  alt: f.imageAlt("Alt text"),
27
29
  poster: f.image("Video poster image", { aspectRatio: "landscape", width: 768, height: 512 }),
28
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
+ };
29
65
  registerBlock("TwoColumn", {
30
66
  schema: z.object({
31
67
  variant: z.enum(["default", "accent"]).default("default").catch("default"),
@@ -41,8 +77,18 @@ registerBlock("TwoColumn", {
41
77
  headingLevel: f.headingLevel(),
42
78
  },
43
79
  listFields: {
44
- left: { label: "Left column items", itemFields: twoColumnItemFields },
45
- right: { label: "Right column items", itemFields: twoColumnItemFields }
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
+ }
46
92
  }
47
93
  }
48
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;
@@ -0,0 +1,342 @@
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
+ if (!DRAWN_KINDS.has(meta.kind))
53
+ return false;
54
+ if (meta.kind === "image")
55
+ return true;
56
+ return meta.inlineEditable !== false;
57
+ }
58
+ /**
59
+ * Elements that cannot contain anything.
60
+ *
61
+ * The overlay mounts its image Change button by *appending it into* the marked
62
+ * element, so a marker on an `<img>` is inert — the attribute is in the HTML,
63
+ * the editor finds the field, and no button can ever appear. It looks
64
+ * instrumented from every angle except the one that matters, and this package's
65
+ * own Card and Hero renderers both did it on their full-bleed variants.
66
+ */
67
+ const VOID_ELEMENTS = new Set([
68
+ "area", "base", "br", "col", "embed", "hr", "img", "input",
69
+ "link", "meta", "param", "source", "track", "wbr",
70
+ ]);
71
+ /** One element: its tag name, and the raw attribute text, quotes respected. */
72
+ const TAG_RE = /<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
73
+ const BLOCK_ID_RE = /\bdata-block-id="([^"]*)"/;
74
+ const BLOCK_TYPE_RE = /\bdata-block-type="([^"]*)"/;
75
+ const TARGET_RE = /\bdata-editable-target="([^"]*)"/;
76
+ /**
77
+ * Read the marked blocks out of a rendered page's HTML.
78
+ *
79
+ * A linear scan: each `data-editable-target` belongs to the most recently seen
80
+ * block. That is exact when block wrappers are siblings, which is the contract
81
+ * `getPreviewWrapperProps` describes — one wrapper per block, not nested. A
82
+ * site that nests blocks inside blocks will see inner fields attributed to the
83
+ * outer one; it would also confuse the overlay's own `closest()` walk, so it is
84
+ * out of contract on both ends rather than a limitation of this function.
85
+ */
86
+ export function extractMarkedBlocks(html) {
87
+ const blocks = [];
88
+ let current = null;
89
+ for (const match of html.matchAll(TAG_RE)) {
90
+ const tag = match[1].toLowerCase();
91
+ const attrs = match[2] ?? "";
92
+ const blockId = BLOCK_ID_RE.exec(attrs)?.[1];
93
+ const blockType = BLOCK_TYPE_RE.exec(attrs)?.[1];
94
+ if (blockId !== undefined || blockType !== undefined) {
95
+ current = {
96
+ blockType: blockType ?? "",
97
+ ...(blockId !== undefined ? { blockId } : {}),
98
+ paths: [],
99
+ };
100
+ blocks.push(current);
101
+ }
102
+ const target = TARGET_RE.exec(attrs)?.[1];
103
+ if (target !== undefined && current) {
104
+ current.paths.push(target);
105
+ if (VOID_ELEMENTS.has(tag))
106
+ (current.voidPaths ??= []).push(target);
107
+ }
108
+ }
109
+ return blocks;
110
+ }
111
+ /** Does this prop hold anything a renderer would draw? */
112
+ function hasContent(value) {
113
+ if (value === null || value === undefined)
114
+ return false;
115
+ if (typeof value === "string")
116
+ return value.trim() !== "";
117
+ if (Array.isArray(value))
118
+ return value.length > 0;
119
+ return true;
120
+ }
121
+ /** `cards[2].title` → `cards[].title`; anything else unchanged. */
122
+ function generalizeIndex(path) {
123
+ return path.replace(/\[\d+\]/g, "[]");
124
+ }
125
+ /**
126
+ * The item fields a list is expected to have markers for, given what is in it.
127
+ *
128
+ * Two refinements of "every field the manifest declares for this list", both
129
+ * for the same reason the top-level fields already consult `props`: an element
130
+ * that was never drawn cannot carry a marker, and reporting it is a finding
131
+ * nobody can act on.
132
+ *
133
+ * 1. A field no item has a value for is not expected. `imageUrl` is optional on
134
+ * every card block here, and a page whose cards are all text would otherwise
135
+ * report a missing image marker on every one of them.
136
+ *
137
+ * 2. When the list is polymorphic — `discriminator` plus `itemFieldsByType`,
138
+ * the shape a `oneOf` schema derives and a permissive one has to declare —
139
+ * each item is measured against *its own* branch. TwoColumn is the case: a
140
+ * `type: "image"` child has `src` and `alt` and no `label`, and asking it for
141
+ * a button label is asking the renderer to draw a field the item does not
142
+ * have.
143
+ *
144
+ * With no props in hand there is nothing to narrow by, so every declared field
145
+ * is expected — the behaviour before this existed.
146
+ */
147
+ function expectedItemFields(listMeta, items) {
148
+ /*
149
+ * The discriminator is never content. It decides which shape the item is —
150
+ * `derivePolymorphicListField` deletes it from every branch it builds for
151
+ * exactly that reason — and a list whose metadata was declared rather than
152
+ * derived has nothing doing the same, so a `type` typed as a plain string
153
+ * arrives here looking like an ordinary text field nobody marked.
154
+ */
155
+ const isContent = ([key, meta]) => key !== listMeta.discriminator && needsMarker(meta);
156
+ const declared = Object.entries(listMeta.itemFields ?? {}).filter(isContent);
157
+ if (!items)
158
+ return declared;
159
+ const expected = new Map();
160
+ for (const raw of items) {
161
+ if (raw === null || typeof raw !== "object" || Array.isArray(raw))
162
+ continue;
163
+ const item = raw;
164
+ const branchKey = listMeta.discriminator ? String(item[listMeta.discriminator] ?? "") : "";
165
+ const fields = listMeta.itemFieldsByType?.[branchKey] ?? listMeta.itemFields ?? {};
166
+ for (const entry of Object.entries(fields)) {
167
+ if (!isContent(entry))
168
+ continue;
169
+ if (!hasContent(item[entry[0]]))
170
+ continue;
171
+ expected.set(entry[0], entry[1]);
172
+ }
173
+ }
174
+ return [...expected];
175
+ }
176
+ export function editableCoverage(manifest, blocks) {
177
+ const definitions = new Map(manifest.blocks.map((block) => [block.type, block]));
178
+ // Aggregate by type: a missing marker is a property of the component, not of
179
+ // the one instance that happened to render first.
180
+ const pathsByType = new Map();
181
+ const exampleIdByType = new Map();
182
+ const unknownBlockTypes = new Set();
183
+ /*
184
+ * Props that at least one instance of a type actually had. Only consulted
185
+ * when some instance supplied props at all — a caller that passes none gets
186
+ * the old behaviour, where every declared field is expected.
187
+ */
188
+ const filledByType = new Map();
189
+ const sawPropsForType = new Set();
190
+ const voidByType = new Map();
191
+ /** Every list item seen for a type, by list key — what `expectedItemFields` narrows by. */
192
+ const listItemsByType = new Map();
193
+ for (const block of blocks) {
194
+ if (!block.blockType)
195
+ continue;
196
+ if (!definitions.has(block.blockType)) {
197
+ unknownBlockTypes.add(block.blockType);
198
+ continue;
199
+ }
200
+ let paths = pathsByType.get(block.blockType);
201
+ if (!paths) {
202
+ paths = new Set();
203
+ pathsByType.set(block.blockType, paths);
204
+ if (block.blockId)
205
+ exampleIdByType.set(block.blockType, block.blockId);
206
+ }
207
+ for (const path of block.paths)
208
+ paths.add(generalizeIndex(path));
209
+ if (block.voidPaths?.length) {
210
+ let voids = voidByType.get(block.blockType);
211
+ if (!voids) {
212
+ voids = new Set();
213
+ voidByType.set(block.blockType, voids);
214
+ }
215
+ for (const path of block.voidPaths)
216
+ voids.add(generalizeIndex(path));
217
+ }
218
+ if (!block.props)
219
+ continue;
220
+ sawPropsForType.add(block.blockType);
221
+ let filled = filledByType.get(block.blockType);
222
+ if (!filled) {
223
+ filled = new Set();
224
+ filledByType.set(block.blockType, filled);
225
+ }
226
+ let lists = listItemsByType.get(block.blockType);
227
+ if (!lists) {
228
+ lists = new Map();
229
+ listItemsByType.set(block.blockType, lists);
230
+ }
231
+ for (const [key, value] of Object.entries(block.props)) {
232
+ if (hasContent(value))
233
+ filled.add(key);
234
+ if (Array.isArray(value)) {
235
+ const seen = lists.get(key);
236
+ if (seen)
237
+ seen.push(...value);
238
+ else
239
+ lists.set(key, [...value]);
240
+ }
241
+ }
242
+ }
243
+ let expected = 0;
244
+ let marked = 0;
245
+ const gaps = [];
246
+ for (const [blockType, paths] of pathsByType) {
247
+ const definition = definitions.get(blockType);
248
+ const { fields, listFields } = resolveManifestFieldMeta(definition);
249
+ const missing = [];
250
+ const missingItemFields = [];
251
+ const unmarkedLists = [];
252
+ const voids = voidByType.get(blockType) ?? new Set();
253
+ const markedOnVoidElement = [];
254
+ const knowsContent = sawPropsForType.has(blockType);
255
+ const filled = filledByType.get(blockType) ?? new Set();
256
+ for (const [key, meta] of Object.entries(fields)) {
257
+ if (!needsMarker(meta))
258
+ continue;
259
+ // Nothing drew it, so nothing could have marked it.
260
+ if (knowsContent && !filled.has(key))
261
+ continue;
262
+ expected += 1;
263
+ if (!paths.has(key)) {
264
+ missing.push(key);
265
+ }
266
+ else if (meta.kind === "image" && voids.has(key)) {
267
+ // Marked, found by the editor, and unable to hold the button.
268
+ markedOnVoidElement.push(key);
269
+ }
270
+ else {
271
+ marked += 1;
272
+ }
273
+ }
274
+ for (const [listKey, listMeta] of Object.entries(listFields)) {
275
+ const listPresent = [...paths].some((path) => path.startsWith(`${listKey}[]`));
276
+ const drawnItemFields = expectedItemFields(listMeta, knowsContent ? (listItemsByType.get(blockType)?.get(listKey) ?? []) : undefined);
277
+ if (drawnItemFields.length === 0)
278
+ continue;
279
+ // An empty list on every page is not an instrumentation question either,
280
+ // and with props in hand we can say so instead of guessing.
281
+ if (knowsContent && !filled.has(listKey))
282
+ continue;
283
+ if (!listPresent) {
284
+ // Could be an empty list on this page. Not counted against coverage.
285
+ unmarkedLists.push(listKey);
286
+ continue;
287
+ }
288
+ for (const [itemKey, itemMeta] of drawnItemFields) {
289
+ const path = `${listKey}[].${itemKey}`;
290
+ expected += 1;
291
+ if (!paths.has(path)) {
292
+ missingItemFields.push(path);
293
+ }
294
+ else if (itemMeta.kind === "image" && voids.has(path)) {
295
+ markedOnVoidElement.push(path);
296
+ }
297
+ else {
298
+ marked += 1;
299
+ }
300
+ }
301
+ }
302
+ if (missing.length > 0 ||
303
+ missingItemFields.length > 0 ||
304
+ unmarkedLists.length > 0 ||
305
+ markedOnVoidElement.length > 0) {
306
+ const exampleBlockId = exampleIdByType.get(blockType);
307
+ gaps.push({
308
+ blockType,
309
+ ...(exampleBlockId ? { exampleBlockId } : {}),
310
+ missing,
311
+ missingItemFields,
312
+ unmarkedLists,
313
+ markedOnVoidElement,
314
+ });
315
+ }
316
+ }
317
+ return { expected, marked, gaps, unknownBlockTypes: [...unknownBlockTypes] };
318
+ }
319
+ /** A report a person can read in a terminal. */
320
+ export function formatEditableCoverage(report) {
321
+ const lines = [];
322
+ const pct = report.expected === 0 ? 100 : Math.round((report.marked / report.expected) * 100);
323
+ lines.push(`editable fields marked: ${report.marked}/${report.expected} (${pct}%)`);
324
+ for (const gap of report.gaps) {
325
+ const where = gap.exampleBlockId ? ` (e.g. ${gap.exampleBlockId})` : "";
326
+ lines.push(` ${gap.blockType}${where}`);
327
+ for (const key of gap.missing)
328
+ lines.push(` unmarked ${key}`);
329
+ for (const key of gap.missingItemFields)
330
+ lines.push(` unmarked ${key}`);
331
+ for (const key of gap.markedOnVoidElement) {
332
+ lines.push(` marked on a void element (no button can mount) ${key}`);
333
+ }
334
+ for (const key of gap.unmarkedLists) {
335
+ lines.push(` no marker for any item of ${key}[] — empty on this page, or not instrumented`);
336
+ }
337
+ }
338
+ if (report.unknownBlockTypes.length > 0) {
339
+ lines.push(` not in the manifest, skipped: ${report.unknownBlockTypes.join(", ")}`);
340
+ }
341
+ return lines.join("\n");
342
+ }
@@ -7,6 +7,17 @@
7
7
  export declare function isImagePath(editablePath: string): boolean;
8
8
  /** Derives the companion alt-text path from an image path. Returns the path unchanged if it is not an image path. */
9
9
  export declare function toAltPath(editablePath: string): string;
10
+ /** Returns true if the editable path points to an image's alt text. */
11
+ export declare function isAltPath(editablePath: string): boolean;
12
+ /**
13
+ * Derives the image path an alt-text path belongs to — the inverse of
14
+ * `toAltPath`. Returns the path unchanged if it is not an alt path.
15
+ *
16
+ * Alt text has no node of its own in a rendered page: it is an attribute on the
17
+ * image. Anything that wants to *show* somebody where an alt-text problem is
18
+ * has to point at the image instead, and this is the only step between the two.
19
+ */
20
+ export declare function toImagePath(editablePath: string): string;
10
21
  /**
11
22
  * Return a copy of `props` with `value` written at `editablePath`, using
12
23
  * copy-on-write so the input (and every untouched subtree) is shared by reference.
@@ -13,6 +13,23 @@ export function isImagePath(editablePath) {
13
13
  export function toAltPath(editablePath) {
14
14
  return editablePath.replace(/imageUrl$/i, "imageAlt").replace(/\.src$/i, ".alt");
15
15
  }
16
+ /** Matches editable paths that point to an image's alt text. */
17
+ const ALT_PATH_RE = /(imageAlt|\.alt)$/i;
18
+ /** Returns true if the editable path points to an image's alt text. */
19
+ export function isAltPath(editablePath) {
20
+ return ALT_PATH_RE.test(editablePath);
21
+ }
22
+ /**
23
+ * Derives the image path an alt-text path belongs to — the inverse of
24
+ * `toAltPath`. Returns the path unchanged if it is not an alt path.
25
+ *
26
+ * Alt text has no node of its own in a rendered page: it is an attribute on the
27
+ * image. Anything that wants to *show* somebody where an alt-text problem is
28
+ * has to point at the image instead, and this is the only step between the two.
29
+ */
30
+ export function toImagePath(editablePath) {
31
+ return editablePath.replace(/imageAlt$/i, "imageUrl").replace(/\.alt$/i, ".src");
32
+ }
16
33
  /**
17
34
  * Parse an editable-target path into a flat list of access steps.
18
35
  *