@avocadostudio-ai/shared 0.5.1 → 0.6.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.
@@ -36,6 +36,8 @@ export declare const fieldMetaSchema: z.ZodObject<{
36
36
  }, z.core.$strip>>;
37
37
  multiline: z.ZodOptional<z.ZodBoolean>;
38
38
  referenceLabelKey: z.ZodOptional<z.ZodString>;
39
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
40
+ internal: z.ZodOptional<z.ZodBoolean>;
39
41
  inline: z.ZodOptional<z.ZodBoolean>;
40
42
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
41
43
  name: z.ZodString;
@@ -80,6 +82,8 @@ export declare const listFieldMetaSchema: z.ZodObject<{
80
82
  }, z.core.$strip>>;
81
83
  multiline: z.ZodOptional<z.ZodBoolean>;
82
84
  referenceLabelKey: z.ZodOptional<z.ZodString>;
85
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
86
+ internal: z.ZodOptional<z.ZodBoolean>;
83
87
  inline: z.ZodOptional<z.ZodBoolean>;
84
88
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
85
89
  name: z.ZodString;
@@ -123,6 +127,8 @@ export declare const listFieldMetaSchema: z.ZodObject<{
123
127
  }, z.core.$strip>>;
124
128
  multiline: z.ZodOptional<z.ZodBoolean>;
125
129
  referenceLabelKey: z.ZodOptional<z.ZodString>;
130
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
131
+ internal: z.ZodOptional<z.ZodBoolean>;
126
132
  inline: z.ZodOptional<z.ZodBoolean>;
127
133
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
128
134
  name: z.ZodString;
@@ -172,6 +178,8 @@ export declare const blockDefinitionSchema: z.ZodObject<{
172
178
  }, z.core.$strip>>;
173
179
  multiline: z.ZodOptional<z.ZodBoolean>;
174
180
  referenceLabelKey: z.ZodOptional<z.ZodString>;
181
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
182
+ internal: z.ZodOptional<z.ZodBoolean>;
175
183
  inline: z.ZodOptional<z.ZodBoolean>;
176
184
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
177
185
  name: z.ZodString;
@@ -216,6 +224,8 @@ export declare const blockDefinitionSchema: z.ZodObject<{
216
224
  }, z.core.$strip>>;
217
225
  multiline: z.ZodOptional<z.ZodBoolean>;
218
226
  referenceLabelKey: z.ZodOptional<z.ZodString>;
227
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
228
+ internal: z.ZodOptional<z.ZodBoolean>;
219
229
  inline: z.ZodOptional<z.ZodBoolean>;
220
230
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
221
231
  name: z.ZodString;
@@ -259,6 +269,8 @@ export declare const blockDefinitionSchema: z.ZodObject<{
259
269
  }, z.core.$strip>>;
260
270
  multiline: z.ZodOptional<z.ZodBoolean>;
261
271
  referenceLabelKey: z.ZodOptional<z.ZodString>;
272
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
273
+ internal: z.ZodOptional<z.ZodBoolean>;
262
274
  inline: z.ZodOptional<z.ZodBoolean>;
263
275
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
264
276
  name: z.ZodString;
@@ -314,6 +326,8 @@ export declare const blockManifestSchema: z.ZodObject<{
314
326
  }, z.core.$strip>>;
315
327
  multiline: z.ZodOptional<z.ZodBoolean>;
316
328
  referenceLabelKey: z.ZodOptional<z.ZodString>;
329
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
330
+ internal: z.ZodOptional<z.ZodBoolean>;
317
331
  inline: z.ZodOptional<z.ZodBoolean>;
318
332
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
319
333
  name: z.ZodString;
@@ -358,6 +372,8 @@ export declare const blockManifestSchema: z.ZodObject<{
358
372
  }, z.core.$strip>>;
359
373
  multiline: z.ZodOptional<z.ZodBoolean>;
360
374
  referenceLabelKey: z.ZodOptional<z.ZodString>;
375
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
376
+ internal: z.ZodOptional<z.ZodBoolean>;
361
377
  inline: z.ZodOptional<z.ZodBoolean>;
362
378
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
363
379
  name: z.ZodString;
@@ -401,6 +417,8 @@ export declare const blockManifestSchema: z.ZodObject<{
401
417
  }, z.core.$strip>>;
402
418
  multiline: z.ZodOptional<z.ZodBoolean>;
403
419
  referenceLabelKey: z.ZodOptional<z.ZodString>;
420
+ panelOnly: z.ZodOptional<z.ZodBoolean>;
421
+ internal: z.ZodOptional<z.ZodBoolean>;
404
422
  inline: z.ZodOptional<z.ZodBoolean>;
405
423
  decorators: z.ZodOptional<z.ZodArray<z.ZodObject<{
406
424
  name: z.ZodString;
@@ -466,7 +484,11 @@ export declare function validateManifestDefaultProps(blocks: BlockDefinition[]):
466
484
  *
467
485
  * - `propsSchema` decides *which* props exist. A declared entry for a key the
468
486
  * schema does not expose is ignored, not added — otherwise a stale manifest
469
- * would put a control on a prop the block cannot store.
487
+ * would put a control on a prop the block cannot store. The one exception is
488
+ * `internal: true`, which adds no control by definition: it is how a site
489
+ * names a prop the derivation skipped for being structural (a Contentful
490
+ * `sys`, a `__source` snapshot) so the coverage checkers stop reporting it as
491
+ * undescribed. Nothing else can be conjured this way.
470
492
  * - A declared entry is merged *over* the derived one, key by key, so a site
471
493
  * can supply just `multiline` and keep the label and options that were
472
494
  * inferred for it.
@@ -61,6 +61,15 @@ export const fieldMetaSchema = z.object({
61
61
  * label it and no sign that anything had been declared.
62
62
  */
63
63
  referenceLabelKey: z.string().optional(),
64
+ /*
65
+ * Both of these decide whether a surface *mentions* a field, and every one
66
+ * of those surfaces is on the far side of this boundary — the panel, the
67
+ * planner's schema summary, the coverage checkers. A key this schema omits
68
+ * is stripped rather than rejected, so an undeclared flag here means the
69
+ * declaration silently does nothing where it was supposed to act.
70
+ */
71
+ panelOnly: z.boolean().optional(),
72
+ internal: z.boolean().optional(),
64
73
  inline: z.boolean().optional(),
65
74
  decorators: z
66
75
  .array(z.object({ name: z.string().min(1), title: z.string().optional() }))
@@ -257,6 +266,29 @@ const FILE_MEDIA_TYPE_RE = /^application\/(?!json\b)|^text\/(?:csv|calendar)\b/i
257
266
  function isInternalPropKey(key) {
258
267
  return key.startsWith("_");
259
268
  }
269
+ /**
270
+ * The same statement the paragraph above makes, as a field rather than as an
271
+ * absence.
272
+ *
273
+ * These props used to be skipped outright, which kept them out of the panel
274
+ * and away from the planner — correct, and invisible to everything that reads
275
+ * the resolved metadata afterwards. `panelCoverage` is the one that noticed:
276
+ * a prop in the content and in no field set is an `orphan_prop`, and one
277
+ * Storyblok integration's `_uid` produced **~800 of them**, burying every real
278
+ * finding under a wall of noise that could not be closed. There was no way to
279
+ * close it, because "skipped" and "never declared" were the same thing.
280
+ *
281
+ * Emitting `internal: true` says it out loud instead. Every surface that must
282
+ * not mention the prop checks that flag, so nothing about the promise above
283
+ * changes; what changes is that the checkers can tell a known non-content prop
284
+ * from an undescribed one.
285
+ *
286
+ * `kind` is a formality here — nothing renders the field — so it does not
287
+ * matter that a `_key` and a `sys` object both come out as `text`.
288
+ */
289
+ function internalFieldMeta(key) {
290
+ return { kind: "text", label: labelFromKey(key), internal: true };
291
+ }
260
292
  function labelFromKey(key) {
261
293
  // ctaText → "CTA text", imageUrl → "Image", logoAlt → "Logo alt",
262
294
  // background_image → "Background image", video_url → "Video", src → "Src"
@@ -348,7 +380,11 @@ function deriveItemFields(itemSchema) {
348
380
  for (const [key, propSchema] of Object.entries(props)) {
349
381
  if (!isObject(propSchema))
350
382
  continue;
351
- if (isInternalPropKey(key) || isItemIdentityKey(key))
383
+ if (isInternalPropKey(key)) {
384
+ fields[key] = internalFieldMeta(key);
385
+ continue;
386
+ }
387
+ if (isItemIdentityKey(key))
352
388
  continue;
353
389
  // Skip opaque object/array props (they'd render as "[object Object]"), but
354
390
  // keep richtext docs — those self-identify and get the richtext editor.
@@ -443,8 +479,10 @@ export function deriveFieldMetaFromSchema(propsSchema) {
443
479
  for (const [key, propSchema] of Object.entries(props)) {
444
480
  if (!isObject(propSchema))
445
481
  continue;
446
- if (isInternalPropKey(key))
482
+ if (isInternalPropKey(key)) {
483
+ fields[key] = internalFieldMeta(key);
447
484
  continue;
485
+ }
448
486
  const type = typeof propSchema.type === "string" ? propSchema.type : undefined;
449
487
  // Array of a discriminated union (oneOf/anyOf of object branches) →
450
488
  // polymorphic list field: per-branch fields keyed by the discriminant value.
@@ -504,7 +542,11 @@ export function validateManifestDefaultProps(blocks) {
504
542
  *
505
543
  * - `propsSchema` decides *which* props exist. A declared entry for a key the
506
544
  * schema does not expose is ignored, not added — otherwise a stale manifest
507
- * would put a control on a prop the block cannot store.
545
+ * would put a control on a prop the block cannot store. The one exception is
546
+ * `internal: true`, which adds no control by definition: it is how a site
547
+ * names a prop the derivation skipped for being structural (a Contentful
548
+ * `sys`, a `__source` snapshot) so the coverage checkers stop reporting it as
549
+ * undescribed. Nothing else can be conjured this way.
508
550
  * - A declared entry is merged *over* the derived one, key by key, so a site
509
551
  * can supply just `multiline` and keep the label and options that were
510
552
  * inferred for it.
@@ -515,7 +557,20 @@ export function resolveManifestFieldMeta(definition) {
515
557
  const resolvedFields = {};
516
558
  for (const [key, derived] of Object.entries(fields)) {
517
559
  const declared = declaredFields[key];
518
- resolvedFields[key] = isObject(declared) ? { ...derived, ...declared } : derived;
560
+ const merged = isObject(declared) ? { ...derived, ...declared } : derived;
561
+ // The underscore convention is not negotiable by declaration: a site may
562
+ // label `_key` or change its kind, and it still may not hand it to a
563
+ // person, because the value typed there is the address a field-level
564
+ // publish patches by.
565
+ resolvedFields[key] = isInternalPropKey(key) ? { ...merged, internal: true } : merged;
566
+ }
567
+ for (const [key, declared] of Object.entries(declaredFields)) {
568
+ if (key in resolvedFields)
569
+ continue;
570
+ if (!isObject(declared) || declared.internal !== true)
571
+ continue;
572
+ const fm = declared;
573
+ resolvedFields[key] = { label: labelFromKey(key), ...fm, kind: fm.kind ?? "text" };
519
574
  }
520
575
  const declaredLists = isObject(definition.listFields) ? definition.listFields : {};
521
576
  const resolvedListFields = {};
@@ -116,6 +116,35 @@ export type FieldMeta = {
116
116
  * Use this one when there is no element at all.
117
117
  */
118
118
  panelOnly?: boolean;
119
+ /**
120
+ * This prop belongs to the system that stores the content, not to anyone
121
+ * editing it.
122
+ *
123
+ * Storyblok's `_uid`, Contentful's `sys.id`, a `__source` snapshot, a
124
+ * revision stamp: the publisher needs them, a person must never edit them,
125
+ * and the planner must never be told they exist. Until this flag there was
126
+ * no way to say so, so they rode along undeclared — which is not the same as
127
+ * absent. An undeclared prop is an `orphan_prop` to `panelCoverage`, and one
128
+ * integration's `_uid` produced **~800 of them**, burying every real finding
129
+ * under a wall of noise that could not be closed.
130
+ *
131
+ * Declaring the prop internal says the opposite of what leaving it out says:
132
+ * "this is known, it is not content, and nothing that talks to a human or a
133
+ * model should mention it." Concretely it is hidden from the property panel,
134
+ * absent from the schema summary the planner reads, not expected to carry a
135
+ * preview marker, never offered for translation, and not a coverage finding
136
+ * in either direction — neither an orphan nor a phantom.
137
+ *
138
+ * It is not access control. The value still rides in `props`, still
139
+ * round-trips through the draft, and is still what the adapter writes back;
140
+ * an operation that names one is refused the way any undeclared prop is,
141
+ * because the planner was never told about it.
142
+ *
143
+ * `panelOnly` is the weaker neighbour and means almost the reverse: *a
144
+ * person does edit this, just not on the page.* Reach for that one when the
145
+ * field has no element; reach for this one when the field has no audience.
146
+ */
147
+ internal?: boolean;
119
148
  /**
120
149
  * For `kind: "reference"`: the key inside the value holding something a
121
150
  * person can read.
@@ -49,6 +49,10 @@ const DRAWN_KINDS = new Set(["text", "richtext", "image"]);
49
49
  * button, and the panel offering one is the site saying the image is editable.
50
50
  */
51
51
  function needsMarker(meta) {
52
+ // Not content at all — a CMS's `_uid`, a revision stamp. Nobody edits it
53
+ // anywhere, so there is nothing on the page for it to be missing from.
54
+ if (meta.internal)
55
+ return false;
52
56
  // The site has said outright that nothing on the page draws this — a
53
57
  // `sectionId`, a `<video poster>`, an input's `placeholder`. See `panelOnly`.
54
58
  if (meta.panelOnly)
@@ -73,10 +77,19 @@ const VOID_ELEMENTS = new Set([
73
77
  "link", "meta", "param", "source", "track", "wbr",
74
78
  ]);
75
79
  /** One element: its tag name, and the raw attribute text, quotes respected. */
76
- const TAG_RE = /<([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
80
+ const TAG_RE = /<(\/?)([a-zA-Z][\w-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g;
77
81
  const BLOCK_ID_RE = /\bdata-block-id="([^"]*)"/;
78
82
  const BLOCK_TYPE_RE = /\bdata-block-type="([^"]*)"/;
79
83
  const TARGET_RE = /\bdata-editable-target="([^"]*)"/;
84
+ const SCOPE_RE = /\bdata-editable-scope="([^"]*)"/;
85
+ /** `items[3]` + `question` → `items[3].question`; `cards` + `[0].title` → `cards[0].title`. */
86
+ function joinPath(scope, path) {
87
+ if (scope === "")
88
+ return path;
89
+ if (path === "")
90
+ return scope;
91
+ return path.startsWith("[") ? `${scope}${path}` : `${scope}.${path}`;
92
+ }
80
93
  /**
81
94
  * Read the marked blocks out of a rendered page's HTML.
82
95
  *
@@ -90,9 +103,35 @@ const TARGET_RE = /\bdata-editable-target="([^"]*)"/;
90
103
  export function extractMarkedBlocks(html) {
91
104
  const blocks = [];
92
105
  let current = null;
106
+ /*
107
+ * The open elements that carry a `data-editable-scope`, innermost last, each
108
+ * remembered with the tag that will close it. A component rendering a list
109
+ * row marks a bare `question` and its parent supplies `items[3]`, so the
110
+ * path this file compares against the manifest exists only as the join —
111
+ * see `editableScopeProps`. Without the stack a scoped site's every field
112
+ * reads as missing, and the check that exists to catch a lost marker becomes
113
+ * the reason nobody runs it.
114
+ */
115
+ const scopes = [];
116
+ const depths = new Map();
93
117
  for (const match of html.matchAll(TAG_RE)) {
94
- const tag = match[1].toLowerCase();
95
- const attrs = match[2] ?? "";
118
+ const closing = match[1] === "/";
119
+ const tag = match[2].toLowerCase();
120
+ const attrs = match[3] ?? "";
121
+ const selfClosing = attrs.trimEnd().endsWith("/");
122
+ const isVoid = VOID_ELEMENTS.has(tag);
123
+ if (closing) {
124
+ const depth = (depths.get(tag) ?? 0) - 1;
125
+ depths.set(tag, depth);
126
+ while (scopes.length > 0) {
127
+ const top = scopes[scopes.length - 1];
128
+ if (top.tag === tag && top.depth === depth)
129
+ scopes.pop();
130
+ else
131
+ break;
132
+ }
133
+ continue;
134
+ }
96
135
  const blockId = BLOCK_ID_RE.exec(attrs)?.[1];
97
136
  const blockType = BLOCK_TYPE_RE.exec(attrs)?.[1];
98
137
  if (blockId !== undefined || blockType !== undefined) {
@@ -102,13 +141,29 @@ export function extractMarkedBlocks(html) {
102
141
  paths: [],
103
142
  };
104
143
  blocks.push(current);
144
+ // A path is scoped from the block down, so a scope opened outside this
145
+ // block must not reach into it.
146
+ scopes.length = 0;
105
147
  }
106
148
  const target = TARGET_RE.exec(attrs)?.[1];
107
149
  if (target !== undefined && current) {
108
- current.paths.push(target);
109
- if (VOID_ELEMENTS.has(tag))
110
- (current.voidPaths ??= []).push(target);
150
+ const path = scopes.reduce((acc, s) => joinPath(acc, s.scope), "");
151
+ const full = joinPath(path, target);
152
+ current.paths.push(full);
153
+ if (isVoid)
154
+ (current.voidPaths ??= []).push(full);
111
155
  }
156
+ if (isVoid || selfClosing)
157
+ continue;
158
+ const depth = depths.get(tag) ?? 0;
159
+ depths.set(tag, depth + 1);
160
+ const scope = SCOPE_RE.exec(attrs)?.[1];
161
+ // The scoping element's own marker is not inside its scope — a component
162
+ // that both scopes its children and marks a field of its own would
163
+ // otherwise prefix its own path with the scope it sets for them. That is
164
+ // why this comes after the target is recorded.
165
+ if (scope !== undefined && scope !== "")
166
+ scopes.push({ tag, scope, depth });
112
167
  }
113
168
  return blocks;
114
169
  }
@@ -58,7 +58,9 @@ function hasContent(value) {
58
58
  * had no way to use it. A finding with no remedy gets switched off.
59
59
  */
60
60
  export function deriveRowLabel(fields, item, index, options) {
61
- const candidates = Object.keys(fields).filter((k) => k !== options?.discriminator);
61
+ // An internal field is not content — `_uid` is a perfectly good string and
62
+ // would label every row in the list with a UUID.
63
+ const candidates = Object.keys(fields).filter((k) => k !== options?.discriminator && !fields[k].internal);
62
64
  for (const key of candidates) {
63
65
  if (fields[key].kind !== "text" && fields[key].kind !== "richtext")
64
66
  continue;
@@ -312,8 +314,12 @@ export function panelCoverage(manifest, pages, options) {
312
314
  }
313
315
  const branchKey = `${block.type}\u0000${listKey}\u0000${discriminantValue}`;
314
316
  let declared = declaredItemKeys.get(branchKey);
315
- if (!declared)
316
- declaredItemKeys.set(branchKey, (declared = new Set(Object.keys(fields))));
317
+ if (!declared) {
318
+ // An internal field is declared so that the props carrying it stop
319
+ // reading as orphans; it is not a promise that a row holds one, so
320
+ // it cannot be a phantom either.
321
+ declaredItemKeys.set(branchKey, (declared = new Set(Object.keys(fields).filter((k) => !fields[k].internal))));
322
+ }
317
323
  let seen = seenItemKeys.get(branchKey);
318
324
  if (!seen)
319
325
  seenItemKeys.set(branchKey, (seen = new Set()));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avocadostudio-ai/shared",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
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.5.1"
22
+ "@avocadostudio-ai/richtext": "^0.6.0"
23
23
  },
24
24
  "devDependencies": {
25
25
  "tsx": "^4.21.0",