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

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 (103) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -2
  3. package/dist/augmentation.d.ts +25 -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/commands/elementBlockCommand.d.ts +46 -0
  8. package/dist/commands/elementBlockCommand.d.ts.map +1 -0
  9. package/dist/commands/elementBlockCommand.js +71 -0
  10. package/dist/commands/elementBlockCommand.js.map +1 -0
  11. package/dist/commands/elementContainerCommand.d.ts +36 -0
  12. package/dist/commands/elementContainerCommand.d.ts.map +1 -0
  13. package/dist/commands/elementContainerCommand.js +67 -0
  14. package/dist/commands/elementContainerCommand.js.map +1 -0
  15. package/dist/commands/elementInlineCommand.d.ts +87 -0
  16. package/dist/commands/elementInlineCommand.d.ts.map +1 -0
  17. package/dist/commands/elementInlineCommand.js +253 -0
  18. package/dist/commands/elementInlineCommand.js.map +1 -0
  19. package/dist/commands/elementInsertCommand.d.ts +33 -0
  20. package/dist/commands/elementInsertCommand.d.ts.map +1 -0
  21. package/dist/commands/elementInsertCommand.js +61 -0
  22. package/dist/commands/elementInsertCommand.js.map +1 -0
  23. package/dist/commands/elementSchemaCommand.d.ts +62 -0
  24. package/dist/commands/elementSchemaCommand.d.ts.map +1 -0
  25. package/dist/commands/elementSchemaCommand.js +107 -0
  26. package/dist/commands/elementSchemaCommand.js.map +1 -0
  27. package/dist/conversion/convertElementSchema.d.ts +17 -0
  28. package/dist/conversion/convertElementSchema.d.ts.map +1 -0
  29. package/dist/conversion/convertElementSchema.js +55 -0
  30. package/dist/conversion/convertElementSchema.js.map +1 -0
  31. package/dist/conversion/inlineTextElement.d.ts +24 -0
  32. package/dist/conversion/inlineTextElement.d.ts.map +1 -0
  33. package/dist/conversion/inlineTextElement.js +28 -0
  34. package/dist/conversion/inlineTextElement.js.map +1 -0
  35. package/dist/definition/canHold.d.ts +10 -0
  36. package/dist/definition/canHold.d.ts.map +1 -0
  37. package/dist/definition/canHold.js +22 -0
  38. package/dist/definition/canHold.js.map +1 -0
  39. package/dist/definition/compileElementSchema.d.ts +41 -0
  40. package/dist/definition/compileElementSchema.d.ts.map +1 -0
  41. package/dist/definition/compileElementSchema.js +60 -0
  42. package/dist/definition/compileElementSchema.js.map +1 -0
  43. package/dist/definition/elementSchemaError.d.ts +12 -0
  44. package/dist/definition/elementSchemaError.d.ts.map +1 -0
  45. package/dist/definition/elementSchemaError.js +16 -0
  46. package/dist/definition/elementSchemaError.js.map +1 -0
  47. package/dist/definition/readDefinition.d.ts +18 -0
  48. package/dist/definition/readDefinition.d.ts.map +1 -0
  49. package/dist/definition/readDefinition.js +60 -0
  50. package/dist/definition/readDefinition.js.map +1 -0
  51. package/dist/definition/suggest.d.ts +5 -0
  52. package/dist/definition/suggest.d.ts.map +1 -0
  53. package/dist/definition/suggest.js +27 -0
  54. package/dist/definition/suggest.js.map +1 -0
  55. package/dist/definition/types.d.ts +105 -0
  56. package/dist/definition/types.d.ts.map +1 -0
  57. package/dist/definition/types.js +2 -0
  58. package/dist/definition/types.js.map +1 -0
  59. package/dist/definition/validateElementSchema.d.ts +26 -0
  60. package/dist/definition/validateElementSchema.d.ts.map +1 -0
  61. package/dist/definition/validateElementSchema.js +145 -0
  62. package/dist/definition/validateElementSchema.js.map +1 -0
  63. package/dist/elementSchema.d.ts +47 -0
  64. package/dist/elementSchema.d.ts.map +1 -0
  65. package/dist/elementSchema.js +108 -0
  66. package/dist/elementSchema.js.map +1 -0
  67. package/dist/elementSchemaCommands.d.ts +83 -0
  68. package/dist/elementSchemaCommands.d.ts.map +1 -0
  69. package/dist/elementSchemaCommands.js +137 -0
  70. package/dist/elementSchemaCommands.js.map +1 -0
  71. package/dist/index.d.ts +18 -0
  72. package/dist/index.d.ts.map +1 -0
  73. package/dist/index.js +22 -0
  74. package/dist/index.js.map +1 -0
  75. package/dist/typing/twoStepCaretMovement.d.ts +11 -0
  76. package/dist/typing/twoStepCaretMovement.d.ts.map +1 -0
  77. package/dist/typing/twoStepCaretMovement.js +76 -0
  78. package/dist/typing/twoStepCaretMovement.js.map +1 -0
  79. package/dist/validate.d.ts +12 -0
  80. package/dist/validate.d.ts.map +1 -0
  81. package/dist/validate.js +11 -0
  82. package/dist/validate.js.map +1 -0
  83. package/package.json +49 -3
  84. package/src/augmentation.ts +28 -0
  85. package/src/commands/elementBlockCommand.ts +87 -0
  86. package/src/commands/elementContainerCommand.ts +83 -0
  87. package/src/commands/elementInlineCommand.ts +311 -0
  88. package/src/commands/elementInsertCommand.ts +74 -0
  89. package/src/commands/elementSchemaCommand.ts +140 -0
  90. package/src/conversion/convertElementSchema.ts +65 -0
  91. package/src/conversion/inlineTextElement.ts +31 -0
  92. package/src/definition/canHold.ts +29 -0
  93. package/src/definition/compileElementSchema.ts +88 -0
  94. package/src/definition/elementSchemaError.ts +21 -0
  95. package/src/definition/readDefinition.ts +98 -0
  96. package/src/definition/suggest.ts +38 -0
  97. package/src/definition/types.ts +123 -0
  98. package/src/definition/validateElementSchema.ts +238 -0
  99. package/src/elementSchema.ts +133 -0
  100. package/src/elementSchemaCommands.ts +202 -0
  101. package/src/index.ts +30 -0
  102. package/src/typing/twoStepCaretMovement.ts +119 -0
  103. package/src/validate.ts +20 -0
@@ -0,0 +1,74 @@
1
+ import type { ModelDocumentSelection, ModelSelection } from 'ckeditor5';
2
+ import { selectNewElement, type ElementOrPosition } from 'ckeditor5-elements-core';
3
+ import ElementSchemaCommand from './elementSchemaCommand.js';
4
+
5
+ /**
6
+ * Inserts the element, as CKEditor's horizontal line and image commands do: an element that is not typed around text,
7
+ * e.g. an `empty` element of the element schema (a `divider`, or an inline `badge`), or an inline `container` (a `card`).
8
+ *
9
+ * ```ts
10
+ * editor.execute('divider'); // In place of the selection.
11
+ * editor.commands.get('divider').executeFor(element); // After the element.
12
+ * ```
13
+ *
14
+ * For the selection, it replaces the selected content, as typing does; at a position, it is inserted there; for an
15
+ * element, after it. Where the element cannot be, the blocks are split up to where it can, e.g. a block-level one in the
16
+ * middle of a paragraph. Then it is selected, as `selectNewElement()` does: an `empty` element as a whole, and an inline
17
+ * container gets a paragraph, with the cursor in it.
18
+ *
19
+ * It does not toggle: it is never "on".
20
+ */
21
+ export default class ElementInsertCommand extends ElementSchemaCommand {
22
+ /**
23
+ * Inserts a new element at the target, and selects it.
24
+ */
25
+ public override execute(elementOrPosition?: ElementOrPosition): void {
26
+ const model = this.editor.model;
27
+ const selectable = this._getSelectable(elementOrPosition);
28
+
29
+ if (!selectable) {
30
+ return;
31
+ }
32
+
33
+ model.change(writer => {
34
+ const element = writer.createElement(this.elementName);
35
+
36
+ model.insertContent(element, selectable);
37
+
38
+ if (element.parent) {
39
+ selectNewElement(writer, element);
40
+ }
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Enabled when the target can be edited, and the element can be there, or in one of its ancestors.
46
+ */
47
+ protected override _isEnabledFor(elementOrPosition?: ElementOrPosition): boolean {
48
+ const position = this._getSelectable(elementOrPosition)?.getFirstPosition();
49
+
50
+ return (
51
+ !!position &&
52
+ super._isEnabledFor(elementOrPosition) &&
53
+ !!this.editor.model.schema.findAllowedParent(position, this.elementName)
54
+ );
55
+ }
56
+
57
+ /**
58
+ * Returns where the element goes: the selection, or the given position, or the position after the given element.
59
+ */
60
+ private _getSelectable(elementOrPosition?: ElementOrPosition): ModelSelection | ModelDocumentSelection | null {
61
+ const model = this.editor.model;
62
+ const target = this._getElementOrPosition(elementOrPosition);
63
+
64
+ if (!target) {
65
+ return null;
66
+ }
67
+
68
+ if (!elementOrPosition) {
69
+ return model.document.selection;
70
+ }
71
+
72
+ return model.createSelection(target.is('position') ? target : model.createPositionAfter(target));
73
+ }
74
+ }
@@ -0,0 +1,140 @@
1
+ import type { Editor, ModelElement, ModelDocumentFragment, ModelItem, ModelPosition, ModelRange } from 'ckeditor5';
2
+ import { ElementOrPositionCommand, type ElementOrPosition } from 'ckeditor5-elements-core';
3
+
4
+ /**
5
+ * The base of the commands of the elements of a schema, one for each kind of element: what they share is the element,
6
+ * the state, and how they find their targets and check the schema.
7
+ *
8
+ * * {@link ElementInlineCommand}: an inline element with text, as bold (an inline `block`);
9
+ * * {@link ElementBlockCommand}: a block, as a heading (a `block`);
10
+ * * {@link ElementContainerCommand}: a container of blocks, as a block quote (a `container`);
11
+ * * {@link ElementInsertCommand}: an element that is inserted, as a horizontal line, an inline image, or a card (an
12
+ * `empty` element, and an inline `container`).
13
+ *
14
+ * As every {@link ElementOrPositionCommand}, it is for the selection, or for a given position or element, e.g. of a
15
+ * context menu.
16
+ */
17
+ export default class ElementSchemaCommand extends ElementOrPositionCommand {
18
+ /**
19
+ * Whether the target is in the element, or is one, for the commands that toggle it.
20
+ *
21
+ * @observable
22
+ */
23
+ declare public isOn: boolean;
24
+
25
+ /**
26
+ * The name of the model element of the command.
27
+ */
28
+ public readonly elementName: string;
29
+
30
+ constructor(editor: Editor, elementName: string) {
31
+ super(editor);
32
+
33
+ this.elementName = elementName;
34
+ this.set('isOn', false);
35
+ }
36
+
37
+ public override refresh(elementOrPosition?: ElementOrPosition): void {
38
+ this.isOn = this._isOnFor(elementOrPosition);
39
+ super.refresh(elementOrPosition);
40
+ }
41
+
42
+ /**
43
+ * Whether a node is an element of the command.
44
+ */
45
+ public isElement(node: ModelItem | ModelDocumentFragment | null): node is ModelElement {
46
+ return !!node?.is('element', this.elementName);
47
+ }
48
+
49
+ /**
50
+ * Returns the closest element of the command that the target is, or is in: by default, the selection. Never a root.
51
+ */
52
+ protected _findElement(elementOrPosition?: ElementOrPosition): ModelElement | null {
53
+ return this._getAncestors(elementOrPosition).find(element => this.isElement(element)) ?? null;
54
+ }
55
+
56
+ /**
57
+ * Returns the elements the target is, or is in (by default, the selection), the inner first: an element and its
58
+ * ancestors, or the parent of a position and its ancestors. Not the root.
59
+ */
60
+ protected _getAncestors(elementOrPosition?: ElementOrPosition): ModelElement[] {
61
+ const target = this._getElementOrPosition(elementOrPosition);
62
+ const element = target && (target.is('position') ? target.parent : target);
63
+
64
+ return element?.is('element')
65
+ ? element
66
+ .getAncestors({ includeSelf: true, parentFirst: true })
67
+ .filter((node): node is ModelElement => node.is('element') && !node.is('rootElement'))
68
+ : [];
69
+ }
70
+
71
+ /**
72
+ * Returns the ranges of the selected content, when the command is for the selection, and the selection is content,
73
+ * not a cursor nor an element.
74
+ */
75
+ protected _getSelectedRanges(elementOrPosition?: ElementOrPosition): ModelRange[] | null {
76
+ const selection = this.editor.model.document.selection;
77
+
78
+ return !elementOrPosition && !selection.isCollapsed && !selection.getSelectedElement()
79
+ ? Array.from(selection.getRanges())
80
+ : null;
81
+ }
82
+
83
+ /**
84
+ * Returns the blocks of the target, by default of the selection: the selected blocks, or the block-level element the
85
+ * target is, or is in (e.g. the paragraph with the cursor, a selected horizontal line, or a block quote).
86
+ */
87
+ protected _getBlocks(elementOrPosition?: ElementOrPosition): ModelElement[] {
88
+ const selection = this.editor.model.document.selection;
89
+ const blocks =
90
+ elementOrPosition || selection.getSelectedElement() ? [] : Array.from(selection.getSelectedBlocks());
91
+
92
+ if (blocks.length) {
93
+ return blocks;
94
+ }
95
+
96
+ const block = this._getAncestors(elementOrPosition).find(
97
+ element => !this.editor.model.schema.isInline(element)
98
+ );
99
+
100
+ return block ? [block] : [];
101
+ }
102
+
103
+ /**
104
+ * Whether an element (by default, the one of the command) can be at a position, and hold the items there: the check
105
+ * for wrapping them, or for renaming their parent.
106
+ */
107
+ protected _canHold(position: ModelPosition, items: Iterable<ModelItem>, name = this.elementName): boolean {
108
+ const schema = this.editor.model.schema;
109
+
110
+ if (!schema.checkChild(position, name)) {
111
+ return false;
112
+ }
113
+
114
+ const parent = position.parent as ModelElement;
115
+ const context = [...parent.getAncestors({ includeSelf: true }).filter(node => node.is('element')), name];
116
+
117
+ for (const item of items) {
118
+ if (!schema.checkChild(context, item.is('$textProxy') ? '$text' : item)) {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ return true;
124
+ }
125
+
126
+ /**
127
+ * Whether the children of an element can be in its place: the check for unwrapping it.
128
+ */
129
+ protected _canUnwrap(element: ModelElement): boolean {
130
+ const model = this.editor.model;
131
+
132
+ if (!element.parent) {
133
+ return false;
134
+ }
135
+
136
+ const position = model.createPositionBefore(element);
137
+
138
+ return Array.from(element.getChildren()).every(child => model.schema.checkChild(position, child));
139
+ }
140
+ }
@@ -0,0 +1,65 @@
1
+ import type { Editor } from 'ckeditor5';
2
+ import type { ElementSchemaElement, ElementSchemaInput } from '../definition/types.js';
3
+ import { createInlineTextElement } from './inlineTextElement.js';
4
+
5
+ /**
6
+ * Returns the tag of an element in the data: a `span` for what is in a line of text (an inline element that is empty or
7
+ * has text), a `div` for the rest. An inline container is a `div`: it holds blocks, which a `span` cannot hold.
8
+ */
9
+ export function getViewTag({ type, isInline }: Pick<ElementSchemaElement, 'type' | 'isInline'>): 'div' | 'span' {
10
+ return isInline && type !== 'container' ? 'span' : 'div';
11
+ }
12
+
13
+ /**
14
+ * Declares the converters of the elements of a schema, one to one: an element is a `div` or a `span` in the data (see
15
+ * {@link getViewTag}), with its name as the class. Its attributes are `data-*` attributes. The data is loaded as it is
16
+ * saved. The editing view is the same, except for the tag, when the element has an `editingTag`.
17
+ *
18
+ * The `empty` elements are empty in the data too: `<div class="divider"></div>`, with no filler. The inline elements with
19
+ * text have no block filler, so the cursor can go in an empty one (see `withoutBlockFiller()`).
20
+ */
21
+ export function convertElementSchema(editor: Editor, input: ElementSchemaInput): void {
22
+ const { conversion } = editor;
23
+
24
+ for (const [name, element] of Object.entries(input)) {
25
+ const { type, isInline, editingTag, allowAttributes } = element as ElementSchemaElement;
26
+ const tag = getViewTag(element as ElementSchemaElement);
27
+ const view = { name: tag, classes: name };
28
+ const isInlineText = type === 'block' && !!isInline;
29
+
30
+ for (const key of allowAttributes ?? []) {
31
+ // The names of HTML attributes are lowercase, so the data has them in lowercase; the model keeps the key as it is.
32
+ conversion.attributeToAttribute({ model: { name, key }, view: `data-${key.toLowerCase()}` });
33
+ }
34
+
35
+ // Upcast, data downcast and editing downcast at once; what differs is declared with a higher priority.
36
+ conversion.elementToElement({ model: name, view });
37
+
38
+ if (type === 'empty') {
39
+ conversion.for('dataDowncast').elementToElement({
40
+ model: name,
41
+ view: (_element, { writer }) => writer.createEmptyElement(tag, { class: name }),
42
+ converterPriority: 'high'
43
+ });
44
+ }
45
+
46
+ if (isInlineText) {
47
+ conversion.for('dataDowncast').elementToElement({
48
+ model: name,
49
+ view: (_element, { writer }) => createInlineTextElement(writer, tag, name),
50
+ converterPriority: 'high'
51
+ });
52
+ }
53
+
54
+ if (editingTag || isInlineText) {
55
+ conversion.for('editingDowncast').elementToElement({
56
+ model: name,
57
+ view: (_element, { writer }) =>
58
+ isInlineText
59
+ ? createInlineTextElement(writer, editingTag ?? tag, name)
60
+ : writer.createContainerElement(editingTag ?? tag, { class: name }),
61
+ converterPriority: 'high'
62
+ });
63
+ }
64
+ }
65
+ }
@@ -0,0 +1,31 @@
1
+ import type { ViewContainerElement, ViewDowncastWriter } from 'ckeditor5';
2
+
3
+ /**
4
+ * Makes a view element have no block filler, and returns it: for the view of an inline element with text, e.g. a bold
5
+ * that is an element, so that the cursor can go in it when it is empty.
6
+ *
7
+ * CKEditor puts a block filler, a `<br>`, in an empty container element, so that an empty paragraph has a height. In an
8
+ * inline element, the browser does not type there: the text goes before the element. Without the block filler, CKEditor
9
+ * puts its inline filler where the cursor is, as in the empty `<strong>` of its own bold, and the text goes in.
10
+ *
11
+ * ```ts
12
+ * conversion.for('downcast').elementToElement({
13
+ * model: 'bold',
14
+ * view: (element, { writer }) => withoutBlockFiller(writer.createContainerElement('strong'))
15
+ * });
16
+ * ```
17
+ *
18
+ * The converters of the element schema (`elementSchemaConverters`) and the `view` of `ElementSchemaCommands` do it already.
19
+ */
20
+ export function withoutBlockFiller<T extends ViewContainerElement>(element: T): T {
21
+ element.getFillerOffset = () => null;
22
+
23
+ return element;
24
+ }
25
+
26
+ /**
27
+ * Creates the view of an inline element with text: an element with no block filler (see {@link withoutBlockFiller}).
28
+ */
29
+ export function createInlineTextElement(writer: ViewDowncastWriter, tag: string, name?: string): ViewContainerElement {
30
+ return withoutBlockFiller(writer.createContainerElement(tag, name ? { class: name } : {}));
31
+ }
@@ -0,0 +1,29 @@
1
+ import type { ElementSchemaKind } from './types.js';
2
+
3
+ /**
4
+ * The root can hold blocks, containers and block-level empty elements, like a container.
5
+ */
6
+ export const ROOT: ElementSchemaKind = { type: 'container', isInline: false };
7
+
8
+ /**
9
+ * Returns why `parent` cannot contain `child` (an element, or `'$text'`), or `null` when it can.
10
+ */
11
+ export function whyNot(parent: ElementSchemaKind, child: ElementSchemaKind | '$text'): string | null {
12
+ const isText = child === '$text';
13
+
14
+ if (parent.type === 'empty') {
15
+ return 'an empty element cannot contain anything';
16
+ }
17
+
18
+ if (parent.type === 'block') {
19
+ const what = parent.isInline ? 'an inline text element' : 'a block';
20
+
21
+ return isText || child.isInline ? null : `${what} can contain only text and inline elements`;
22
+ }
23
+
24
+ if (isText) {
25
+ return 'a container cannot contain text: put the text in a block';
26
+ }
27
+
28
+ return child.isInline ? 'a container cannot contain inline elements: put them in a block' : null;
29
+ }
@@ -0,0 +1,88 @@
1
+ import type { ModelSchemaItemDefinition } from 'ckeditor5';
2
+ import type { ElementSchemaElement, ElementSchemaInput } from './types.js';
3
+
4
+ /**
5
+ * The result of {@link compileElementSchema}.
6
+ */
7
+ export interface CompiledElementSchema {
8
+ /**
9
+ * The items to register in CKEditor's schema, by name.
10
+ */
11
+ items: [name: string, definition: ModelSchemaItemDefinition][];
12
+
13
+ /**
14
+ * The `allowChildren` of the elements, as the places to add to the children: `schema.extend(child, { allowIn: parent })`.
15
+ * The child can be an element of another plugin, which is extended, not registered.
16
+ */
17
+ children: { parent: string; child: string }[];
18
+ }
19
+
20
+ /**
21
+ * Translates a valid element schema to the definitions of CKEditor's schema. Each type is made like a generic item of
22
+ * CKEditor:
23
+ *
24
+ * * `container`: like `$container`: allowed in the root and in containers; holds what is allowed in a container: blocks,
25
+ * containers and block objects, not text.
26
+ * * `block`: like `$block`: a block, allowed in the root and in containers; holds text and inline objects.
27
+ * * `empty` and not inline: like `$blockObject`: an object, allowed where blocks are.
28
+ * * `empty` and inline: like `$inlineObject`: an object, allowed where text is, with the attributes of text.
29
+ * * `block` and inline: an inline element that holds text and inline elements, allowed where text is, with the attributes
30
+ * of text: like `$text`, but an element, so it can have attributes of its own.
31
+ * * `container` and inline: a container laid out in a line, e.g. with others in a block: allowed where text is, like every
32
+ * inline element, and holds the same as a container: blocks, not text.
33
+ *
34
+ * `isInline` is about the layout and the position of an element, so it is set for every type, and CKEditor's features and
35
+ * the converters can use it.
36
+ *
37
+ * `allowAttributes` is passed as it is. `allowIn` replaces the default places of the type. `allowChildren` adds to what
38
+ * the type holds, as in CKEditor.
39
+ */
40
+ export function compileElementSchema(input: ElementSchemaInput): CompiledElementSchema {
41
+ const items: CompiledElementSchema['items'] = [];
42
+ const children: CompiledElementSchema['children'] = [];
43
+
44
+ for (const [name, element] of Object.entries(input)) {
45
+ items.push([name, compileElement(element as ElementSchemaElement)]);
46
+
47
+ for (const child of element.allowChildren ?? []) {
48
+ children.push({ parent: name, child });
49
+ }
50
+ }
51
+
52
+ return { items, children };
53
+ }
54
+
55
+ function compileElement({ type, isInline, allowIn, allowAttributes }: ElementSchemaElement): ModelSchemaItemDefinition {
56
+ // Where the element is allowed: the places of its type, or the given ones.
57
+ const places = (defaultPlaces: string): Pick<ModelSchemaItemDefinition, 'allowIn' | 'allowWhere'> =>
58
+ allowIn ? { allowIn } : { allowWhere: defaultPlaces };
59
+ const attributes = allowAttributes ? { allowAttributes } : {};
60
+
61
+ if (type === 'empty' && isInline) {
62
+ return { inheritTypesFrom: '$inlineObject', allowAttributesOf: '$text', ...places('$text'), ...attributes };
63
+ }
64
+
65
+ if (type === 'empty') {
66
+ return { inheritTypesFrom: '$blockObject', ...places('$block'), ...attributes };
67
+ }
68
+
69
+ if (type === 'block' && isInline) {
70
+ return {
71
+ isInline: true,
72
+ allowContentOf: '$block',
73
+ allowAttributesOf: '$text',
74
+ ...places('$text'),
75
+ ...attributes
76
+ };
77
+ }
78
+
79
+ if (type === 'block') {
80
+ return { inheritTypesFrom: '$block', allowContentOf: '$block', ...places('$block'), ...attributes };
81
+ }
82
+
83
+ return {
84
+ allowContentOf: '$container',
85
+ ...(isInline ? { isInline: true, ...places('$text') } : places('$block')),
86
+ ...attributes
87
+ };
88
+ }
@@ -0,0 +1,21 @@
1
+ import type { ElementSchemaIssue } from './types.js';
2
+
3
+ /**
4
+ * Thrown when the element schema is not valid. It lists all the problems, and has them in {@link issues}.
5
+ */
6
+ export default class ElementSchemaError extends Error {
7
+ /**
8
+ * The problems of the schema.
9
+ */
10
+ public readonly issues: readonly ElementSchemaIssue[];
11
+
12
+ constructor(issues: readonly ElementSchemaIssue[]) {
13
+ super(
14
+ `The element schema is not valid (${String(issues.length)} ${issues.length === 1 ? 'problem' : 'problems'}):\n` +
15
+ issues.map(({ path, message }) => `- ${path ? `${path}: ` : ''}${message}`).join('\n')
16
+ );
17
+
18
+ this.name = 'ElementSchemaError';
19
+ this.issues = issues;
20
+ }
21
+ }
@@ -0,0 +1,98 @@
1
+ import type { ElementSchemaKind } from './types.js';
2
+ import { suggest } from './suggest.js';
3
+
4
+ /**
5
+ * Reports a problem of the schema, at the path.
6
+ */
7
+ export type Report = (path: string, message: string) => void;
8
+
9
+ const TYPES = ['empty', 'block', 'container'];
10
+ export const ELEMENT_KEYS = ['type', 'isInline', 'allowChildren', 'allowIn', 'allowAttributes', 'editingTag'];
11
+
12
+ export const ELEMENT_NAME = /^[a-z][A-Za-z0-9]*$/;
13
+ export const ATTRIBUTE_NAME = /^[A-Za-z][\w.:-]*$/;
14
+ export const TAG_NAME = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
15
+
16
+ export function readKind(definition: Record<string, unknown>, path: string, report: Report): ElementSchemaKind | null {
17
+ const { type, isInline } = definition;
18
+
19
+ if (isInline !== undefined && typeof isInline !== 'boolean') {
20
+ report(`${path}.isInline`, 'must be true or false.');
21
+ }
22
+
23
+ if (type === undefined) {
24
+ report(`${path}.type`, 'is required: "empty", "block" or "container".');
25
+
26
+ return null;
27
+ }
28
+
29
+ if (typeof type !== 'string' || !TYPES.includes(type)) {
30
+ report(
31
+ `${path}.type`,
32
+ `must be "empty", "block" or "container", not ${JSON.stringify(type)}${typeof type === 'string' ? suggest(type, TYPES) : ''}.`
33
+ );
34
+
35
+ return null;
36
+ }
37
+
38
+ return { type: type as ElementSchemaKind['type'], isInline: isInline === true };
39
+ }
40
+
41
+ /**
42
+ * Reads a list of names, and reports what is not a list of unique, non-empty strings. Returns the list, or
43
+ * `undefined` when it is missing.
44
+ */
45
+ export function readNames(
46
+ definition: Record<string, unknown>,
47
+ key: string,
48
+ path: string,
49
+ report: Report
50
+ ): string[] | undefined {
51
+ const value = definition[key];
52
+
53
+ if (value === undefined) {
54
+ return undefined;
55
+ }
56
+
57
+ if (!Array.isArray(value)) {
58
+ report(`${path}.${key}`, `must be an array of names, e.g. ["a", "b"].`);
59
+
60
+ return undefined;
61
+ }
62
+
63
+ const names: string[] = [];
64
+
65
+ value.forEach((item: unknown, index) => {
66
+ const itemPath = `${path}.${key}[${String(index)}]`;
67
+
68
+ if (typeof item !== 'string' || !item) {
69
+ report(itemPath, 'must be a non-empty string.');
70
+ } else if (names.includes(item)) {
71
+ report(itemPath, `"${item}" is listed twice.`);
72
+ } else {
73
+ names.push(item);
74
+ }
75
+ });
76
+
77
+ return names;
78
+ }
79
+
80
+ export function checkKeys(
81
+ object: Record<string, unknown>,
82
+ allowed: readonly string[],
83
+ path: string,
84
+ report: Report
85
+ ): void {
86
+ for (const key of Object.keys(object)) {
87
+ if (!allowed.includes(key)) {
88
+ report(
89
+ path ? `${path}.${key}` : key,
90
+ `is not a known key${suggest(key, allowed)}. The keys are: ${allowed.map(name => `"${name}"`).join(', ')}.`
91
+ );
92
+ }
93
+ }
94
+ }
95
+
96
+ export function isRecord(value: unknown): value is Record<string, unknown> {
97
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
98
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Returns ` Did you mean "x"?` for the closest candidate, when it is close enough to be a typo.
3
+ */
4
+ export function suggest(name: string, candidates: readonly string[]): string {
5
+ let best: string | undefined;
6
+ let bestDistance = 3;
7
+
8
+ for (const candidate of candidates) {
9
+ const distance = editDistance(name.toLowerCase(), candidate.toLowerCase());
10
+
11
+ if (distance < bestDistance) {
12
+ best = candidate;
13
+ bestDistance = distance;
14
+ }
15
+ }
16
+
17
+ return best ? ` (did you mean "${best}"?)` : '';
18
+ }
19
+
20
+ function editDistance(a: string, b: string): number {
21
+ let previous = Array.from({ length: b.length + 1 }, (_, index) => index);
22
+
23
+ for (let i = 1; i <= a.length; i++) {
24
+ const current = [i];
25
+
26
+ for (let j = 1; j <= b.length; j++) {
27
+ current[j] = Math.min(
28
+ (previous[j] ?? 0) + 1,
29
+ (current[j - 1] ?? 0) + 1,
30
+ (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1)
31
+ );
32
+ }
33
+
34
+ previous = current;
35
+ }
36
+
37
+ return previous[b.length] ?? 0;
38
+ }