@pantheon-systems/p1-content-validator 1.0.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/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/registry.d.ts +27 -0
- package/dist/registry.d.ts.map +1 -0
- package/dist/registry.js +106 -0
- package/dist/types.d.ts +47 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +1 -0
- package/dist/validator.d.ts +5 -0
- package/dist/validator.d.ts.map +1 -0
- package/dist/validator.js +304 -0
- package/package.json +40 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export { validateOps } from './validator.js';
|
|
2
|
+
export { fetchRegistry, listRegistryVersions, snapshotToComponentSchema } from './registry.js';
|
|
3
|
+
export type { EditOperation, ComponentSchema, ComponentField, FieldOption, ValidationError, ValidateInput, FetchRegistryOpts, } from './types.js';
|
|
4
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EAAE,aAAa,EAAE,oBAAoB,EAAE,yBAAyB,EAAE,MAAM,eAAe,CAAC;AAC/F,YAAY,EACV,aAAa,EACb,eAAe,EACf,cAAc,EACd,WAAW,EACX,eAAe,EACf,aAAa,EACb,iBAAiB,GAClB,MAAM,YAAY,CAAC"}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { ComponentSchema, FetchRegistryOpts } from './types.js';
|
|
2
|
+
export declare function snapshotToComponentSchema(name: string, snapshot: Record<string, unknown>): ComponentSchema;
|
|
3
|
+
/**
|
|
4
|
+
* Fetch and cache all component schemas from the CSS registry.
|
|
5
|
+
*
|
|
6
|
+
* Makes two types of requests:
|
|
7
|
+
* 1. GET /api/sites/{siteId}/branches/{branchId}/documents?pathPrefix=_registry%2Fcomponents%2F
|
|
8
|
+
* 2. GET .../versions/latest for each component document
|
|
9
|
+
*
|
|
10
|
+
* Results are cached per (cssBaseUrl, siteId, branchId) with a 5-minute TTL.
|
|
11
|
+
* If the cache is fresh, no network calls are made.
|
|
12
|
+
*/
|
|
13
|
+
export declare function fetchRegistry(cssBaseUrl: string, siteId: string, branchId: string, opts: FetchRegistryOpts): Promise<Record<string, ComponentSchema>>;
|
|
14
|
+
/**
|
|
15
|
+
* Metadata-only listing: returns one entry per component with its document ID.
|
|
16
|
+
* Useful for cache invalidation checks — compare IDs against a local cache
|
|
17
|
+
* to detect which component schemas have been updated without fetching bodies.
|
|
18
|
+
*
|
|
19
|
+
* Note: the CSS list endpoint does not currently expose versionId; the document
|
|
20
|
+
* id is returned as a stable identifier. Full version-id tracking requires a
|
|
21
|
+
* backend extension (tracked separately).
|
|
22
|
+
*/
|
|
23
|
+
export declare function listRegistryVersions(cssBaseUrl: string, siteId: string, branchId: string, opts: FetchRegistryOpts): Promise<{
|
|
24
|
+
name: string;
|
|
25
|
+
versionId: string;
|
|
26
|
+
}[]>;
|
|
27
|
+
//# sourceMappingURL=registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"registry.d.ts","sourceRoot":"","sources":["../src/registry.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAkB,iBAAiB,EAAE,MAAM,YAAY,CAAC;AAQrF,wBAAgB,yBAAyB,CACvC,IAAI,EAAE,MAAM,EACZ,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,eAAe,CAcjB;AAsBD;;;;;;;;;GASG;AACH,wBAAsB,aAAa,CACjC,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAAC,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC,CAoD1C;AAED;;;;;;;;GAQG;AACH,wBAAsB,oBAAoB,CACxC,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,IAAI,EAAE,iBAAiB,GACtB,OAAO,CAAC;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,SAAS,EAAE,MAAM,CAAA;CAAE,EAAE,CAAC,CAwBhD"}
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Shared snapshot → ComponentSchema transformation
|
|
3
|
+
// Used by both fetchRegistry (raw fetch path) and McpApiClient.fetchRegistrySchemas
|
|
4
|
+
// (circuit-breaker-wrapped path) to ensure consistent extraction logic.
|
|
5
|
+
// ---------------------------------------------------------------------------
|
|
6
|
+
export function snapshotToComponentSchema(name, snapshot) {
|
|
7
|
+
return {
|
|
8
|
+
name,
|
|
9
|
+
defaultProps: snapshot.defaultProps ?? {},
|
|
10
|
+
allowedAdditionalProps: Array.isArray(snapshot.allowedAdditionalProps)
|
|
11
|
+
? snapshot.allowedAdditionalProps
|
|
12
|
+
: undefined,
|
|
13
|
+
opaqueProps: Array.isArray(snapshot.opaqueProps)
|
|
14
|
+
? snapshot.opaqueProps
|
|
15
|
+
: undefined,
|
|
16
|
+
fields: Array.isArray(snapshot.fields)
|
|
17
|
+
? snapshot.fields
|
|
18
|
+
: undefined,
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
const cache = new Map();
|
|
22
|
+
const TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
23
|
+
function cacheKey(cssBaseUrl, siteId, branchId) {
|
|
24
|
+
return `${cssBaseUrl}:${siteId}:${branchId}`;
|
|
25
|
+
}
|
|
26
|
+
// ---------------------------------------------------------------------------
|
|
27
|
+
// Public API
|
|
28
|
+
// ---------------------------------------------------------------------------
|
|
29
|
+
/**
|
|
30
|
+
* Fetch and cache all component schemas from the CSS registry.
|
|
31
|
+
*
|
|
32
|
+
* Makes two types of requests:
|
|
33
|
+
* 1. GET /api/sites/{siteId}/branches/{branchId}/documents?pathPrefix=_registry%2Fcomponents%2F
|
|
34
|
+
* 2. GET .../versions/latest for each component document
|
|
35
|
+
*
|
|
36
|
+
* Results are cached per (cssBaseUrl, siteId, branchId) with a 5-minute TTL.
|
|
37
|
+
* If the cache is fresh, no network calls are made.
|
|
38
|
+
*/
|
|
39
|
+
export async function fetchRegistry(cssBaseUrl, siteId, branchId, opts) {
|
|
40
|
+
const key = cacheKey(cssBaseUrl, siteId, branchId);
|
|
41
|
+
const cached = cache.get(key);
|
|
42
|
+
if (cached !== undefined && Date.now() - cached.cachedAt < TTL_MS) {
|
|
43
|
+
return cached.schemas;
|
|
44
|
+
}
|
|
45
|
+
const base = cssBaseUrl.replace(/\/$/, '');
|
|
46
|
+
const headers = {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
'X-API-Key': opts.token,
|
|
49
|
+
};
|
|
50
|
+
const listUrl = `${base}/api/sites/${siteId}/branches/${branchId}/documents` +
|
|
51
|
+
`?pathPrefix=${encodeURIComponent('_registry/components/')}`;
|
|
52
|
+
const listRes = await fetch(listUrl, { method: 'GET', headers, signal: opts.signal });
|
|
53
|
+
if (!listRes.ok) {
|
|
54
|
+
throw new Error(`fetchRegistry: list failed with ${String(listRes.status)}`);
|
|
55
|
+
}
|
|
56
|
+
const { documents } = (await listRes.json());
|
|
57
|
+
if (documents.length === 0) {
|
|
58
|
+
// Cache the empty result so we don't re-hit listDocuments on every call within the TTL
|
|
59
|
+
cache.set(key, { cachedAt: Date.now(), schemas: {} });
|
|
60
|
+
return {};
|
|
61
|
+
}
|
|
62
|
+
const schemas = {};
|
|
63
|
+
await Promise.all(documents.map(async (doc) => {
|
|
64
|
+
const name = doc.path.slice('_registry/components/'.length);
|
|
65
|
+
const versionUrl = `${base}/api/sites/${siteId}/branches/${branchId}/documents/${doc.id}/versions/latest`;
|
|
66
|
+
try {
|
|
67
|
+
const vRes = await fetch(versionUrl, { method: 'GET', headers, signal: opts.signal });
|
|
68
|
+
if (!vRes.ok)
|
|
69
|
+
return;
|
|
70
|
+
const { snapshot } = (await vRes.json());
|
|
71
|
+
schemas[name] = snapshotToComponentSchema(name, snapshot);
|
|
72
|
+
}
|
|
73
|
+
catch {
|
|
74
|
+
// Skip components that fail to fetch — don't block the rest
|
|
75
|
+
}
|
|
76
|
+
}));
|
|
77
|
+
cache.set(key, { cachedAt: Date.now(), schemas });
|
|
78
|
+
return schemas;
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Metadata-only listing: returns one entry per component with its document ID.
|
|
82
|
+
* Useful for cache invalidation checks — compare IDs against a local cache
|
|
83
|
+
* to detect which component schemas have been updated without fetching bodies.
|
|
84
|
+
*
|
|
85
|
+
* Note: the CSS list endpoint does not currently expose versionId; the document
|
|
86
|
+
* id is returned as a stable identifier. Full version-id tracking requires a
|
|
87
|
+
* backend extension (tracked separately).
|
|
88
|
+
*/
|
|
89
|
+
export async function listRegistryVersions(cssBaseUrl, siteId, branchId, opts) {
|
|
90
|
+
const base = cssBaseUrl.replace(/\/$/, '');
|
|
91
|
+
const headers = {
|
|
92
|
+
'Content-Type': 'application/json',
|
|
93
|
+
'X-API-Key': opts.token,
|
|
94
|
+
};
|
|
95
|
+
const listUrl = `${base}/api/sites/${siteId}/branches/${branchId}/documents` +
|
|
96
|
+
`?pathPrefix=${encodeURIComponent('_registry/components/')}`;
|
|
97
|
+
const res = await fetch(listUrl, { method: 'GET', headers, signal: opts.signal });
|
|
98
|
+
if (!res.ok) {
|
|
99
|
+
throw new Error(`listRegistryVersions: list failed with ${String(res.status)}`);
|
|
100
|
+
}
|
|
101
|
+
const { documents } = (await res.json());
|
|
102
|
+
return documents.map((doc) => ({
|
|
103
|
+
name: doc.path.slice('_registry/components/'.length),
|
|
104
|
+
versionId: doc.id, // document id as proxy until backend exposes versionId
|
|
105
|
+
}));
|
|
106
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface EditOperation {
|
|
2
|
+
type: 'add' | 'remove' | 'replace' | 'move' | 'reorder';
|
|
3
|
+
path: string;
|
|
4
|
+
content?: unknown;
|
|
5
|
+
index?: number;
|
|
6
|
+
fromIndex?: number;
|
|
7
|
+
toIndex?: number;
|
|
8
|
+
}
|
|
9
|
+
export interface FieldOption {
|
|
10
|
+
label: string;
|
|
11
|
+
value: string | number | boolean;
|
|
12
|
+
}
|
|
13
|
+
export interface ComponentField {
|
|
14
|
+
name: string;
|
|
15
|
+
type: string;
|
|
16
|
+
options?: FieldOption[];
|
|
17
|
+
}
|
|
18
|
+
export interface ComponentSchema {
|
|
19
|
+
name: string;
|
|
20
|
+
defaultProps: Record<string, unknown>;
|
|
21
|
+
allowedAdditionalProps?: string[];
|
|
22
|
+
opaqueProps?: string[];
|
|
23
|
+
/** Field definitions from the registry, used for enum value validation. */
|
|
24
|
+
fields?: ComponentField[];
|
|
25
|
+
}
|
|
26
|
+
export interface ValidationError {
|
|
27
|
+
opIndex: number;
|
|
28
|
+
path: string;
|
|
29
|
+
code: 'unknown_component_type' | 'invalid_prop_key' | 'invalid_prop_value' | 'missing_required_prop' | 'invalid_readonly_key' | 'deprecated_zones_usage';
|
|
30
|
+
message: string;
|
|
31
|
+
}
|
|
32
|
+
export interface FetchRegistryOpts {
|
|
33
|
+
token: string;
|
|
34
|
+
signal?: AbortSignal;
|
|
35
|
+
}
|
|
36
|
+
export interface ValidateInput {
|
|
37
|
+
operations: EditOperation[];
|
|
38
|
+
currentSnapshot?: Record<string, unknown>;
|
|
39
|
+
registry: Record<string, ComponentSchema>;
|
|
40
|
+
config?: {
|
|
41
|
+
rootKey?: string;
|
|
42
|
+
contentKey?: string;
|
|
43
|
+
zonesKey?: string;
|
|
44
|
+
warnOnZonesUsage?: boolean;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=types.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,aAAa;IAC5B,IAAI,EAAE,KAAK,GAAG,QAAQ,GAAG,SAAS,GAAG,MAAM,GAAG,SAAS,CAAC;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;CAClB;AAED,MAAM,WAAW,WAAW;IAC1B,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,GAAG,MAAM,GAAG,OAAO,CAAC;CAClC;AAED,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,WAAW,EAAE,CAAC;CACzB;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,sBAAsB,CAAC,EAAE,MAAM,EAAE,CAAC;IAClC,WAAW,CAAC,EAAE,MAAM,EAAE,CAAC;IACvB,2EAA2E;IAC3E,MAAM,CAAC,EAAE,cAAc,EAAE,CAAC;CAC3B;AAED,MAAM,WAAW,eAAe;IAC9B,OAAO,EAAE,MAAM,CAAC;IAChB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,EACA,wBAAwB,GACxB,kBAAkB,GAClB,oBAAoB,GACpB,uBAAuB,GACvB,sBAAsB,GACtB,wBAAwB,CAAC;IAC7B,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,MAAM,CAAC;IACd,MAAM,CAAC,EAAE,WAAW,CAAC;CACtB;AAED,MAAM,WAAW,aAAa;IAC5B,UAAU,EAAE,aAAa,EAAE,CAAC;IAC5B,eAAe,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC1C,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,eAAe,CAAC,CAAC;IAC1C,MAAM,CAAC,EAAE;QACP,OAAO,CAAC,EAAE,MAAM,CAAC;QACjB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;QAClB,gBAAgB,CAAC,EAAE,OAAO,CAAC;KAC5B,CAAC;CACH"}
|
package/dist/types.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"validator.d.ts","sourceRoot":"","sources":["../src/validator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAiB,aAAa,EAAE,eAAe,EAAmC,MAAM,YAAY,CAAC;AAyUjH,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG;IAAE,MAAM,EAAE,eAAe,EAAE,CAAA;CAAE,CAgD/E"}
|
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
function isPuckComponentShape(v) {
|
|
2
|
+
return (v !== null &&
|
|
3
|
+
typeof v === 'object' &&
|
|
4
|
+
!Array.isArray(v) &&
|
|
5
|
+
typeof v.type === 'string' &&
|
|
6
|
+
typeof v.props === 'object' &&
|
|
7
|
+
v.props !== null);
|
|
8
|
+
}
|
|
9
|
+
function getAtPath(obj, path) {
|
|
10
|
+
if (path === '')
|
|
11
|
+
return obj;
|
|
12
|
+
return path.split('.').reduce((cur, key) => {
|
|
13
|
+
if (cur === null || cur === undefined)
|
|
14
|
+
return undefined;
|
|
15
|
+
if (Array.isArray(cur)) {
|
|
16
|
+
const idx = parseInt(key, 10);
|
|
17
|
+
return isNaN(idx) ? undefined : cur[idx];
|
|
18
|
+
}
|
|
19
|
+
return cur[key];
|
|
20
|
+
}, obj);
|
|
21
|
+
}
|
|
22
|
+
// UUID v4: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
|
|
23
|
+
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;
|
|
24
|
+
// Puck type-prefixed: {ComponentType}-{uuid-v4}
|
|
25
|
+
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;
|
|
26
|
+
// ULID: 26 Crockford base32 chars (legacy — MCP server previously generated these)
|
|
27
|
+
const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
28
|
+
function isValidPuckId(value) {
|
|
29
|
+
if (typeof value !== 'string' || value === '')
|
|
30
|
+
return false;
|
|
31
|
+
return UUID_V4_RE.test(value) || PREFIXED_UUID_RE.test(value) || ULID_RE.test(value);
|
|
32
|
+
}
|
|
33
|
+
function allowedPropsForSchema(schema) {
|
|
34
|
+
return new Set([
|
|
35
|
+
'id',
|
|
36
|
+
...Object.keys(schema.defaultProps),
|
|
37
|
+
...(schema.fields?.map((f) => f.name) ?? []),
|
|
38
|
+
...(schema.allowedAdditionalProps ?? []),
|
|
39
|
+
]);
|
|
40
|
+
}
|
|
41
|
+
/** Returns the field definition for a prop key, or undefined if not found. */
|
|
42
|
+
function findField(schema, propKey) {
|
|
43
|
+
return schema.fields?.find((f) => f.name === propKey);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Validates a prop value against a select/radio field's allowed options.
|
|
47
|
+
* Only fires when the field has options defined and the value is a string.
|
|
48
|
+
*/
|
|
49
|
+
function validateEnumValue(field, value, componentType, propKey, opIndex, path, errors) {
|
|
50
|
+
if ((field.type !== 'select' && field.type !== 'radio') ||
|
|
51
|
+
field.options === undefined ||
|
|
52
|
+
field.options.length === 0) {
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
// Only validate primitives — skip array/object values (e.g. stats array items)
|
|
56
|
+
if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')
|
|
57
|
+
return;
|
|
58
|
+
const allowedValues = field.options.map((o) => o.value);
|
|
59
|
+
if (!allowedValues.includes(value)) {
|
|
60
|
+
errors.push({
|
|
61
|
+
opIndex,
|
|
62
|
+
path,
|
|
63
|
+
code: 'invalid_prop_value',
|
|
64
|
+
message: `Invalid value ${JSON.stringify(value)} for ${field.type} prop "${propKey}" on "${componentType}". ` +
|
|
65
|
+
`Allowed values: ${allowedValues.map((v) => JSON.stringify(v)).join(', ')}.`,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function validateComponent(comp, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage) {
|
|
70
|
+
// readOnly is a Puck runtime-managed sibling — writers must not set it
|
|
71
|
+
if ('readOnly' in comp) {
|
|
72
|
+
errors.push({
|
|
73
|
+
opIndex,
|
|
74
|
+
path: `${path}.readOnly`,
|
|
75
|
+
code: 'invalid_readonly_key',
|
|
76
|
+
message: `"readOnly" at "${path}" is a Puck runtime field and must not be set by writers.`,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
// Every Puck component must have a valid id in its props — it is how the
|
|
80
|
+
// editor tracks the component instance. Accept UUID v4, type-prefixed UUID v4,
|
|
81
|
+
// or ULID; reject arbitrary strings like "roger".
|
|
82
|
+
if (!('id' in comp.props)) {
|
|
83
|
+
errors.push({
|
|
84
|
+
opIndex,
|
|
85
|
+
path: `${path}.props`,
|
|
86
|
+
code: 'missing_required_prop',
|
|
87
|
+
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).`,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
else if (!isValidPuckId(comp.props.id)) {
|
|
91
|
+
errors.push({
|
|
92
|
+
opIndex,
|
|
93
|
+
path: `${path}.props.id`,
|
|
94
|
+
code: 'invalid_prop_value',
|
|
95
|
+
message: `Invalid id "${String(comp.props.id)}" on "${comp.type}" at "${path}". Must be UUID v4, type-prefixed UUID v4 (e.g. Hero-{uuid}), or ULID.`,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
const schema = registry[comp.type];
|
|
99
|
+
if (!schema) {
|
|
100
|
+
errors.push({
|
|
101
|
+
opIndex,
|
|
102
|
+
path,
|
|
103
|
+
code: 'unknown_component_type',
|
|
104
|
+
message: `Unknown component type "${comp.type}" at "${path}". ` +
|
|
105
|
+
`Use list_components to see available types: ${Object.keys(registry).join(', ')}.`,
|
|
106
|
+
});
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const allowedKeys = allowedPropsForSchema(schema);
|
|
110
|
+
for (const [key, value] of Object.entries(comp.props)) {
|
|
111
|
+
if (!allowedKeys.has(key)) {
|
|
112
|
+
errors.push({
|
|
113
|
+
opIndex,
|
|
114
|
+
path: `${path}.props.${key}`,
|
|
115
|
+
code: 'invalid_prop_key',
|
|
116
|
+
message: `Unknown prop "${key}" on "${comp.type}" at "${path}.props". ` +
|
|
117
|
+
`Allowed: ${[...allowedKeys].join(', ')}.`,
|
|
118
|
+
});
|
|
119
|
+
continue; // no point checking value if key is invalid
|
|
120
|
+
}
|
|
121
|
+
// Validate enum values for select/radio fields
|
|
122
|
+
const field = findField(schema, key);
|
|
123
|
+
if (field !== undefined) {
|
|
124
|
+
validateEnumValue(field, value, comp.type, key, opIndex, `${path}.props.${key}`, errors);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Recurse into slot props: non-opaque array props containing component shapes
|
|
128
|
+
const opaqueProps = new Set(schema.opaqueProps ?? []);
|
|
129
|
+
for (const [key, val] of Object.entries(comp.props)) {
|
|
130
|
+
if (opaqueProps.has(key) || key === 'id')
|
|
131
|
+
continue;
|
|
132
|
+
if (Array.isArray(val) && val.some(isPuckComponentShape)) {
|
|
133
|
+
validateContent(val, registry, opIndex, `${path}.props.${key}`, errors, zonesKey, warnOnZonesUsage);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
function validateContent(value, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage) {
|
|
138
|
+
if (value === null || typeof value !== 'object')
|
|
139
|
+
return;
|
|
140
|
+
if (Array.isArray(value)) {
|
|
141
|
+
value.forEach((item, i) => {
|
|
142
|
+
validateContent(item, registry, opIndex, `${path}.${i}`, errors, zonesKey, warnOnZonesUsage);
|
|
143
|
+
});
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (isPuckComponentShape(value)) {
|
|
147
|
+
validateComponent(value, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
// Plain object (e.g., whole-document or sub-document): walk its keys
|
|
151
|
+
for (const [key, val] of Object.entries(value)) {
|
|
152
|
+
const childPath = path !== '' ? `${path}.${key}` : key;
|
|
153
|
+
if (warnOnZonesUsage && key === zonesKey) {
|
|
154
|
+
errors.push({
|
|
155
|
+
opIndex,
|
|
156
|
+
path: childPath,
|
|
157
|
+
code: 'deprecated_zones_usage',
|
|
158
|
+
message: `"${zonesKey}" is deprecated. Use slot props instead.`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
validateContent(val, registry, opIndex, childPath, errors, zonesKey, warnOnZonesUsage);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Validates a targeted prop write (e.g. content.2.props.background = "roger")
|
|
166
|
+
* using the current document snapshot to resolve the component type at the
|
|
167
|
+
* parent path. Checks both the prop key and the value against the registry schema.
|
|
168
|
+
*
|
|
169
|
+
* Only fires when the op path contains ".props." and a snapshot is available.
|
|
170
|
+
*/
|
|
171
|
+
function validatePropPathOp(op, opIndex, snapshot, registry, errors) {
|
|
172
|
+
const parts = op.path.split('.');
|
|
173
|
+
const propsIdx = parts.indexOf('props');
|
|
174
|
+
// Must have at least one segment before 'props'
|
|
175
|
+
if (propsIdx <= 0)
|
|
176
|
+
return;
|
|
177
|
+
// Resolve the component from the snapshot using the path prefix before 'props'
|
|
178
|
+
const componentPath = parts.slice(0, propsIdx).join('.');
|
|
179
|
+
const val = getAtPath(snapshot, componentPath);
|
|
180
|
+
if (!isPuckComponentShape(val))
|
|
181
|
+
return;
|
|
182
|
+
const schema = registry[val.type];
|
|
183
|
+
if (!schema)
|
|
184
|
+
return; // unknown type — caught elsewhere when the component is replaced
|
|
185
|
+
// Case A: path ends exactly at .props — content is the full props object
|
|
186
|
+
// e.g. replace content.0.props { id, label, visible }
|
|
187
|
+
if (propsIdx === parts.length - 1) {
|
|
188
|
+
if (op.content === null || typeof op.content !== 'object' || Array.isArray(op.content))
|
|
189
|
+
return;
|
|
190
|
+
const propsObj = op.content;
|
|
191
|
+
const allowedKeys = allowedPropsForSchema(schema);
|
|
192
|
+
if (!('id' in propsObj)) {
|
|
193
|
+
errors.push({
|
|
194
|
+
opIndex,
|
|
195
|
+
path: op.path,
|
|
196
|
+
code: 'missing_required_prop',
|
|
197
|
+
message: `Component "${val.type}" at "${componentPath}" is missing required prop "id".`,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
else if (!isValidPuckId(propsObj.id)) {
|
|
201
|
+
errors.push({
|
|
202
|
+
opIndex,
|
|
203
|
+
path: `${op.path}.id`,
|
|
204
|
+
code: 'invalid_prop_value',
|
|
205
|
+
message: `Invalid id "${String(propsObj.id)}" at "${op.path}.id". Must be UUID v4, type-prefixed UUID v4, or ULID.`,
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
for (const [key, value] of Object.entries(propsObj)) {
|
|
209
|
+
if (key === 'id')
|
|
210
|
+
continue;
|
|
211
|
+
if (!allowedKeys.has(key)) {
|
|
212
|
+
errors.push({
|
|
213
|
+
opIndex,
|
|
214
|
+
path: `${op.path}.${key}`,
|
|
215
|
+
code: 'invalid_prop_key',
|
|
216
|
+
message: `Unknown prop "${key}" on "${val.type}" at "${op.path}". ` +
|
|
217
|
+
`Allowed: ${[...allowedKeys].join(', ')}.`,
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
else {
|
|
221
|
+
const field = findField(schema, key);
|
|
222
|
+
if (field !== undefined) {
|
|
223
|
+
validateEnumValue(field, value, val.type, key, opIndex, `${op.path}.${key}`, errors);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
// Case B: path goes through .props.KEY — targeted single-prop write
|
|
230
|
+
// e.g. replace content.0.props.background "roger"
|
|
231
|
+
const propKey = parts[propsIdx + 1];
|
|
232
|
+
// id is always an allowed key but validate its value format
|
|
233
|
+
if (propKey === 'id') {
|
|
234
|
+
if (!isValidPuckId(op.content)) {
|
|
235
|
+
errors.push({
|
|
236
|
+
opIndex,
|
|
237
|
+
path: op.path,
|
|
238
|
+
code: 'invalid_prop_value',
|
|
239
|
+
message: `Invalid id value "${String(op.content)}" at "${op.path}". Must be UUID v4, type-prefixed UUID v4, or ULID.`,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
const allowedKeys = allowedPropsForSchema(schema);
|
|
245
|
+
if (!allowedKeys.has(propKey)) {
|
|
246
|
+
errors.push({
|
|
247
|
+
opIndex,
|
|
248
|
+
path: op.path,
|
|
249
|
+
code: 'invalid_prop_key',
|
|
250
|
+
message: `Unknown prop "${propKey}" on "${val.type}" at "${componentPath}.props". ` +
|
|
251
|
+
`Allowed: ${[...allowedKeys].join(', ')}.`,
|
|
252
|
+
});
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
const field = findField(schema, propKey);
|
|
256
|
+
if (field !== undefined) {
|
|
257
|
+
validateEnumValue(field, op.content, val.type, propKey, opIndex, op.path, errors);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// Public API
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
export function validateOps(input) {
|
|
264
|
+
const { operations, registry, currentSnapshot, config = {} } = input;
|
|
265
|
+
// Graceful degradation: if registry is empty skip all validation.
|
|
266
|
+
if (Object.keys(registry).length === 0) {
|
|
267
|
+
return { errors: [] };
|
|
268
|
+
}
|
|
269
|
+
const zonesKey = config.zonesKey ?? 'zones';
|
|
270
|
+
const warnOnZonesUsage = config.warnOnZonesUsage ?? true;
|
|
271
|
+
const errors = [];
|
|
272
|
+
for (let opIndex = 0; opIndex < operations.length; opIndex++) {
|
|
273
|
+
const op = operations[opIndex];
|
|
274
|
+
if (op.type !== 'add' && op.type !== 'replace')
|
|
275
|
+
continue;
|
|
276
|
+
if (op.content === undefined)
|
|
277
|
+
continue;
|
|
278
|
+
if (op.path.split('.').includes('readOnly')) {
|
|
279
|
+
errors.push({
|
|
280
|
+
opIndex,
|
|
281
|
+
path: op.path,
|
|
282
|
+
code: 'invalid_readonly_key',
|
|
283
|
+
message: `Path "${op.path}" targets "readOnly" which is a Puck runtime field and must not be written to.`,
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (warnOnZonesUsage && (op.path === zonesKey || op.path.startsWith(`${zonesKey}.`))) {
|
|
288
|
+
errors.push({
|
|
289
|
+
opIndex,
|
|
290
|
+
path: op.path,
|
|
291
|
+
code: 'deprecated_zones_usage',
|
|
292
|
+
message: `"${zonesKey}" is deprecated. Use slot props instead.`,
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
// Snapshot-based validation: catches targeted prop writes where the content
|
|
296
|
+
// is a primitive and the component type must be resolved from the live document.
|
|
297
|
+
if (currentSnapshot !== undefined) {
|
|
298
|
+
validatePropPathOp(op, opIndex, currentSnapshot, registry, errors);
|
|
299
|
+
}
|
|
300
|
+
// Content-shape validation: catches component replacements and slot content.
|
|
301
|
+
validateContent(op.content, registry, opIndex, op.path, errors, zonesKey, warnOnZonesUsage);
|
|
302
|
+
}
|
|
303
|
+
return { errors };
|
|
304
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@pantheon-systems/p1-content-validator",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Validates Puck component edit operations against the CSS component registry",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/pantheon-systems/collaborative-state-system.git",
|
|
8
|
+
"directory": "packages/p1-content-validator"
|
|
9
|
+
},
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"types": "./dist/index.d.ts"
|
|
17
|
+
}
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"dist"
|
|
21
|
+
],
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"eslint": "^9.27.0",
|
|
24
|
+
"typescript": "^5.9.3",
|
|
25
|
+
"vitest": "^4.1.0",
|
|
26
|
+
"@pantheon-systems/eslint-config": "0.1.0"
|
|
27
|
+
},
|
|
28
|
+
"engines": {
|
|
29
|
+
"node": ">=20.0.0"
|
|
30
|
+
},
|
|
31
|
+
"license": "MIT",
|
|
32
|
+
"scripts": {
|
|
33
|
+
"build": "tsc",
|
|
34
|
+
"clean": "rm -rf dist *.tsbuildinfo",
|
|
35
|
+
"test": "vitest run",
|
|
36
|
+
"test:watch": "vitest",
|
|
37
|
+
"typecheck": "tsc --noEmit",
|
|
38
|
+
"lint": "eslint src tests"
|
|
39
|
+
}
|
|
40
|
+
}
|