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:
@@ -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)
|
||||
|
@@ -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,
|
||||
|
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
@@ -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)
|
||||
}
|
||||
|
||||
/**
|
||||
|
2
test/schema/fixtures/default-no-block-void-text/index.js
Normal file
2
test/schema/fixtures/default-no-block-void-text/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
export default {}
|
@@ -0,0 +1,8 @@
|
||||
|
||||
nodes:
|
||||
- kind: block
|
||||
type: default
|
||||
isVoid: true
|
||||
nodes:
|
||||
- kind: text
|
||||
text: one
|
@@ -0,0 +1,5 @@
|
||||
|
||||
nodes:
|
||||
- kind: block
|
||||
type: default
|
||||
isVoid: true
|
2
test/schema/fixtures/default-no-document-inline/index.js
Normal file
2
test/schema/fixtures/default-no-document-inline/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
export default {}
|
@@ -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
|
@@ -4,5 +4,4 @@ nodes:
|
||||
type: default
|
||||
nodes:
|
||||
- kind: text
|
||||
ranges:
|
||||
- text: two
|
||||
text: two
|
2
test/schema/fixtures/default-no-document-text/index.js
Normal file
2
test/schema/fixtures/default-no-document-text/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
export default {}
|
9
test/schema/fixtures/default-no-document-text/input.yaml
Normal file
9
test/schema/fixtures/default-no-document-text/input.yaml
Normal file
@@ -0,0 +1,9 @@
|
||||
|
||||
nodes:
|
||||
- kind: text
|
||||
text: one
|
||||
- kind: block
|
||||
type: default
|
||||
nodes:
|
||||
- kind: text
|
||||
text: two
|
@@ -0,0 +1,7 @@
|
||||
|
||||
nodes:
|
||||
- kind: block
|
||||
type: default
|
||||
nodes:
|
||||
- kind: text
|
||||
text: two
|
2
test/schema/fixtures/default-no-inline-block/index.js
Normal file
2
test/schema/fixtures/default-no-inline-block/index.js
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
export default {}
|
15
test/schema/fixtures/default-no-inline-block/input.yaml
Normal file
15
test/schema/fixtures/default-no-inline-block/input.yaml
Normal 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
|
10
test/schema/fixtures/default-no-inline-block/output.yaml
Normal file
10
test/schema/fixtures/default-no-inline-block/output.yaml
Normal file
@@ -0,0 +1,10 @@
|
||||
|
||||
nodes:
|
||||
- kind: block
|
||||
type: default
|
||||
nodes:
|
||||
- kind: inline
|
||||
type: default
|
||||
nodes:
|
||||
- kind: text
|
||||
text: one
|
@@ -0,0 +1,2 @@
|
||||
|
||||
export default {}
|
11
test/schema/fixtures/default-no-inline-void-text/input.yaml
Normal file
11
test/schema/fixtures/default-no-inline-void-text/input.yaml
Normal file
@@ -0,0 +1,11 @@
|
||||
|
||||
nodes:
|
||||
- kind: block
|
||||
type: default
|
||||
nodes:
|
||||
- kind: inline
|
||||
type: default
|
||||
isVoid: true
|
||||
nodes:
|
||||
- kind: text
|
||||
text: one
|
@@ -0,0 +1,8 @@
|
||||
|
||||
nodes:
|
||||
- kind: block
|
||||
type: default
|
||||
nodes:
|
||||
- kind: inline
|
||||
type: default
|
||||
isVoid: true
|
@@ -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))
|
||||
})
|
||||
|
Reference in New Issue
Block a user