diff --git a/examples/auto-markdown/index.js b/examples/auto-markdown/index.js
index ac141c868..acd0b8f26 100644
--- a/examples/auto-markdown/index.js
+++ b/examples/auto-markdown/index.js
@@ -9,6 +9,48 @@ import initialState from './state.json'
* @type {Object}
*/
+const schema = {
+ rules: [
+ {
+ match: { type: 'block-quote' },
+ component: props =>
{props.children}
+ },
+ {
+ match: { type: 'bulleted-list' },
+ component: props => ,
+ },
+ {
+ match: { type: 'heading-one' },
+ component: props => {props.children}
,
+ },
+ {
+ match: { type: 'heading-two' },
+ component: props => {props.children}
,
+ },
+ {
+ match: { type: 'heading-three' },
+ component: props => {props.children}
,
+ },
+ {
+ match: { type: 'heading-four' },
+ component: props => {props.children}
,
+ },
+ {
+ match: { type: 'heading-five' },
+ component: props => {props.children}
,
+ },
+ {
+ match: { type: 'heading-six' },
+ component: props => {props.children}
,
+ },
+ {
+ match: { type: 'list-item' },
+ component: props => {props.children},
+ },
+ ]
+}
+
+
const NODES = {
'block-quote': props => {props.children}
,
'bulleted-list': props => ,
diff --git a/lib/components/content.js b/lib/components/content.js
index 4e2db718e..5ac7188f3 100644
--- a/lib/components/content.js
+++ b/lib/components/content.js
@@ -6,11 +6,11 @@ import OffsetKey from '../utils/offset-key'
import React from 'react'
import Selection from '../models/selection'
import Transfer from '../utils/transfer'
-import TYPES from '../utils/types'
+import TYPES from '../constants/types'
import getWindow from 'get-window'
import includes from 'lodash/includes'
import keycode from 'keycode'
-import { IS_FIREFOX, IS_MAC } from '../utils/environment'
+import { IS_FIREFOX, IS_MAC } from '../constants/environment'
/**
* Debug.
diff --git a/lib/components/editor.js b/lib/components/editor.js
index 91b40d82b..82a2ab1cc 100644
--- a/lib/components/editor.js
+++ b/lib/components/editor.js
@@ -1,9 +1,11 @@
import Content from './content'
import CorePlugin from '../plugins/core'
-import React from 'react'
-import State from '../models/state'
import Debug from 'debug'
+import React from 'react'
+import Schema from '../models/schema'
+import State from '../models/state'
+import isReactComponent from '../utils/is-react-component'
import typeOf from 'type-of'
/**
@@ -77,6 +79,7 @@ class Editor extends React.Component {
onSelectionChange: noop,
plugins: [],
readOnly: false,
+ schema: {},
spellCheck: true
};
@@ -90,6 +93,7 @@ class Editor extends React.Component {
super(props)
this.tmp = {}
this.state = {}
+ this.state.schema = Schema.create(props.schema)
this.state.plugins = this.resolvePlugins(props)
this.state.state = this.onBeforeChange(props.state)
@@ -108,6 +112,12 @@ class Editor extends React.Component {
*/
componentWillReceiveProps = (props) => {
+ if (props.schema != this.props.schema) {
+ this.setState({
+ schema: Schema.create(props.schema)
+ })
+ }
+
if (props.plugins != this.props.plugins) {
this.setState({
plugins: this.resolvePlugins(props)
@@ -145,6 +155,16 @@ class Editor extends React.Component {
this.onChange(state)
}
+ /**
+ * Get the editor's current `schema`.
+ *
+ * @return {Schema}
+ */
+
+ getSchema = () => {
+ return this.state.schema
+ }
+
/**
* Get the editor's current `state`.
*
@@ -288,7 +308,7 @@ class Editor extends React.Component {
let ret = plugin.renderMark(mark, marks, this.state.state, this)
// Handle React components that aren't stateless functions.
- if (ret && ret.prototype && ret.prototype.isReactComponent) return ret
+ if (isReactComponent(ret)) return ret
// Handle all other types...
switch (typeOf(ret)) {
diff --git a/lib/components/node.js b/lib/components/node.js
index ffd7e8147..3d6c68c4d 100644
--- a/lib/components/node.js
+++ b/lib/components/node.js
@@ -3,7 +3,7 @@ import Base64 from '../serializers/base-64'
import Debug from 'debug'
import React from 'react'
import ReactDOM from 'react-dom'
-import TYPES from '../utils/types'
+import TYPES from '../constants/types'
import Leaf from './leaf'
import Void from './void'
import scrollTo from '../utils/scroll-to'
diff --git a/lib/components/void.js b/lib/components/void.js
index bbe2d9ac1..93cc36f2f 100644
--- a/lib/components/void.js
+++ b/lib/components/void.js
@@ -5,7 +5,7 @@ import OffsetKey from '../utils/offset-key'
import React from 'react'
import ReactDOM from 'react-dom'
import keycode from 'keycode'
-import { IS_FIREFOX } from '../utils/environment'
+import { IS_FIREFOX } from '../constants/environment'
/**
* Noop.
diff --git a/lib/utils/environment.js b/lib/constants/environment.js
similarity index 100%
rename from lib/utils/environment.js
rename to lib/constants/environment.js
diff --git a/lib/constants/rules.js b/lib/constants/rules.js
new file mode 100644
index 000000000..991ff6e73
--- /dev/null
+++ b/lib/constants/rules.js
@@ -0,0 +1,100 @@
+
+/**
+ * The default Slate schema rules, which enforce the most basic constraints.
+ *
+ * @type {Array}
+ */
+
+const RULES = [
+ {
+ match: {
+ kind: 'document'
+ },
+ validate: {
+ nodes: {
+ anyOf: [
+ { kind: 'block' }
+ ]
+ }
+ },
+ transform: (transform, node) => {
+ return node.nodes
+ .filter(child => child.kind != 'block')
+ .reduce((tr, child) => tr.removeNodeByKey(child.key), transform)
+ }
+ },
+ // {
+ // match: { kind: 'block' },
+ // nodes: {
+ // anyOf: [
+ // { kind: 'block' },
+ // { kind: 'inline' },
+ // { kind: 'text' },
+ // ]
+ // },
+ // transform: (transform, node) => {
+ // return node
+ // .filterChildren(child => {
+ // return (
+ // child.kind != 'block' ||
+ // child.kind != 'inline' ||
+ // child.kind != 'text'
+ // )
+ // })
+ // .reduce((transform, child) => {
+ // return transform.removeNodeByKey(child.key)
+ // })
+ // }
+ // },
+ // {
+ // match: { kind: 'inline' },
+ // nodes: {
+ // anyOf: [
+ // { kind: 'inline' },
+ // { kind: 'text' }
+ // ]
+ // },
+ // transform: (transform, node) => {
+ // return node
+ // .filterChildren(child => {
+ // return child.kind != 'inline' || child.kind != 'text'
+ // })
+ // .reduce((transform, child) => {
+ // return transform.removeNodeByKey(child.key)
+ // })
+ // }
+ // },
+ // {
+ // match: { isVoid: true },
+ // text: ' ',
+ // transform: (transform, node) => {
+ // const { state } = transform
+ // const range = state.selection.moveToRangeOf(node)
+ // return transform.delete().insertText(' ')
+ // }
+ // },
+ // {
+ // match: (object) => {
+ // return (
+ // object.kind == 'block' &&
+ // object.nodes.size == 1 &&
+ // object.nodes.first().isVoid
+ // )
+ // },
+ // invalid: true,
+ // transform: (transform, node) => {
+ // const child = node.nodes.first()
+ // const text =
+ // return transform
+ // .insertNodeBeforeNodeByKey(child.)
+ // }
+ // }
+]
+
+/**
+ * Export.
+ *
+ * @type {Array}
+ */
+
+export default RULES
diff --git a/lib/constants/schema.js b/lib/constants/schema.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/lib/utils/types.js b/lib/constants/types.js
similarity index 100%
rename from lib/utils/types.js
rename to lib/constants/types.js
diff --git a/lib/models/node.js b/lib/models/node.js
index c4160199a..7c38699bc 100644
--- a/lib/models/node.js
+++ b/lib/models/node.js
@@ -117,6 +117,21 @@ const Node = {
}, Block.createList())
},
+ /**
+ * Recursively filter all ancestor nodes with `iterator`, depth-first.
+ *
+ * @param {Function} iterator
+ * @return {List} nodes
+ */
+
+ filterDescendantsDeep(iterator) {
+ return this.nodes.reduce((matches, child, i, nodes) => {
+ if (child.kind != 'text') matches = matches.concat(child.filterDescendantsDeep(iterator))
+ if (iterator(child, i, nodes)) matches = matches.push(child)
+ return matches
+ }, Block.createList())
+ },
+
/**
* Get the closest block nodes for each text node in the node.
*
diff --git a/lib/models/schema.js b/lib/models/schema.js
new file mode 100644
index 000000000..51dd96f38
--- /dev/null
+++ b/lib/models/schema.js
@@ -0,0 +1,312 @@
+
+import RULES from '../constants/rules'
+import includes from 'lodash/includes'
+import isReactComponent from '../utils/is-react-component'
+import typeOf from 'type-of'
+import memoize from '../utils/memoize'
+import { Record } from 'immutable'
+
+/**
+ * Default properties.
+ *
+ * @type {Object}
+ */
+
+const DEFAULTS = {
+ rules: [],
+}
+
+/**
+ * Schema.
+ *
+ * @type {Record}
+ */
+
+class Schema extends new Record(DEFAULTS) {
+
+ /**
+ * Create a new `Schema` with `properties`.
+ *
+ * @param {Object} properties
+ * @return {Schema} mark
+ */
+
+ static create(properties = {}) {
+ if (properties instanceof Schema) return properties
+
+ let rules = [
+ ...(properties.rules || []),
+ ...RULES,
+ ]
+
+ return new Schema({
+ rules: rules.map(normalizeRule)
+ })
+ }
+
+ /**
+ * Get the kind.
+ *
+ * @return {String} kind
+ */
+
+ get kind() {
+ return 'schema'
+ }
+
+ /**
+ * Normalize a `state` against the schema.
+ *
+ * @param {State} state
+ * @return {State}
+ */
+
+ normalize(state) {
+ const { document } = state
+ let transform = state.transform()
+
+ document.filterDescendants((node) => {
+ const rule = this.validateNode(node)
+ if (rule) transform = rule.transform(transform, node, state)
+ })
+
+ const next = transform.apply({ snapshot: false })
+ return next
+ }
+
+ /**
+ * Validate a `state` against the schema.
+ *
+ * @param {State} state
+ * @return {State}
+ */
+
+ validate(state) {
+ return !!state.document.findDescendant(node => this.validateNode(node))
+ }
+
+ /**
+ * Validate a `node` against the schema, returning the rule that was not met
+ * if the node is invalid, or null if the rule was valid.
+ *
+ * @param {Node} node
+ * @return {Object || Void}
+ */
+
+ validateNode(node) {
+ return this.rules
+ .filter(rule => rule.match(node))
+ .find(rule => !rule.validate(node))
+ }
+
+}
+
+/**
+ * Memoize read methods.
+ */
+
+memoize(Schema.prototype, [
+ 'normalize',
+ 'validate',
+ 'validateNode',
+])
+
+/**
+ * Normalize the `properties` of a schema.
+ *
+ * @param {Object} properties
+ * @return {Object}
+ */
+
+function normalizeProperties(properties) {
+ let rules = []
+
+ // If there's a `rules` property, it is not the shorthand.
+ if (properties.rules) {
+ rules = properties.rules
+ }
+
+ // Otherwise it's the shorthand syntax, so expand each of the properties.
+ else {
+ for (const key in properties) {
+ const value = properties[key]
+ let rule
+
+ if (isReactComponent(value)) {
+ rule.match = { type: key }
+ rule.component = value
+ } else {
+ rule = {
+ type: key,
+ ...value
+ }
+ }
+
+ rules.push(rule)
+ }
+ }
+
+ return {
+ rules: rules
+ .concat(RULES)
+ .map(normalizeRule)
+ }
+}
+
+/**
+ * Normalize a `rule` object.
+ *
+ * @param {Object} rule
+ * @return {Object}
+ */
+
+function normalizeRule(rule) {
+ return {
+ match: normalizeMatch(rule.match),
+ validate: normalizeValidate(rule.validate),
+ transform: normalizeTransform(rule.transform),
+ }
+}
+
+/**
+ * Normalize a `match` spec.
+ *
+ * @param {Function || Object || String} match
+ * @return {Function}
+ */
+
+function normalizeMatch(match) {
+ switch (typeOf(match)) {
+ case 'function': return match
+ case 'object': return normalizeSpec(match)
+ default: {
+ throw new Error(`Invalid \`match\` spec: "${match}".`)
+ }
+ }
+}
+
+/**
+ * Normalize a `validate` spec.
+ *
+ * @param {Function || Object || String} validate
+ * @return {Function}
+ */
+
+function normalizeValidate(validate) {
+ switch (typeOf(validate)) {
+ case 'function': return validate
+ case 'object': return normalizeSpec(validate)
+ default: {
+ throw new Error(`Invalid \`validate\` spec: "${validate}".`)
+ }
+ }
+}
+
+/**
+ * Normalize a `transform` spec.
+ *
+ * @param {Function || Object || String} transform
+ * @return {Function}
+ */
+
+function normalizeTransform(transform) {
+ switch (typeOf(transform)) {
+ case 'function': return transform
+ default: {
+ throw new Error(`Invalid \`transform\` spec: "${transform}".`)
+ }
+ }
+}
+
+/**
+ * Normalize a `spec` object.
+ *
+ * @param {Object} obj
+ * @return {Boolean}
+ */
+
+function normalizeSpec(obj) {
+ const spec = { ...obj }
+ const { nodes } = spec
+
+ // Normalize recursively for the node specs.
+ if (nodes) {
+ if (nodes.exactlyOf) spec.nodes.exactlyOf = nodes.exactlyOf.map(normalizeSpec)
+ if (nodes.anyOf) spec.nodes.anyOf = nodes.anyOf.map(normalizeSpec)
+ if (nodes.noneOf) spec.nodes.noneOf = nodes.noneOf.map(normalizeSpec)
+ }
+
+ // Return a checking function.
+ return (node) => {
+ // If marked as invalid explicitly, return early.
+ if (spec.invalid === true) return false
+
+ // Run the simple equality checks first.
+ if (
+ (spec.kind != null && spec.kind != node.kind) ||
+ (spec.type != null && spec.type != node.type) ||
+ (spec.isVoid != null && spec.isVoid != node.type) ||
+ (spec.kinds != null && !includes(spec.kinds, node.kind)) ||
+ (spec.types != null && !includes(spec.types, node.type))
+ ) {
+ return false
+ }
+
+ // Ensure that the node has nodes.
+ if (spec.nodes && !node.nodes) {
+ return false
+ }
+
+ // Run the node recursive checks next, start with `exactlyOf`, which likely
+ // has the greatest chance of not matching.
+ if (spec.nodes && spec.nodes.exactlyOf) {
+ const specs = spec.nodes.exactlyOf
+ const matches = node.nodes.reduce((valid, child, i) => {
+ if (!valid) return false
+ const checker = specs[i]
+ if (!checker) return false
+ return checker(child)
+ }, true)
+
+ if (!matches) return false
+ }
+
+ // Run the `anyOf` next check.
+ if (spec.nodes && spec.nodes.anyOf) {
+ const specs = spec.nodes.anyOf
+ const matches = node.nodes.reduce((valid, child) => {
+ if (!valid) return false
+ return specs.reduce((pass, checker) => {
+ if (!pass) return false
+ return checker(child)
+ }, true)
+ })
+
+ if (!matches) return false
+ }
+
+ // Run the `noneOf` next check.
+ if (spec.nodes && spec.nodes.noneOf) {
+ const specs = spec.nodes.noneOf
+ const matches = node.nodes.reduce((valid, child) => {
+ if (!valid) return false
+ return specs.reduce((pass, checker) => {
+ if (!pass) return false
+ return !!checker(child)
+ }, true)
+ })
+
+ if (!matches) return false
+ }
+
+ return true
+ }
+}
+
+/**
+ * Export.
+ *
+ * @type {Record}
+ */
+
+export default Schema
diff --git a/lib/plugins/core.js b/lib/plugins/core.js
index 5945d46c9..23105d5db 100644
--- a/lib/plugins/core.js
+++ b/lib/plugins/core.js
@@ -73,6 +73,21 @@ function Plugin(options = {}) {
)
}
+ /**
+ * On before change, enforce the editor's schema.
+ *
+ * @param {State} state
+ * @param {Editor} schema
+ * @return {State}
+ */
+
+ function onBeforeChange(state, editor) {
+ if (state.isNative) return state
+ return editor
+ .getSchema()
+ .normalize(state)
+ }
+
/**
* On before input, see if we can let the browser continue with it's native
* input behavior, to avoid a re-render for performance.
@@ -615,6 +630,7 @@ function Plugin(options = {}) {
*/
return {
+ onBeforeChange,
onBeforeInput,
onBlur,
onCopy,
diff --git a/lib/utils/is-react-component.js b/lib/utils/is-react-component.js
new file mode 100644
index 000000000..a879c69ea
--- /dev/null
+++ b/lib/utils/is-react-component.js
@@ -0,0 +1,23 @@
+
+/**
+ * Check if an `object` is a React component.
+ *
+ * @param {Object} object
+ * @return {Boolean}
+ */
+
+function isReactComponent(object) {
+ return (
+ object &&
+ object.prototype &&
+ object.prototype.isReactComponent
+ )
+}
+
+/**
+ * Export.
+ *
+ * @type {Function}
+ */
+
+export default isReactComponent
diff --git a/lib/utils/transfer.js b/lib/utils/transfer.js
index b98c247c3..133c00e6e 100644
--- a/lib/utils/transfer.js
+++ b/lib/utils/transfer.js
@@ -1,6 +1,6 @@
import Base64 from '../serializers/base-64'
-import TYPES from './types'
+import TYPES from '../constants/types'
/**
* Fragment matching regexp for HTML nodes.
diff --git a/test/schema/fixtures/no-top-level-inline/index.js b/test/schema/fixtures/no-top-level-inline/index.js
new file mode 100644
index 000000000..e69de29bb
diff --git a/test/schema/fixtures/no-top-level-inline/input.yaml b/test/schema/fixtures/no-top-level-inline/input.yaml
new file mode 100644
index 000000000..814b099e3
--- /dev/null
+++ b/test/schema/fixtures/no-top-level-inline/input.yaml
@@ -0,0 +1,14 @@
+
+nodes:
+ - kind: inline
+ type: default
+ nodes:
+ - kind: text
+ ranges:
+ - text: one
+ - kind: block
+ type: default
+ nodes:
+ - kind: text
+ ranges:
+ - text: two
diff --git a/test/schema/fixtures/no-top-level-inline/output.yaml b/test/schema/fixtures/no-top-level-inline/output.yaml
new file mode 100644
index 000000000..405663bb7
--- /dev/null
+++ b/test/schema/fixtures/no-top-level-inline/output.yaml
@@ -0,0 +1,8 @@
+
+nodes:
+ - kind: block
+ type: default
+ nodes:
+ - kind: text
+ ranges:
+ - text: two
diff --git a/test/schema/index.js b/test/schema/index.js
new file mode 100644
index 000000000..e4d1aec33
--- /dev/null
+++ b/test/schema/index.js
@@ -0,0 +1,31 @@
+
+import fs from 'fs'
+import strip from '../helpers/strip-dynamic'
+import readMetadata from 'read-metadata'
+import { Raw } from '../..'
+import { strictEqual } from '../helpers/assert-json'
+import { resolve } from 'path'
+
+/**
+ * Tests.
+ */
+
+describe('schema', () => {
+ const tests = fs.readdirSync(resolve(__dirname, './fixtures'))
+
+ for (const test of tests) {
+ if (test[0] == '.') continue
+
+ it(test, () => {
+ const dir = resolve(__dirname, './fixtures', test)
+ const input = readMetadata.sync(resolve(dir, 'input.yaml'))
+ const expected = readMetadata.sync(resolve(dir, 'output.yaml'))
+ const schema = require(dir)
+
+ let state = Raw.deserialize(input, { terse: true })
+ state = schema.normalize(state)
+ const output = Raw.serialize(state, { terse: true })
+ strictEqual(strip(output), strip(expected))
+ })
+ }
+})
diff --git a/test/server.js b/test/server.js
index 2e0a0061f..59e6c1aed 100644
--- a/test/server.js
+++ b/test/server.js
@@ -1,4 +1,5 @@
import './serializers'
+import './schema'
import './rendering'
import './transforms'
diff --git a/test/transforms/index.js b/test/transforms/index.js
index 7f2db575d..3013d3621 100644
--- a/test/transforms/index.js
+++ b/test/transforms/index.js
@@ -1,11 +1,10 @@
-import assert from 'assert'
import fs from 'fs'
import readMetadata from 'read-metadata'
import strip from '../helpers/strip-dynamic'
import toCamel from 'to-camel-case'
-import { Raw, State } from '../..'
-import { equal, strictEqual } from '../helpers/assert-json'
+import { Raw } from '../..'
+import { strictEqual } from '../helpers/assert-json'
import { resolve } from 'path'
/**