eslint-plugin-ember
Version:
ESLint plugin for Ember.js apps
202 lines (179 loc) • 6.53 kB
JavaScript
const DISALLOWED_LINK_TEXTS = new Set(['click here', 'more info', 'read more', 'more']);
function getTextContentResult(node) {
if (node.type === 'GlimmerTextNode') {
return { text: node.chars.replaceAll(' ', ' '), hasDynamic: false };
}
if (
node.type === 'GlimmerMustacheStatement' ||
node.type === 'GlimmerSubExpression' ||
node.type === 'GlimmerBlockStatement'
) {
return { text: '', hasDynamic: true };
}
if (node.type === 'GlimmerElementNode' && node.children) {
let text = '';
let hasDynamic = false;
for (const child of node.children) {
const result = getTextContentResult(child);
text += result.text;
if (result.hasDynamic) {
hasDynamic = true;
}
}
return { text, hasDynamic };
}
return { text: '', hasDynamic: false };
}
function isDynamicValue(value) {
return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement';
}
/**
* Checks aria-labelledby and aria-label attributes.
* Returns:
* { skip: true } — has valid accessible name, skip element
* { report: true, text: string } — aria-label is itself a disallowed text, report it
* { skip: false } — no valid aria override, check text content
*/
function checkAriaAttributes(attrs) {
const ariaLabelledby = attrs.find((a) => a.name === 'aria-labelledby');
if (ariaLabelledby) {
if (isDynamicValue(ariaLabelledby.value)) {
return { skip: true };
}
if (ariaLabelledby.value?.type === 'GlimmerTextNode') {
if (ariaLabelledby.value.chars.trim().length > 0) {
return { skip: true }; // valid non-empty labelledby
}
}
// empty aria-labelledby → fall through
return { skip: false };
}
const ariaLabel = attrs.find((a) => a.name === 'aria-label');
if (ariaLabel) {
if (isDynamicValue(ariaLabel.value)) {
return { skip: true };
}
if (ariaLabel.value?.type === 'GlimmerTextNode') {
const val = ariaLabel.value.chars.replaceAll(' ', ' ').toLowerCase().trim();
if (val.length > 0 && !DISALLOWED_LINK_TEXTS.has(val)) {
return { skip: true }; // valid aria-label
}
if (val.length > 0) {
return { skip: true, report: true, text: val }; // aria-label itself is disallowed
}
}
}
return { skip: false };
}
/** @type {import('eslint').Rule.RuleModule} */
module.exports = {
meta: {
type: 'problem',
docs: {
description: 'disallow invalid or uninformative link text content',
category: 'Accessibility',
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-invalid-link-text.md',
templateMode: 'both',
},
fixable: null,
schema: [
{
type: 'object',
properties: {
allowEmptyLinks: { type: 'boolean' },
linkComponents: { type: 'array', items: { type: 'string' } },
},
additionalProperties: false,
},
],
messages: {
invalidText:
'Link text "{{text}}" is not descriptive. Use meaningful text that describes the link destination.',
},
originallyFrom: {
name: 'ember-template-lint',
rule: 'lib/rules/no-invalid-link-text.js',
docs: 'docs/rule/no-invalid-link-text.md',
tests: 'test/unit/rules/no-invalid-link-text-test.js',
},
},
create(context) {
const options = context.options[0] || {};
const allowEmptyLinks = options.allowEmptyLinks || false;
const customLinkComponents = options.linkComponents || [];
const filename = context.filename;
const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts');
// In HBS, LinkTo always refers to Ember's router link component.
// In GJS/GTS, LinkTo must be explicitly imported from '@ember/routing'.
// local alias → true (any truthy value marks it as a tracked link component)
const importedLinkComponents = new Map();
const linkTags = new Set(['a', ...customLinkComponents]);
if (!isStrictMode) {
linkTags.add('LinkTo');
}
function checkLinkContent(node, children) {
const attrs = node.attributes || [];
// Skip if aria-hidden="true"
const ariaHidden = attrs.find((a) => a.name === 'aria-hidden');
if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') {
return;
}
// Skip if hidden attribute present
if (attrs.some((a) => a.name === 'hidden')) {
return;
}
const ariaResult = checkAriaAttributes(attrs);
if (ariaResult.report) {
context.report({ node, messageId: 'invalidText', data: { text: ariaResult.text } });
return;
}
if (ariaResult.skip) {
return;
}
// If the link contains any non-TextNode child, content is dynamic/opaque — don't flag.
const childList = children || [];
const allTextNodes = childList.every((child) => child.type === 'GlimmerTextNode');
if (!allTextNodes) {
return;
}
// Concatenate text content (only TextNode children at this point).
let fullText = '';
for (const child of childList) {
fullText += child.chars.replaceAll(' ', ' ');
}
const normalized = fullText.trim().toLowerCase().replaceAll(/\s+/g, ' ');
if (!normalized.replaceAll(' ', '')) {
if (!allowEmptyLinks) {
context.report({ node, messageId: 'invalidText', data: { text: '(empty)' } });
}
return;
}
if (DISALLOWED_LINK_TEXTS.has(normalized)) {
context.report({ node, messageId: 'invalidText', data: { text: normalized } });
}
}
return {
ImportDeclaration(node) {
if (node.source.value === '@ember/routing') {
for (const specifier of node.specifiers) {
if (specifier.type === 'ImportSpecifier' && specifier.imported.name === 'LinkTo') {
importedLinkComponents.set(specifier.local.name, true);
linkTags.add(specifier.local.name);
}
}
}
},
GlimmerElementNode(node) {
if (!linkTags.has(node.tag)) {
return;
}
checkLinkContent(node, node.children);
},
GlimmerBlockStatement(node) {
if (node.path?.type === 'GlimmerPathExpression' && node.path.original === 'link-to') {
checkLinkContent(node, node.program?.body);
}
},
};
},
};