1
0
mirror of https://github.com/ianstormtaylor/slate.git synced 2025-08-20 06:01:24 +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'
},
validate: {
nodes: {
anyOf: [
{ kind: 'block' }
]
}
anyOf: [
{ kind: 'block' }
]
},
transform: (transform, node) => {
return node.nodes
.filter(child => child.kind != 'block')
.reduce((tr, child) => tr.removeNodeByKey(child.key), transform)
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)
}
},
{
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 },
// text: ' ',
// validate: {
// text: ' '
// },
// transform: (transform, node) => {
// const { state } = transform
// const range = state.selection.moveToRangeOf(node)

View File

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

View File

@@ -6,6 +6,97 @@ import typeOf from 'type-of'
import memoize from '../utils/memoize'
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.
*
@@ -33,15 +124,7 @@ class Schema extends new Record(DEFAULTS) {
static create(properties = {}) {
if (properties instanceof Schema) return properties
let rules = [
...(properties.rules || []),
...RULES,
]
return new Schema({
rules: rules.map(normalizeRule)
})
return new Schema(normalizeProperties(properties))
}
/**
@@ -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
* @return {State}
*/
normalize(state) {
transform(state) {
const { document } = state
let transform = state.transform()
let failure
document.filterDescendants((node) => {
const rule = this.validateNode(node)
if (rule) transform = rule.transform(transform, node, state)
document.filterDescendantsDeep((node) => {
if (failure = this.validateNode(node)) {
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 })
return next
}
@@ -94,9 +185,20 @@ class Schema extends new Record(DEFAULTS) {
*/
validateNode(node) {
return this.rules
.filter(rule => rule.match(node))
.find(rule => !rule.validate(node))
let reason
let match = this.rules
.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, [
'normalize',
'transform',
'validate',
'validateNode',
])
@@ -119,17 +221,13 @@ memoize(Schema.prototype, [
*/
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 (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]
// If there's a `nodes` property, it's the node rule shorthand dictionary.
if (nodes) {
for (const key in nodes) {
const value = nodes[key]
let rule
if (isReactComponent(value)) {
@@ -137,6 +235,7 @@ function normalizeProperties(properties) {
rule.component = value
} else {
rule = {
kinds: ['block', 'inline'],
type: key,
...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 {
rules: rules
.concat(RULES)
@@ -178,6 +298,7 @@ function normalizeRule(rule) {
function normalizeMatch(match) {
switch (typeOf(match)) {
case 'function': return match
case 'boolean': return () => match
case 'object': return normalizeSpec(match)
default: {
throw new Error(`Invalid \`match\` spec: "${match}".`)
@@ -195,6 +316,7 @@ function normalizeMatch(match) {
function normalizeValidate(validate) {
switch (typeOf(validate)) {
case 'function': return validate
case 'boolean': return () => validate
case 'object': return normalizeSpec(validate)
default: {
throw new Error(`Invalid \`validate\` spec: "${validate}".`)
@@ -227,79 +349,25 @@ function normalizeTransform(transform) {
function normalizeSpec(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) => {
// If marked as invalid explicitly, return early.
if (spec.invalid === true) return false
for (const key in CHECKS) {
const value = spec[key]
if (value == null) continue
// 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
const fail = CHECKS[key]
const failure = fail(node, value)
if (failure != undefined) {
return {
type: key,
value: failure
}
}
}
// 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.
*
@@ -85,7 +146,7 @@ function Plugin(options = {}) {
if (state.isNative) return state
return editor
.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
nodes:
- kind: text
ranges:
- text: one
text: one
- kind: block
type: default
nodes:
- kind: text
ranges:
- text: two
text: two

View File

@@ -4,5 +4,4 @@ nodes:
type: default
nodes:
- 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 strip from '../helpers/strip-dynamic'
import readMetadata from 'read-metadata'
import { Raw } from '../..'
import { Raw, Schema } from '../..'
import { strictEqual } from '../helpers/assert-json'
import { resolve } from 'path'
@@ -20,10 +20,10 @@ describe('schema', () => {
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)
const schema = Schema.create(require(dir))
let state = Raw.deserialize(input, { terse: true })
state = schema.normalize(state)
state = schema.transform(state)
const output = Raw.serialize(state, { terse: true })
strictEqual(strip(output), strip(expected))
})