eslint-plugin-lit-a11y
Version:
linting plugin for lit-a11y
166 lines (150 loc) • 6.98 kB
JavaScript
/**
* @fileoverview anchor-is-valid
* @author open-wc
*/
import ruleExtender from 'eslint-rule-extender';
import { TemplateAnalyzer } from 'eslint-plugin-lit/lib/template-analyzer.js';
import { generateObjSchema, enumArraySchema } from '../utils/schemas.js';
import { isHtmlTaggedTemplate } from '../utils/isLitHtmlTemplate.js';
import { HasLitHtmlImportRuleExtension } from '../utils/HasLitHtmlImportRuleExtension.js';
import { getLiteralAttributeValue } from '../utils/getLiteralAttributeValue.js';
import { getContextSourceCode } from '../utils/getContextSourceCode.js';
//------------------------------------------------------------------------------
// Rule Definition
//------------------------------------------------------------------------------
/** @type {['noHref', 'invalidHref', 'preferButton']} */
const allAspects = ['noHref', 'invalidHref', 'preferButton'];
/** @type {import("eslint").Rule.RuleModule} */
const AnchorIsValidRule = {
meta: {
type: 'suggestion',
docs: {
description: 'anchor-is-valid',
category: 'Accessibility',
recommended: false,
url: 'https://github.com/open-wc/open-wc/blob/master/packages/eslint-plugin-lit-a11y/docs/rules/anchor-is-valid.md',
},
messages: {
preferButtonErrorMessage:
'Anchor used as a button. Anchors are primarily expected to navigate. Use the button element instead.',
noHrefErrorMessage:
'The href attribute is required for an anchor to be keyboard accessible. Provide a valid, navigable address as the href value. If you cannot provide an href, but still need the element to resemble a link, use a button and change it with appropriate styles.',
invalidHrefErrorMessage:
'The href attribute requires a valid value to be accessible. Provide a valid, navigable address as the href value. If you cannot provide a valid href, but still need the element to resemble a link, use a button and change it with appropriate styles.',
},
fixable: null,
schema: [
generateObjSchema({
aspects: enumArraySchema(allAspects, 1),
allowHash: {
type: 'boolean',
default: true,
},
}),
],
},
create(context) {
return {
TaggedTemplateExpression(node) {
if (isHtmlTaggedTemplate(node, context)) {
const analyzer = TemplateAnalyzer.create(node);
analyzer.traverse({
enterElement(element) {
if (!element.sourceCodeLocation) {
return; // probably a tree correction node
}
if (element.name === 'a') {
// Set up the rule aspects to check.
const options = context.options[0] || {};
const hasAspectsOption = Array.isArray(options.aspects);
// Create active aspect flag object. Failing checks will only report
// if the related flag is set to true.
const activeAspects = {
noHref: hasAspectsOption ? options.aspects.includes('noHref') : true,
invalidHref: hasAspectsOption ? options.aspects.includes('invalidHref') : true,
preferButton: hasAspectsOption ? options.aspects.includes('preferButton') : true,
};
const hasAnyHref =
Object.keys(element.attribs).includes('href') ||
Object.keys(element.attribs).includes('.href');
const hasClickListener = Object.keys(element.attribs).includes('@click');
// When there is no href at all, specific scenarios apply:
if (!hasAnyHref) {
// If no spread operator is found and no click handler is present
// it is a link without href.
if (
activeAspects.noHref &&
(!hasClickListener || (hasClickListener && !activeAspects.preferButton))
) {
const loc =
analyzer.resolveLocation(
element.sourceCodeLocation.startTag,
getContextSourceCode(context),
) ?? node.loc;
if (loc) {
context.report({ loc, messageId: 'noHrefErrorMessage' });
}
}
// If no spread operator is found but a click handler is preset it should be a button.
if (hasClickListener && activeAspects.preferButton) {
const loc =
analyzer.resolveLocation(
element.sourceCodeLocation.startTag,
getContextSourceCode(context),
) ?? node.loc;
if (loc) {
context.report({ loc, messageId: 'preferButtonErrorMessage' });
}
}
return;
}
// Hrefs have been found, now check for validity.
const hrefAttribute = getLiteralAttributeValue(
analyzer,
element,
'href',
getContextSourceCode(context),
);
const hrefProperty = getLiteralAttributeValue(
analyzer,
element,
'.href',
getContextSourceCode(context),
);
const value = hrefAttribute ?? hrefProperty;
const invalidHrefValue =
typeof value === 'string' &&
(!value.length ||
(options.allowHash === false && value === '#') ||
/^\W*?javascript:/.test(value));
if (invalidHrefValue) {
// If a click handler is found it should be a button, otherwise it is an invalid link.
if (hasClickListener && activeAspects.preferButton) {
const loc =
analyzer.resolveLocation(
element.sourceCodeLocation.startTag,
getContextSourceCode(context),
) ?? node.loc;
if (loc) {
context.report({ loc, messageId: 'preferButtonErrorMessage' });
}
} else if (activeAspects.invalidHref) {
const loc =
analyzer.resolveLocation(
element.sourceCodeLocation.startTag,
getContextSourceCode(context),
) ?? node.loc;
if (loc) {
context.report({ loc, messageId: 'invalidHrefErrorMessage' });
}
}
}
}
},
});
}
},
};
},
};
export default ruleExtender(AnchorIsValidRule, HasLitHtmlImportRuleExtension);