diff --git a/lib/constants/rules.js b/lib/constants/rules.js index 991ff6e73..bf0639f79 100644 --- a/lib/constants/rules.js +++ b/lib/constants/rules.js @@ -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) diff --git a/lib/index.js b/lib/index.js index 2a6565720..80f251740 100644 --- a/lib/index.js +++ b/lib/index.js @@ -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, diff --git a/lib/models/schema.js b/lib/models/schema.js index 51dd96f38..588c65bdd 100644 --- a/lib/models/schema.js +++ b/lib/models/schema.js @@ -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 } } diff --git a/lib/plugins/core.js b/lib/plugins/core.js index 23105d5db..d48780a67 100644 --- a/lib/plugins/core.js +++ b/lib/plugins/core.js @@ -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) } /** diff --git a/test/schema/fixtures/default-no-block-void-text/index.js b/test/schema/fixtures/default-no-block-void-text/index.js new file mode 100644 index 000000000..9a676e861 --- /dev/null +++ b/test/schema/fixtures/default-no-block-void-text/index.js @@ -0,0 +1,2 @@ + +export default {} diff --git a/test/schema/fixtures/default-no-block-void-text/input.yaml b/test/schema/fixtures/default-no-block-void-text/input.yaml new file mode 100644 index 000000000..224990dfb --- /dev/null +++ b/test/schema/fixtures/default-no-block-void-text/input.yaml @@ -0,0 +1,8 @@ + +nodes: + - kind: block + type: default + isVoid: true + nodes: + - kind: text + text: one diff --git a/test/schema/fixtures/default-no-block-void-text/output.yaml b/test/schema/fixtures/default-no-block-void-text/output.yaml new file mode 100644 index 000000000..e80ded17a --- /dev/null +++ b/test/schema/fixtures/default-no-block-void-text/output.yaml @@ -0,0 +1,5 @@ + +nodes: + - kind: block + type: default + isVoid: true diff --git a/test/schema/fixtures/default-no-document-inline/index.js b/test/schema/fixtures/default-no-document-inline/index.js new file mode 100644 index 000000000..9a676e861 --- /dev/null +++ b/test/schema/fixtures/default-no-document-inline/index.js @@ -0,0 +1,2 @@ + +export default {} diff --git a/test/schema/fixtures/no-top-level-inline/input.yaml b/test/schema/fixtures/default-no-document-inline/input.yaml similarity index 64% rename from test/schema/fixtures/no-top-level-inline/input.yaml rename to test/schema/fixtures/default-no-document-inline/input.yaml index 814b099e3..e8ce380b2 100644 --- a/test/schema/fixtures/no-top-level-inline/input.yaml +++ b/test/schema/fixtures/default-no-document-inline/input.yaml @@ -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 diff --git a/test/schema/fixtures/no-top-level-inline/output.yaml b/test/schema/fixtures/default-no-document-inline/output.yaml similarity index 65% rename from test/schema/fixtures/no-top-level-inline/output.yaml rename to test/schema/fixtures/default-no-document-inline/output.yaml index 405663bb7..515a308df 100644 --- a/test/schema/fixtures/no-top-level-inline/output.yaml +++ b/test/schema/fixtures/default-no-document-inline/output.yaml @@ -4,5 +4,4 @@ nodes: type: default nodes: - kind: text - ranges: - - text: two + text: two diff --git a/test/schema/fixtures/default-no-document-text/index.js b/test/schema/fixtures/default-no-document-text/index.js new file mode 100644 index 000000000..9a676e861 --- /dev/null +++ b/test/schema/fixtures/default-no-document-text/index.js @@ -0,0 +1,2 @@ + +export default {} diff --git a/test/schema/fixtures/default-no-document-text/input.yaml b/test/schema/fixtures/default-no-document-text/input.yaml new file mode 100644 index 000000000..b467d9897 --- /dev/null +++ b/test/schema/fixtures/default-no-document-text/input.yaml @@ -0,0 +1,9 @@ + +nodes: + - kind: text + text: one + - kind: block + type: default + nodes: + - kind: text + text: two diff --git a/test/schema/fixtures/default-no-document-text/output.yaml b/test/schema/fixtures/default-no-document-text/output.yaml new file mode 100644 index 000000000..515a308df --- /dev/null +++ b/test/schema/fixtures/default-no-document-text/output.yaml @@ -0,0 +1,7 @@ + +nodes: + - kind: block + type: default + nodes: + - kind: text + text: two diff --git a/test/schema/fixtures/default-no-inline-block/index.js b/test/schema/fixtures/default-no-inline-block/index.js new file mode 100644 index 000000000..9a676e861 --- /dev/null +++ b/test/schema/fixtures/default-no-inline-block/index.js @@ -0,0 +1,2 @@ + +export default {} diff --git a/test/schema/fixtures/default-no-inline-block/input.yaml b/test/schema/fixtures/default-no-inline-block/input.yaml new file mode 100644 index 000000000..76d9af459 --- /dev/null +++ b/test/schema/fixtures/default-no-inline-block/input.yaml @@ -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 diff --git a/test/schema/fixtures/default-no-inline-block/output.yaml b/test/schema/fixtures/default-no-inline-block/output.yaml new file mode 100644 index 000000000..fa6cf7b7c --- /dev/null +++ b/test/schema/fixtures/default-no-inline-block/output.yaml @@ -0,0 +1,10 @@ + +nodes: + - kind: block + type: default + nodes: + - kind: inline + type: default + nodes: + - kind: text + text: one diff --git a/test/schema/fixtures/default-no-inline-void-text/index.js b/test/schema/fixtures/default-no-inline-void-text/index.js new file mode 100644 index 000000000..9a676e861 --- /dev/null +++ b/test/schema/fixtures/default-no-inline-void-text/index.js @@ -0,0 +1,2 @@ + +export default {} diff --git a/test/schema/fixtures/default-no-inline-void-text/input.yaml b/test/schema/fixtures/default-no-inline-void-text/input.yaml new file mode 100644 index 000000000..970dd66a4 --- /dev/null +++ b/test/schema/fixtures/default-no-inline-void-text/input.yaml @@ -0,0 +1,11 @@ + +nodes: + - kind: block + type: default + nodes: + - kind: inline + type: default + isVoid: true + nodes: + - kind: text + text: one diff --git a/test/schema/fixtures/default-no-inline-void-text/output.yaml b/test/schema/fixtures/default-no-inline-void-text/output.yaml new file mode 100644 index 000000000..5060e5507 --- /dev/null +++ b/test/schema/fixtures/default-no-inline-void-text/output.yaml @@ -0,0 +1,8 @@ + +nodes: + - kind: block + type: default + nodes: + - kind: inline + type: default + isVoid: true diff --git a/test/schema/fixtures/no-top-level-inline/index.js b/test/schema/fixtures/no-top-level-inline/index.js deleted file mode 100644 index e69de29bb..000000000 diff --git a/test/schema/index.js b/test/schema/index.js index e4d1aec33..046569ba3 100644 --- a/test/schema/index.js +++ b/test/schema/index.js @@ -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)) })