import * as namespaces from '../../utils/namespaces'; import validateEventHandler from './validateEventHandler'; import validate, { Validator } from '../index'; import { Node } from '../../interfaces'; import { dimensions } from '../../utils/patterns'; import isVoidElementName from '../../utils/isVoidElementName'; import isValidIdentifier from '../../utils/isValidIdentifier'; const svg = /^(?:altGlyph|altGlyphDef|altGlyphItem|animate|animateColor|animateMotion|animateTransform|circle|clipPath|color-profile|cursor|defs|desc|discard|ellipse|feBlend|feColorMatrix|feComponentTransfer|feComposite|feConvolveMatrix|feDiffuseLighting|feDisplacementMap|feDistantLight|feDropShadow|feFlood|feFuncA|feFuncB|feFuncG|feFuncR|feGaussianBlur|feImage|feMerge|feMergeNode|feMorphology|feOffset|fePointLight|feSpecularLighting|feSpotLight|feTile|feTurbulence|filter|font|font-face|font-face-format|font-face-name|font-face-src|font-face-uri|foreignObject|g|glyph|glyphRef|hatch|hatchpath|hkern|image|line|linearGradient|marker|mask|mesh|meshgradient|meshpatch|meshrow|metadata|missing-glyph|mpath|path|pattern|polygon|polyline|radialGradient|rect|set|solidcolor|stop|switch|symbol|text|textPath|tref|tspan|unknown|use|view|vkern)$/; export default function validateElement( validator: Validator, node: Node, refs: Map<string, Node[]>, refCallees: Node[], stack: Node[], elementStack: Node[] ) { if (elementStack.length === 0 && validator.namespace !== namespaces.svg && svg.test(node.name)) { validator.warn(node, { code: `missing-namespace`, message: `<${node.name}> is an SVG element – did you forget to add { namespace: 'svg' } ?` }); } if (node.name === 'slot') { const nameAttribute = node.attributes.find((attribute: Node) => attribute.name === 'name'); if (nameAttribute) { if (nameAttribute.value.length !== 1 || nameAttribute.value[0].type !== 'Text') { validator.error(nameAttribute, { code: `dynamic-slot-name`, message: `<slot> name cannot be dynamic` }); } const slotName = nameAttribute.value[0].data; if (slotName === 'default') { validator.error(nameAttribute, { code: `invalid-slot-name`, message: `default is a reserved word — it cannot be used as a slot name` }); } // TODO should duplicate slots be disallowed? Feels like it's more likely to be a // bug than anything. Perhaps it should be a warning // if (validator.slots.has(slotName)) { // validator.error(`duplicate '${slotName}' <slot> element`, nameAttribute.start); // } // validator.slots.add(slotName); } else { // if (validator.slots.has('default')) { // validator.error(`duplicate default <slot> element`, node.start); // } // validator.slots.add('default'); } } if (node.name === 'title') { if (node.attributes.length > 0) { validator.error(node.attributes[0], { code: `illegal-attribute`, message: `<title> cannot have attributes` }); } node.children.forEach(child => { if (child.type !== 'Text' && child.type !== 'MustacheTag') { validator.error(child, { code: 'illegal-structure', message: `<title> can only contain text and {tags}` }); } }); } let hasIntro: boolean; let hasOutro: boolean; let hasTransition: boolean; let hasAnimation: boolean; node.attributes.forEach((attribute: Node) => { if (attribute.type === 'Ref') { if (!isValidIdentifier(attribute.name)) { const suggestion = attribute.name.replace(/[^_$a-z0-9]/ig, '_').replace(/^\d/, '_$&'); validator.error(attribute, { code: `invalid-reference-name`, message: `Reference name '${attribute.name}' is invalid — must be a valid identifier such as ${suggestion}` }); } else { if (!refs.has(attribute.name)) refs.set(attribute.name, []); refs.get(attribute.name).push(node); } } if (attribute.type === 'Binding') { const { name } = attribute; if (name === 'value') { if ( node.name !== 'input' && node.name !== 'textarea' && node.name !== 'select' ) { validator.error(attribute, { code: `invalid-binding`, message: `'value' is not a valid binding on <${node.name}> elements` }); } if (node.name === 'select') { const attribute = node.attributes.find( (attribute: Node) => attribute.name === 'multiple' ); if (attribute && isDynamic(attribute)) { validator.error(attribute, { code: `dynamic-multiple-attribute`, message: `'multiple' attribute cannot be dynamic if select uses two-way binding` }); } } else { checkTypeAttribute(validator, node); } } else if (name === 'checked' || name === 'indeterminate') { if (node.name !== 'input') { validator.error(attribute, { code: `invalid-binding`, message: `'${name}' is not a valid binding on <${node.name}> elements` }); } if (checkTypeAttribute(validator, node) !== 'checkbox') { validator.error(attribute, { code: `invalid-binding`, message: `'${name}' binding can only be used with <input type="checkbox">` }); } } else if (name === 'group') { if (node.name !== 'input') { validator.error(attribute, { code: `invalid-binding`, message: `'group' is not a valid binding on <${node.name}> elements` }); } const type = checkTypeAttribute(validator, node); if (type !== 'checkbox' && type !== 'radio') { validator.error(attribute, { code: `invalid-binding`, message: `'checked' binding can only be used with <input type="checkbox"> or <input type="radio">` }); } } else if (name == 'files') { if (node.name !== 'input') { validator.error(attribute, { code: `invalid-binding`, message: `'files' binding acn only be used with <input type="file">` }); } const type = checkTypeAttribute(validator, node); if (type !== 'file') { validator.error(attribute, { code: `invalid-binding`, message: `'files' binding can only be used with <input type="file">` }); } } else if ( name === 'currentTime' || name === 'duration' || name === 'paused' || name === 'buffered' || name === 'seekable' || name === 'played' || name === 'volume' ) { if (node.name !== 'audio' && node.name !== 'video') { validator.error(attribute, { code: `invalid-binding`, message: `'${name}' binding can only be used with <audio> or <video>` }); } } else if (dimensions.test(name)) { if (node.name === 'svg' && (name === 'offsetWidth' || name === 'offsetHeight')) { validator.error(attribute, { code: 'invalid-binding', message: `'${attribute.name}' is not a valid binding on <svg>. Use '${name.replace('offset', 'client')}' instead` }); } else if (svg.test(node.name)) { validator.error(attribute, { code: 'invalid-binding', message: `'${attribute.name}' is not a valid binding on SVG elements` }); } else if (isVoidElementName(node.name)) { validator.error(attribute, { code: 'invalid-binding', message: `'${attribute.name}' is not a valid binding on void elements like <${node.name}>. Use a wrapper element instead` }); } } else { validator.error(attribute, { code: `invalid-binding`, message: `'${attribute.name}' is not a valid binding` }); } } else if (attribute.type === 'EventHandler') { validator.used.events.add(attribute.name); validateEventHandler(validator, attribute, refCallees); } else if (attribute.type === 'Transition') { validator.used.transitions.add(attribute.name); const bidi = attribute.intro && attribute.outro; if (hasTransition) { if (bidi) { validator.error(attribute, { code: `duplicate-transition`, message: `An element can only have one 'transition' directive` }); } validator.error(attribute, { code: `duplicate-transition`, message: `An element cannot have both a 'transition' directive and an '${attribute.intro ? 'in' : 'out'}' directive` }); } if ((hasIntro && attribute.intro) || (hasOutro && attribute.outro)) { if (bidi) { validator.error(attribute, { code: `duplicate-transition`, message: `An element cannot have both an '${hasIntro ? 'in' : 'out'}' directive and a 'transition' directive` }); } validator.error(attribute, { code: `duplicate-transition`, message: `An element can only have one '${hasIntro ? 'in' : 'out'}' directive` }); } if (attribute.intro) hasIntro = true; if (attribute.outro) hasOutro = true; if (bidi) hasTransition = true; if (!validator.transitions.has(attribute.name)) { validator.error(attribute, { code: `missing-transition`, message: `Missing transition '${attribute.name}'` }); } } else if (attribute.type === 'Animation') { validator.used.animations.add(attribute.name); if (hasAnimation) { validator.error(attribute, { code: `duplicate-animation`, message: `An element can only have one 'animate' directive` }); } if (!validator.animations.has(attribute.name)) { validator.error(attribute, { code: `missing-animation`, message: `Missing animation '${attribute.name}'` }); } const parent = stack[stack.length - 1]; if (!parent || parent.type !== 'EachBlock' || !parent.key) { // TODO can we relax the 'immediate child' rule? validator.error(attribute, { code: `invalid-animation`, message: `An element that use the animate directive must be the immediate child of a keyed each block` }); } if (parent.children.length > 1) { validator.error(attribute, { code: `invalid-animation`, message: `An element that use the animate directive must be the sole child of a keyed each block` }); } hasAnimation = true; } else if (attribute.type === 'Attribute') { if (attribute.name === 'value' && node.name === 'textarea') { if (node.children.length) { validator.error(attribute, { code: `textarea-duplicate-value`, message: `A <textarea> can have either a value attribute or (equivalently) child content, but not both` }); } } if (attribute.name === 'slot') { checkSlotAttribute(validator, node, attribute, stack); } } else if (attribute.type === 'Action') { validator.used.actions.add(attribute.name); if (!validator.actions.has(attribute.name)) { validator.error(attribute, { code: `missing-action`, message: `Missing action '${attribute.name}'` }); } } }); } function checkTypeAttribute(validator: Validator, node: Node) { const attribute = node.attributes.find( (attribute: Node) => attribute.name === 'type' ); if (!attribute) return null; if (attribute.value === true) { validator.error(attribute, { code: `missing-type`, message: `'type' attribute must be specified` }); } if (isDynamic(attribute)) { validator.error(attribute, { code: `invalid-type`, message: `'type' attribute cannot be dynamic if input uses two-way binding` }); } return attribute.value[0].data; } function checkSlotAttribute(validator: Validator, node: Node, attribute: Node, stack: Node[]) { if (isDynamic(attribute)) { validator.error(attribute, { code: `invalid-slot-attribute`, message: `slot attribute cannot have a dynamic value` }); } let i = stack.length; while (i--) { const parent = stack[i]; if (parent.type === 'Component') { // if we're inside a component or a custom element, gravy if (parent.name === 'svelte:self' || parent.name === 'svelte:component' || validator.components.has(parent.name)) return; } else if (parent.type === 'Element') { if (/-/.test(parent.name)) return; } if (parent.type === 'IfBlock' || parent.type === 'EachBlock') { const message = `Cannot place slotted elements inside an ${parent.type === 'IfBlock' ? 'if' : 'each'}-block`; validator.error(attribute, { code: `invalid-slotted-content`, message }); } } validator.error(attribute, { code: `invalid-slotted-content`, message: `Element with a slot='...' attribute must be a descendant of a component or custom element` }); } function isDynamic(attribute: Node) { if (attribute.value === true) return false; return attribute.value.length > 1 || attribute.value[0].type !== 'Text'; }