1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-20 14:11:35 +02:00

got first stab at schema working with tests

This commit is contained in:
Ian Storm Taylor
2016-08-12 15:58:45 -07:00
parent 8b5305f239
commit f4b584a00b
21 changed files with 362 additions and 158 deletions

View File

@@ -11,62 +11,54 @@ const RULES = [
kind: 'document' kind: 'document'
}, },
validate: { validate: {
nodes: {
anyOf: [ anyOf: [
{ kind: 'block' } { kind: 'block' }
] ]
},
transform: (transform, match, reason) => {
return reason.value.reduce((tr, node) => {
return tr.removeNodeByKey(node.key)
}, transform)
} }
}, },
transform: (transform, node) => { {
return node.nodes match: {
.filter(child => child.kind != 'block') kind: 'block'
.reduce((tr, child) => tr.removeNodeByKey(child.key), transform) },
validate: {
anyOf: [
{ kind: 'block' },
{ kind: 'inline' },
{ kind: 'text' },
]
},
transform: (transform, match, reason) => {
return reason.value.reduce((tr, node) => {
return tr.removeNodeByKey(node.key)
}, transform)
}
},
{
match: {
kind: 'inline'
},
validate: {
anyOf: [
{ kind: 'inline' },
{ kind: 'text' },
]
},
transform: (transform, match, reason) => {
return reason.value.reduce((tr, node) => {
return tr.removeNodeByKey(node.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 }, // match: { isVoid: true },
// text: ' ', // validate: {
// text: ' '
// },
// transform: (transform, node) => { // transform: (transform, node) => {
// const { state } = transform // const { state } = transform
// const range = state.selection.moveToRangeOf(node) // const range = state.selection.moveToRangeOf(node)

View File

@@ -16,6 +16,7 @@ import Data from './models/data'
import Document from './models/document' import Document from './models/document'
import Inline from './models/inline' import Inline from './models/inline'
import Mark from './models/mark' import Mark from './models/mark'
import Schema from './models/schema'
import Selection from './models/selection' import Selection from './models/selection'
import State from './models/state' import State from './models/state'
import Text from './models/text' import Text from './models/text'
@@ -50,6 +51,7 @@ export {
Placeholder, Placeholder,
Plain, Plain,
Raw, Raw,
Schema,
Selection, Selection,
State, State,
Text, Text,
@@ -68,6 +70,7 @@ export default {
Placeholder, Placeholder,
Plain, Plain,
Raw, Raw,
Schema,
Selection, Selection,
State, State,
Text, Text,

View File

@@ -6,6 +6,97 @@ import typeOf from 'type-of'
import memoize from '../utils/memoize' import memoize from '../utils/memoize'
import { Record } from 'immutable' import { Record } from 'immutable'
/**
* Checks that the schema can perform, ordered by performance.
*
* @type {Object}
*/
const CHECKS = {
kind(object, value) {
if (object.kind != value) return object.kind
},
type(object, value) {
if (object.type != value) return object.type
},
isVoid(object, value) {
if (object.isVoid != value) return object.isVoid
},
minChildren(object, value) {
if (object.nodes.size < value) return object.nodes.size
},
maxChildren(object, value) {
if (object.nodes.size > value) return object.nodes.size
},
kinds(object, value) {
if (!includes(value, object.kind)) return object.kind
},
types(object, value) {
if (!includes(value, object.type)) return object.type
},
minLength(object, value) {
const { length } = object
if (length < value) return length
},
maxLength(object, value) {
const { length } = object
if (length > value) return length
},
text(object, value) {
const { text } = object
switch (typeOf(value)) {
case 'function': if (value(text)) return text
case 'regexp': if (!text.match(value)) return text
default: if (text != value) return text
}
},
anyOf(object, value) {
const { nodes } = object
if (!nodes) return
const invalids = nodes.filterNot((child) => {
return value.some(fail => !fail(child))
})
if (invalids.size) return invalids
},
noneOf(object, value) {
const { nodes } = object
if (!nodes) return
const invalids = nodes.filterNot((child) => {
return value.every(fail => fail(child))
})
if (invalids.size) return invalids
},
exactlyOf(object, value) {
const { nodes } = object
if (!nodes) return
if (nodes.size != value.length) return nodes
const invalids = nodes.filter((child, i) => {
const fail = value[i]
if (!fail) return true
return fail(child)
})
if (invalids.size) return invalids
},
}
/** /**
* Default properties. * Default properties.
* *
@@ -33,15 +124,7 @@ class Schema extends new Record(DEFAULTS) {
static create(properties = {}) { static create(properties = {}) {
if (properties instanceof Schema) return properties if (properties instanceof Schema) return properties
return new Schema(normalizeProperties(properties))
let rules = [
...(properties.rules || []),
...RULES,
]
return new Schema({
rules: rules.map(normalizeRule)
})
} }
/** /**
@@ -55,21 +138,29 @@ class Schema extends new Record(DEFAULTS) {
} }
/** /**
* Normalize a `state` against the schema. * Transform a `state` to abide by the schema.
* *
* @param {State} state * @param {State} state
* @return {State} * @return {State}
*/ */
normalize(state) { transform(state) {
const { document } = state const { document } = state
let transform = state.transform() let transform = state.transform()
let failure
document.filterDescendants((node) => { document.filterDescendantsDeep((node) => {
const rule = this.validateNode(node) if (failure = this.validateNode(node)) {
if (rule) transform = rule.transform(transform, node, state) const { reason, rule } = failure
transform = rule.transform(transform, node, reason)
}
}) })
if (failure = this.validateNode(document)) {
const { reason, rule } = failure
transform = rule.transform(transform, document, reason)
}
const next = transform.apply({ snapshot: false }) const next = transform.apply({ snapshot: false })
return next return next
} }
@@ -94,9 +185,20 @@ class Schema extends new Record(DEFAULTS) {
*/ */
validateNode(node) { validateNode(node) {
return this.rules let reason
.filter(rule => rule.match(node)) let match = this.rules
.find(rule => !rule.validate(node)) .filter(rule => !rule.match(node))
.find((rule) => {
reason = rule.validate(node)
if (reason) return true
})
if (!match) return
return {
rule: match,
reason
}
} }
} }
@@ -106,7 +208,7 @@ class Schema extends new Record(DEFAULTS) {
*/ */
memoize(Schema.prototype, [ memoize(Schema.prototype, [
'normalize', 'transform',
'validate', 'validate',
'validateNode', 'validateNode',
]) ])
@@ -119,17 +221,13 @@ memoize(Schema.prototype, [
*/ */
function normalizeProperties(properties) { function normalizeProperties(properties) {
let rules = [] let { rules, nodes, marks } = properties
if (!rules) rules = []
// If there's a `rules` property, it is not the shorthand. // If there's a `nodes` property, it's the node rule shorthand dictionary.
if (properties.rules) { if (nodes) {
rules = properties.rules for (const key in nodes) {
} const value = nodes[key]
// Otherwise it's the shorthand syntax, so expand each of the properties.
else {
for (const key in properties) {
const value = properties[key]
let rule let rule
if (isReactComponent(value)) { if (isReactComponent(value)) {
@@ -137,6 +235,7 @@ function normalizeProperties(properties) {
rule.component = value rule.component = value
} else { } else {
rule = { rule = {
kinds: ['block', 'inline'],
type: key, type: key,
...value ...value
} }
@@ -146,6 +245,27 @@ function normalizeProperties(properties) {
} }
} }
// If there's a `marks` property, it's the mark rule shorthand dictionary.
if (marks) {
for (const key in marks) {
const value = marks[key]
let rule
if (rule.match) {
rule = {
kind: 'mark',
type: key,
...value
}
} else {
rule.match = { type: key }
rule.component = value
}
rules.push(rule)
}
}
return { return {
rules: rules rules: rules
.concat(RULES) .concat(RULES)
@@ -178,6 +298,7 @@ function normalizeRule(rule) {
function normalizeMatch(match) { function normalizeMatch(match) {
switch (typeOf(match)) { switch (typeOf(match)) {
case 'function': return match case 'function': return match
case 'boolean': return () => match
case 'object': return normalizeSpec(match) case 'object': return normalizeSpec(match)
default: { default: {
throw new Error(`Invalid \`match\` spec: "${match}".`) throw new Error(`Invalid \`match\` spec: "${match}".`)
@@ -195,6 +316,7 @@ function normalizeMatch(match) {
function normalizeValidate(validate) { function normalizeValidate(validate) {
switch (typeOf(validate)) { switch (typeOf(validate)) {
case 'function': return validate case 'function': return validate
case 'boolean': return () => validate
case 'object': return normalizeSpec(validate) case 'object': return normalizeSpec(validate)
default: { default: {
throw new Error(`Invalid \`validate\` spec: "${validate}".`) throw new Error(`Invalid \`validate\` spec: "${validate}".`)
@@ -227,79 +349,25 @@ function normalizeTransform(transform) {
function normalizeSpec(obj) { function normalizeSpec(obj) {
const spec = { ...obj } const spec = { ...obj }
const { nodes } = spec if (spec.exactlyOf) spec.exactlyOf = spec.exactlyOf.map(normalizeSpec)
if (spec.anyOf) spec.anyOf = spec.anyOf.map(normalizeSpec)
if (spec.noneOf) spec.noneOf = spec.noneOf.map(normalizeSpec)
// 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) => { return (node) => {
// If marked as invalid explicitly, return early. for (const key in CHECKS) {
if (spec.invalid === true) return false const value = spec[key]
if (value == null) continue
// Run the simple equality checks first. const fail = CHECKS[key]
if ( const failure = fail(node, value)
(spec.kind != null && spec.kind != node.kind) ||
(spec.type != null && spec.type != node.type) || if (failure != undefined) {
(spec.isVoid != null && spec.isVoid != node.type) || return {
(spec.kinds != null && !includes(spec.kinds, node.kind)) || type: key,
(spec.types != null && !includes(spec.types, node.type)) value: failure
) {
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
} }
} }

View File

@@ -73,6 +73,67 @@ function Plugin(options = {}) {
) )
} }
// /**
// * The default schema.
// *
// * @type {Object}
// */
// const DEFAULT_SCHEMA = {
// rules: [
// {
// match: {
// kind: 'document'
// },
// validate: {
// anyOf: [
// { kind: 'block' }
// ]
// },
// transform: (transform, match, reason) => {
// return reason.value.reduce((tr, node) => {
// return tr.removeNodeByKey(node.key)
// }, transform)
// }
// },
// {
// match: {
// kind: 'block'
// },
// validate: {
// anyOf: [
// { kind: 'block' },
// { kind: 'inline' },
// { kind: 'text' },
// ]
// },
// transform: (transform, match, reason) => {
// return reason.value.reduce((tr, node) => {
// return tr.removeNodeByKey(node.key)
// }, transform)
// },
// component: DEFAULT_BLOCK
// },
// {
// match: {
// kind: 'inline'
// },
// validate: {
// anyOf: [
// { kind: 'inline' },
// { kind: 'text' },
// ]
// },
// transform: (transform, match, reason) => {
// return reason.value.reduce((tr, node) => {
// return tr.removeNodeByKey(node.key)
// }, transform)
// },
// component: DEFAULT_INLINE
// },
// ]
// }
/** /**
* On before change, enforce the editor's schema. * On before change, enforce the editor's schema.
* *
@@ -85,7 +146,7 @@ function Plugin(options = {}) {
if (state.isNative) return state if (state.isNative) return state
return editor return editor
.getSchema() .getSchema()
.normalize(state) .transform(state)
} }
/** /**

View File

@@ -0,0 +1,2 @@
export default {}

View File

@@ -0,0 +1,8 @@
nodes:
- kind: block
type: default
isVoid: true
nodes:
- kind: text
text: one

View File

@@ -0,0 +1,5 @@
nodes:
- kind: block
type: default
isVoid: true

View File

@@ -0,0 +1,2 @@
export default {}

View File

@@ -4,11 +4,9 @@ nodes:
type: default type: default
nodes: nodes:
- kind: text - kind: text
ranges: text: one
- text: one
- kind: block - kind: block
type: default type: default
nodes: nodes:
- kind: text - kind: text
ranges: text: two
- text: two

View File

@@ -4,5 +4,4 @@ nodes:
type: default type: default
nodes: nodes:
- kind: text - kind: text
ranges: text: two
- text: two

View File

@@ -0,0 +1,2 @@
export default {}

View File

@@ -0,0 +1,9 @@
nodes:
- kind: text
text: one
- kind: block
type: default
nodes:
- kind: text
text: two

View File

@@ -0,0 +1,7 @@
nodes:
- kind: block
type: default
nodes:
- kind: text
text: two

View File

@@ -0,0 +1,2 @@
export default {}

View File

@@ -0,0 +1,15 @@
nodes:
- kind: block
type: default
nodes:
- kind: inline
type: default
nodes:
- kind: text
text: one
- kind: block
type: default
nodes:
- kind: text
text: two

View File

@@ -0,0 +1,10 @@
nodes:
- kind: block
type: default
nodes:
- kind: inline
type: default
nodes:
- kind: text
text: one

View File

@@ -0,0 +1,2 @@
export default {}

View File

@@ -0,0 +1,11 @@
nodes:
- kind: block
type: default
nodes:
- kind: inline
type: default
isVoid: true
nodes:
- kind: text
text: one

View File

@@ -0,0 +1,8 @@
nodes:
- kind: block
type: default
nodes:
- kind: inline
type: default
isVoid: true

View File

@@ -2,7 +2,7 @@
import fs from 'fs' import fs from 'fs'
import strip from '../helpers/strip-dynamic' import strip from '../helpers/strip-dynamic'
import readMetadata from 'read-metadata' import readMetadata from 'read-metadata'
import { Raw } from '../..' import { Raw, Schema } from '../..'
import { strictEqual } from '../helpers/assert-json' import { strictEqual } from '../helpers/assert-json'
import { resolve } from 'path' import { resolve } from 'path'
@@ -20,10 +20,10 @@ describe('schema', () => {
const dir = resolve(__dirname, './fixtures', test) const dir = resolve(__dirname, './fixtures', test)
const input = readMetadata.sync(resolve(dir, 'input.yaml')) const input = readMetadata.sync(resolve(dir, 'input.yaml'))
const expected = readMetadata.sync(resolve(dir, 'output.yaml')) const expected = readMetadata.sync(resolve(dir, 'output.yaml'))
const schema = require(dir) const schema = Schema.create(require(dir))
let state = Raw.deserialize(input, { terse: true }) let state = Raw.deserialize(input, { terse: true })
state = schema.normalize(state) state = schema.transform(state)
const output = Raw.serialize(state, { terse: true }) const output = Raw.serialize(state, { terse: true })
strictEqual(strip(output), strip(expected)) strictEqual(strip(output), strip(expected))
}) })