@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.
- package/dist/authority-enforcement.d.ts +5 -0
- package/dist/authority-enforcement.d.ts.map +1 -0
- package/dist/authority-enforcement.js +85 -0
- package/dist/guards.d.ts +39 -0
- package/dist/guards.d.ts.map +1 -0
- package/dist/guards.js +56 -0
- package/dist/index.d.ts +5 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +4 -1
- package/dist/localization.d.ts +43 -0
- package/dist/localization.d.ts.map +1 -0
- package/dist/localization.js +71 -0
- package/dist/registry.d.ts +23 -1
- package/dist/registry.d.ts.map +1 -1
- package/dist/registry.js +55 -5
- package/dist/structure-validator.d.ts +30 -0
- package/dist/structure-validator.d.ts.map +1 -0
- package/dist/structure-validator.js +177 -0
- package/dist/types.d.ts +83 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/validator.d.ts.map +1 -1
- package/dist/validator.js +62 -37
- package/package.json +9 -3
|
@@ -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
|
+
}
|
package/dist/guards.d.ts
ADDED
|
@@ -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
|
|
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
|
package/dist/index.d.ts.map
CHANGED
|
@@ -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,
|
|
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
|
+
}
|
package/dist/registry.d.ts
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
import type { ComponentSchema, FetchRegistryOpts } from './types.js';
|
|
2
|
-
export declare function
|
|
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
|
*
|
package/dist/registry.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
package/dist/types.d.ts.map
CHANGED
|
@@ -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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"}
|
package/dist/validator.d.ts.map
CHANGED
|
@@ -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;
|
|
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
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
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.
|
|
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(
|
|
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 (
|
|
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
|
|
173
|
-
|
|
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
|
|
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,
|
|
323
|
+
validatePropPathOp(op, opIndex, currentSnapshot, normalizedRegistry, errors);
|
|
299
324
|
}
|
|
300
325
|
// Content-shape validation: catches component replacements and slot content.
|
|
301
|
-
validateContent(op.content,
|
|
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
|
|
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
|
}
|