|
|
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';
|
|
|
}
|