@lblod/ember-rdfa-editor-lblod-plugins 5.0.0 → 6.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -2
- package/README.md +126 -70
- package/addon/components/address-plugin/insert.hbs +47 -0
- package/addon/components/address-plugin/insert.ts +100 -0
- package/addon/components/address-plugin/types.ts +26 -0
- package/addon/components/address-plugin/utils.ts +59 -0
- package/addon/components/besluit-type-plugin/toolbar-dropdown.ts +6 -5
- package/addon/components/{besluit-plugin/besluit-plugin-card.ts → decision-plugin/decision-plugin-card.ts} +22 -8
- package/addon/components/hover-tooltip.ts +15 -12
- package/addon/components/roadsign-regulation-plugin/measure-template.ts +5 -1
- package/addon/components/roadsign-regulation-plugin/roadsign-regulation-card.hbs +1 -1
- package/addon/components/roadsign-regulation-plugin/roadsigns-modal.hbs +1 -0
- package/addon/components/roadsign-regulation-plugin/roadsigns-modal.ts +24 -21
- package/addon/components/roadsign-regulation-plugin/roadsigns-table.ts +8 -11
- package/addon/components/table-of-contents-plugin/ember-nodes/table-of-contents.ts +16 -1
- package/addon/components/validation-plugin/validation-card.hbs +12 -0
- package/addon/components/validation-plugin/validation-card.ts +19 -0
- package/addon/components/validation-plugin/validation-item.hbs +2 -0
- package/addon/components/validation-plugin/validation-item.ts +55 -0
- package/addon/components/variable-plugin/template-variable-card.ts +8 -25
- package/addon/plugins/besluit-type-plugin/index.ts +3 -0
- package/addon/plugins/besluit-type-plugin/utils/fetchBesluitTypes.ts +1 -6
- package/addon/plugins/decision-plugin/commands/index.ts +4 -0
- package/addon/plugins/decision-plugin/commands/insert-article-container.ts +56 -0
- package/addon/plugins/decision-plugin/commands/insert-description.ts +53 -0
- package/addon/plugins/decision-plugin/commands/insert-motivation.ts +62 -0
- package/addon/plugins/decision-plugin/commands/insert-title.ts +54 -0
- package/addon/plugins/roadsign-regulation-plugin/index.ts +4 -0
- package/addon/plugins/standard-template-plugin/utils/nodes.ts +0 -1
- package/addon/plugins/table-of-contents-plugin/index.ts +2 -0
- package/addon/plugins/validation/README.md +84 -0
- package/addon/plugins/validation/index.ts +427 -0
- package/addon/plugins/validation/utils/transaction-complies-with-shapes.ts +28 -0
- package/addon/plugins/variable-plugin/index.ts +5 -0
- package/addon/services/roadsign-registry.ts +54 -50
- package/addon/utils/find-insertion-pos-in-ancestor-of-type.ts +35 -0
- package/app/components/{besluit-plugin/besluit-plugin-card.js → address-plugin/insert.js} +1 -1
- package/app/components/decision-plugin/decision-plugin-card.js +1 -0
- package/app/components/{besluit-plugin/besluit-context-card.js → validation-plugin/validation-card.js} +1 -1
- package/app/components/validation-plugin/validation-item.js +1 -0
- package/app/styles/address-plugin.scss +7 -0
- package/components/address-plugin/insert.d.ts +21 -0
- package/components/address-plugin/types.d.ts +25 -0
- package/components/address-plugin/utils.d.ts +9 -0
- package/components/besluit-type-plugin/toolbar-dropdown.d.ts +2 -0
- package/components/{besluit-plugin/besluit-plugin-card.d.ts → decision-plugin/decision-plugin-card.d.ts} +4 -1
- package/components/roadsign-regulation-plugin/measure-template.d.ts +2 -0
- package/components/roadsign-regulation-plugin/roadsigns-modal.d.ts +3 -0
- package/components/roadsign-regulation-plugin/roadsigns-table.d.ts +6 -1
- package/components/validation-plugin/validation-card.d.ts +13 -0
- package/components/validation-plugin/validation-item.d.ts +18 -0
- package/components/variable-plugin/template-variable-card.d.ts +2 -4
- package/package.json +1 -1
- package/plugins/besluit-type-plugin/index.d.ts +3 -0
- package/plugins/besluit-type-plugin/utils/fetchBesluitTypes.d.ts +1 -5
- package/plugins/decision-plugin/commands/index.d.ts +4 -0
- package/plugins/decision-plugin/commands/insert-article-container.d.ts +8 -0
- package/plugins/decision-plugin/commands/insert-description.d.ts +7 -0
- package/plugins/decision-plugin/commands/insert-motivation.d.ts +7 -0
- package/plugins/decision-plugin/commands/insert-title.d.ts +7 -0
- package/plugins/roadsign-regulation-plugin/index.d.ts +4 -0
- package/plugins/table-of-contents-plugin/index.d.ts +2 -0
- package/plugins/validation/index.d.ts +62 -0
- package/plugins/validation/utils/transaction-complies-with-shapes.d.ts +10 -0
- package/plugins/variable-plugin/index.d.ts +5 -0
- package/services/roadsign-registry.d.ts +7 -9
- package/translations/en-US.yaml +58 -47
- package/translations/nl-BE.yaml +18 -0
- package/utils/find-insertion-pos-in-ancestor-of-type.d.ts +11 -0
- package/addon/plugins/besluit-plugin/commands/index.ts +0 -1
- package/addon/plugins/besluit-plugin/commands/insert-title.ts +0 -43
- package/config/environment.js +0 -20
- package/plugins/besluit-plugin/commands/index.d.ts +0 -1
- package/plugins/besluit-plugin/commands/insert-title.d.ts +0 -3
- /package/addon/components/{besluit-plugin/besluit-plugin-card.hbs → decision-plugin/decision-plugin-card.hbs} +0 -0
- /package/addon/plugins/{besluit-plugin → decision-plugin}/utils/get-title-for-decision.ts +0 -0
- /package/plugins/{besluit-plugin → decision-plugin}/utils/get-title-for-decision.d.ts +0 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Command,
|
|
3
|
+
EditorState,
|
|
4
|
+
NodeSelection,
|
|
5
|
+
Transaction,
|
|
6
|
+
} from '@lblod/ember-rdfa-editor';
|
|
7
|
+
import { v4 as uuid } from 'uuid';
|
|
8
|
+
import { isNone } from '@lblod/ember-rdfa-editor-lblod-plugins/utils/option';
|
|
9
|
+
import { transactionCompliesWithShapes } from '@lblod/ember-rdfa-editor-lblod-plugins/plugins/validation/utils/transaction-complies-with-shapes';
|
|
10
|
+
import { findInsertionPosInAncestorOfType } from '@lblod/ember-rdfa-editor-lblod-plugins/utils/find-insertion-pos-in-ancestor-of-type';
|
|
11
|
+
|
|
12
|
+
interface InsertMotivationArgs {
|
|
13
|
+
placeholderText?: string;
|
|
14
|
+
validateShapes?: Set<string>;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export default function insertMotivation({
|
|
18
|
+
validateShapes,
|
|
19
|
+
placeholderText = 'Insert motivation',
|
|
20
|
+
}: InsertMotivationArgs = {}): Command {
|
|
21
|
+
return function (state: EditorState, dispatch?: (tr: Transaction) => void) {
|
|
22
|
+
const { selection, schema } = state;
|
|
23
|
+
const nodeToInsert = schema.node('motivering', { __rdfaId: uuid() }, [
|
|
24
|
+
schema.node(
|
|
25
|
+
'paragraph',
|
|
26
|
+
null,
|
|
27
|
+
schema.node('placeholder', {
|
|
28
|
+
placeholderText,
|
|
29
|
+
})
|
|
30
|
+
),
|
|
31
|
+
]);
|
|
32
|
+
// how the offset between the insertion point and the point where the cursor should end up
|
|
33
|
+
const cursorOffset = 2;
|
|
34
|
+
|
|
35
|
+
const insertionPos = findInsertionPosInAncestorOfType(
|
|
36
|
+
selection,
|
|
37
|
+
schema.nodes.besluit,
|
|
38
|
+
nodeToInsert
|
|
39
|
+
);
|
|
40
|
+
if (isNone(insertionPos)) {
|
|
41
|
+
return false;
|
|
42
|
+
}
|
|
43
|
+
const tr = state.tr;
|
|
44
|
+
|
|
45
|
+
tr.replaceRangeWith(insertionPos, insertionPos, nodeToInsert);
|
|
46
|
+
if (!transactionCompliesWithShapes(state, tr, validateShapes)) {
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
if (dispatch) {
|
|
50
|
+
console.log('inserts in ', insertionPos);
|
|
51
|
+
console.log('selection on ', insertionPos + cursorOffset);
|
|
52
|
+
const selectionPos = tr.doc.resolve(insertionPos + cursorOffset);
|
|
53
|
+
// const targetPos = tr.doc.resolve(insertionPos + cursorOffset + 1);
|
|
54
|
+
// TODO figure out why I cant just set a nodeSelection here
|
|
55
|
+
tr.setSelection(
|
|
56
|
+
new NodeSelection(tr.doc.resolve(selectionPos.posAtIndex(0)))
|
|
57
|
+
);
|
|
58
|
+
dispatch(tr);
|
|
59
|
+
}
|
|
60
|
+
return true;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { v4 as uuid } from 'uuid';
|
|
2
|
+
import { isNone } from '@lblod/ember-rdfa-editor-lblod-plugins/utils/option';
|
|
3
|
+
import {
|
|
4
|
+
EditorState,
|
|
5
|
+
NodeSelection,
|
|
6
|
+
Transaction,
|
|
7
|
+
} from '@lblod/ember-rdfa-editor';
|
|
8
|
+
import { transactionCompliesWithShapes } from '@lblod/ember-rdfa-editor-lblod-plugins/plugins/validation/utils/transaction-complies-with-shapes';
|
|
9
|
+
import { findInsertionPosInAncestorOfType } from '@lblod/ember-rdfa-editor-lblod-plugins/utils/find-insertion-pos-in-ancestor-of-type';
|
|
10
|
+
|
|
11
|
+
interface InsertTitleArgs {
|
|
12
|
+
placeholderText: string;
|
|
13
|
+
validateShapes?: Set<string>;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export default function insertTitle({
|
|
17
|
+
placeholderText,
|
|
18
|
+
validateShapes,
|
|
19
|
+
}: InsertTitleArgs) {
|
|
20
|
+
return function (state: EditorState, dispatch?: (tr: Transaction) => void) {
|
|
21
|
+
const { selection, schema } = state;
|
|
22
|
+
const nodeToInsert = schema.node(
|
|
23
|
+
'besluit_title',
|
|
24
|
+
{ __rdfaId: uuid() },
|
|
25
|
+
schema.node(
|
|
26
|
+
'paragraph',
|
|
27
|
+
null,
|
|
28
|
+
schema.node('placeholder', {
|
|
29
|
+
placeholderText,
|
|
30
|
+
})
|
|
31
|
+
)
|
|
32
|
+
);
|
|
33
|
+
|
|
34
|
+
const insertionPos = findInsertionPosInAncestorOfType(
|
|
35
|
+
selection,
|
|
36
|
+
schema.nodes.besluit,
|
|
37
|
+
nodeToInsert
|
|
38
|
+
);
|
|
39
|
+
if (isNone(insertionPos)) {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
const tr = state.tr;
|
|
43
|
+
tr.replaceRangeWith(insertionPos, insertionPos, nodeToInsert);
|
|
44
|
+
|
|
45
|
+
if (!transactionCompliesWithShapes(state, tr, validateShapes)) {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
if (dispatch) {
|
|
49
|
+
tr.setSelection(NodeSelection.create(tr.doc, insertionPos + 2));
|
|
50
|
+
dispatch(tr.scrollIntoView());
|
|
51
|
+
}
|
|
52
|
+
return true;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# Validation Plugin
|
|
2
|
+
|
|
3
|
+
A plugin to validate the document state, with an interface inspired by the SHACL specification.
|
|
4
|
+
|
|
5
|
+
## usage
|
|
6
|
+
|
|
7
|
+
This plugin consists of a ProseMirror plugin as well as a `ValidationPlugin::ValidationCard` component
|
|
8
|
+
which provides an easy way to render the validation errors. It is a keyed plugin, meaning only one instance can be
|
|
9
|
+
active at any time.
|
|
10
|
+
|
|
11
|
+
```ts
|
|
12
|
+
|
|
13
|
+
import { validation } from "@lblod/ember-rdfa-editor-lblod-plugins/plugins/validation";
|
|
14
|
+
|
|
15
|
+
validationPlugin = validation((schema: Schema) => ({
|
|
16
|
+
shapes: [
|
|
17
|
+
{
|
|
18
|
+
name: "exactly-one-title",
|
|
19
|
+
focusNodeType: schema.nodes.besluit,
|
|
20
|
+
path: ["title"],
|
|
21
|
+
message: "Document must contain exactly one title block.",
|
|
22
|
+
constraints: {
|
|
23
|
+
minCount: 1,
|
|
24
|
+
maxCount: 1
|
|
25
|
+
}
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: "exactly-one-description",
|
|
29
|
+
focusNodeType: schema.nodes.besluit,
|
|
30
|
+
path: ["description"],
|
|
31
|
+
message: "Document must contain exactly one description block.",
|
|
32
|
+
constraints: {
|
|
33
|
+
minCount: 1,
|
|
34
|
+
maxCount: 1
|
|
35
|
+
}
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
name: "max-one-motivation",
|
|
39
|
+
focusNodeType: schema.nodes.besluit,
|
|
40
|
+
path: ["motivering"],
|
|
41
|
+
message: "Document may not contain more than one motivation block.",
|
|
42
|
+
constraints: {
|
|
43
|
+
maxCount: 1
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
]
|
|
47
|
+
}));
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```handlebars
|
|
51
|
+
|
|
52
|
+
<ValidationPlugin::ValidationCard
|
|
53
|
+
@title={{t "dummy.validation-card.title"}}
|
|
54
|
+
@controller={{this.controller}}
|
|
55
|
+
@report={{this.report}}/>
|
|
56
|
+
```
|
|
57
|
+
In which the report can be extracted from the plugin state of the validation plugin.
|
|
58
|
+
|
|
59
|
+
## Validation algorithm
|
|
60
|
+
|
|
61
|
+
This high-level overview of the algorithm explains how the focusNode and path configurations define which
|
|
62
|
+
"shapes" are validated:
|
|
63
|
+
|
|
64
|
+
- traverse the entire document depth-first
|
|
65
|
+
- on every node, keep track of the validation shapes that have a focusNodeType set to that node's type
|
|
66
|
+
- for any shapes that have been triggered already, check if this node's type is correct for the corresponding
|
|
67
|
+
position in the path
|
|
68
|
+
- every time we find a complete and exact match for a focusNode->path chain of nodes, we count it
|
|
69
|
+
- once we've seen the entire subtree of a particular focusNode, we validate the constraints on all "completed" shapes
|
|
70
|
+
|
|
71
|
+
## Currently supported constraints
|
|
72
|
+
|
|
73
|
+
### maxCount
|
|
74
|
+
|
|
75
|
+
- type: `number`
|
|
76
|
+
- meaning: the specified shape may not occur more times than the specified amount in the focusNode
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
### `minCount: number`
|
|
81
|
+
|
|
82
|
+
- type: `number`
|
|
83
|
+
- meaning: the specified shape must occur at least as many times as the specified amount in the focusNode
|
|
84
|
+
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
import {
|
|
2
|
+
EditorState,
|
|
3
|
+
NodeType,
|
|
4
|
+
PluginKey,
|
|
5
|
+
PNode,
|
|
6
|
+
ProsePlugin,
|
|
7
|
+
Schema,
|
|
8
|
+
Transaction,
|
|
9
|
+
} from '@lblod/ember-rdfa-editor';
|
|
10
|
+
import {
|
|
11
|
+
expect,
|
|
12
|
+
Option,
|
|
13
|
+
unwrap,
|
|
14
|
+
} from '@lblod/ember-rdfa-editor-lblod-plugins/utils/option';
|
|
15
|
+
|
|
16
|
+
export function validateTransaction(
|
|
17
|
+
state: EditorState,
|
|
18
|
+
tr: Transaction
|
|
19
|
+
): ValidationReport {
|
|
20
|
+
const validationState = VALIDATION_KEY.getState(state);
|
|
21
|
+
if (!validationState) {
|
|
22
|
+
return { conforms: true };
|
|
23
|
+
} else {
|
|
24
|
+
if (!tr.docChanged) {
|
|
25
|
+
return validationState.report;
|
|
26
|
+
}
|
|
27
|
+
const newState = state.apply(tr);
|
|
28
|
+
const newValidationState = VALIDATION_KEY.getState(newState);
|
|
29
|
+
if (!newValidationState) {
|
|
30
|
+
return { conforms: true };
|
|
31
|
+
}
|
|
32
|
+
const cachedValidation = tr.getMeta(
|
|
33
|
+
'validated'
|
|
34
|
+
) as Option<ValidationReport>;
|
|
35
|
+
if (cachedValidation) {
|
|
36
|
+
return cachedValidation;
|
|
37
|
+
}
|
|
38
|
+
tr.setMeta('validated', newValidationState);
|
|
39
|
+
tr.setMeta('firstPass', true);
|
|
40
|
+
|
|
41
|
+
return newValidationState.report;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const VALIDATION_KEY: PluginKey<ValidationState> =
|
|
46
|
+
new PluginKey<ValidationState>('validation');
|
|
47
|
+
|
|
48
|
+
export interface ValidationSpec {
|
|
49
|
+
[key: string]: ValidationShape[];
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export type ValidationState = {
|
|
53
|
+
report: ValidationReport;
|
|
54
|
+
spec: ValidationSpec;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type PropertyPath = string[];
|
|
58
|
+
|
|
59
|
+
export type Severity = 'info' | 'warning' | 'violation';
|
|
60
|
+
|
|
61
|
+
export interface ValidationReport {
|
|
62
|
+
conforms: boolean;
|
|
63
|
+
results?: ValidationResult[];
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export type ValidationPlugin = ProsePlugin<ValidationState>;
|
|
67
|
+
|
|
68
|
+
export interface MinCountConstraint {
|
|
69
|
+
kind: 'minCount';
|
|
70
|
+
value: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface MaxCountConstraint {
|
|
74
|
+
kind: 'maxCount';
|
|
75
|
+
value: number;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export type ValidationConstraint = MinCountConstraint | MaxCountConstraint;
|
|
79
|
+
|
|
80
|
+
export type ConstraintMap = {
|
|
81
|
+
[C in ValidationConstraint as C['kind']]?: C['value'];
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type ConstraintValidator = (
|
|
85
|
+
constraint: ValidationConstraint,
|
|
86
|
+
shapeContext: ShapeContext
|
|
87
|
+
) => Option<ValidationResult>;
|
|
88
|
+
export type ConstraintValidatorMap = {
|
|
89
|
+
[C in ValidationConstraint as C['kind']]: ConstraintValidator;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
export interface ValidationShape {
|
|
93
|
+
name: string;
|
|
94
|
+
focusNodeType: NodeType;
|
|
95
|
+
path: string[] | number[];
|
|
96
|
+
|
|
97
|
+
message?: string;
|
|
98
|
+
constraints?: ConstraintMap;
|
|
99
|
+
|
|
100
|
+
severity?: Severity;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ValidationResult {
|
|
104
|
+
focusNode: PNode;
|
|
105
|
+
|
|
106
|
+
resultPath: PNode[];
|
|
107
|
+
|
|
108
|
+
value?: unknown;
|
|
109
|
+
|
|
110
|
+
sourceShape: ValidationShape;
|
|
111
|
+
|
|
112
|
+
sourceConstraint: ValidationConstraint;
|
|
113
|
+
|
|
114
|
+
message: string;
|
|
115
|
+
severity: Severity;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export interface ValidationPluginConfig {
|
|
119
|
+
shapes: ValidationShape[];
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const CONSTRAINT_VALIDATOR_MAP: ConstraintValidatorMap = {
|
|
123
|
+
maxCount: validateMaxCount,
|
|
124
|
+
minCount: validateMinCount,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
export function validation(
|
|
128
|
+
configurator: (schema: Schema) => ValidationPluginConfig
|
|
129
|
+
): ValidationPlugin {
|
|
130
|
+
const validation = new ProsePlugin<ValidationState>({
|
|
131
|
+
key: VALIDATION_KEY,
|
|
132
|
+
state: {
|
|
133
|
+
init(_stateConfig, state) {
|
|
134
|
+
const spec = compileSpec(configurator, state.schema);
|
|
135
|
+
return {
|
|
136
|
+
spec,
|
|
137
|
+
report: { conforms: true },
|
|
138
|
+
};
|
|
139
|
+
},
|
|
140
|
+
apply(tr, oldPluginState, oldState, newState): ValidationState {
|
|
141
|
+
const schemaChanged = oldState.schema !== newState.schema;
|
|
142
|
+
if (schemaChanged) {
|
|
143
|
+
const spec = compileSpec(configurator, newState.schema);
|
|
144
|
+
const newValidation = doValidation(
|
|
145
|
+
tr,
|
|
146
|
+
oldPluginState,
|
|
147
|
+
oldState,
|
|
148
|
+
newState,
|
|
149
|
+
spec
|
|
150
|
+
);
|
|
151
|
+
tr.setMeta('validated', newValidation);
|
|
152
|
+
tr.setMeta('firstPass', false);
|
|
153
|
+
return { spec, report: newValidation };
|
|
154
|
+
} else {
|
|
155
|
+
const spec = oldPluginState.spec;
|
|
156
|
+
|
|
157
|
+
if (!tr.docChanged) {
|
|
158
|
+
return oldPluginState;
|
|
159
|
+
}
|
|
160
|
+
const cachedValidation = tr.getMeta(
|
|
161
|
+
'validated'
|
|
162
|
+
) as Option<ValidationState>;
|
|
163
|
+
const firstPass = tr.getMeta('firstPass') as Option<boolean>;
|
|
164
|
+
|
|
165
|
+
if (firstPass && cachedValidation) {
|
|
166
|
+
tr.setMeta('firstPass', false);
|
|
167
|
+
return cachedValidation;
|
|
168
|
+
}
|
|
169
|
+
const newValidation = doValidation(
|
|
170
|
+
tr,
|
|
171
|
+
oldPluginState,
|
|
172
|
+
oldState,
|
|
173
|
+
newState,
|
|
174
|
+
spec
|
|
175
|
+
);
|
|
176
|
+
tr.setMeta('validated', newValidation);
|
|
177
|
+
tr.setMeta('firstPass', false);
|
|
178
|
+
return { spec, report: newValidation };
|
|
179
|
+
}
|
|
180
|
+
},
|
|
181
|
+
},
|
|
182
|
+
});
|
|
183
|
+
return validation;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Calculate the validation shapes and organize them by the name of the type
|
|
188
|
+
* of their focusNode
|
|
189
|
+
* @param configurator
|
|
190
|
+
* @param schema
|
|
191
|
+
*/
|
|
192
|
+
function compileSpec(
|
|
193
|
+
configurator: (schema: Schema) => ValidationPluginConfig,
|
|
194
|
+
schema: Schema
|
|
195
|
+
): ValidationSpec {
|
|
196
|
+
const spec: ValidationSpec = {};
|
|
197
|
+
const shapes = configurator(schema).shapes;
|
|
198
|
+
for (const shape of shapes) {
|
|
199
|
+
const typeName = shape.focusNodeType.name;
|
|
200
|
+
if (spec[typeName]) {
|
|
201
|
+
spec[typeName].push(shape);
|
|
202
|
+
} else {
|
|
203
|
+
spec[typeName] = [shape];
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return spec;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
interface ShapeContext {
|
|
210
|
+
shape: ValidationShape;
|
|
211
|
+
focusNode: PNode;
|
|
212
|
+
count: number;
|
|
213
|
+
|
|
214
|
+
path: PNode[];
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
interface ValidationContext {
|
|
218
|
+
activeShapes: { depth: number; shape: ValidationShape }[][];
|
|
219
|
+
path: PNode[];
|
|
220
|
+
spec: ValidationSpec;
|
|
221
|
+
shapeContext: Map<ValidationShape, ShapeContext>;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Main validation driver. Sets up the recursive validation function.
|
|
226
|
+
* @param tr
|
|
227
|
+
* @param oldPluginState
|
|
228
|
+
* @param oldState
|
|
229
|
+
* @param newState
|
|
230
|
+
* @param spec
|
|
231
|
+
*/
|
|
232
|
+
function doValidation(
|
|
233
|
+
tr: Transaction,
|
|
234
|
+
oldPluginState: ValidationState,
|
|
235
|
+
oldState: EditorState,
|
|
236
|
+
newState: EditorState,
|
|
237
|
+
spec: ValidationSpec
|
|
238
|
+
): ValidationReport {
|
|
239
|
+
const results: ValidationResult[] = [];
|
|
240
|
+
const context: ValidationContext = {
|
|
241
|
+
activeShapes: [],
|
|
242
|
+
path: [],
|
|
243
|
+
spec,
|
|
244
|
+
shapeContext: new Map(),
|
|
245
|
+
};
|
|
246
|
+
recValidate(context, results, newState.doc, 0);
|
|
247
|
+
let conforms = true;
|
|
248
|
+
for (const result of results) {
|
|
249
|
+
if (result.severity === 'violation') {
|
|
250
|
+
conforms = false;
|
|
251
|
+
break;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
return { conforms, results };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* The actual validation function. Traverses the document with DFS.
|
|
259
|
+
* Collects the results in the array given in the results argument.
|
|
260
|
+
* Uses the context argument to keep track of various needed state.
|
|
261
|
+
* @param context
|
|
262
|
+
* @param results
|
|
263
|
+
* @param node
|
|
264
|
+
* @param currentDepth
|
|
265
|
+
*/
|
|
266
|
+
function recValidate(
|
|
267
|
+
context: ValidationContext,
|
|
268
|
+
results: ValidationResult[],
|
|
269
|
+
node: PNode,
|
|
270
|
+
currentDepth: number
|
|
271
|
+
) {
|
|
272
|
+
// keep track of the global path we're on
|
|
273
|
+
context.path.push(node);
|
|
274
|
+
// get all shapes that focus on the current node
|
|
275
|
+
// and start a new context for them
|
|
276
|
+
const nodeShapes =
|
|
277
|
+
context.spec[node.type.name]?.map((shape) => ({
|
|
278
|
+
depth: currentDepth,
|
|
279
|
+
shape,
|
|
280
|
+
})) ?? [];
|
|
281
|
+
for (const { shape } of nodeShapes) {
|
|
282
|
+
context.shapeContext.set(shape, {
|
|
283
|
+
path: context.path,
|
|
284
|
+
focusNode: node,
|
|
285
|
+
shape,
|
|
286
|
+
count: 0,
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
// get the shapes that are active in this depth
|
|
290
|
+
const activeShapes = context.activeShapes.length
|
|
291
|
+
? context.activeShapes[context.activeShapes.length - 1]
|
|
292
|
+
: [];
|
|
293
|
+
const shapesToCheck = [...activeShapes, ...nodeShapes];
|
|
294
|
+
const nodeIsTargetOfShapes = [];
|
|
295
|
+
const pathIsValidForShapes = [];
|
|
296
|
+
|
|
297
|
+
for (const depthShape of shapesToCheck) {
|
|
298
|
+
const {
|
|
299
|
+
depth,
|
|
300
|
+
shape: { path },
|
|
301
|
+
} = depthShape;
|
|
302
|
+
// check if we're at the right depth for the shape's path to matter
|
|
303
|
+
// if we are deeper than the length of the path + the depth
|
|
304
|
+
// of the shapes focusNode, we can ignore the shape
|
|
305
|
+
if (path.length + depth === currentDepth) {
|
|
306
|
+
if (path.length === 0 || path[path.length - 1] === node.type.name) {
|
|
307
|
+
// we're at the end of the shapeSpec's path, and our node
|
|
308
|
+
// has the right type. This means we have fully completed the shape,
|
|
309
|
+
// and we need to validate it
|
|
310
|
+
nodeIsTargetOfShapes.push(depthShape);
|
|
311
|
+
}
|
|
312
|
+
} else if (path.length + depth > currentDepth) {
|
|
313
|
+
// we're somewhere along the spec's path.
|
|
314
|
+
// So either our node has the right type,
|
|
315
|
+
// meaning we're still "on the path", or it doesn't,
|
|
316
|
+
// and we can abandon this particular shape
|
|
317
|
+
// reminder that currentDepth is our "global" depth in the doc tree,
|
|
318
|
+
// and depth is the depth of the shape's focusNode
|
|
319
|
+
// so currentDepth - depth gives us the depth relative to the focusNode,
|
|
320
|
+
// which is how far along its path we are. Subtract 1 for usual array
|
|
321
|
+
// index shenanigans.
|
|
322
|
+
if (currentDepth - depth - 1 > 0) {
|
|
323
|
+
if (path[currentDepth - 1 - depth] === node.type.name) {
|
|
324
|
+
pathIsValidForShapes.push(depthShape);
|
|
325
|
+
}
|
|
326
|
+
} else {
|
|
327
|
+
// special case where we're at the same depth as the focusNode
|
|
328
|
+
// meaning we just activated the shape, so it's definitely still valid
|
|
329
|
+
// the fact that I need to write this comment probably means this
|
|
330
|
+
// could be refactored to be a bit cleaner, but here we are
|
|
331
|
+
pathIsValidForShapes.push(depthShape);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
// count all shapes we just completed with our node
|
|
336
|
+
for (const { shape } of nodeIsTargetOfShapes) {
|
|
337
|
+
const shapeContext = unwrap(context.shapeContext.get(shape));
|
|
338
|
+
context.shapeContext.set(shape, {
|
|
339
|
+
...shapeContext,
|
|
340
|
+
count: shapeContext.count + 1,
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// store all shapes where we're still on the path
|
|
345
|
+
context.activeShapes.push(pathIsValidForShapes);
|
|
346
|
+
|
|
347
|
+
// it's recursion time
|
|
348
|
+
for (let i = 0; i < node.content.childCount; i++) {
|
|
349
|
+
const child = node.content.child(i);
|
|
350
|
+
recValidate(context, results, child, currentDepth + 1);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// after the recursion, we've seen the entire subtree under our node
|
|
354
|
+
// so we're ready to validate any shapes that focus this node
|
|
355
|
+
for (const { shape } of nodeShapes) {
|
|
356
|
+
// a "this should never be null" nullcheck
|
|
357
|
+
const shapeContext = expect(
|
|
358
|
+
'Shapecontext not initialized',
|
|
359
|
+
context.shapeContext.get(shape)
|
|
360
|
+
);
|
|
361
|
+
// imagine if after all that it turns out the shape has no constraints...
|
|
362
|
+
if (shape.constraints) {
|
|
363
|
+
// with the shape's context nicely filled, we can now validate
|
|
364
|
+
for (const constraint of Object.entries(shape.constraints).map(
|
|
365
|
+
([kind, value]) => ({ kind, value } as ValidationConstraint)
|
|
366
|
+
)) {
|
|
367
|
+
const result = validateConstraint(constraint, shapeContext);
|
|
368
|
+
if (result) {
|
|
369
|
+
results.push(result);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
// clean up our context stack before we go back up
|
|
375
|
+
context.path.pop();
|
|
376
|
+
context.activeShapes.pop();
|
|
377
|
+
for (const depthShape of nodeShapes) {
|
|
378
|
+
context.shapeContext.delete(depthShape.shape);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function validateConstraint(
|
|
383
|
+
constraint: ValidationConstraint,
|
|
384
|
+
shapeContext: ShapeContext
|
|
385
|
+
): Option<ValidationResult> {
|
|
386
|
+
return CONSTRAINT_VALIDATOR_MAP[constraint.kind](constraint, shapeContext);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function validateMinCount(
|
|
390
|
+
constraint: MinCountConstraint,
|
|
391
|
+
shapeContext: ShapeContext
|
|
392
|
+
): Option<ValidationResult> {
|
|
393
|
+
const { shape, focusNode, count, path } = shapeContext;
|
|
394
|
+
if (count < constraint.value) {
|
|
395
|
+
return {
|
|
396
|
+
focusNode,
|
|
397
|
+
sourceConstraint: constraint,
|
|
398
|
+
sourceShape: shape,
|
|
399
|
+
message:
|
|
400
|
+
shape.message ??
|
|
401
|
+
`minCount violation for node with type ${focusNode.type.name}`,
|
|
402
|
+
resultPath: path,
|
|
403
|
+
severity: shape.severity ?? 'violation',
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
return null;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function validateMaxCount(
|
|
411
|
+
constraint: MaxCountConstraint,
|
|
412
|
+
shapeContext: ShapeContext
|
|
413
|
+
): Option<ValidationResult> {
|
|
414
|
+
const { shape, focusNode, count, path } = shapeContext;
|
|
415
|
+
if (count > constraint.value) {
|
|
416
|
+
return {
|
|
417
|
+
focusNode,
|
|
418
|
+
sourceConstraint: constraint,
|
|
419
|
+
sourceShape: shape,
|
|
420
|
+
message: `maxCount violation for node with type ${focusNode.type.name}`,
|
|
421
|
+
resultPath: path,
|
|
422
|
+
severity: shape.severity ?? 'violation',
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return null;
|
|
427
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { EditorState, Transaction } from '@lblod/ember-rdfa-editor';
|
|
2
|
+
import { validateTransaction } from '@lblod/ember-rdfa-editor-lblod-plugins/plugins/validation';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Validate a given transaction against a given set of shapes. Returns true when the
|
|
6
|
+
* state after applying the transaction is valid according to all given shapes,
|
|
7
|
+
* false otherwise.
|
|
8
|
+
* @param state the state the transaction was created from
|
|
9
|
+
* @param tr
|
|
10
|
+
* @param validateShapes
|
|
11
|
+
*/
|
|
12
|
+
export function transactionCompliesWithShapes(
|
|
13
|
+
state: EditorState,
|
|
14
|
+
tr: Transaction,
|
|
15
|
+
validateShapes?: Set<string>
|
|
16
|
+
) {
|
|
17
|
+
if (validateShapes?.size) {
|
|
18
|
+
const report = validateTransaction(state, tr);
|
|
19
|
+
if (
|
|
20
|
+
report.results?.some((result) =>
|
|
21
|
+
validateShapes.has(result.sourceShape.name)
|
|
22
|
+
)
|
|
23
|
+
) {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
return true;
|
|
28
|
+
}
|