svelte/src/validate/html/validateElement.ts

379 lines
12 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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