@pantheon-systems/p1-content-validator 1.0.0 → 2.1.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,5 @@
1
+ import type { AuthorityDiagnostic, ValidateTranslationAuthorityInput } from './types.js';
2
+ export declare function validateTranslationAuthority(input: ValidateTranslationAuthorityInput): {
3
+ diagnostics: AuthorityDiagnostic[];
4
+ };
5
+ //# sourceMappingURL=authority-enforcement.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,85 @@
1
+ import { isAuthority, resolveSlotAuthority } from './localization.js';
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
+ function resolveSlot(path, snapshot) {
20
+ const resolved = resolvePropPath(path, snapshot);
21
+ if (resolved === undefined || typeof resolved.component.props.id !== 'string') {
22
+ return undefined;
23
+ }
24
+ return { slotId: resolved.component.props.id, propsIdx: resolved.propsIdx, parts: resolved.parts };
25
+ }
26
+ export function validateTranslationAuthority(input) {
27
+ const { operations, currentSnapshot, templateSnapshot, authorityOverrides = {}, slotAuthority = {}, severity = 'warning', } = input;
28
+ 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
+ const overrides = new Map(Object.entries(authorityOverrides).map(([slotId, props]) => [
34
+ slotId,
35
+ new Map(Object.entries(props).filter(([, value]) => isAuthority(value))),
36
+ ]));
37
+ const slotDefaults = new Map(Object.entries(slotAuthority).filter((entry) => isAuthority(entry[1])));
38
+ const effectiveAuthority = (slotId, propName) => overrides.get(slotId)?.get(propName) ??
39
+ slotDefaults.get(slotId) ??
40
+ resolveSlotAuthority(templateSnapshot, slotId);
41
+ const flag = (opIndex, path, slotId, propName) => {
42
+ diagnostics.push({
43
+ opIndex,
44
+ path,
45
+ code: 'canonical_authority_write',
46
+ severity,
47
+ slotId,
48
+ propName,
49
+ authority: 'canonical',
50
+ message: `Write to canonical-authority prop "${propName}" on slot "${slotId}" of a translation. ` +
51
+ `This prop is owned by the canonical; edit it there and let sync propagate the value.`,
52
+ });
53
+ };
54
+ operations.forEach((op, opIndex) => {
55
+ if (op.type !== 'add' && op.type !== 'replace')
56
+ return;
57
+ if (op.content === undefined)
58
+ return;
59
+ const resolved = resolveSlot(op.path, currentSnapshot);
60
+ if (resolved === undefined)
61
+ return;
62
+ const { slotId, propsIdx, parts } = resolved;
63
+ // Case A: the path ends at `.props` — the content is the whole props object.
64
+ if (propsIdx === parts.length - 1) {
65
+ if (!isPlainObject(op.content))
66
+ return;
67
+ for (const propName of Object.keys(op.content)) {
68
+ if (propName === 'id')
69
+ continue;
70
+ if (effectiveAuthority(slotId, propName) === 'canonical') {
71
+ flag(opIndex, `${op.path}.${propName}`, slotId, propName);
72
+ }
73
+ }
74
+ return;
75
+ }
76
+ // Case B: the path targets a single prop — `.props.<name>`.
77
+ const propName = parts[propsIdx + 1];
78
+ if (propName === 'id')
79
+ return;
80
+ if (effectiveAuthority(slotId, propName) === 'canonical') {
81
+ flag(opIndex, op.path, slotId, propName);
82
+ }
83
+ });
84
+ return { diagnostics };
85
+ }
@@ -0,0 +1,39 @@
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
+ /** A component in a content-shaped snapshot: a type and a props bag. */
6
+ export interface ComponentShape {
7
+ type: string;
8
+ props: Record<string, unknown>;
9
+ [key: string]: unknown;
10
+ }
11
+ /** Whether a value is a non-null, non-array object. */
12
+ export declare function isPlainObject(value: unknown): value is Record<string, unknown>;
13
+ /** Whether a value has the shape of a component: a string `type` and a props object. */
14
+ export declare function isComponentShape(value: unknown): value is ComponentShape;
15
+ /**
16
+ * Reads the value at a dot-notation path (`content.0.props.title`), treating a
17
+ * numeric segment as an array index. An empty path reads the root. Resolves to
18
+ * undefined when a segment is absent or the path runs into a non-object.
19
+ */
20
+ export declare function getAtPath(obj: unknown, path: string): unknown;
21
+ /** The component a prop-path op targets, and where its props begin in the path. */
22
+ export interface ResolvedPropPath {
23
+ component: ComponentShape;
24
+ componentPath: string;
25
+ propsIdx: number;
26
+ parts: string[];
27
+ }
28
+ /**
29
+ * Resolves the component whose prop a path writes to.
30
+ *
31
+ * Components nest through their slot props, so a path can carry several `props`
32
+ * segments (`content.0.props.items.1.props.title`). The last one belongs to the
33
+ * component being written; the earlier ones are its ancestors.
34
+ *
35
+ * Resolves to undefined when the path names no component before its props, or when
36
+ * the snapshot holds something other than a component there.
37
+ */
38
+ export declare function resolvePropPath(path: string, snapshot: unknown): ResolvedPropPath | undefined;
39
+ //# sourceMappingURL=guards.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"guards.d.ts","sourceRoot":"","sources":["../src/guards.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,wEAAwE;AACxE,MAAM,WAAW,cAAc;IAC7B,IAAI,EAAE,MAAM,CAAC;IACb,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IAC/B,CAAC,GAAG,EAAE,MAAM,GAAG,OAAO,CAAC;CACxB;AAED,uDAAuD;AACvD,wBAAgB,aAAa,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAE9E;AAED,wFAAwF;AACxF,wBAAgB,gBAAgB,CAAC,KAAK,EAAE,OAAO,GAAG,KAAK,IAAI,cAAc,CAExE;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,GAAG,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAW7D;AAED,mFAAmF;AACnF,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,cAAc,CAAC;IAC1B,aAAa,EAAE,MAAM,CAAC;IACtB,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,EAAE,MAAM,EAAE,CAAC;CACjB;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE,MAAM,EAAE,QAAQ,EAAE,OAAO,GAAG,gBAAgB,GAAG,SAAS,CAa7F"}
package/dist/guards.js ADDED
@@ -0,0 +1,56 @@
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
+ export function isPlainObject(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+ /** Whether a value has the shape of a component: a string `type` and a props object. */
10
+ export function isComponentShape(value) {
11
+ return isPlainObject(value) && typeof value.type === 'string' && isPlainObject(value.props);
12
+ }
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
+ export function getAtPath(obj, path) {
19
+ if (path === '')
20
+ return obj;
21
+ return path.split('.').reduce((cur, key) => {
22
+ if (cur === null || cur === undefined)
23
+ return undefined;
24
+ if (Array.isArray(cur)) {
25
+ const idx = parseInt(key, 10);
26
+ return isNaN(idx) ? undefined : cur[idx];
27
+ }
28
+ if (typeof cur !== 'object')
29
+ return undefined;
30
+ return cur[key];
31
+ }, obj);
32
+ }
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
+ export function resolvePropPath(path, snapshot) {
44
+ const parts = path.split('.');
45
+ const propsIdx = parts.lastIndexOf('props');
46
+ // A prop write has at least one segment before 'props' (the component path).
47
+ if (propsIdx <= 0) {
48
+ return undefined;
49
+ }
50
+ const componentPath = parts.slice(0, propsIdx).join('.');
51
+ const component = getAtPath(snapshot, componentPath);
52
+ if (!isComponentShape(component)) {
53
+ return undefined;
54
+ }
55
+ return { component, componentPath, propsIdx, parts };
56
+ }
package/dist/index.d.ts CHANGED
@@ -1,4 +1,7 @@
1
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';
2
+ export { fetchRegistry, listRegistryVersions, snapshotToComponentSchema, registryComponentKey, componentNameFromPath, } from './registry.js';
3
+ export { validateDocumentStructure } from './structure-validator.js';
4
+ export { validateTranslationAuthority } from './authority-enforcement.js';
5
+ export { resolveTranslatable, resolveSlotAuthority, resolveSlotAuthorityMap, isAuthority, AUTHORITIES, DEFAULT_AUTHORITY, } from './localization.js';
6
+ export type { EditOperation, Authority, ComponentSchema, ComponentField, FieldOption, ValidationError, ValidateInput, FetchRegistryOpts, TemplateComponent, TemplateSnapshot, StructuralConformanceError, ValidateStructureInput, AuthoritySeverity, AuthorityOverrideMap, AuthorityDiagnostic, ValidateTranslationAuthorityInput, } from './types.js';
4
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,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"}
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"}
package/dist/index.js CHANGED
@@ -1,2 +1,5 @@
1
1
  export { validateOps } from './validator.js';
2
- export { fetchRegistry, listRegistryVersions, snapshotToComponentSchema } from './registry.js';
2
+ export { fetchRegistry, listRegistryVersions, snapshotToComponentSchema, registryComponentKey, componentNameFromPath, } from './registry.js';
3
+ export { validateDocumentStructure } from './structure-validator.js';
4
+ export { validateTranslationAuthority } from './authority-enforcement.js';
5
+ export { resolveTranslatable, resolveSlotAuthority, resolveSlotAuthorityMap, isAuthority, AUTHORITIES, DEFAULT_AUTHORITY, } from './localization.js';
@@ -0,0 +1,43 @@
1
+ import type { Authority } from './types.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
+ export declare const AUTHORITIES: readonly ["canonical", "locale"];
16
+ /** The authority a slot falls back to when the template declares none. */
17
+ export declare const DEFAULT_AUTHORITY: Authority;
18
+ /** Whether a value is one of the two authorities. */
19
+ export declare function isAuthority(value: unknown): value is Authority;
20
+ /**
21
+ * Resolves whether a prop is translatable from the canonical page snapshot's
22
+ * `root.props._localeTranslatable` map, keyed by slot id then prop name. The flag
23
+ * applies to every language variant, so it lives on the canonical only. A prop is
24
+ * translatable unless an entry is explicitly stored as `false`; absent entries,
25
+ * absent maps, malformed snapshots, and non-boolean stored values all resolve to
26
+ * true.
27
+ */
28
+ export declare function resolveTranslatable(snapshot: unknown, slotId: string, propName: string): boolean;
29
+ /**
30
+ * Resolves the per-slot authority default from a template snapshot's
31
+ * `root.props._localeAuthority` map, keyed by slot id. Slots with no declared
32
+ * authority, absent maps, malformed snapshots, and unrecognized stored values
33
+ * all resolve to `canonical`.
34
+ */
35
+ export declare function resolveSlotAuthority(templateSnapshot: unknown, slotId: string): Authority;
36
+ /**
37
+ * Resolves a template's whole `root.props._localeAuthority` map, keyed by slot id.
38
+ * Slots storing an unrecognized value are omitted, so a slot present in the result
39
+ * carries a declared authority and a slot absent from it falls back to
40
+ * `DEFAULT_AUTHORITY`. Absent maps and malformed snapshots resolve to no slots.
41
+ */
42
+ export declare function resolveSlotAuthorityMap(templateSnapshot: unknown): Record<string, Authority>;
43
+ //# sourceMappingURL=localization.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,71 @@
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
+ export const AUTHORITIES = ['canonical', 'locale'];
16
+ /** The authority a slot falls back to when the template declares none. */
17
+ export const DEFAULT_AUTHORITY = 'canonical';
18
+ /** Whether a value is one of the two authorities. */
19
+ export function isAuthority(value) {
20
+ return AUTHORITIES.includes(value);
21
+ }
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
+ function localeConfigMap(snapshot, key) {
28
+ if (!isPlainObject(snapshot)) {
29
+ return {};
30
+ }
31
+ const root = snapshot.root;
32
+ if (!isPlainObject(root) || !isPlainObject(root.props)) {
33
+ return {};
34
+ }
35
+ const map = root.props[key];
36
+ return isPlainObject(map) ? map : {};
37
+ }
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
+ export function resolveTranslatable(snapshot, slotId, propName) {
47
+ const slot = localeConfigMap(snapshot, '_localeTranslatable')[slotId];
48
+ if (!isPlainObject(slot)) {
49
+ return true;
50
+ }
51
+ return slot[propName] !== false;
52
+ }
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
+ export function resolveSlotAuthority(templateSnapshot, slotId) {
60
+ const value = localeConfigMap(templateSnapshot, '_localeAuthority')[slotId];
61
+ return isAuthority(value) ? value : DEFAULT_AUTHORITY;
62
+ }
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
+ export function resolveSlotAuthorityMap(templateSnapshot) {
70
+ return Object.fromEntries(Object.entries(localeConfigMap(templateSnapshot, '_localeAuthority')).filter((entry) => isAuthority(entry[1])));
71
+ }
@@ -1,5 +1,27 @@
1
1
  import type { ComponentSchema, FetchRegistryOpts } from './types.js';
2
- export declare function snapshotToComponentSchema(name: string, snapshot: Record<string, unknown>): ComponentSchema;
2
+ export declare function registryComponentKey(name: string): string;
3
+ /**
4
+ * Derives a component's fallback name from its registry document path
5
+ * (e.g. "_registry/components/leadcapture" -> "leadcapture"). This is a
6
+ * fallback only — paths are lowercased server-side, so the result does not
7
+ * reflect the component's real casing (e.g. "LeadCapture"). The descriptor
8
+ * snapshot's own `name` field (see `snapshotToComponentSchema`) is the
9
+ * source of truth for the real, original-case name.
10
+ */
11
+ export declare function componentNameFromPath(path: string): string;
12
+ /**
13
+ * Returns null when the descriptor carries no usable `name`.
14
+ *
15
+ * There used to be a fallback to the path-derived name here. A document path
16
+ * cannot carry a component's casing reliably, so that fallback invented one and
17
+ * advertised it as a valid component type — teaching every consumer (and every
18
+ * agent calling list_components) a casing Puck may not resolve. A descriptor
19
+ * with no `name` is corrupt; callers must skip and log it rather than guess.
20
+ *
21
+ * This holds however paths are stored: the descriptor body is the source of
22
+ * truth for casing, so the path is never the place to recover it from.
23
+ */
24
+ export declare function snapshotToComponentSchema(snapshot: Record<string, unknown>): ComponentSchema | null;
3
25
  /**
4
26
  * Fetch and cache all component schemas from the CSS registry.
5
27
  *
@@ -1 +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"}
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;AAiBrF,wBAAgB,oBAAoB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAEzD;AAID;;;;;;;GAOG;AACH,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAE1D;AAQD;;;;;;;;;;;GAWG;AACH,wBAAgB,yBAAyB,CACvC,QAAQ,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAChC,eAAe,GAAG,IAAI,CAiBxB;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,CA4D1C;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 CHANGED
@@ -1,11 +1,55 @@
1
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
+ export function registryComponentKey(name) {
16
+ return name.toLowerCase();
17
+ }
18
+ 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
+ export function componentNameFromPath(path) {
28
+ return path.slice(REGISTRY_COMPONENTS_PATH_PREFIX.length);
29
+ }
30
+ // ---------------------------------------------------------------------------
2
31
  // Shared snapshot → ComponentSchema transformation
3
32
  // Used by both fetchRegistry (raw fetch path) and McpApiClient.fetchRegistrySchemas
4
33
  // (circuit-breaker-wrapped path) to ensure consistent extraction logic.
5
34
  // ---------------------------------------------------------------------------
6
- export function snapshotToComponentSchema(name, snapshot) {
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
+ export function snapshotToComponentSchema(snapshot) {
48
+ if (typeof snapshot.name !== 'string' || snapshot.name === '') {
49
+ return null;
50
+ }
7
51
  return {
8
- name,
52
+ name: snapshot.name,
9
53
  defaultProps: snapshot.defaultProps ?? {},
10
54
  allowedAdditionalProps: Array.isArray(snapshot.allowedAdditionalProps)
11
55
  ? snapshot.allowedAdditionalProps
@@ -61,14 +105,20 @@ export async function fetchRegistry(cssBaseUrl, siteId, branchId, opts) {
61
105
  }
62
106
  const schemas = {};
63
107
  await Promise.all(documents.map(async (doc) => {
64
- const name = doc.path.slice('_registry/components/'.length);
65
108
  const versionUrl = `${base}/api/sites/${siteId}/branches/${branchId}/documents/${doc.id}/versions/latest`;
66
109
  try {
67
110
  const vRes = await fetch(versionUrl, { method: 'GET', headers, signal: opts.signal });
68
111
  if (!vRes.ok)
69
112
  return;
70
113
  const { snapshot } = (await vRes.json());
71
- schemas[name] = snapshotToComponentSchema(name, snapshot);
114
+ const schema = snapshotToComponentSchema(snapshot);
115
+ if (schema === null) {
116
+ console.warn(`[p1-content-validator] Registry descriptor at "${doc.path}" has no "name" — ` +
117
+ 'skipping. Its component cannot be validated until the descriptor is rewritten ' +
118
+ '(reopen the editor or rerun the registry sync).');
119
+ return;
120
+ }
121
+ schemas[registryComponentKey(schema.name)] = schema;
72
122
  }
73
123
  catch {
74
124
  // Skip components that fail to fetch — don't block the rest
@@ -100,7 +150,7 @@ export async function listRegistryVersions(cssBaseUrl, siteId, branchId, opts) {
100
150
  }
101
151
  const { documents } = (await res.json());
102
152
  return documents.map((doc) => ({
103
- name: doc.path.slice('_registry/components/'.length),
153
+ name: componentNameFromPath(doc.path),
104
154
  versionId: doc.id, // document id as proxy until backend exposes versionId
105
155
  }));
106
156
  }
@@ -0,0 +1,30 @@
1
+ import type { StructuralConformanceError, ValidateStructureInput } from './types.js';
2
+ /**
3
+ * Validates a document snapshot against a content-shaped template by slot-id membership.
4
+ *
5
+ * A template component is a pinned slot when it has a string `props.id` and
6
+ * `root.props._pinMap[id]` is strictly `true`. Slots are read from the template's
7
+ * `content[]` and each `zones[key][]`. A document conforms when:
8
+ *
9
+ * 1. Presence: every pinned slot id appears among the document's component ids,
10
+ * collected from the top-level `content[]` (or `root.props.content` when the
11
+ * top-level array is absent) and every `zones[key][]`.
12
+ * 2. Order: within each list, the pinned slots found in that list keep the template's
13
+ * relative order. A pinned slot found in a different list than the template placed it
14
+ * in raises no order error and does not advance that list's order chain.
15
+ *
16
+ * Matching is by id, so a same-typed local component never satisfies a pinned slot and a
17
+ * duplicated type cannot mask a missing one. A template that is not a content-shaped
18
+ * snapshot (missing `content` array, malformed `root`/`_pinMap`, or the legacy
19
+ * `{ components }` manifest) pins nothing, and every document conforms.
20
+ *
21
+ * Never throws: every property access is type-guarded, and malformed input yields an
22
+ * empty error list rather than an exception.
23
+ *
24
+ * @param input - Document snapshot and the template snapshot to validate against
25
+ * @returns Object with an array of structural conformance errors (empty when valid)
26
+ */
27
+ export declare function validateDocumentStructure(input: ValidateStructureInput): {
28
+ errors: StructuralConformanceError[];
29
+ };
30
+ //# sourceMappingURL=structure-validator.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"structure-validator.d.ts","sourceRoot":"","sources":["../src/structure-validator.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,0BAA0B,EAC1B,sBAAsB,EACvB,MAAM,YAAY,CAAC;AA+HpB;;;;;;;;;;;;;;;;;;;;;;;;GAwBG;AACH,wBAAgB,yBAAyB,CACvC,KAAK,EAAE,sBAAsB,GAC5B;IAAE,MAAM,EAAE,0BAA0B,EAAE,CAAA;CAAE,CAyD1C"}
@@ -0,0 +1,177 @@
1
+ import { isPlainObject } from './guards.js';
2
+ function asComponentList(value) {
3
+ return Array.isArray(value) ? value : [];
4
+ }
5
+ function componentId(component) {
6
+ if (!isPlainObject(component)) {
7
+ return undefined;
8
+ }
9
+ const props = component.props;
10
+ if (!isPlainObject(props)) {
11
+ return undefined;
12
+ }
13
+ return typeof props.id === 'string' ? props.id : undefined;
14
+ }
15
+ function componentType(component) {
16
+ if (isPlainObject(component) && typeof component.type === 'string') {
17
+ return component.type;
18
+ }
19
+ return '';
20
+ }
21
+ function zonesOf(snapshot) {
22
+ if (!isPlainObject(snapshot)) {
23
+ return {};
24
+ }
25
+ return isPlainObject(snapshot.zones) ? snapshot.zones : {};
26
+ }
27
+ function pinMapOf(template) {
28
+ const root = template.root;
29
+ if (!isPlainObject(root)) {
30
+ return {};
31
+ }
32
+ const props = root.props;
33
+ if (!isPlainObject(props)) {
34
+ return {};
35
+ }
36
+ return isPlainObject(props._pinMap) ? props._pinMap : {};
37
+ }
38
+ // A pinned slot is a component with a string props.id whose _pinMap entry is strictly true.
39
+ function pinnedSlots(list, pinMap) {
40
+ const slots = [];
41
+ for (const component of list) {
42
+ const id = componentId(component);
43
+ if (id !== undefined && pinMap[id] === true) {
44
+ slots.push({ id, type: componentType(component) });
45
+ }
46
+ }
47
+ return slots;
48
+ }
49
+ // Document content is the top-level content[]; when absent it falls back to root.props.content.
50
+ function documentContentOf(documentSnapshot) {
51
+ if (!isPlainObject(documentSnapshot)) {
52
+ return [];
53
+ }
54
+ if (Array.isArray(documentSnapshot.content)) {
55
+ return documentSnapshot.content;
56
+ }
57
+ const root = documentSnapshot.root;
58
+ if (!isPlainObject(root)) {
59
+ return [];
60
+ }
61
+ const props = root.props;
62
+ if (!isPlainObject(props)) {
63
+ return [];
64
+ }
65
+ return asComponentList(props.content);
66
+ }
67
+ function collectIds(list, ids) {
68
+ for (const component of list) {
69
+ const id = componentId(component);
70
+ if (id !== undefined) {
71
+ ids.add(id);
72
+ }
73
+ }
74
+ }
75
+ function indexOfId(list, id) {
76
+ for (let i = 0; i < list.length; i++) {
77
+ if (componentId(list[i]) === id) {
78
+ return i;
79
+ }
80
+ }
81
+ return -1;
82
+ }
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
+ function checkListOrder(slots, documentList, errors) {
86
+ let lastFoundIndex = -1;
87
+ slots.forEach((slot, expectedIndex) => {
88
+ const actualIndex = indexOfId(documentList, slot.id);
89
+ if (actualIndex === -1) {
90
+ return;
91
+ }
92
+ if (actualIndex < lastFoundIndex) {
93
+ errors.push({
94
+ code: 'pinned_component_out_of_order',
95
+ componentType: slot.type,
96
+ expectedIndex,
97
+ actualIndex,
98
+ message: `Pinned component "${slot.type}" appears out of order. ` +
99
+ `Expected after index ${lastFoundIndex} but found at index ${actualIndex}.`,
100
+ });
101
+ return;
102
+ }
103
+ lastFoundIndex = actualIndex;
104
+ });
105
+ }
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
+ export function validateDocumentStructure(input) {
132
+ const { documentSnapshot, templateSnapshot } = input;
133
+ const errors = [];
134
+ // Only a content-shaped snapshot pins slots; anything else conforms unconditionally.
135
+ 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
+ console.warn('[p1-content-validator] Template snapshot is not content-shaped; skipping structural validation and treating the document as conforming.');
140
+ return { errors };
141
+ }
142
+ const pinMap = pinMapOf(templateSnapshot);
143
+ const contentSlots = pinnedSlots(templateSnapshot.content, pinMap);
144
+ const templateZones = zonesOf(templateSnapshot);
145
+ const zoneSlots = [];
146
+ for (const key of Object.keys(templateZones)) {
147
+ zoneSlots.push({
148
+ key,
149
+ slots: pinnedSlots(asComponentList(templateZones[key]), pinMap),
150
+ });
151
+ }
152
+ const allSlots = [...contentSlots, ...zoneSlots.flatMap((zone) => zone.slots)];
153
+ if (allSlots.length === 0) {
154
+ return { errors };
155
+ }
156
+ const documentContent = documentContentOf(documentSnapshot);
157
+ const documentZones = zonesOf(documentSnapshot);
158
+ const documentIds = new Set();
159
+ collectIds(documentContent, documentIds);
160
+ for (const key of Object.keys(documentZones)) {
161
+ collectIds(asComponentList(documentZones[key]), documentIds);
162
+ }
163
+ for (const slot of allSlots) {
164
+ if (!documentIds.has(slot.id)) {
165
+ errors.push({
166
+ code: 'missing_pinned_component',
167
+ componentType: slot.type,
168
+ message: `Required component "${slot.type}" is missing from the document.`,
169
+ });
170
+ }
171
+ }
172
+ checkListOrder(contentSlots, documentContent, errors);
173
+ for (const zone of zoneSlots) {
174
+ checkListOrder(zone.slots, asComponentList(documentZones[zone.key]), errors);
175
+ }
176
+ return { errors };
177
+ }
package/dist/types.d.ts CHANGED
@@ -10,6 +10,12 @@ export interface FieldOption {
10
10
  label: string;
11
11
  value: string | number | boolean;
12
12
  }
13
+ /**
14
+ * Whether a translation prop's value is owned by the canonical it derives from
15
+ * (`canonical` — inherited/propagated down) or by the translation itself
16
+ * (`locale` — sovereign). A property of the relationship, not of the field.
17
+ */
18
+ export type Authority = 'canonical' | 'locale';
13
19
  export interface ComponentField {
14
20
  name: string;
15
21
  type: string;
@@ -26,7 +32,7 @@ export interface ComponentSchema {
26
32
  export interface ValidationError {
27
33
  opIndex: number;
28
34
  path: string;
29
- code: 'unknown_component_type' | 'invalid_prop_key' | 'invalid_prop_value' | 'missing_required_prop' | 'invalid_readonly_key' | 'deprecated_zones_usage';
35
+ code: 'unknown_component_type' | 'component_type_case_mismatch' | 'invalid_prop_key' | 'invalid_prop_value' | 'missing_required_prop' | 'invalid_readonly_key' | 'deprecated_zones_usage';
30
36
  message: string;
31
37
  }
32
38
  export interface FetchRegistryOpts {
@@ -44,4 +50,80 @@ export interface ValidateInput {
44
50
  warnOnZonesUsage?: boolean;
45
51
  };
46
52
  }
53
+ export interface TemplateComponent {
54
+ type: string;
55
+ props: {
56
+ id?: string;
57
+ [key: string]: unknown;
58
+ };
59
+ }
60
+ export interface TemplateSnapshot {
61
+ content: TemplateComponent[];
62
+ root: {
63
+ props: {
64
+ _pinMap?: Record<string, boolean>;
65
+ /**
66
+ * Per-slot authority default, keyed by slot id (a component's `props.id`).
67
+ * A slot absent from the map defaults to `canonical`. An individual
68
+ * translation may override this per prop in its localization edge metadata.
69
+ */
70
+ _localeAuthority?: Record<string, Authority>;
71
+ [key: string]: unknown;
72
+ };
73
+ };
74
+ zones?: Record<string, TemplateComponent[]>;
75
+ }
76
+ export interface StructuralConformanceError {
77
+ code: 'missing_pinned_component' | 'pinned_component_out_of_order' | 'unexpected_component_at_pinned_slot';
78
+ message: string;
79
+ componentType: string;
80
+ expectedIndex?: number;
81
+ actualIndex?: number;
82
+ }
83
+ export interface ValidateStructureInput {
84
+ documentSnapshot: Record<string, unknown>;
85
+ templateSnapshot: unknown;
86
+ }
87
+ /**
88
+ * Severity stamped on an authority diagnostic. `warning` surfaces a
89
+ * canonical-authority write without blocking it; `error` marks it a violation.
90
+ */
91
+ export type AuthoritySeverity = 'warning' | 'error';
92
+ /**
93
+ * Per-prop authority overrides on a translation's localization edge, nested by
94
+ * slot id then prop name. An entry breaks that prop's inheritance from the
95
+ * slot's template default.
96
+ */
97
+ export type AuthorityOverrideMap = Record<string, Record<string, Authority>>;
98
+ /**
99
+ * A diagnostic raised when a write targets a prop whose effective authority is
100
+ * `canonical` — a prop the translation does not own. `authority` carries that
101
+ * resolved authority, so a consumer branches on it without re-deriving it.
102
+ */
103
+ export interface AuthorityDiagnostic {
104
+ opIndex: number;
105
+ path: string;
106
+ code: 'canonical_authority_write';
107
+ severity: AuthoritySeverity;
108
+ slotId: string;
109
+ propName: string;
110
+ authority: Authority;
111
+ message: string;
112
+ }
113
+ export interface ValidateTranslationAuthorityInput {
114
+ operations: EditOperation[];
115
+ currentSnapshot: Record<string, unknown>;
116
+ /** Template snapshot supplying each slot's `_localeAuthority` default. */
117
+ templateSnapshot: unknown;
118
+ /**
119
+ * Per-slot defaults for a caller holding the resolved map rather than the
120
+ * template it came from. Consulted before `templateSnapshot`; a slot named by
121
+ * neither defaults to `canonical`.
122
+ */
123
+ slotAuthority?: Record<string, Authority>;
124
+ /** Per-prop overrides from the localization edge; absent props follow the default. */
125
+ authorityOverrides?: AuthorityOverrideMap;
126
+ /** Severity to stamp on emitted diagnostics. Defaults to `warning`. */
127
+ severity?: AuthoritySeverity;
128
+ }
47
129
  //# sourceMappingURL=types.d.ts.map
@@ -1 +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"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
@@ -1 +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"}
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;AA+UjH,wBAAgB,WAAW,CAAC,KAAK,EAAE,aAAa,GAAG;IAAE,MAAM,EAAE,eAAe,EAAE,CAAA;CAAE,CA2D/E"}
package/dist/validator.js CHANGED
@@ -1,24 +1,8 @@
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
- }
1
+ import { registryComponentKey } from './registry.js';
2
+ import { isComponentShape, resolvePropPath } from './guards.js';
3
+ // ---------------------------------------------------------------------------
4
+ // Internal helpers
5
+ // ---------------------------------------------------------------------------
22
6
  // UUID v4: xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx
23
7
  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
8
  // Puck type-prefixed: {ComponentType}-{uuid-v4}
@@ -95,14 +79,45 @@ function validateComponent(comp, registry, opIndex, path, errors, zonesKey, warn
95
79
  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
80
  });
97
81
  }
98
- const schema = registry[comp.type];
82
+ const schema = registry[registryComponentKey(comp.type)];
99
83
  if (!schema) {
100
84
  errors.push({
101
85
  opIndex,
102
86
  path,
103
87
  code: 'unknown_component_type',
104
88
  message: `Unknown component type "${comp.type}" at "${path}". ` +
105
- `Use list_components to see available types: ${Object.keys(registry).join(', ')}.`,
89
+ `Use list_components to see available types: ${Object.entries(registry).map(([key, s]) => s.name ?? key).join(', ')}.`,
90
+ });
91
+ return;
92
+ }
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
+ if (typeof schema.name === 'string' && schema.name !== '' && comp.type !== schema.name) {
115
+ errors.push({
116
+ opIndex,
117
+ path,
118
+ code: 'component_type_case_mismatch',
119
+ message: `Component type "${comp.type}" at "${path}" does not match the registered ` +
120
+ `casing "${schema.name}". Component types are case-sensitive — use "${schema.name}".`,
106
121
  });
107
122
  return;
108
123
  }
@@ -129,7 +144,7 @@ function validateComponent(comp, registry, opIndex, path, errors, zonesKey, warn
129
144
  for (const [key, val] of Object.entries(comp.props)) {
130
145
  if (opaqueProps.has(key) || key === 'id')
131
146
  continue;
132
- if (Array.isArray(val) && val.some(isPuckComponentShape)) {
147
+ if (Array.isArray(val) && val.some(isComponentShape)) {
133
148
  validateContent(val, registry, opIndex, `${path}.props.${key}`, errors, zonesKey, warnOnZonesUsage);
134
149
  }
135
150
  }
@@ -143,7 +158,7 @@ function validateContent(value, registry, opIndex, path, errors, zonesKey, warnO
143
158
  });
144
159
  return;
145
160
  }
146
- if (isPuckComponentShape(value)) {
161
+ if (isComponentShape(value)) {
147
162
  validateComponent(value, registry, opIndex, path, errors, zonesKey, warnOnZonesUsage);
148
163
  return;
149
164
  }
@@ -169,19 +184,19 @@ function validateContent(value, registry, opIndex, path, errors, zonesKey, warnO
169
184
  * Only fires when the op path contains ".props." and a snapshot is available.
170
185
  */
171
186
  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))
187
+ const resolved = resolvePropPath(op.path, snapshot);
188
+ if (resolved === undefined)
181
189
  return;
182
- const schema = registry[val.type];
190
+ const { component: val, componentPath, propsIdx, parts } = resolved;
191
+ const schema = registry[registryComponentKey(val.type)];
183
192
  if (!schema)
184
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).
185
200
  // Case A: path ends exactly at .props — content is the full props object
186
201
  // e.g. replace content.0.props { id, label, visible }
187
202
  if (propsIdx === parts.length - 1) {
@@ -266,6 +281,16 @@ export function validateOps(input) {
266
281
  if (Object.keys(registry).length === 0) {
267
282
  return { errors: [] };
268
283
  }
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
+ const normalizedRegistry = {};
291
+ for (const [key, schema] of Object.entries(registry)) {
292
+ normalizedRegistry[registryComponentKey(key)] = schema;
293
+ }
269
294
  const zonesKey = config.zonesKey ?? 'zones';
270
295
  const warnOnZonesUsage = config.warnOnZonesUsage ?? true;
271
296
  const errors = [];
@@ -295,10 +320,10 @@ export function validateOps(input) {
295
320
  // Snapshot-based validation: catches targeted prop writes where the content
296
321
  // is a primitive and the component type must be resolved from the live document.
297
322
  if (currentSnapshot !== undefined) {
298
- validatePropPathOp(op, opIndex, currentSnapshot, registry, errors);
323
+ validatePropPathOp(op, opIndex, currentSnapshot, normalizedRegistry, errors);
299
324
  }
300
325
  // Content-shape validation: catches component replacements and slot content.
301
- validateContent(op.content, registry, opIndex, op.path, errors, zonesKey, warnOnZonesUsage);
326
+ validateContent(op.content, normalizedRegistry, opIndex, op.path, errors, zonesKey, warnOnZonesUsage);
302
327
  }
303
328
  return { errors };
304
329
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pantheon-systems/p1-content-validator",
3
- "version": "1.0.0",
3
+ "version": "2.1.0",
4
4
  "description": "Validates Puck component edit operations against the CSS component registry",
5
5
  "repository": {
6
6
  "type": "git",
@@ -28,13 +28,19 @@
28
28
  "engines": {
29
29
  "node": ">=20.0.0"
30
30
  },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "registry": "https://registry.npmjs.org/",
34
+ "provenance": false
35
+ },
31
36
  "license": "MIT",
32
37
  "scripts": {
33
38
  "build": "tsc",
34
39
  "clean": "rm -rf dist *.tsbuildinfo",
40
+ "lint": "eslint src tests",
41
+ "lint:fix": "eslint src tests --fix",
35
42
  "test": "vitest run",
36
43
  "test:watch": "vitest",
37
- "typecheck": "tsc --noEmit",
38
- "lint": "eslint src tests"
44
+ "typecheck": "tsc --noEmit"
39
45
  }
40
46
  }