@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.
- package/dist/component-descriptor.d.ts +117 -0
- package/dist/component-descriptor.js +20 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/validator.js +34 -12
- package/package.json +1 -1
|
@@ -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 (!
|
|
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 (!
|
|
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