ckeditor5-elements-schema 0.0.0-stage → 49.0.0-alpha.2

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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +44 -2
  3. package/dist/augmentation.d.ts +19 -0
  4. package/dist/augmentation.d.ts.map +1 -0
  5. package/dist/augmentation.js +2 -0
  6. package/dist/augmentation.js.map +1 -0
  7. package/dist/canHold.d.ts +10 -0
  8. package/dist/canHold.d.ts.map +1 -0
  9. package/dist/canHold.js +22 -0
  10. package/dist/canHold.js.map +1 -0
  11. package/dist/compileElementSchema.d.ts +41 -0
  12. package/dist/compileElementSchema.d.ts.map +1 -0
  13. package/dist/compileElementSchema.js +60 -0
  14. package/dist/compileElementSchema.js.map +1 -0
  15. package/dist/convertElementSchema.d.ts +16 -0
  16. package/dist/convertElementSchema.d.ts.map +1 -0
  17. package/dist/convertElementSchema.js +43 -0
  18. package/dist/convertElementSchema.js.map +1 -0
  19. package/dist/elementSchema.d.ts +47 -0
  20. package/dist/elementSchema.d.ts.map +1 -0
  21. package/dist/elementSchema.js +108 -0
  22. package/dist/elementSchema.js.map +1 -0
  23. package/dist/elementSchemaError.d.ts +12 -0
  24. package/dist/elementSchemaError.d.ts.map +1 -0
  25. package/dist/elementSchemaError.js +16 -0
  26. package/dist/elementSchemaError.js.map +1 -0
  27. package/dist/index.d.ts +10 -0
  28. package/dist/index.d.ts.map +1 -0
  29. package/dist/index.js +12 -0
  30. package/dist/index.js.map +1 -0
  31. package/dist/readDefinition.d.ts +18 -0
  32. package/dist/readDefinition.d.ts.map +1 -0
  33. package/dist/readDefinition.js +60 -0
  34. package/dist/readDefinition.js.map +1 -0
  35. package/dist/suggest.d.ts +5 -0
  36. package/dist/suggest.d.ts.map +1 -0
  37. package/dist/suggest.js +27 -0
  38. package/dist/suggest.js.map +1 -0
  39. package/dist/types.d.ts +105 -0
  40. package/dist/types.d.ts.map +1 -0
  41. package/dist/types.js +2 -0
  42. package/dist/types.js.map +1 -0
  43. package/dist/validate.d.ts +12 -0
  44. package/dist/validate.d.ts.map +1 -0
  45. package/dist/validate.js +11 -0
  46. package/dist/validate.js.map +1 -0
  47. package/dist/validateElementSchema.d.ts +26 -0
  48. package/dist/validateElementSchema.d.ts.map +1 -0
  49. package/dist/validateElementSchema.js +145 -0
  50. package/dist/validateElementSchema.js.map +1 -0
  51. package/package.json +49 -3
  52. package/src/augmentation.ts +21 -0
  53. package/src/canHold.ts +29 -0
  54. package/src/compileElementSchema.ts +88 -0
  55. package/src/convertElementSchema.ts +51 -0
  56. package/src/elementSchema.ts +133 -0
  57. package/src/elementSchemaError.ts +21 -0
  58. package/src/index.ts +14 -0
  59. package/src/readDefinition.ts +98 -0
  60. package/src/suggest.ts +38 -0
  61. package/src/types.ts +123 -0
  62. package/src/validate.ts +20 -0
  63. package/src/validateElementSchema.ts +238 -0
@@ -0,0 +1,238 @@
1
+ import { ROOT, whyNot } from './canHold.js';
2
+ import {
3
+ ATTRIBUTE_NAME,
4
+ ELEMENT_KEYS,
5
+ ELEMENT_NAME,
6
+ TAG_NAME,
7
+ checkKeys,
8
+ isRecord,
9
+ readKind,
10
+ readNames,
11
+ type Report
12
+ } from './readDefinition.js';
13
+ import { suggest } from './suggest.js';
14
+ import type { ElementSchemaIssue, ElementSchemaKind, ValidateElementSchemaOptions } from './types.js';
15
+
16
+ /**
17
+ * Checks an element schema, so that it is not contradictory, and returns the problems. An empty array means that the
18
+ * schema is valid.
19
+ *
20
+ * The schema is rejected when it:
21
+ *
22
+ * * is not of the shape {@link ElementSchemaDefinition}: an object of elements, by name; has keys that are not part of
23
+ * it (typos are problems), or has values of the wrong type;
24
+ * * declares an element with an unknown `type`, or an `empty` element with `allowChildren`;
25
+ * * has attribute names that are not valid, or that differ only in case (HTML attribute names are not case sensitive),
26
+ * or an `editingTag` that is not the name of a tag;
27
+ * * refers to elements that do not exist (a name that is neither declared in the schema, nor in `external`), or lists the
28
+ * same name twice;
29
+ * * allows what its type cannot hold: text or an inline element in a container or in the root, a block-level (not inline)
30
+ * element in a block, or anything in an `empty` element, whichever side says it: `allowChildren` of the parent, or
31
+ * `allowIn` of the child.
32
+ *
33
+ * `isInline` is about the position and the layout of an element, so it is valid for every type: an inline element is
34
+ * laid out in a line with its siblings. Inline elements are allowed only in blocks, which can hold several of them,
35
+ * e.g. inline containers, or inline images between text.
36
+ *
37
+ * It does not need an editor. The editor of `ElementSchema` runs it with the elements of the other plugins as `external`.
38
+ */
39
+ export function validateElementSchema(
40
+ input: unknown,
41
+ { external = {} }: ValidateElementSchemaOptions = {}
42
+ ): ElementSchemaIssue[] {
43
+ const issues: ElementSchemaIssue[] = [];
44
+ const report = (path: string, message: string): void => {
45
+ issues.push({ path, message });
46
+ };
47
+
48
+ if (!isRecord(input)) {
49
+ report(
50
+ '',
51
+ 'The schema must be an object of element definitions, by name: { "note": { "type": "container" } }.'
52
+ );
53
+
54
+ return issues;
55
+ }
56
+
57
+ const elements = input;
58
+
59
+ if (!Object.keys(elements).length) {
60
+ report('', 'The schema declares no elements: declare at least one.');
61
+
62
+ return issues;
63
+ }
64
+
65
+ if (isRecord(elements.elements) && !('type' in elements.elements)) {
66
+ report(
67
+ 'elements',
68
+ 'the schema is the object of the elements itself: remove the "elements" wrapper, and put the elements at the top.'
69
+ );
70
+
71
+ return issues;
72
+ }
73
+
74
+ // The first pass checks every definition on its own, and collects the kinds and the lists to cross-check.
75
+ const kinds = new Map<string, ElementSchemaKind>();
76
+ const lists = new Map<string, { allowChildren: string[]; allowIn: string[] }>();
77
+
78
+ for (const [name, definition] of Object.entries(elements)) {
79
+ const path = name;
80
+
81
+ if (!ELEMENT_NAME.test(name)) {
82
+ report(
83
+ path,
84
+ `"${name}" is not a valid element name: use camelCase letters and digits, starting with a lowercase letter.`
85
+ );
86
+ }
87
+
88
+ if (name in external) {
89
+ report(
90
+ path,
91
+ `"${name}" already exists in the editor (it is declared by another plugin): remove it, or rename it.`
92
+ );
93
+ }
94
+
95
+ if (!isRecord(definition)) {
96
+ report(path, 'must be an object, e.g. { "type": "block" }.');
97
+ continue;
98
+ }
99
+
100
+ checkKeys(definition, ELEMENT_KEYS, path, report);
101
+
102
+ const kind = readKind(definition, path, report);
103
+
104
+ if (kind) {
105
+ kinds.set(name, kind);
106
+ }
107
+
108
+ const allowChildren = readNames(definition, 'allowChildren', path, report);
109
+ const allowIn = readNames(definition, 'allowIn', path, report);
110
+ const allowAttributes = readNames(definition, 'allowAttributes', path, report);
111
+
112
+ if (allowIn && !allowIn.length) {
113
+ report(
114
+ `${path}.allowIn`,
115
+ 'is empty, so the element would be allowed nowhere: list its parents, or remove it for the default places.'
116
+ );
117
+ }
118
+
119
+ const lowercase = new Map<string, string>();
120
+
121
+ for (const [index, attribute] of (allowAttributes ?? []).entries()) {
122
+ const attributePath = `${path}.allowAttributes[${String(index)}]`;
123
+ const collides = lowercase.get(attribute.toLowerCase());
124
+
125
+ if (!ATTRIBUTE_NAME.test(attribute)) {
126
+ report(attributePath, `"${attribute}" is not a valid attribute name.`);
127
+ } else if (collides !== undefined) {
128
+ report(
129
+ attributePath,
130
+ `"${attribute}" collides with "${collides}": the names of HTML attributes are not case sensitive, so the data could not tell them apart.`
131
+ );
132
+ } else {
133
+ lowercase.set(attribute.toLowerCase(), attribute);
134
+ }
135
+ }
136
+
137
+ if (
138
+ definition.editingTag !== undefined &&
139
+ (typeof definition.editingTag !== 'string' || !TAG_NAME.test(definition.editingTag))
140
+ ) {
141
+ report(
142
+ `${path}.editingTag`,
143
+ `must be the name of an HTML tag, in lowercase, e.g. "section", not ${JSON.stringify(definition.editingTag)}.`
144
+ );
145
+ }
146
+
147
+ if (kind?.type === 'empty' && allowChildren?.length) {
148
+ report(
149
+ `${path}.allowChildren`,
150
+ 'an empty element has no content: remove allowChildren, or change the type.'
151
+ );
152
+ }
153
+
154
+ lists.set(name, { allowChildren: kind?.type === 'empty' ? [] : (allowChildren ?? []), allowIn: allowIn ?? [] });
155
+ }
156
+
157
+ // The second pass checks what the lists refer to, and whether the types can hold each other.
158
+ const known = (name: string): ElementSchemaKind | undefined => kinds.get(name) ?? external[name];
159
+ const candidates = [...kinds.keys(), ...Object.keys(external)];
160
+
161
+ for (const [name, { allowChildren, allowIn }] of lists) {
162
+ const kind = kinds.get(name);
163
+
164
+ if (!kind) {
165
+ continue;
166
+ }
167
+
168
+ allowChildren.forEach((child, index) => {
169
+ const path = `${name}.allowChildren[${String(index)}]`;
170
+ const childKind = child === '$text' ? child : resolveReference(child, known, candidates, path, report);
171
+
172
+ if (childKind) {
173
+ const reason = whyNot(kind, childKind);
174
+
175
+ if (reason) {
176
+ report(path, `"${child}" cannot be a child of "${name}": ${reason}.`);
177
+ }
178
+ }
179
+ });
180
+
181
+ allowIn.forEach((parent, index) => {
182
+ const path = `${name}.allowIn[${String(index)}]`;
183
+ const parentKind = parent === '$root' ? ROOT : resolveReference(parent, known, candidates, path, report);
184
+
185
+ if (parentKind) {
186
+ const reason = whyNot(parentKind, kind);
187
+
188
+ if (reason) {
189
+ report(
190
+ path,
191
+ `"${name}" cannot be allowed in ${parent === '$root' ? 'the root' : `"${parent}"`}: ${reason}.`
192
+ );
193
+ }
194
+ }
195
+ });
196
+ }
197
+
198
+ // In the order of the file: the problems of an element together, whichever pass found them.
199
+ const names = Object.keys(elements);
200
+ const position = ({ path }: ElementSchemaIssue): number =>
201
+ names.findIndex(name => path === name || path.startsWith(`${name}.`));
202
+
203
+ return issues.sort((a, b) => position(a) - position(b));
204
+ }
205
+
206
+ /**
207
+ * Returns the kind of the element the list entry names. Reports an unknown name (with the closest known one), and the
208
+ * names that cannot be used there: `$text` and `$root` are not elements that can be both parents and children.
209
+ */
210
+ function resolveReference(
211
+ name: string,
212
+ known: (name: string) => ElementSchemaKind | undefined,
213
+ candidates: readonly string[],
214
+ path: string,
215
+ report: Report
216
+ ): ElementSchemaKind | undefined {
217
+ if (name === '$text' || name === '$root') {
218
+ report(
219
+ path,
220
+ name === '$text'
221
+ ? '"$text" is not an element: it is text, and cannot have children or parents.'
222
+ : '"$root" is the root of the editor: it can be a parent (allowIn), not a child.'
223
+ );
224
+
225
+ return undefined;
226
+ }
227
+
228
+ const kind = known(name);
229
+
230
+ if (!kind) {
231
+ report(
232
+ path,
233
+ `unknown element "${name}"${suggest(name, candidates)}. It must be declared in the schema, or be an element of another plugin that is loaded.`
234
+ );
235
+ }
236
+
237
+ return kind;
238
+ }