@pantheon-systems/p1-content-validator 2.3.0 → 2.5.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.js +12 -4
- package/dist/block-paths.d.ts +43 -0
- package/dist/block-paths.js +90 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +2 -1
- package/dist/localization.d.ts +8 -1
- package/dist/localization.js +7 -0
- package/dist/types.d.ts +13 -1
- package/dist/validator.js +9 -1
- package/package.json +1 -1
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { isAuthority, resolveSlotAuthority, ROOT_SLOT_ID } from './localization.js';
|
|
2
2
|
import { isPlainObject, resolvePropPath } from './guards.js';
|
|
3
|
+
import { mapToWrittenPaths, positionalOps } from './block-paths.js';
|
|
3
4
|
function resolveSlot(path, snapshot) {
|
|
4
5
|
const parts = path.split('.');
|
|
5
6
|
if (parts[0] === 'root' && parts.lastIndexOf('props') === 1) {
|
|
@@ -15,16 +16,19 @@ function isContentProp(propName) {
|
|
|
15
16
|
return propName !== 'id' && !propName.startsWith('_');
|
|
16
17
|
}
|
|
17
18
|
export function validateTranslationAuthority(input) {
|
|
18
|
-
const { operations, currentSnapshot, templateSnapshot, authorityOverrides = {}, slotAuthority = {}, severity = 'warning', } = input;
|
|
19
|
+
const { operations, currentSnapshot, templateSnapshot, authorityOverrides = {}, slotAuthority = {}, translatable = {}, severity = 'warning', } = input;
|
|
19
20
|
const diagnostics = [];
|
|
20
21
|
const overrides = new Map(Object.entries(authorityOverrides).map(([slotId, props]) => [
|
|
21
22
|
slotId,
|
|
22
23
|
new Map(Object.entries(props).filter(([, value]) => isAuthority(value))),
|
|
23
24
|
]));
|
|
24
25
|
const slotDefaults = new Map(Object.entries(slotAuthority).filter((entry) => isAuthority(entry[1])));
|
|
26
|
+
const translatableFlags = new Map(Object.entries(translatable).map(([slotId, props]) => [slotId, new Map(Object.entries(props))]));
|
|
25
27
|
const effectiveAuthority = (slotId, propName) => overrides.get(slotId)?.get(propName) ??
|
|
26
28
|
slotDefaults.get(slotId) ??
|
|
27
29
|
resolveSlotAuthority(templateSnapshot, slotId);
|
|
30
|
+
const isInheritedNonTranslatable = (slotId, propName) => effectiveAuthority(slotId, propName) === 'canonical' &&
|
|
31
|
+
translatableFlags.get(slotId)?.get(propName) === false;
|
|
28
32
|
const flag = (opIndex, path, slotId, propName) => {
|
|
29
33
|
diagnostics.push({
|
|
30
34
|
opIndex,
|
|
@@ -38,7 +42,10 @@ export function validateTranslationAuthority(input) {
|
|
|
38
42
|
`This prop is owned by the canonical; edit it there and let sync propagate the value.`,
|
|
39
43
|
});
|
|
40
44
|
};
|
|
41
|
-
operations
|
|
45
|
+
const { ops: positional } = positionalOps(operations, currentSnapshot);
|
|
46
|
+
positional.forEach((op, opIndex) => {
|
|
47
|
+
if (op === null)
|
|
48
|
+
return;
|
|
42
49
|
if (op.type !== 'add' && op.type !== 'replace')
|
|
43
50
|
return;
|
|
44
51
|
if (op.content === undefined)
|
|
@@ -53,7 +60,7 @@ export function validateTranslationAuthority(input) {
|
|
|
53
60
|
for (const propName of Object.keys(op.content)) {
|
|
54
61
|
if (!isContentProp(propName))
|
|
55
62
|
continue;
|
|
56
|
-
if (
|
|
63
|
+
if (isInheritedNonTranslatable(slotId, propName)) {
|
|
57
64
|
flag(opIndex, `${op.path}.${propName}`, slotId, propName);
|
|
58
65
|
}
|
|
59
66
|
}
|
|
@@ -62,9 +69,10 @@ export function validateTranslationAuthority(input) {
|
|
|
62
69
|
const propName = parts[propsIdx + 1];
|
|
63
70
|
if (!isContentProp(propName))
|
|
64
71
|
return;
|
|
65
|
-
if (
|
|
72
|
+
if (isInheritedNonTranslatable(slotId, propName)) {
|
|
66
73
|
flag(opIndex, op.path, slotId, propName);
|
|
67
74
|
}
|
|
68
75
|
});
|
|
76
|
+
mapToWrittenPaths(diagnostics, operations, positional);
|
|
69
77
|
return { diagnostics };
|
|
70
78
|
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/** Where a path points on a page, or why that can't be said. */
|
|
2
|
+
export type BlockPathResult = {
|
|
3
|
+
ok: true;
|
|
4
|
+
path: string;
|
|
5
|
+
} | {
|
|
6
|
+
ok: false;
|
|
7
|
+
reason: string;
|
|
8
|
+
};
|
|
9
|
+
/** One op whose path could not be resolved, with the reason and its index in the input array. */
|
|
10
|
+
export type BlockPathFailure = {
|
|
11
|
+
opIndex: number;
|
|
12
|
+
path: string;
|
|
13
|
+
reason: string;
|
|
14
|
+
};
|
|
15
|
+
/**
|
|
16
|
+
* Rewrites a path that starts at a block's id (`#<id>`, `#<id>.props.title`) to that block's
|
|
17
|
+
* position on `page`, however deep it sits. A positional path comes back unchanged.
|
|
18
|
+
*/
|
|
19
|
+
export declare function resolveBlockPath(page: unknown, path: string): BlockPathResult;
|
|
20
|
+
/**
|
|
21
|
+
* Rewrites each report's path, and the block's position in its message (e.g. "content.0"), back to
|
|
22
|
+
* the block id the caller wrote.
|
|
23
|
+
*/
|
|
24
|
+
export declare function mapToWrittenPaths(reports: {
|
|
25
|
+
opIndex: number;
|
|
26
|
+
path: string;
|
|
27
|
+
message: string;
|
|
28
|
+
}[], written: readonly {
|
|
29
|
+
path: string;
|
|
30
|
+
}[], resolved: readonly ({
|
|
31
|
+
path: string;
|
|
32
|
+
} | null)[]): void;
|
|
33
|
+
/**
|
|
34
|
+
* Each op with a block-id path rewritten to its position on `page`, the page as read before the
|
|
35
|
+
* edit. `null` stands in for an op whose path can't be resolved, with the reason in `failures`.
|
|
36
|
+
* Without a page every op comes back as written.
|
|
37
|
+
*/
|
|
38
|
+
export declare function positionalOps<T extends {
|
|
39
|
+
path: string;
|
|
40
|
+
}>(ops: readonly T[], page: unknown): {
|
|
41
|
+
ops: (T | null)[];
|
|
42
|
+
failures: BlockPathFailure[];
|
|
43
|
+
};
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { isComponentShape, isPlainObject } from './guards.js';
|
|
2
|
+
export function resolveBlockPath(page, path) {
|
|
3
|
+
const segments = path.split('.');
|
|
4
|
+
const later = segments.findIndex((segment, i) => i > 0 && segment.startsWith('#'));
|
|
5
|
+
if (later !== -1) {
|
|
6
|
+
return { ok: false, reason: `Start the path at the block: ${segments.slice(later).join('.')}` };
|
|
7
|
+
}
|
|
8
|
+
const [first = '', ...rest] = segments;
|
|
9
|
+
if (!first.startsWith('#'))
|
|
10
|
+
return { ok: true, path };
|
|
11
|
+
const id = first.slice(1);
|
|
12
|
+
if (id === '')
|
|
13
|
+
return { ok: false, reason: 'Path names no block id' };
|
|
14
|
+
const found = pathsOfBlock(page, id);
|
|
15
|
+
if (found.length === 0)
|
|
16
|
+
return { ok: false, reason: `No block #${id} on this page` };
|
|
17
|
+
if (found.length > 1) {
|
|
18
|
+
return { ok: false, reason: `#${id} names ${String(found.length)} blocks on this page; name it by position` };
|
|
19
|
+
}
|
|
20
|
+
return { ok: true, path: [found[0], ...rest].join('.') };
|
|
21
|
+
}
|
|
22
|
+
function pathsOfBlock(value, id, at = [], found = []) {
|
|
23
|
+
if (Array.isArray(value)) {
|
|
24
|
+
value.forEach((item, i) => pathsOfBlock(item, id, [...at, String(i)], found));
|
|
25
|
+
}
|
|
26
|
+
else if (isPlainObject(value)) {
|
|
27
|
+
if (isComponentShape(value) && value.props.id === id)
|
|
28
|
+
found.push(at.join('.'));
|
|
29
|
+
for (const [key, child] of Object.entries(value))
|
|
30
|
+
pathsOfBlock(child, id, [...at, key], found);
|
|
31
|
+
}
|
|
32
|
+
return found;
|
|
33
|
+
}
|
|
34
|
+
export function mapToWrittenPaths(reports, written, resolved) {
|
|
35
|
+
for (const report of reports) {
|
|
36
|
+
const resolvedOp = resolved[report.opIndex];
|
|
37
|
+
const writtenOp = written[report.opIndex];
|
|
38
|
+
if (!resolvedOp || !writtenOp)
|
|
39
|
+
continue;
|
|
40
|
+
const from = resolvedOp.path;
|
|
41
|
+
const to = writtenOp.path;
|
|
42
|
+
if (from === to || !to.startsWith('#'))
|
|
43
|
+
continue;
|
|
44
|
+
const dotIdx = to.indexOf('.');
|
|
45
|
+
const tail = dotIdx === -1 ? '' : to.slice(dotIdx);
|
|
46
|
+
if (tail !== '' && !from.endsWith(tail))
|
|
47
|
+
continue;
|
|
48
|
+
const head = from.slice(0, from.length - tail.length);
|
|
49
|
+
const blockRef = dotIdx === -1 ? to : to.slice(0, dotIdx);
|
|
50
|
+
if (report.path === head || (report.path.startsWith(head) && report.path[head.length] === '.')) {
|
|
51
|
+
report.path = blockRef + report.path.slice(head.length);
|
|
52
|
+
}
|
|
53
|
+
report.message = replaceStandalone(report.message, head, blockRef);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function replaceStandalone(text, path, by) {
|
|
57
|
+
if (path === '')
|
|
58
|
+
return text;
|
|
59
|
+
let out = '';
|
|
60
|
+
let from = 0;
|
|
61
|
+
let at = text.indexOf(path);
|
|
62
|
+
while (at !== -1) {
|
|
63
|
+
const before = at > 0 ? text.charAt(at - 1) : '';
|
|
64
|
+
const after = text.charAt(at + path.length);
|
|
65
|
+
if (!/[A-Za-z0-9_.]/.test(before) && (after === '' || /[."\s,:]/.test(after))) {
|
|
66
|
+
out += text.slice(from, at) + by;
|
|
67
|
+
from = at + path.length;
|
|
68
|
+
at = text.indexOf(path, from);
|
|
69
|
+
}
|
|
70
|
+
else {
|
|
71
|
+
at = text.indexOf(path, at + 1);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return out + text.slice(from);
|
|
75
|
+
}
|
|
76
|
+
export function positionalOps(ops, page) {
|
|
77
|
+
const failures = [];
|
|
78
|
+
if (page == null)
|
|
79
|
+
return { ops: [...ops], failures };
|
|
80
|
+
const resolved = ops.map((op, opIndex) => {
|
|
81
|
+
if (!op.path.includes('#'))
|
|
82
|
+
return op;
|
|
83
|
+
const result = resolveBlockPath(page, op.path);
|
|
84
|
+
if (result.ok)
|
|
85
|
+
return { ...op, path: result.path };
|
|
86
|
+
failures.push({ opIndex, path: op.path, reason: result.reason });
|
|
87
|
+
return null;
|
|
88
|
+
});
|
|
89
|
+
return { ops: resolved, failures };
|
|
90
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,5 +4,7 @@ export { hashDescriptor } from './component-descriptor.js';
|
|
|
4
4
|
export type { ComponentDescriptor, ComponentProvenance, FieldAiMeta, RegistryIndex, SerializedField, } from './component-descriptor.js';
|
|
5
5
|
export { validateDocumentStructure } from './structure-validator.js';
|
|
6
6
|
export { validateTranslationAuthority } from './authority-enforcement.js';
|
|
7
|
-
export {
|
|
8
|
-
export type {
|
|
7
|
+
export { resolveBlockPath, positionalOps } from './block-paths.js';
|
|
8
|
+
export type { BlockPathResult, BlockPathFailure } from './block-paths.js';
|
|
9
|
+
export { resolveTranslatable, resolveTranslatableMap, resolveSlotAuthority, resolveSlotAuthorityMap, isAuthority, AUTHORITIES, DEFAULT_AUTHORITY, ROOT_SLOT_ID, } from './localization.js';
|
|
10
|
+
export type { EditOperation, Authority, ComponentSchema, ComponentField, FieldOption, ValidationError, ValidateInput, FetchRegistryOpts, TemplateComponent, TemplateSnapshot, StructuralConformanceError, ValidateStructureInput, AuthoritySeverity, AuthorityOverrideMap, TranslatableMap, AuthorityDiagnostic, ValidateTranslationAuthorityInput, } from './types.js';
|
package/dist/index.js
CHANGED
|
@@ -3,4 +3,5 @@ export { fetchRegistry, listRegistryVersions, snapshotToComponentSchema, registr
|
|
|
3
3
|
export { hashDescriptor } from './component-descriptor.js';
|
|
4
4
|
export { validateDocumentStructure } from './structure-validator.js';
|
|
5
5
|
export { validateTranslationAuthority } from './authority-enforcement.js';
|
|
6
|
-
export {
|
|
6
|
+
export { resolveBlockPath, positionalOps } from './block-paths.js';
|
|
7
|
+
export { resolveTranslatable, resolveTranslatableMap, resolveSlotAuthority, resolveSlotAuthorityMap, isAuthority, AUTHORITIES, DEFAULT_AUTHORITY, ROOT_SLOT_ID, } from './localization.js';
|
package/dist/localization.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Authority } from './types.js';
|
|
1
|
+
import type { Authority, TranslatableMap } from './types.js';
|
|
2
2
|
/**
|
|
3
3
|
* Localization resolvers — the single source of truth for two independent
|
|
4
4
|
* properties of the localization model:
|
|
@@ -31,6 +31,13 @@ export declare function isAuthority(value: unknown): value is Authority;
|
|
|
31
31
|
* true.
|
|
32
32
|
*/
|
|
33
33
|
export declare function resolveTranslatable(snapshot: unknown, slotId: string, propName: string): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Resolves a canonical page snapshot's whole `root.props._localeTranslatable` map,
|
|
36
|
+
* keyed by slot id then prop name. Only boolean entries are kept, so a prop absent
|
|
37
|
+
* from the result is translatable, matching `resolveTranslatable`. Absent maps,
|
|
38
|
+
* malformed snapshots and non-object slot entries resolve to no entries.
|
|
39
|
+
*/
|
|
40
|
+
export declare function resolveTranslatableMap(snapshot: unknown): TranslatableMap;
|
|
34
41
|
/**
|
|
35
42
|
* Resolves the per-slot authority default from a template snapshot's
|
|
36
43
|
* `root.props._localeAuthority` map, keyed by slot id. Slots with no declared
|
package/dist/localization.js
CHANGED
|
@@ -23,6 +23,13 @@ export function resolveTranslatable(snapshot, slotId, propName) {
|
|
|
23
23
|
}
|
|
24
24
|
return slot[propName] !== false;
|
|
25
25
|
}
|
|
26
|
+
export function resolveTranslatableMap(snapshot) {
|
|
27
|
+
const slots = Object.entries(localeConfigMap(snapshot, '_localeTranslatable')).filter((entry) => isPlainObject(entry[1]));
|
|
28
|
+
return Object.fromEntries(slots.map(([slotId, props]) => [
|
|
29
|
+
slotId,
|
|
30
|
+
Object.fromEntries(Object.entries(props).filter((entry) => typeof entry[1] === 'boolean')),
|
|
31
|
+
]));
|
|
32
|
+
}
|
|
26
33
|
export function resolveSlotAuthority(templateSnapshot, slotId) {
|
|
27
34
|
const value = localeConfigMap(templateSnapshot, '_localeAuthority')[slotId];
|
|
28
35
|
return isAuthority(value) ? value : DEFAULT_AUTHORITY;
|
package/dist/types.d.ts
CHANGED
|
@@ -32,7 +32,7 @@ export interface ComponentSchema {
|
|
|
32
32
|
export interface ValidationError {
|
|
33
33
|
opIndex: number;
|
|
34
34
|
path: string;
|
|
35
|
-
code: 'unknown_component_type' | 'component_type_case_mismatch' | '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' | 'unresolved_block';
|
|
36
36
|
message: string;
|
|
37
37
|
}
|
|
38
38
|
export interface FetchRegistryOpts {
|
|
@@ -95,6 +95,12 @@ export type AuthoritySeverity = 'warning' | 'error';
|
|
|
95
95
|
* slot's template default.
|
|
96
96
|
*/
|
|
97
97
|
export type AuthorityOverrideMap = Record<string, Record<string, Authority>>;
|
|
98
|
+
/**
|
|
99
|
+
* Per-prop translatable flags from a canonical page's `_localeTranslatable` map,
|
|
100
|
+
* nested by slot id then prop name. A prop is translatable unless its entry is
|
|
101
|
+
* `false`.
|
|
102
|
+
*/
|
|
103
|
+
export type TranslatableMap = Record<string, Record<string, boolean>>;
|
|
98
104
|
/**
|
|
99
105
|
* A diagnostic raised when a write targets a prop whose effective authority is
|
|
100
106
|
* `canonical` — a prop the translation does not own. `authority` carries that
|
|
@@ -123,6 +129,12 @@ export interface ValidateTranslationAuthorityInput {
|
|
|
123
129
|
slotAuthority?: Record<string, Authority>;
|
|
124
130
|
/** Per-prop overrides from the localization edge; absent props follow the default. */
|
|
125
131
|
authorityOverrides?: AuthorityOverrideMap;
|
|
132
|
+
/**
|
|
133
|
+
* The canonical's translatable flags. A translatable prop is not judged, since
|
|
134
|
+
* the translation holds its own wording of it; an absent map or entry reads as
|
|
135
|
+
* translatable.
|
|
136
|
+
*/
|
|
137
|
+
translatable?: TranslatableMap;
|
|
126
138
|
/** Severity to stamp on emitted diagnostics. Defaults to `warning`. */
|
|
127
139
|
severity?: AuthoritySeverity;
|
|
128
140
|
}
|
package/dist/validator.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { registryComponentKey } from './registry.js';
|
|
2
2
|
import { getAtPath, isComponentShape, isPlainObject, resolvePropPath } from './guards.js';
|
|
3
|
+
import { mapToWrittenPaths, positionalOps } from './block-paths.js';
|
|
3
4
|
const UUID_V4_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
4
5
|
const PREFIXED_UUID_RE = /^[A-Za-z][A-Za-z0-9]*-[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
5
6
|
const ULID_RE = /^[0-9A-HJKMNP-TV-Z]{26}$/;
|
|
@@ -247,8 +248,14 @@ export function validateOps(input) {
|
|
|
247
248
|
const zonesKey = config.zonesKey ?? 'zones';
|
|
248
249
|
const warnOnZonesUsage = config.warnOnZonesUsage ?? true;
|
|
249
250
|
const errors = [];
|
|
251
|
+
const { ops: positional, failures } = positionalOps(operations, currentSnapshot);
|
|
252
|
+
for (const failure of failures) {
|
|
253
|
+
errors.push({ opIndex: failure.opIndex, path: failure.path, code: 'unresolved_block', message: failure.reason });
|
|
254
|
+
}
|
|
250
255
|
for (let opIndex = 0; opIndex < operations.length; opIndex++) {
|
|
251
|
-
const op =
|
|
256
|
+
const op = positional[opIndex];
|
|
257
|
+
if (op === null || op === undefined)
|
|
258
|
+
continue;
|
|
252
259
|
if (op.type !== 'add' && op.type !== 'replace')
|
|
253
260
|
continue;
|
|
254
261
|
if (op.content === undefined)
|
|
@@ -276,5 +283,6 @@ export function validateOps(input) {
|
|
|
276
283
|
}
|
|
277
284
|
validateContent(op.content, normalizedRegistry, opIndex, op.path, errors, zonesKey, warnOnZonesUsage, existingIds);
|
|
278
285
|
}
|
|
286
|
+
mapToWrittenPaths(errors, operations, positional);
|
|
279
287
|
return { errors };
|
|
280
288
|
}
|