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' /**