UNPKG

ember-template-lint

Version:
135 lines (118 loc) 4.12 kB
import AstNodeInfo from '../helpers/ast-node-info.js'; import Rule from './_base.js'; const DEFAULT_CONFIG = { allowEmptyLinks: false, linkComponents: [] }; const DISALLOWED_LINK_TEXT = new Set([ // WCAG F84-Provided Examples 'click here', 'more info', 'read more', 'more', ]); function getTrimmedText(text) { const nbspRemoved = text.replaceAll('&nbsp;', ' '); return nbspRemoved.toLowerCase().trim(); } function isHidden(element) { const ariaHiddenAttr = AstNodeInfo.findAttribute(element, 'aria-hidden'); return ( (ariaHiddenAttr && ariaHiddenAttr.value.chars === 'true') || AstNodeInfo.hasAttribute(element, 'hidden') ); } function hasValidAriaLabel(node) { const ariaLabelledbyAttr = AstNodeInfo.findAttribute(node, 'aria-labelledby'); if (ariaLabelledbyAttr) { let ariaLabelledby = getTrimmedText(ariaLabelledbyAttr.value.chars); return ariaLabelledby.length > 0; } const ariaLabelAttr = AstNodeInfo.findAttribute(node, 'aria-label'); if (ariaLabelAttr) { if (ariaLabelAttr.value?.type === 'MustacheStatement') { // We can't evaluate MustacheStatements so we assume this is valid return true; } let ariaLabel = getTrimmedText(ariaLabelAttr.value.chars); return !DISALLOWED_LINK_TEXT.has(ariaLabel); } } function hasInvalidLinkText(node, allowEmptyLinks) { // Extract the text content(s) from the TextNode child(ren) const nodeChildren = AstNodeInfo.childrenFor(node); const textChildren = nodeChildren.filter((child) => child.type === 'TextNode'); let linkTexts; if (nodeChildren.length !== textChildren.length) { // do not flag an error when the link contains additional dynamic (non-text) children return; } if (allowEmptyLinks) { linkTexts = textChildren.map((linkText) => linkText['chars'].toLowerCase().trim()); } else { if (isHidden(node) || hasValidAriaLabel(node)) { return; } if (!nodeChildren.length) { return true; } linkTexts = textChildren.map((linkText) => getTrimmedText(linkText.chars)); DISALLOWED_LINK_TEXT.add(''); } // Check to see if the text content is too `generic` by checking it against // the reference list (array, above) of `disallowed` link text Strings/phrases const hasGenericLinkTexts = linkTexts.some((linkText) => DISALLOWED_LINK_TEXT.has(linkText)); return hasGenericLinkTexts; } export default class NoInvalidLinkText extends Rule { parseConfig(config) { let configType = typeof config; switch (configType) { case 'boolean': { return config ? structuredClone(DEFAULT_CONFIG) : false; } case 'object': { return { allowEmptyLinks: config.allowEmptyLinks, linkComponents: config.linkComponents || [], }; } case 'undefined': { return false; } } } /** * @returns {import('./types.js').VisitorReturnType<NoInvalidLinkText>} */ visitor() { return { ElementNode(node) { // In strict mode, LinkTo is ignored even with invalid text // This is because we can't tell if the component is actually a built-in LinkTo // or just has the same name. It's better to risk false negatives than false positives. if ( node.tag === 'a' || (node.tag === 'LinkTo' && !this.isStrictMode) || this.config.linkComponents.includes(node.tag) ) { // Report if one or more child TextNode element(s) is on the disallowed list if (hasInvalidLinkText(node, this.config.allowEmptyLinks)) { this.log({ message: 'Links should have descriptive text', node, }); } } }, BlockStatement(node) { if (node.path.original === 'link-to') { // Report if one or more child TextNode element(s) is on the disallowed list if (hasInvalidLinkText(node, this.config.allowEmptyLinks)) { this.log({ message: 'Links should have descriptive text', node, }); } } }, }; } }