diff --git a/docs/reference/slate/schema.md b/docs/reference/slate/schema.md
index 6c91d951a..69231d1ad 100644
--- a/docs/reference/slate/schema.md
+++ b/docs/reference/slate/schema.md
@@ -69,7 +69,9 @@ A dictionary of inlines by type, each with its own set of validation rules.
```js
{
data: Object,
+ first: Object,
isVoid: Boolean,
+ last: Object,
nodes: Array,
normalize: Function,
parent: Object,
@@ -90,7 +92,18 @@ Slate schemas are built up of a set of validation rules. Each of the properties
}
```
-A dictionary of
+A dictionary of data attributes and their corresponding validation functions. The functions should return a boolean indicating whether the data value is valid or not.
+
+### `first`
+`Object`
+
+```js
+{
+ first: { types: ['quote', 'paragraph'] },
+}
+```
+
+Will validate the first child node. The `first` definition can declare `kinds` and `types` properties.
### `isVoid`
`Boolean`
@@ -103,6 +116,17 @@ A dictionary of
Will validate a node's `isVoid` property.
+### `last`
+`Object`
+
+```js
+{
+ last: { types: ['quote', 'paragraph'] },
+}
+```
+
+Will validate the last child node. The `last` definition can declare `kinds` and `types` properties.
+
### `nodes`
`Array`
@@ -115,7 +139,9 @@ Will validate a node's `isVoid` property.
}
```
-Will validate a node's children. The node definitions can declare the `kinds`, `types`, `min` and `max` properties.
+Will validate a node's children. The `nodes` definitions can declare the `kinds`, `types`, `min` and `max` properties.
+
+> 🤖 The `nodes` array is order-sensitive! The example above will require that the first node be either an `image` or `video`, and that it be followed by one or more `paragraph` nodes.
### `normalize`
`normalize(change: Change, reason: String, context: Object) => Void`
@@ -233,6 +259,46 @@ When supplying your own `normalize` property for a schema rule, it will be calle
}
```
+### `first_child_kind_invalid`
+
+```js
+{
+ child: Node,
+ node: Node,
+ rule: Object,
+}
+```
+
+### `first_child_type_invalid`
+
+```js
+{
+ child: Node,
+ node: Node,
+ rule: Object,
+}
+```
+
+### `last_child_kind_invalid`
+
+```js
+{
+ child: Node,
+ node: Node,
+ rule: Object,
+}
+```
+
+### `last_child_type_invalid`
+
+```js
+{
+ child: Node,
+ node: Node,
+ rule: Object,
+}
+```
+
### `node_data_invalid`
```js
diff --git a/examples/forced-layout/index.js b/examples/forced-layout/index.js
index 37d1d9bb9..343ebec0a 100644
--- a/examples/forced-layout/index.js
+++ b/examples/forced-layout/index.js
@@ -74,7 +74,6 @@ class ForcedLayout extends React.Component {
schema={schema}
onChange={this.onChange}
renderNode={this.renderNode}
- validateNode={this.validateNode}
/>
)
diff --git a/examples/images/index.js b/examples/images/index.js
index 284f989f3..83f0933b8 100644
--- a/examples/images/index.js
+++ b/examples/images/index.js
@@ -27,6 +27,26 @@ function insertImage(change, src, target) {
})
}
+/**
+ * A schema to enforce that there's always a paragraph as the last block.
+ *
+ * @type {Object}
+ */
+
+const schema = {
+ document: {
+ last: { types: ['paragraph'] },
+ normalize: (change, reason, { node, child }) => {
+ switch (reason) {
+ case 'last_child_type_invalid': {
+ const paragraph = Block.create('paragraph')
+ return change.insertNodeByKey(node.key, node.nodes.size, paragraph)
+ }
+ }
+ }
+ }
+}
+
/**
* The images example.
*
@@ -88,11 +108,11 @@ class Images extends React.Component {
)
@@ -119,24 +139,6 @@ class Images extends React.Component {
}
}
- /**
- * Perform node validation on the document.
- *
- * @param {Node} node
- * @return {Function|Void}
- */
-
- validateNode = (node) => {
- if (node.kind != 'document') return
- const last = node.nodes.last()
-
- if (!last || last.type != 'paragraph') {
- const index = node.nodes.size
- const paragraph = Block.create('paragraph')
- return change => change.insertNodeByKey(node.key, index, paragraph)
- }
- }
-
/**
* On change.
*
diff --git a/packages/slate/src/models/schema.js b/packages/slate/src/models/schema.js
index 9bcaf2ace..4fd0bbd94 100644
--- a/packages/slate/src/models/schema.js
+++ b/packages/slate/src/models/schema.js
@@ -19,6 +19,10 @@ const CHILD_KIND_INVALID = 'child_kind_invalid'
const CHILD_REQUIRED = 'child_required'
const CHILD_TYPE_INVALID = 'child_type_invalid'
const CHILD_UNKNOWN = 'child_unknown'
+const FIRST_CHILD_KIND_INVALID = 'first_child_kind_invalid'
+const FIRST_CHILD_TYPE_INVALID = 'first_child_type_invalid'
+const LAST_CHILD_KIND_INVALID = 'last_child_kind_invalid'
+const LAST_CHILD_TYPE_INVALID = 'last_child_type_invalid'
const NODE_DATA_INVALID = 'node_data_invalid'
const NODE_IS_VOID_INVALID = 'node_is_void_invalid'
const NODE_MARK_INVALID = 'node_mark_invalid'
@@ -204,7 +208,11 @@ class Schema extends Record(DEFAULTS) {
switch (reason) {
case CHILD_KIND_INVALID:
case CHILD_TYPE_INVALID:
- case CHILD_UNKNOWN: {
+ case CHILD_UNKNOWN:
+ case FIRST_CHILD_KIND_INVALID:
+ case FIRST_CHILD_TYPE_INVALID:
+ case LAST_CHILD_KIND_INVALID:
+ case LAST_CHILD_TYPE_INVALID: {
const { child, node } = context
return child.kind == 'text' && node.kind == 'block' && node.nodes.size == 1
? change.removeNodeByKey(node.key)
@@ -295,6 +303,30 @@ class Schema extends Record(DEFAULTS) {
}
}
+ if (rule.first != null) {
+ const first = node.nodes.first()
+
+ if (rule.first.kinds != null && !rule.first.kinds.includes(first.kind)) {
+ return this.fail(FIRST_CHILD_KIND_INVALID, { ...ctx, child: first })
+ }
+
+ if (rule.first.types != null && !rule.first.types.includes(first.type)) {
+ return this.fail(FIRST_CHILD_TYPE_INVALID, { ...ctx, child: first })
+ }
+ }
+
+ if (rule.last != null) {
+ const last = node.nodes.last()
+
+ if (rule.last.kinds != null && !rule.last.kinds.includes(last.kind)) {
+ return this.fail(LAST_CHILD_KIND_INVALID, { ...ctx, child: last })
+ }
+
+ if (rule.last.types != null && !rule.last.types.includes(last.type)) {
+ return this.fail(LAST_CHILD_TYPE_INVALID, { ...ctx, child: last })
+ }
+ }
+
if (rule.nodes != null || parents != null) {
const children = node.nodes.toArray()
const defs = rule.nodes != null ? rule.nodes.slice() : []
@@ -470,6 +502,8 @@ function resolveNodeRule(kind, type, obj) {
data: {},
isVoid: null,
nodes: null,
+ first: null,
+ last: null,
parent: null,
text: null,
...obj,
diff --git a/packages/slate/test/schema/custom/first-child-kind-invalid-custom.js b/packages/slate/test/schema/custom/first-child-kind-invalid-custom.js
new file mode 100644
index 000000000..8c836353e
--- /dev/null
+++ b/packages/slate/test/schema/custom/first-child-kind-invalid-custom.js
@@ -0,0 +1,39 @@
+/** @jsx h */
+
+import h from '../../helpers/h'
+
+export const schema = {
+ blocks: {
+ paragraph: {},
+ quote: {
+ first: { kinds: ['block'] },
+ normalize: (change, reason, { child }) => {
+ if (reason == 'first_child_kind_invalid') {
+ change.wrapBlockByKey(child.key, 'paragraph')
+ }
+ }
+ }
+ }
+}
+
+export const input = (
+
+
+
+ text
+
+
+
+)
+
+export const output = (
+
+
+
+
+ text
+
+
+
+
+)
diff --git a/packages/slate/test/schema/custom/first-child-kind-invalid-default.js b/packages/slate/test/schema/custom/first-child-kind-invalid-default.js
new file mode 100644
index 000000000..8e165d884
--- /dev/null
+++ b/packages/slate/test/schema/custom/first-child-kind-invalid-default.js
@@ -0,0 +1,30 @@
+/** @jsx h */
+
+import h from '../../helpers/h'
+
+export const schema = {
+ blocks: {
+ paragraph: {},
+ quote: {
+ first: { kinds: ['text'] },
+ }
+ }
+}
+
+export const input = (
+
+
+
+
+
+
+
+)
+
+export const output = (
+
+
+
+
+
+)
diff --git a/packages/slate/test/schema/custom/first-child-type-invalid-custom.js b/packages/slate/test/schema/custom/first-child-type-invalid-custom.js
new file mode 100644
index 000000000..912af00f8
--- /dev/null
+++ b/packages/slate/test/schema/custom/first-child-type-invalid-custom.js
@@ -0,0 +1,43 @@
+/** @jsx h */
+
+import h from '../../helpers/h'
+
+export const schema = {
+ blocks: {
+ paragraph: {},
+ quote: {
+ first: { types: ['paragraph'] },
+ normalize: (change, reason, { child }) => {
+ if (reason == 'first_child_type_invalid') {
+ change.wrapBlockByKey(child.key, 'paragraph')
+ }
+ }
+ }
+ }
+}
+
+export const input = (
+
+
+
+
+
+
+
+
+
+)
+
+export const output = (
+
+
+
+
+
+
+
+
+
+
+
+)
diff --git a/packages/slate/test/schema/custom/first-child-type-invalid-default.js b/packages/slate/test/schema/custom/first-child-type-invalid-default.js
new file mode 100644
index 000000000..faff1f4bc
--- /dev/null
+++ b/packages/slate/test/schema/custom/first-child-type-invalid-default.js
@@ -0,0 +1,35 @@
+/** @jsx h */
+
+import h from '../../helpers/h'
+
+export const schema = {
+ blocks: {
+ paragraph: {},
+ quote: {
+ first: { types: ['paragraph'] }
+ }
+ }
+}
+
+export const input = (
+
+
+
+
+
+
+
+
+
+)
+
+export const output = (
+
+
+
+
+
+
+
+
+)
diff --git a/packages/slate/test/schema/custom/last-child-kind-invalid-custom.js b/packages/slate/test/schema/custom/last-child-kind-invalid-custom.js
new file mode 100644
index 000000000..35fa79300
--- /dev/null
+++ b/packages/slate/test/schema/custom/last-child-kind-invalid-custom.js
@@ -0,0 +1,39 @@
+/** @jsx h */
+
+import h from '../../helpers/h'
+
+export const schema = {
+ blocks: {
+ paragraph: {},
+ quote: {
+ last: { kinds: ['block'] },
+ normalize: (change, reason, { child }) => {
+ if (reason == 'last_child_kind_invalid') {
+ change.wrapBlockByKey(child.key, 'paragraph')
+ }
+ }
+ }
+ }
+}
+
+export const input = (
+
+
+
+ text
+
+
+
+)
+
+export const output = (
+
+
+
+
+ text
+
+
+
+
+)
diff --git a/packages/slate/test/schema/custom/last-child-kind-invalid-default.js b/packages/slate/test/schema/custom/last-child-kind-invalid-default.js
new file mode 100644
index 000000000..a8632ef0e
--- /dev/null
+++ b/packages/slate/test/schema/custom/last-child-kind-invalid-default.js
@@ -0,0 +1,30 @@
+/** @jsx h */
+
+import h from '../../helpers/h'
+
+export const schema = {
+ blocks: {
+ paragraph: {},
+ quote: {
+ last: { kinds: ['text'] },
+ }
+ }
+}
+
+export const input = (
+
+
+
+
+
+
+
+)
+
+export const output = (
+
+
+
+
+
+)
diff --git a/packages/slate/test/schema/custom/last-child-type-invalid-custom.js b/packages/slate/test/schema/custom/last-child-type-invalid-custom.js
new file mode 100644
index 000000000..8f80afe46
--- /dev/null
+++ b/packages/slate/test/schema/custom/last-child-type-invalid-custom.js
@@ -0,0 +1,43 @@
+/** @jsx h */
+
+import h from '../../helpers/h'
+
+export const schema = {
+ blocks: {
+ paragraph: {},
+ quote: {
+ last: { types: ['paragraph'] },
+ normalize: (change, reason, { child }) => {
+ if (reason == 'last_child_type_invalid') {
+ change.wrapBlockByKey(child.key, 'paragraph')
+ }
+ }
+ }
+ }
+}
+
+export const input = (
+
+
+
+
+
+
+
+
+
+)
+
+export const output = (
+
+
+
+
+
+
+
+
+
+
+
+)
diff --git a/packages/slate/test/schema/custom/last-child-type-invalid-default.js b/packages/slate/test/schema/custom/last-child-type-invalid-default.js
new file mode 100644
index 000000000..82c0f8111
--- /dev/null
+++ b/packages/slate/test/schema/custom/last-child-type-invalid-default.js
@@ -0,0 +1,35 @@
+/** @jsx h */
+
+import h from '../../helpers/h'
+
+export const schema = {
+ blocks: {
+ paragraph: {},
+ quote: {
+ last: { types: ['paragraph'] }
+ }
+ }
+}
+
+export const input = (
+
+
+
+
+
+
+
+
+
+)
+
+export const output = (
+
+
+
+
+
+
+
+
+)