@pantheon-systems/p1-content-validator 2.1.2 → 2.2.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/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @pantheon-systems/p1-content-validator
2
2
 
3
- Validates Puck component edit operations against a Collaborative State System (CSS) component
3
+ Validates Puck component edit operations against Pantheon's P1 component
4
4
  registry, so writes that don't match a component's registered prop shape are rejected before
5
5
  they reach a document.
6
6
 
@@ -25,7 +25,7 @@ const result = validateOps(ops, registry);
25
25
 
26
26
  Operations are checked against the registry entry for each component — unknown components,
27
27
  unknown props, and type mismatches are reported rather than silently written. This is the same
28
- validation the CSS backend applies to AI-assisted and API-driven edits.
28
+ validation the backend applies to AI-assisted and API-driven edits.
29
29
 
30
30
  Also exported:
31
31
 
@@ -1 +1 @@
1
- {"version":3,"file":"authority-enforcement.d.ts","sourceRoot":"","sources":["../src/authority-enforcement.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,mBAAmB,EACnB,iCAAiC,EAClC,MAAM,YAAY,CAAC;AAgCpB,wBAAgB,4BAA4B,CAC1C,KAAK,EAAE,iCAAiC,GACvC;IAAE,WAAW,EAAE,mBAAmB,EAAE,CAAA;CAAE,CA4ExC"}
1
+ {"version":3,"file":"authority-enforcement.d.ts","sourceRoot":"","sources":["../src/authority-enforcement.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAEV,mBAAmB,EACnB,iCAAiC,EAClC,MAAM,YAAY,CAAC;AAgDpB,wBAAgB,4BAA4B,CAC1C,KAAK,EAAE,iCAAiC,GACvC;IAAE,WAAW,EAAE,mBAAmB,EAAE,CAAA;CAAE,CA4ExC"}
@@ -1,35 +1,22 @@
1
- import { isAuthority, resolveSlotAuthority } from './localization.js';
1
+ import { isAuthority, resolveSlotAuthority, ROOT_SLOT_ID } from './localization.js';
2
2
  import { isPlainObject, resolvePropPath } from './guards.js';
3
- /**
4
- * Authority enforcement for writes against a translation document.
5
- *
6
- * A translation owns its `locale`-authority props and inherits its
7
- * `canonical`-authority props from the canonical it derives from. Writing a
8
- * `canonical`-authority prop outside the sync/reconcile workflow is reported as a
9
- * diagnostic at the caller's severity, never rejected here.
10
- *
11
- * This module is pure: the caller resolves the template snapshot and the
12
- * localization edge's per-prop overrides and passes them in.
13
- */
14
- /**
15
- * Resolves the component a prop-path op targets and returns its slot id, or
16
- * `undefined` when the path does not target a prop on a resolvable component.
17
- * Authority is keyed by slot id, so a component without one is not judged.
18
- */
19
3
  function resolveSlot(path, snapshot) {
4
+ const parts = path.split('.');
5
+ if (parts[0] === 'root' && parts.lastIndexOf('props') === 1) {
6
+ return { slotId: ROOT_SLOT_ID, propsIdx: 1, parts };
7
+ }
20
8
  const resolved = resolvePropPath(path, snapshot);
21
9
  if (resolved === undefined || typeof resolved.component.props.id !== 'string') {
22
10
  return undefined;
23
11
  }
24
12
  return { slotId: resolved.component.props.id, propsIdx: resolved.propsIdx, parts: resolved.parts };
25
13
  }
14
+ function isContentProp(propName) {
15
+ return propName !== 'id' && !propName.startsWith('_');
16
+ }
26
17
  export function validateTranslationAuthority(input) {
27
18
  const { operations, currentSnapshot, templateSnapshot, authorityOverrides = {}, slotAuthority = {}, severity = 'warning', } = input;
28
19
  const diagnostics = [];
29
- // Slot ids and prop names are caller-chosen keys arriving as parsed JSON, so both
30
- // maps are read through a Map: a missing key stays missing instead of resolving to
31
- // an Object.prototype member. A stored value that is not an authority is dropped,
32
- // leaving its prop on `canonical`.
33
20
  const overrides = new Map(Object.entries(authorityOverrides).map(([slotId, props]) => [
34
21
  slotId,
35
22
  new Map(Object.entries(props).filter(([, value]) => isAuthority(value))),
@@ -60,12 +47,11 @@ export function validateTranslationAuthority(input) {
60
47
  if (resolved === undefined)
61
48
  return;
62
49
  const { slotId, propsIdx, parts } = resolved;
63
- // Case A: the path ends at `.props` — the content is the whole props object.
64
50
  if (propsIdx === parts.length - 1) {
65
51
  if (!isPlainObject(op.content))
66
52
  return;
67
53
  for (const propName of Object.keys(op.content)) {
68
- if (propName === 'id')
54
+ if (!isContentProp(propName))
69
55
  continue;
70
56
  if (effectiveAuthority(slotId, propName) === 'canonical') {
71
57
  flag(opIndex, `${op.path}.${propName}`, slotId, propName);
@@ -73,9 +59,8 @@ export function validateTranslationAuthority(input) {
73
59
  }
74
60
  return;
75
61
  }
76
- // Case B: the path targets a single prop — `.props.<name>`.
77
62
  const propName = parts[propsIdx + 1];
78
- if (propName === 'id')
63
+ if (!isContentProp(propName))
79
64
  return;
80
65
  if (effectiveAuthority(slotId, propName) === 'canonical') {
81
66
  flag(opIndex, op.path, slotId, propName);
package/dist/guards.js CHANGED
@@ -1,20 +1,9 @@
1
- /**
2
- * Shape guards and path reads shared by the validators. Every entry point takes
3
- * `unknown` — snapshots, templates, and operations all arrive as parsed JSON.
4
- */
5
- /** Whether a value is a non-null, non-array object. */
6
1
  export function isPlainObject(value) {
7
2
  return value !== null && typeof value === 'object' && !Array.isArray(value);
8
3
  }
9
- /** Whether a value has the shape of a component: a string `type` and a props object. */
10
4
  export function isComponentShape(value) {
11
5
  return isPlainObject(value) && typeof value.type === 'string' && isPlainObject(value.props);
12
6
  }
13
- /**
14
- * Reads the value at a dot-notation path (`content.0.props.title`), treating a
15
- * numeric segment as an array index. An empty path reads the root. Resolves to
16
- * undefined when a segment is absent or the path runs into a non-object.
17
- */
18
7
  export function getAtPath(obj, path) {
19
8
  if (path === '')
20
9
  return obj;
@@ -30,20 +19,9 @@ export function getAtPath(obj, path) {
30
19
  return cur[key];
31
20
  }, obj);
32
21
  }
33
- /**
34
- * Resolves the component whose prop a path writes to.
35
- *
36
- * Components nest through their slot props, so a path can carry several `props`
37
- * segments (`content.0.props.items.1.props.title`). The last one belongs to the
38
- * component being written; the earlier ones are its ancestors.
39
- *
40
- * Resolves to undefined when the path names no component before its props, or when
41
- * the snapshot holds something other than a component there.
42
- */
43
22
  export function resolvePropPath(path, snapshot) {
44
23
  const parts = path.split('.');
45
24
  const propsIdx = parts.lastIndexOf('props');
46
- // A prop write has at least one segment before 'props' (the component path).
47
25
  if (propsIdx <= 0) {
48
26
  return undefined;
49
27
  }
package/dist/index.d.ts CHANGED
@@ -2,6 +2,6 @@ export { validateOps } from './validator.js';
2
2
  export { fetchRegistry, listRegistryVersions, snapshotToComponentSchema, registryComponentKey, componentNameFromPath, } from './registry.js';
3
3
  export { validateDocumentStructure } from './structure-validator.js';
4
4
  export { validateTranslationAuthority } from './authority-enforcement.js';
5
- export { resolveTranslatable, resolveSlotAuthority, resolveSlotAuthorityMap, isAuthority, AUTHORITIES, DEFAULT_AUTHORITY, } from './localization.js';
5
+ export { resolveTranslatable, resolveSlotAuthority, resolveSlotAuthorityMap, isAuthority, AUTHORITIES, DEFAULT_AUTHORITY, ROOT_SLOT_ID, } from './localization.js';
6
6
  export type { EditOperation, Authority, ComponentSchema, ComponentField, FieldOption, ValidationError, ValidateInput, FetchRegistryOpts, TemplateComponent, TemplateSnapshot, StructuralConformanceError, ValidateStructureInput, AuthoritySeverity, AuthorityOverrideMap, AuthorityDiagnostic, ValidateTranslationAuthorityInput, } from './types.js';
7
7
  //# sourceMappingURL=index.d.ts.map
@@ -1 +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,EACL,aAAa,EACb,oBAAoB,EACpB,yBAAyB,EACzB,oBAAoB,EACpB,qBAAqB,GACtB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AACrE,OAAO,EAAE,4BAA4B,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EACL,mBAAmB,EACnB,oBAAoB,EACpB,uBAAuB,EACvB,WAAW,EACX,WAAW,EACX,iBAAiB,GAClB,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,aAAa,EACb,SAAS,EACT,eAAe,EACf,cAAc,EACd,WAAW,EACX,eAAe,EACf,aAAa,EACb,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,EAChB,0BAA0B,EAC1B,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,mBAAmB,EACnB,iCAAiC,GAClC,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,gBAAgB,CAAC;AAC7C,OAAO,EACL,aAAa,EACb,oBAAoB,EACpB,yBAAyB,EACzB,oBAAoB,EACpB,qBAAqB,GACtB,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,yBAAyB,EAAE,MAAM,0BAA0B,CAAC;AACrE,OAAO,EAAE,4BAA4B,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EACL,mBAAmB,EACnB,oBAAoB,EACpB,uBAAuB,EACvB,WAAW,EACX,WAAW,EACX,iBAAiB,EACjB,YAAY,GACb,MAAM,mBAAmB,CAAC;AAC3B,YAAY,EACV,aAAa,EACb,SAAS,EACT,eAAe,EACf,cAAc,EACd,WAAW,EACX,eAAe,EACf,aAAa,EACb,iBAAiB,EACjB,iBAAiB,EACjB,gBAAgB,EAChB,0BAA0B,EAC1B,sBAAsB,EACtB,iBAAiB,EACjB,oBAAoB,EACpB,mBAAmB,EACnB,iCAAiC,GAClC,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -2,4 +2,4 @@ export { validateOps } from './validator.js';
2
2
  export { fetchRegistry, listRegistryVersions, snapshotToComponentSchema, registryComponentKey, componentNameFromPath, } from './registry.js';
3
3
  export { validateDocumentStructure } from './structure-validator.js';
4
4
  export { validateTranslationAuthority } from './authority-enforcement.js';
5
- export { resolveTranslatable, resolveSlotAuthority, resolveSlotAuthorityMap, isAuthority, AUTHORITIES, DEFAULT_AUTHORITY, } from './localization.js';
5
+ export { resolveTranslatable, resolveSlotAuthority, resolveSlotAuthorityMap, isAuthority, AUTHORITIES, DEFAULT_AUTHORITY, ROOT_SLOT_ID, } from './localization.js';
@@ -15,6 +15,11 @@ import type { Authority } from './types.js';
15
15
  export declare const AUTHORITIES: readonly ["canonical", "locale"];
16
16
  /** The authority a slot falls back to when the template declares none. */
17
17
  export declare const DEFAULT_AUTHORITY: Authority;
18
+ /**
19
+ * The slot id root props are keyed by. Authority and translatability are keyed by
20
+ * the slot id of the component owning a prop; root props belong to no component.
21
+ */
22
+ export declare const ROOT_SLOT_ID = "__root__";
18
23
  /** Whether a value is one of the two authorities. */
19
24
  export declare function isAuthority(value: unknown): value is Authority;
20
25
  /**
@@ -1 +1 @@
1
- {"version":3,"file":"localization.d.ts","sourceRoot":"","sources":["../src/localization.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAG5C;;;;;;;;;;;GAWG;AAEH,gDAAgD;AAChD,eAAO,MAAM,WAAW,kCAAmC,CAAC;AAE5D,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,EAAE,SAAuB,CAAC;AAExD,qDAAqD;AACrD,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,SAAS,CAE9D;AAmBD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,OAAO,EACjB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,GACf,OAAO,CAMT;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,gBAAgB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,CAGzF;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,gBAAgB,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAM5F"}
1
+ {"version":3,"file":"localization.d.ts","sourceRoot":"","sources":["../src/localization.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,YAAY,CAAC;AAG5C;;;;;;;;;;;GAWG;AAEH,gDAAgD;AAChD,eAAO,MAAM,WAAW,kCAAmC,CAAC;AAE5D,0EAA0E;AAC1E,eAAO,MAAM,iBAAiB,EAAE,SAAuB,CAAC;AAExD;;;GAGG;AACH,eAAO,MAAM,YAAY,aAAa,CAAC;AAEvC,qDAAqD;AACrD,wBAAgB,WAAW,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,SAAS,CAE9D;AAmBD;;;;;;;GAOG;AACH,wBAAgB,mBAAmB,CACjC,QAAQ,EAAE,OAAO,EACjB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,GACf,OAAO,CAMT;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,gBAAgB,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,GAAG,SAAS,CAGzF;AAED;;;;;GAKG;AACH,wBAAgB,uBAAuB,CAAC,gBAAgB,EAAE,OAAO,GAAG,MAAM,CAAC,MAAM,EAAE,SAAS,CAAC,CAM5F"}
@@ -1,29 +1,10 @@
1
1
  import { isPlainObject } from './guards.js';
2
- /**
3
- * Localization resolvers — the single source of truth for two independent
4
- * properties of the localization model:
5
- *
6
- * - `translatable`: whether a prop holds natural-language text a human would
7
- * translate. A per-(slotId, propName) flag that defaults to true, stored once
8
- * on the canonical page (it applies to every language variant) and resolved
9
- * via `resolveTranslatable`.
10
- * - `authority`: a property of the RELATIONSHIP between a translation and its
11
- * canonical. The per-slot default is declared on the template's
12
- * `_localeAuthority` map and resolved via `resolveSlotAuthority`.
13
- */
14
- /** Every authority a slot or prop can carry. */
15
2
  export const AUTHORITIES = ['canonical', 'locale'];
16
- /** The authority a slot falls back to when the template declares none. */
17
3
  export const DEFAULT_AUTHORITY = 'canonical';
18
- /** Whether a value is one of the two authorities. */
4
+ export const ROOT_SLOT_ID = '__root__';
19
5
  export function isAuthority(value) {
20
6
  return AUTHORITIES.includes(value);
21
7
  }
22
- /**
23
- * Reads one of the localization config maps off a snapshot's `root.props`. Absent
24
- * maps and malformed snapshots read as an empty map, so a resolver's fallback is
25
- * the same whether the map is missing or the snapshot is not content-shaped.
26
- */
27
8
  function localeConfigMap(snapshot, key) {
28
9
  if (!isPlainObject(snapshot)) {
29
10
  return {};
@@ -35,14 +16,6 @@ function localeConfigMap(snapshot, key) {
35
16
  const map = root.props[key];
36
17
  return isPlainObject(map) ? map : {};
37
18
  }
38
- /**
39
- * Resolves whether a prop is translatable from the canonical page snapshot's
40
- * `root.props._localeTranslatable` map, keyed by slot id then prop name. The flag
41
- * applies to every language variant, so it lives on the canonical only. A prop is
42
- * translatable unless an entry is explicitly stored as `false`; absent entries,
43
- * absent maps, malformed snapshots, and non-boolean stored values all resolve to
44
- * true.
45
- */
46
19
  export function resolveTranslatable(snapshot, slotId, propName) {
47
20
  const slot = localeConfigMap(snapshot, '_localeTranslatable')[slotId];
48
21
  if (!isPlainObject(slot)) {
@@ -50,22 +23,10 @@ export function resolveTranslatable(snapshot, slotId, propName) {
50
23
  }
51
24
  return slot[propName] !== false;
52
25
  }
53
- /**
54
- * Resolves the per-slot authority default from a template snapshot's
55
- * `root.props._localeAuthority` map, keyed by slot id. Slots with no declared
56
- * authority, absent maps, malformed snapshots, and unrecognized stored values
57
- * all resolve to `canonical`.
58
- */
59
26
  export function resolveSlotAuthority(templateSnapshot, slotId) {
60
27
  const value = localeConfigMap(templateSnapshot, '_localeAuthority')[slotId];
61
28
  return isAuthority(value) ? value : DEFAULT_AUTHORITY;
62
29
  }
63
- /**
64
- * Resolves a template's whole `root.props._localeAuthority` map, keyed by slot id.
65
- * Slots storing an unrecognized value are omitted, so a slot present in the result
66
- * carries a declared authority and a slot absent from it falls back to
67
- * `DEFAULT_AUTHORITY`. Absent maps and malformed snapshots resolve to no slots.
68
- */
69
30
  export function resolveSlotAuthorityMap(templateSnapshot) {
70
31
  return Object.fromEntries(Object.entries(localeConfigMap(templateSnapshot, '_localeAuthority')).filter((entry) => isAuthority(entry[1])));
71
32
  }
@@ -23,26 +23,26 @@ export declare function componentNameFromPath(path: string): string;
23
23
  */
24
24
  export declare function snapshotToComponentSchema(snapshot: Record<string, unknown>): ComponentSchema | null;
25
25
  /**
26
- * Fetch and cache all component schemas from the CSS registry.
26
+ * Fetch and cache all component schemas from the registry.
27
27
  *
28
28
  * Makes two types of requests:
29
29
  * 1. GET /api/sites/{siteId}/branches/{branchId}/documents?pathPrefix=_registry%2Fcomponents%2F
30
30
  * 2. GET .../versions/latest for each component document
31
31
  *
32
- * Results are cached per (cssBaseUrl, siteId, branchId) with a 5-minute TTL.
32
+ * Results are cached per (ccrBaseUrl, siteId, branchId) with a 5-minute TTL.
33
33
  * If the cache is fresh, no network calls are made.
34
34
  */
35
- export declare function fetchRegistry(cssBaseUrl: string, siteId: string, branchId: string, opts: FetchRegistryOpts): Promise<Record<string, ComponentSchema>>;
35
+ export declare function fetchRegistry(ccrBaseUrl: string, siteId: string, branchId: string, opts: FetchRegistryOpts): Promise<Record<string, ComponentSchema>>;
36
36
  /**
37
37
  * Metadata-only listing: returns one entry per component with its document ID.
38
38
  * Useful for cache invalidation checks — compare IDs against a local cache
39
39
  * to detect which component schemas have been updated without fetching bodies.
40
40
  *
41
- * Note: the CSS list endpoint does not currently expose versionId; the document
41
+ * Note: the list endpoint does not currently expose versionId; the document
42
42
  * id is returned as a stable identifier. Full version-id tracking requires a
43
43
  * backend extension (tracked separately).
44
44
  */
45
- export declare function listRegistryVersions(cssBaseUrl: string, siteId: string, branchId: string, opts: FetchRegistryOpts): Promise<{
45
+ export declare function listRegistryVersions(ccrBaseUrl: string, siteId: string, branchId: string, opts: FetchRegistryOpts): Promise<{
46
46
  name: string;
47
47
  versionId: string;
48
48
  }[]>;
package/dist/registry.js CHANGED
@@ -1,49 +1,10 @@
1
- // ---------------------------------------------------------------------------
2
- // Registry key normalization
3
- //
4
- // Document paths (including "_registry/components/{Name}") are lowercased
5
- // server-side on write (see workers/src/services/document-types.ts
6
- // normalizePath), but the component's real, original-case name is preserved
7
- // in the descriptor snapshot body's own `name` field. Lookups against the
8
- // registry must therefore be case-insensitive, while the schema's `name`
9
- // (what's stored/displayed to callers) stays the true original case.
10
- //
11
- // Mirrors the `registryComponentKey` pattern established in p1-chatbot /
12
- // puck-css-integration#122 for the client-side registry — same name, same
13
- // approach, so both sides of the registry stay consistent.
14
- // ---------------------------------------------------------------------------
15
1
  export function registryComponentKey(name) {
16
2
  return name.toLowerCase();
17
3
  }
18
4
  const REGISTRY_COMPONENTS_PATH_PREFIX = '_registry/components/';
19
- /**
20
- * Derives a component's fallback name from its registry document path
21
- * (e.g. "_registry/components/leadcapture" -> "leadcapture"). This is a
22
- * fallback only — paths are lowercased server-side, so the result does not
23
- * reflect the component's real casing (e.g. "LeadCapture"). The descriptor
24
- * snapshot's own `name` field (see `snapshotToComponentSchema`) is the
25
- * source of truth for the real, original-case name.
26
- */
27
5
  export function componentNameFromPath(path) {
28
6
  return path.slice(REGISTRY_COMPONENTS_PATH_PREFIX.length);
29
7
  }
30
- // ---------------------------------------------------------------------------
31
- // Shared snapshot → ComponentSchema transformation
32
- // Used by both fetchRegistry (raw fetch path) and McpApiClient.fetchRegistrySchemas
33
- // (circuit-breaker-wrapped path) to ensure consistent extraction logic.
34
- // ---------------------------------------------------------------------------
35
- /**
36
- * Returns null when the descriptor carries no usable `name`.
37
- *
38
- * There used to be a fallback to the path-derived name here. A document path
39
- * cannot carry a component's casing reliably, so that fallback invented one and
40
- * advertised it as a valid component type — teaching every consumer (and every
41
- * agent calling list_components) a casing Puck may not resolve. A descriptor
42
- * with no `name` is corrupt; callers must skip and log it rather than guess.
43
- *
44
- * This holds however paths are stored: the descriptor body is the source of
45
- * truth for casing, so the path is never the place to recover it from.
46
- */
47
8
  export function snapshotToComponentSchema(snapshot) {
48
9
  if (typeof snapshot.name !== 'string' || snapshot.name === '') {
49
10
  return null;
@@ -63,30 +24,17 @@ export function snapshotToComponentSchema(snapshot) {
63
24
  };
64
25
  }
65
26
  const cache = new Map();
66
- const TTL_MS = 5 * 60 * 1000; // 5 minutes
67
- function cacheKey(cssBaseUrl, siteId, branchId) {
68
- return `${cssBaseUrl}:${siteId}:${branchId}`;
27
+ const TTL_MS = 5 * 60 * 1000;
28
+ function cacheKey(ccrBaseUrl, siteId, branchId) {
29
+ return `${ccrBaseUrl}:${siteId}:${branchId}`;
69
30
  }
70
- // ---------------------------------------------------------------------------
71
- // Public API
72
- // ---------------------------------------------------------------------------
73
- /**
74
- * Fetch and cache all component schemas from the CSS registry.
75
- *
76
- * Makes two types of requests:
77
- * 1. GET /api/sites/{siteId}/branches/{branchId}/documents?pathPrefix=_registry%2Fcomponents%2F
78
- * 2. GET .../versions/latest for each component document
79
- *
80
- * Results are cached per (cssBaseUrl, siteId, branchId) with a 5-minute TTL.
81
- * If the cache is fresh, no network calls are made.
82
- */
83
- export async function fetchRegistry(cssBaseUrl, siteId, branchId, opts) {
84
- const key = cacheKey(cssBaseUrl, siteId, branchId);
31
+ export async function fetchRegistry(ccrBaseUrl, siteId, branchId, opts) {
32
+ const key = cacheKey(ccrBaseUrl, siteId, branchId);
85
33
  const cached = cache.get(key);
86
34
  if (cached !== undefined && Date.now() - cached.cachedAt < TTL_MS) {
87
35
  return cached.schemas;
88
36
  }
89
- const base = cssBaseUrl.replace(/\/$/, '');
37
+ const base = ccrBaseUrl.replace(/\/$/, '');
90
38
  const headers = {
91
39
  'Content-Type': 'application/json',
92
40
  'X-API-Key': opts.token,
@@ -99,7 +47,6 @@ export async function fetchRegistry(cssBaseUrl, siteId, branchId, opts) {
99
47
  }
100
48
  const { documents } = (await listRes.json());
101
49
  if (documents.length === 0) {
102
- // Cache the empty result so we don't re-hit listDocuments on every call within the TTL
103
50
  cache.set(key, { cachedAt: Date.now(), schemas: {} });
104
51
  return {};
105
52
  }
@@ -121,23 +68,13 @@ export async function fetchRegistry(cssBaseUrl, siteId, branchId, opts) {
121
68
  schemas[registryComponentKey(schema.name)] = schema;
122
69
  }
123
70
  catch {
124
- // Skip components that fail to fetch — don't block the rest
125
71
  }
126
72
  }));
127
73
  cache.set(key, { cachedAt: Date.now(), schemas });
128
74
  return schemas;
129
75
  }
130
- /**
131
- * Metadata-only listing: returns one entry per component with its document ID.
132
- * Useful for cache invalidation checks — compare IDs against a local cache
133
- * to detect which component schemas have been updated without fetching bodies.
134
- *
135
- * Note: the CSS list endpoint does not currently expose versionId; the document
136
- * id is returned as a stable identifier. Full version-id tracking requires a
137
- * backend extension (tracked separately).
138
- */
139
- export async function listRegistryVersions(cssBaseUrl, siteId, branchId, opts) {
140
- const base = cssBaseUrl.replace(/\/$/, '');
76
+ export async function listRegistryVersions(ccrBaseUrl, siteId, branchId, opts) {
77
+ const base = ccrBaseUrl.replace(/\/$/, '');
141
78
  const headers = {
142
79
  'Content-Type': 'application/json',
143
80
  'X-API-Key': opts.token,
@@ -151,6 +88,6 @@ export async function listRegistryVersions(cssBaseUrl, siteId, branchId, opts) {
151
88
  const { documents } = (await res.json());
152
89
  return documents.map((doc) => ({
153
90
  name: componentNameFromPath(doc.path),
154
- versionId: doc.id, // document id as proxy until backend exposes versionId
91
+ versionId: doc.id,
155
92
  }));
156
93
  }
@@ -35,7 +35,6 @@ function pinMapOf(template) {
35
35
  }
36
36
  return isPlainObject(props._pinMap) ? props._pinMap : {};
37
37
  }
38
- // A pinned slot is a component with a string props.id whose _pinMap entry is strictly true.
39
38
  function pinnedSlots(list, pinMap) {
40
39
  const slots = [];
41
40
  for (const component of list) {
@@ -46,7 +45,6 @@ function pinnedSlots(list, pinMap) {
46
45
  }
47
46
  return slots;
48
47
  }
49
- // Document content is the top-level content[]; when absent it falls back to root.props.content.
50
48
  function documentContentOf(documentSnapshot) {
51
49
  if (!isPlainObject(documentSnapshot)) {
52
50
  return [];
@@ -80,8 +78,6 @@ function indexOfId(list, id) {
80
78
  }
81
79
  return -1;
82
80
  }
83
- // Within one list, pinned slots must keep their template-relative order. A slot absent
84
- // from this document list (present elsewhere or missing) neither errors nor advances the chain.
85
81
  function checkListOrder(slots, documentList, errors) {
86
82
  let lastFoundIndex = -1;
87
83
  slots.forEach((slot, expectedIndex) => {
@@ -103,39 +99,10 @@ function checkListOrder(slots, documentList, errors) {
103
99
  lastFoundIndex = actualIndex;
104
100
  });
105
101
  }
106
- /**
107
- * Validates a document snapshot against a content-shaped template by slot-id membership.
108
- *
109
- * A template component is a pinned slot when it has a string `props.id` and
110
- * `root.props._pinMap[id]` is strictly `true`. Slots are read from the template's
111
- * `content[]` and each `zones[key][]`. A document conforms when:
112
- *
113
- * 1. Presence: every pinned slot id appears among the document's component ids,
114
- * collected from the top-level `content[]` (or `root.props.content` when the
115
- * top-level array is absent) and every `zones[key][]`.
116
- * 2. Order: within each list, the pinned slots found in that list keep the template's
117
- * relative order. A pinned slot found in a different list than the template placed it
118
- * in raises no order error and does not advance that list's order chain.
119
- *
120
- * Matching is by id, so a same-typed local component never satisfies a pinned slot and a
121
- * duplicated type cannot mask a missing one. A template that is not a content-shaped
122
- * snapshot (missing `content` array, malformed `root`/`_pinMap`, or the legacy
123
- * `{ components }` manifest) pins nothing, and every document conforms.
124
- *
125
- * Never throws: every property access is type-guarded, and malformed input yields an
126
- * empty error list rather than an exception.
127
- *
128
- * @param input - Document snapshot and the template snapshot to validate against
129
- * @returns Object with an array of structural conformance errors (empty when valid)
130
- */
131
102
  export function validateDocumentStructure(input) {
132
103
  const { documentSnapshot, templateSnapshot } = input;
133
104
  const errors = [];
134
- // Only a content-shaped snapshot pins slots; anything else conforms unconditionally.
135
105
  if (!isPlainObject(templateSnapshot) || !Array.isArray(templateSnapshot.content)) {
136
- // A template bound to live documents that is not content-shaped disables
137
- // structural validation for all of them; surface it so operators can catch
138
- // a broken template deployment rather than have every document silently pass.
139
106
  console.warn('[p1-content-validator] Template snapshot is not content-shaped; skipping structural validation and treating the document as conforming.');
140
107
  return { errors };
141
108
  }
package/dist/validator.js CHANGED
@@ -1,13 +1,7 @@
1
1
  import { registryComponentKey } from './registry.js';
2
2
  import { isComponentShape, resolvePropPath } from './guards.js';
3
- // ---------------------------------------------------------------------------
4
- // Internal helpers
5
- // ---------------------------------------------------------------------------
6
- // UUID v4: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
7
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;
8
- // Puck type-prefixed: {ComponentType}-{uuid-v4}
9
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;
10
- // ULID: 26 Crockford base32 chars (legacy — MCP server previously generated these)
11
5
  const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
12
6
  function isValidPuckId(value) {
13
7
  if (typeof value !== 'string' || value === '')
@@ -22,21 +16,15 @@ function allowedPropsForSchema(schema) {
22
16
  ...(schema.allowedAdditionalProps ?? []),
23
17
  ]);
24
18
  }
25
- /** Returns the field definition for a prop key, or undefined if not found. */
26
19
  function findField(schema, propKey) {
27
20
  return schema.fields?.find((f) => f.name === propKey);
28
21
  }
29
- /**
30
- * Validates a prop value against a select/radio field's allowed options.
31
- * Only fires when the field has options defined and the value is a string.
32
- */
33
22
  function validateEnumValue(field, value, componentType, propKey, opIndex, path, errors) {
34
23
  if ((field.type !== 'select' && field.type !== 'radio') ||
35
24
  field.options === undefined ||
36
25
  field.options.length === 0) {
37
26
  return;
38
27
  }
39
- // Only validate primitives — skip array/object values (e.g. stats array items)
40
28
  if (typeof value !== 'string' && typeof value !== 'number' && typeof value !== 'boolean')
41
29
  return;
42
30
  const allowedValues = field.options.map((o) => o.value);
@@ -51,7 +39,6 @@ function validateEnumValue(field, value, componentType, propKey, opIndex, path,
51
39
  }
52
40
  }
53
41
  function validateComponent(comp, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage) {
54
- // readOnly is a Puck runtime-managed sibling — writers must not set it
55
42
  if ('readOnly' in comp) {
56
43
  errors.push({
57
44
  opIndex,
@@ -60,9 +47,6 @@ function validateComponent(comp, registry, opIndex, path, errors, zonesKey, warn
60
47
  message: `"readOnly" at "${path}" is a Puck runtime field and must not be set by writers.`,
61
48
  });
62
49
  }
63
- // Every Puck component must have a valid id in its props — it is how the
64
- // editor tracks the component instance. Accept UUID v4, type-prefixed UUID v4,
65
- // or ULID; reject arbitrary strings like "roger".
66
50
  if (!('id' in comp.props)) {
67
51
  errors.push({
68
52
  opIndex,
@@ -90,27 +74,6 @@ function validateComponent(comp, registry, opIndex, path, errors, zonesKey, warn
90
74
  });
91
75
  return;
92
76
  }
93
- // The descriptor's own `name` is the source of truth for a component type's
94
- // casing. The lookup above is case-insensitive on purpose, and must stay that
95
- // way: it is how a mis-cased type gets *found* so the error below can name the
96
- // casing the writer should have used. Making it exact would degrade this into
97
- // a bare unknown_component_type and lose the actionable hint.
98
- //
99
- // Matching loosely is not licence to write loosely. Puck resolves `type` by
100
- // exact key lookup into config.components with no normalisation and no guard
101
- // (verified in @puckeditor/core@0.21.1 — chunk-EBISZQTK.mjs:4124 reads
102
- // `config.components[item.type]` then dereferences `.fields` unguarded, so a
103
- // miss throws), which means a document holding "quoteblock" against a config
104
- // keyed "QuoteBlock" breaks the editor and the published page even though
105
- // every prop on it validated fine.
106
- //
107
- // Reject rather than silently canonicalise: a writer that meant a different
108
- // component should hear about it, and naming the expected casing makes the fix
109
- // mechanical.
110
- //
111
- // `schema.name` is required for in-repo callers but validateOps is a public
112
- // boundary — a hand-built registry may omit it, and there is no canonical
113
- // casing to hold the writer to in that case.
114
77
  if (typeof schema.name === 'string' && schema.name !== '' && comp.type !== schema.name) {
115
78
  errors.push({
116
79
  opIndex,
@@ -131,15 +94,13 @@ function validateComponent(comp, registry, opIndex, path, errors, zonesKey, warn
131
94
  message: `Unknown prop "${key}" on "${comp.type}" at "${path}.props". ` +
132
95
  `Allowed: ${[...allowedKeys].join(', ')}.`,
133
96
  });
134
- continue; // no point checking value if key is invalid
97
+ continue;
135
98
  }
136
- // Validate enum values for select/radio fields
137
99
  const field = findField(schema, key);
138
100
  if (field !== undefined) {
139
101
  validateEnumValue(field, value, comp.type, key, opIndex, `${path}.props.${key}`, errors);
140
102
  }
141
103
  }
142
- // Recurse into slot props: non-opaque array props containing component shapes
143
104
  const opaqueProps = new Set(schema.opaqueProps ?? []);
144
105
  for (const [key, val] of Object.entries(comp.props)) {
145
106
  if (opaqueProps.has(key) || key === 'id')
@@ -162,7 +123,6 @@ function validateContent(value, registry, opIndex, path, errors, zonesKey, warnO
162
123
  validateComponent(value, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage);
163
124
  return;
164
125
  }
165
- // Plain object (e.g., whole-document or sub-document): walk its keys
166
126
  for (const [key, val] of Object.entries(value)) {
167
127
  const childPath = path !== '' ? `${path}.${key}` : key;
168
128
  if (warnOnZonesUsage && key === zonesKey) {
@@ -176,13 +136,6 @@ function validateContent(value, registry, opIndex, path, errors, zonesKey, warnO
176
136
  validateContent(val, registry, opIndex, childPath, errors, zonesKey, warnOnZonesUsage);
177
137
  }
178
138
  }
179
- /**
180
- * Validates a targeted prop write (e.g. content.2.props.background = "roger")
181
- * using the current document snapshot to resolve the component type at the
182
- * parent path. Checks both the prop key and the value against the registry schema.
183
- *
184
- * Only fires when the op path contains ".props." and a snapshot is available.
185
- */
186
139
  function validatePropPathOp(op, opIndex, snapshot, registry, errors) {
187
140
  const resolved = resolvePropPath(op.path, snapshot);
188
141
  if (resolved === undefined)
@@ -190,15 +143,7 @@ function validatePropPathOp(op, opIndex, snapshot, registry, errors) {
190
143
  const { component: val, componentPath, propsIdx, parts } = resolved;
191
144
  const schema = registry[registryComponentKey(val.type)];
192
145
  if (!schema)
193
- return; // unknown type — caught elsewhere when the component is replaced
194
- // Deliberately no case-mismatch check here: `val.type` is read from the
195
- // stored snapshot, not from this op. A mis-cased type in the document is
196
- // damage some earlier write already did, and erroring on it would make the
197
- // document unrepairable — the prop edits that fix it would themselves be
198
- // rejected. Casing is enforced where a type actually enters the document
199
- // (validateComponent).
200
- // Case A: path ends exactly at .props — content is the full props object
201
- // e.g. replace content.0.props { id, label, visible }
146
+ return;
202
147
  if (propsIdx === parts.length - 1) {
203
148
  if (op.content === null || typeof op.content !== 'object' || Array.isArray(op.content))
204
149
  return;
@@ -241,10 +186,7 @@ function validatePropPathOp(op, opIndex, snapshot, registry, errors) {
241
186
  }
242
187
  return;
243
188
  }
244
- // Case B: path goes through .props.KEY — targeted single-prop write
245
- // e.g. replace content.0.props.background "roger"
246
189
  const propKey = parts[propsIdx + 1];
247
- // id is always an allowed key but validate its value format
248
190
  if (propKey === 'id') {
249
191
  if (!isValidPuckId(op.content)) {
250
192
  errors.push({
@@ -272,21 +214,11 @@ function validatePropPathOp(op, opIndex, snapshot, registry, errors) {
272
214
  validateEnumValue(field, op.content, val.type, propKey, opIndex, op.path, errors);
273
215
  }
274
216
  }
275
- // ---------------------------------------------------------------------------
276
- // Public API
277
- // ---------------------------------------------------------------------------
278
217
  export function validateOps(input) {
279
218
  const { operations, registry, currentSnapshot, config = {} } = input;
280
- // Graceful degradation: if registry is empty skip all validation.
281
219
  if (Object.keys(registry).length === 0) {
282
220
  return { errors: [] };
283
221
  }
284
- // Defense-in-depth: re-key the registry by its case-insensitive lookup key,
285
- // regardless of what casing the caller's keys already use. Registry
286
- // producers (fetchRegistry / McpApiClient.fetchRegistrySchemas) already key
287
- // by registryComponentKey, but validateOps normalizes independently so a
288
- // caller-supplied registry (e.g. a hand-built one, or one that changes its
289
- // convention later) can't silently reintroduce case-sensitive misses.
290
222
  const normalizedRegistry = {};
291
223
  for (const [key, schema] of Object.entries(registry)) {
292
224
  normalizedRegistry[registryComponentKey(key)] = schema;
@@ -317,12 +249,9 @@ export function validateOps(input) {
317
249
  message: `"${zonesKey}" is deprecated. Use slot props instead.`,
318
250
  });
319
251
  }
320
- // Snapshot-based validation: catches targeted prop writes where the content
321
- // is a primitive and the component type must be resolved from the live document.
322
252
  if (currentSnapshot !== undefined) {
323
253
  validatePropPathOp(op, opIndex, currentSnapshot, normalizedRegistry, errors);
324
254
  }
325
- // Content-shape validation: catches component replacements and slot content.
326
255
  validateContent(op.content, normalizedRegistry, opIndex, op.path, errors, zonesKey, warnOnZonesUsage);
327
256
  }
328
257
  return { errors };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pantheon-systems/p1-content-validator",
3
- "version": "2.1.2",
4
- "description": "Validates Puck component edit operations against the CSS component registry",
3
+ "version": "2.2.0",
4
+ "description": "Validates Puck component edit operations against the P1 component registry",
5
5
  "repository": {
6
6
  "type": "git",
7
7
  "url": "git+https://github.com/pantheon-systems/p1-platform.git",
@@ -36,7 +36,7 @@
36
36
  "license": "UNLICENSED",
37
37
  "homepage": "https://github.com/pantheon-systems/p1-platform/tree/main/packages/p1-content-validator",
38
38
  "scripts": {
39
- "build": "tsc",
39
+ "build": "pnpm run clean && tsc --emitDeclarationOnly && tsc --declaration false --declarationMap false --removeComments",
40
40
  "clean": "rm -rf dist *.tsbuildinfo",
41
41
  "lint": "eslint src tests",
42
42
  "lint:fix": "eslint src tests --fix",