@pantheon-systems/p1-content-validator 2.2.2-canary-20260928152900 → 2.3.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,117 @@
1
+ /**
2
+ * The stored shape of a registered component and of the registry index, and
3
+ * the content hash both are compared by.
4
+ */
5
+ /**
6
+ * Where a component's definition comes from: the site itself, an upstream it
7
+ * inherits from unchanged, or an upstream component the site redefines.
8
+ */
9
+ export type ComponentProvenance = 'site' | 'upstream' | 'overridden';
10
+ export interface FieldAiMeta {
11
+ instructions?: string;
12
+ required?: boolean;
13
+ schema?: unknown;
14
+ exclude?: boolean;
15
+ /** Set to false to disable streaming for this field (e.g., image URLs, hrefs — streaming partial URLs causes broken values) */
16
+ stream?: boolean;
17
+ /** Connects this field to the output of an inline AI tool (Puck AI plugin feature) */
18
+ bind?: string;
19
+ }
20
+ interface FieldBase {
21
+ name: string;
22
+ label?: string;
23
+ ai?: FieldAiMeta;
24
+ }
25
+ interface FieldOption {
26
+ label: string;
27
+ value: string | number | boolean;
28
+ }
29
+ interface TextField extends FieldBase {
30
+ type: 'text';
31
+ }
32
+ interface TextAreaField extends FieldBase {
33
+ type: 'textarea';
34
+ }
35
+ interface NumberField extends FieldBase {
36
+ type: 'number';
37
+ min?: number;
38
+ max?: number;
39
+ }
40
+ interface SelectField extends FieldBase {
41
+ type: 'select';
42
+ options: FieldOption[];
43
+ }
44
+ interface RadioField extends FieldBase {
45
+ type: 'radio';
46
+ options: FieldOption[];
47
+ }
48
+ interface ArrayField extends FieldBase {
49
+ type: 'array';
50
+ arrayFields: SerializedField[];
51
+ }
52
+ interface ObjectField extends FieldBase {
53
+ type: 'object';
54
+ objectFields: SerializedField[];
55
+ }
56
+ interface CustomField extends FieldBase {
57
+ type: 'custom';
58
+ }
59
+ export type SerializedField = TextField | TextAreaField | NumberField | SelectField | RadioField | ArrayField | ObjectField | CustomField;
60
+ export interface ComponentDescriptor {
61
+ name: string;
62
+ label: string;
63
+ fields: SerializedField[];
64
+ defaultProps: Record<string, unknown>;
65
+ ai?: {
66
+ instructions?: string;
67
+ defaultZone?: string;
68
+ exclude?: boolean;
69
+ };
70
+ slots?: Record<string, {
71
+ allowedComponents?: string[];
72
+ minItems?: number;
73
+ maxItems?: number;
74
+ }>;
75
+ provenance: ComponentProvenance;
76
+ descriptorHash: string;
77
+ upstreamHash?: string;
78
+ registeredAt: string;
79
+ }
80
+ export interface RegistryIndex {
81
+ siteId: string;
82
+ branchId: string;
83
+ updatedAt: string;
84
+ componentNames: string[];
85
+ provenance: Record<string, ComponentProvenance>;
86
+ /**
87
+ * Map of componentName → descriptorHash for fast change detection on startup.
88
+ * When present, the registry hook reads all hashes from this single index version
89
+ * instead of fetching each component document individually (N requests → 1).
90
+ */
91
+ hashes?: Record<string, string>;
92
+ /**
93
+ * Timestamp of the last full, per-component verification of every hash in
94
+ * `hashes` against its component document's own stored content.
95
+ * Once `verifiedAt` is older than REGISTRY_VERIFICATION_INTERVAL_MS, the
96
+ * hook forces a real per-component check instead of trusting `hashes`.
97
+ * Carried forward unchanged on fast-path runs; only refreshed
98
+ * when a full per-component verification actually just ran.
99
+ */
100
+ verifiedAt?: string;
101
+ }
102
+ /**
103
+ * djb2 hash over the canonical JSON of a descriptor.
104
+ *
105
+ * The following fields are intentionally excluded from the hash input:
106
+ * - `descriptorHash`: the hash itself must not feed into its own computation.
107
+ * - `registeredAt`: a timestamp that changes on every registration run; including
108
+ * it would make every component appear "changed" on every editor open.
109
+ * - `provenance`: a classification artifact derived from comparing site vs upstream
110
+ * schemas; it does not describe the component's own shape and should not affect
111
+ * whether a write is triggered.
112
+ * - `upstreamHash`: similarly a comparative artifact, not part of the schema identity.
113
+ */
114
+ export declare function hashDescriptor(descriptor: Omit<ComponentDescriptor, 'descriptorHash'> & {
115
+ descriptorHash?: string;
116
+ }): string;
117
+ export {};
@@ -0,0 +1,20 @@
1
+ function sortKeys(value) {
2
+ if (Array.isArray(value))
3
+ return value.map(sortKeys);
4
+ if (value !== null && typeof value === 'object') {
5
+ return Object.fromEntries(Object.entries(value)
6
+ .sort(([a], [b]) => a.localeCompare(b))
7
+ .map(([k, v]) => [k, sortKeys(v)]));
8
+ }
9
+ return value;
10
+ }
11
+ export function hashDescriptor(descriptor) {
12
+ const { descriptorHash: _ignored, registeredAt: _ts, provenance: _prov, upstreamHash: _up, ...hashable } = descriptor;
13
+ const json = JSON.stringify(sortKeys(hashable));
14
+ let hash = 5381;
15
+ for (let i = 0; i < json.length; i++) {
16
+ hash = ((hash << 5) + hash) ^ json.charCodeAt(i);
17
+ hash = hash >>> 0;
18
+ }
19
+ return hash.toString(16);
20
+ }
package/dist/index.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  export { validateOps } from './validator.js';
2
2
  export { fetchRegistry, listRegistryVersions, snapshotToComponentSchema, registryComponentKey, componentNameFromPath, } from './registry.js';
3
+ export { hashDescriptor } from './component-descriptor.js';
4
+ export type { ComponentDescriptor, ComponentProvenance, FieldAiMeta, RegistryIndex, SerializedField, } from './component-descriptor.js';
3
5
  export { validateDocumentStructure } from './structure-validator.js';
4
6
  export { validateTranslationAuthority } from './authority-enforcement.js';
5
7
  export { resolveTranslatable, resolveSlotAuthority, resolveSlotAuthorityMap, isAuthority, AUTHORITIES, DEFAULT_AUTHORITY, ROOT_SLOT_ID, } from './localization.js';
package/dist/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export { validateOps } from './validator.js';
2
2
  export { fetchRegistry, listRegistryVersions, snapshotToComponentSchema, registryComponentKey, componentNameFromPath, } from './registry.js';
3
+ export { hashDescriptor } from './component-descriptor.js';
3
4
  export { validateDocumentStructure } from './structure-validator.js';
4
5
  export { validateTranslationAuthority } from './authority-enforcement.js';
5
6
  export { resolveTranslatable, resolveSlotAuthority, resolveSlotAuthorityMap, isAuthority, AUTHORITIES, DEFAULT_AUTHORITY, ROOT_SLOT_ID, } from './localization.js';
package/dist/validator.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { registryComponentKey } from './registry.js';
2
- import { isComponentShape, resolvePropPath } from './guards.js';
2
+ import { getAtPath, isComponentShape, isPlainObject, resolvePropPath } from './guards.js';
3
3
  const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
4
4
  const PREFIXED_UUID_RE = /^[A-Za-z][A-Za-z0-9]*-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
5
5
  const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -8,6 +8,27 @@ function isValidPuckId(value) {
8
8
  return false;
9
9
  return UUID_V4_RE.test(value) || PREFIXED_UUID_RE.test(value) || ULID_RE.test(value);
10
10
  }
11
+ function isAcceptedId(value, existingIds) {
12
+ return isValidPuckId(value) || (typeof value === 'string' && existingIds.has(value));
13
+ }
14
+ function componentIds(value, ids = new Set()) {
15
+ if (Array.isArray(value)) {
16
+ for (const item of value)
17
+ componentIds(item, ids);
18
+ }
19
+ else if (isPlainObject(value)) {
20
+ if (isComponentShape(value) && typeof value.props.id === 'string')
21
+ ids.add(value.props.id);
22
+ for (const child of Object.values(value))
23
+ componentIds(child, ids);
24
+ }
25
+ return ids;
26
+ }
27
+ function idsHeldAt(snapshot, op) {
28
+ if (op.type !== 'replace' || snapshot === undefined)
29
+ return new Set();
30
+ return componentIds(getAtPath(snapshot, op.path.replace(/\.props$/, '')));
31
+ }
11
32
  function allowedPropsForSchema(schema) {
12
33
  return new Set([
13
34
  'id',
@@ -38,7 +59,7 @@ function validateEnumValue(field, value, componentType, propKey, opIndex, path,
38
59
  });
39
60
  }
40
61
  }
41
- function validateComponent(comp, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage) {
62
+ function validateComponent(comp, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage, existingIds) {
42
63
  if ('readOnly' in comp) {
43
64
  errors.push({
44
65
  opIndex,
@@ -55,7 +76,7 @@ function validateComponent(comp, registry, opIndex, path, errors, zonesKey, warn
55
76
  message: `Component "${comp.type}" at "${path}" is missing required prop "id". Every Puck component must have an id (UUID v4, {Type}-{uuid-v4}, or ULID).`,
56
77
  });
57
78
  }
58
- else if (!isValidPuckId(comp.props.id)) {
79
+ else if (!isAcceptedId(comp.props.id, existingIds)) {
59
80
  errors.push({
60
81
  opIndex,
61
82
  path: `${path}.props.id`,
@@ -106,21 +127,21 @@ function validateComponent(comp, registry, opIndex, path, errors, zonesKey, warn
106
127
  if (opaqueProps.has(key) || key === 'id')
107
128
  continue;
108
129
  if (Array.isArray(val) && val.some(isComponentShape)) {
109
- validateContent(val, registry, opIndex, `${path}.props.${key}`, errors, zonesKey, warnOnZonesUsage);
130
+ validateContent(val, registry, opIndex, `${path}.props.${key}`, errors, zonesKey, warnOnZonesUsage, existingIds);
110
131
  }
111
132
  }
112
133
  }
113
- function validateContent(value, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage) {
134
+ function validateContent(value, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage, existingIds) {
114
135
  if (value === null || typeof value !== 'object')
115
136
  return;
116
137
  if (Array.isArray(value)) {
117
138
  value.forEach((item, i) => {
118
- validateContent(item, registry, opIndex, `${path}.${i}`, errors, zonesKey, warnOnZonesUsage);
139
+ validateContent(item, registry, opIndex, `${path}.${i}`, errors, zonesKey, warnOnZonesUsage, existingIds);
119
140
  });
120
141
  return;
121
142
  }
122
143
  if (isComponentShape(value)) {
123
- validateComponent(value, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage);
144
+ validateComponent(value, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage, existingIds);
124
145
  return;
125
146
  }
126
147
  for (const [key, val] of Object.entries(value)) {
@@ -133,10 +154,10 @@ function validateContent(value, registry, opIndex, path, errors, zonesKey, warnO
133
154
  message: `"${zonesKey}" is deprecated. Use slot props instead.`,
134
155
  });
135
156
  }
136
- validateContent(val, registry, opIndex, childPath, errors, zonesKey, warnOnZonesUsage);
157
+ validateContent(val, registry, opIndex, childPath, errors, zonesKey, warnOnZonesUsage, existingIds);
137
158
  }
138
159
  }
139
- function validatePropPathOp(op, opIndex, snapshot, registry, errors) {
160
+ function validatePropPathOp(op, opIndex, snapshot, registry, errors, existingIds) {
140
161
  const resolved = resolvePropPath(op.path, snapshot);
141
162
  if (resolved === undefined)
142
163
  return;
@@ -157,7 +178,7 @@ function validatePropPathOp(op, opIndex, snapshot, registry, errors) {
157
178
  message: `Component "${val.type}" at "${componentPath}" is missing required prop "id".`,
158
179
  });
159
180
  }
160
- else if (!isValidPuckId(propsObj.id)) {
181
+ else if (!isAcceptedId(propsObj.id, existingIds)) {
161
182
  errors.push({
162
183
  opIndex,
163
184
  path: `${op.path}.id`,
@@ -232,6 +253,7 @@ export function validateOps(input) {
232
253
  continue;
233
254
  if (op.content === undefined)
234
255
  continue;
256
+ const existingIds = idsHeldAt(currentSnapshot, op);
235
257
  if (op.path.split('.').includes('readOnly')) {
236
258
  errors.push({
237
259
  opIndex,
@@ -250,9 +272,9 @@ export function validateOps(input) {
250
272
  });
251
273
  }
252
274
  if (currentSnapshot !== undefined) {
253
- validatePropPathOp(op, opIndex, currentSnapshot, normalizedRegistry, errors);
275
+ validatePropPathOp(op, opIndex, currentSnapshot, normalizedRegistry, errors, existingIds);
254
276
  }
255
- validateContent(op.content, normalizedRegistry, opIndex, op.path, errors, zonesKey, warnOnZonesUsage);
277
+ validateContent(op.content, normalizedRegistry, opIndex, op.path, errors, zonesKey, warnOnZonesUsage, existingIds);
256
278
  }
257
279
  return { errors };
258
280
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pantheon-systems/p1-content-validator",
3
- "version": "2.2.2-canary-20260928152900",
3
+ "version": "2.3.0",
4
4
  "description": "Validates Puck component edit operations against the P1 component registry",
5
5
  "repository": {
6
6
  "type": "git",