substance
Version:
Substance is a JavaScript library for web-based content editing. It provides building blocks for realizing custom text editors and web-based publishing system. It is developed to power our online editing platform [Substance](http://substance.io).
272 lines (250 loc) • 8.25 kB
JavaScript
import _requiresPropertyElements from './_requiresPropertyElements'
import { isArray } from '../util'
export default function createValidator (rootType, definition) {
const nodeChecks = new Map()
for (const nodeSpec of definition.nodes.values()) {
const type = nodeSpec.type
nodeChecks.set(type, _createChecks(nodeSpec))
}
return new Validator(rootType, nodeChecks)
}
class Validator {
constructor (rootType, nodeChecks) {
this.rootType = rootType
this.nodeChecks = nodeChecks
}
validate (xmlDom) {
const state = new ValidatorState()
const rootEls = xmlDom.children.filter(c => c.tagName === this.rootType)
if (rootEls === 0) {
state.error({ message: `Root element ${this.rootType} not found` })
} else if (rootEls.length > 1) {
state.error({ message: `Only one root element ${this.rootType} is allowed` })
}
xmlDom.findAll(['id']).reduce((s, el) => {
const id = el.id
if (s.has(id)) {
state.error({ message: `Another element with the same id exists: ${id}` })
} else {
s.add(id)
}
return s
}, new Set())
state.requestChecks(rootEls)
while (!state.hasFinished()) {
const el = state.next()
if (!this.nodeChecks.has(el.tagName)) {
state.error({ message: `Unknown element type ${el.tagName}` })
} else {
const { checks, nodeSpec } = this.nodeChecks.get(el.tagName)
for (const check of checks) {
check(state, el, { nodeSpec })
}
// check for unused property elements, which is only necessary where property elements are used
if (_requiresPropertyElements(nodeSpec)) {
for (const c of state._currentPropertyElements) {
state.error({ message: `element ${c.tagName} is not allowed in ${el.tagName}.` })
}
}
for (const attr of state._currentAttributes) {
state.error({ message: `attribute ${attr} is not allowed in ${el.tagName}.` })
}
}
}
return state
}
}
class ValidatorState {
constructor () {
this.queue = []
this.errors = []
}
get ok () {
return this.errors.length === 0
}
error (err) {
this.errors.push(err)
}
requestChecks (els) {
this.queue = this.queue.concat(els)
}
hasFinished () {
return this.queue.length === 0
}
next () {
const el = this.queue.shift()
this._currentAttributes = new Set(Array.from(el.getAttributes().keys()))
this._currentPropertyElements = new Set(el.children)
return el
}
elementChecked (el) {
this._currentPropertyElements.delete(el)
}
attributeChecked (attr) {
this._currentAttributes.delete(attr)
}
}
function _createChecks (nodeSpec) {
// validation checks
const checks = []
const nodeType = nodeSpec.type
// add checkers for built-in properties (only id at the moment)
checks.push(_attributeChecker(nodeType, 'id', str => {
if (!/^[_@a-zA-Z][_@a-zA-Z0-9-]+$/.exec(str)) {
return `Invalid id: ${str}`
}
}))
for (const [propName, propSpec] of nodeSpec.properties.entries()) {
checks.push(_createPropertyChecker(nodeSpec, propName, propSpec))
}
return {
checks,
nodeSpec
}
}
function _createPropertyChecker (nodeSpec, propName, propSpec) {
const nodeType = nodeSpec.type
function _checkChildType (child, errors) {
if (!targetTypes.has(child.tagName)) {
errors.push(`Element of type ${child.tagName} not allowed in ${nodeType} > ${propName}. Expected one of ${Array.from(targetTypes).join(',')}`)
}
}
function _checkTargetType (el, id, errors) {
const target = el.getOwnerDocument().getElementById(id)
if (!target) {
errors.push(`Target element ${id} does not exist for ${nodeType}@${propName}`)
} else if (!targetTypes.has(target.tagName)) {
errors.push(`Target type ${target.tagName} is not allowed for ${nodeType}@${propName}`)
}
}
const type = propSpec.type
const options = propSpec.options || {}
const targetTypes = new Set(options.childTypes || options.targetTypes || [])
switch (type) {
case 'integer': {
return _attributeChecker(nodeType, propName, str => {
if (isNaN(str) || str !== parseInt(str)) {
return `Expected integer. Actual value: ${str}`
}
})
}
case 'number': {
return _attributeChecker(nodeType, propName, str => {
if (isNaN(str)) {
return `Expected number. Actual value: ${str}`
}
})
}
case 'boolean': {
return _attributeChecker(nodeType, propName, str => {
if (str !== 'true' && str !== 'false') {
return `Expected boolean (true|false). Actual value: ${str}`
}
})
}
case 'string': {
return _attributeChecker(nodeType, propName, str => {
// nothing special here
})
}
// string array properties are mapped to a element with comma separated text
case 'string-array': {
return _attributeChecker(nodeType, propName, str => {
// nothing special here
})
}
case 'enum': {
return _attributeChecker(nodeType, propName, str => {
if (!options.values.has(str)) {
return `Unsupported enum value. Expected one of ${Array.from(options.values).join(',')}, but was ${str}`
}
})
}
case 'one': {
return _attributeChecker(nodeType, propName, (str, el) => {
const errors = []
this._checkTargetType(el, str, errors)
return errors
})
}
case 'many': {
return _attributeChecker(nodeType, propName, (str, el) => {
const errors = []
const ids = str.split(/\s+/).map(id => id.trim())
for (const id of ids) {
_checkTargetType(el, id, errors)
}
return errors
})
}
case 'child': {
return _elementChecker(nodeSpec, propName, (state, el) => {
const errors = []
_checkChildType(el, errors)
state.requestChecks(el)
return errors
})
}
case 'children':
case 'container':
case 'text': {
return _elementChecker(nodeSpec, propName, (state, el) => {
const children = el.getChildren()
const errors = []
for (const child of children) {
_checkChildType(child, errors)
}
return errors
})
}
default:
throw new Error('Invalid type: ' + type)
}
}
function _attributeChecker (type, propertyName, check) {
return (state, el) => {
const str = el.getAttribute(propertyName)
if (str) {
// checker returns error message
let errors = check(str, el)
if (errors) {
if (!isArray(errors)) errors = [errors]
for (const error of errors) {
state.error({ type, propertyName, error })
}
}
}
state.attributeChecked(propertyName)
}
}
function _elementChecker (nodeSpec, propertyName, check) {
return (state, el, { nodeSpec }) => {
const nodeType = nodeSpec.type
const propSpec = nodeSpec.properties.get(propertyName)
// For now, we force property elements for all 'structured' nodes
if (_requiresPropertyElements(nodeSpec)) {
const propertyEl = el.children.find(c => c.tagName === propertyName)
if (!propertyEl) {
if (!propSpec.options.optional) {
state.error({ type: nodeType, propertyName, message: `Child element ${propertyName} is missing` })
}
} else {
const errors = check(state, propertyEl, { nodeSpec })
if (errors && errors.length > 0) {
errors.forEach(error => state.error({ type: nodeType, propertyName, error }))
}
state.requestChecks(propertyEl.children)
state.elementChecked(propertyEl)
}
// no property elements: this is the case for text nodes
// TODO: in the future we might want to add a flag to the node spec to allow
// this for 'structured' nodes, too (e.g. a list could spare an extra 'items' element)
} else {
const errors = check(state, el, { nodeSpec })
if (errors && errors.length > 0) {
errors.forEach(error => state.error({ type: nodeType, propertyName, error }))
}
state.requestChecks(el.children)
}
}
}