From ad4c9290ef9a88b5061eb977dcfaad81f255648b Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Wed, 11 Mar 2026 13:45:37 -0400
Subject: [PATCH 1/2] Extract rule: template-no-invalid-link-text
---
README.md | 1 +
docs/rules/template-no-invalid-link-text.md | 75 +++++
lib/rules/template-no-invalid-link-text.js | 189 +++++++++++
.../rules/template-no-invalid-link-text.js | 293 ++++++++++++++++++
4 files changed, 558 insertions(+)
create mode 100644 docs/rules/template-no-invalid-link-text.md
create mode 100644 lib/rules/template-no-invalid-link-text.js
create mode 100644 tests/lib/rules/template-no-invalid-link-text.js
diff --git a/README.md b/README.md
index ec19cb3f57..78663fbb6f 100644
--- a/README.md
+++ b/README.md
@@ -191,6 +191,7 @@ rules in templates can be disabled with eslint directives with mustache or html
| [template-no-heading-inside-button](docs/rules/template-no-heading-inside-button.md) | disallow heading elements inside button elements | | | |
| [template-no-invalid-aria-attributes](docs/rules/template-no-invalid-aria-attributes.md) | disallow invalid aria-* attributes | | | |
| [template-no-invalid-interactive](docs/rules/template-no-invalid-interactive.md) | disallow non-interactive elements with interactive handlers | | | |
+| [template-no-invalid-link-text](docs/rules/template-no-invalid-link-text.md) | disallow invalid or uninformative link text content | | | |
| [template-no-invalid-link-title](docs/rules/template-no-invalid-link-title.md) | disallow invalid title attributes on link elements | | | |
| [template-no-invalid-role](docs/rules/template-no-invalid-role.md) | disallow invalid ARIA roles | | | |
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
diff --git a/docs/rules/template-no-invalid-link-text.md b/docs/rules/template-no-invalid-link-text.md
new file mode 100644
index 0000000000..c08b40f2f8
--- /dev/null
+++ b/docs/rules/template-no-invalid-link-text.md
@@ -0,0 +1,75 @@
+# ember/template-no-invalid-link-text
+
+
+
+Disallows invalid or uninformative link text content.
+
+Link text should be descriptive and provide context about the destination. Generic phrases like "click here" or "read more" are not accessible because they don't convey meaningful information, especially for screen reader users who may navigate by links alone.
+
+## Rule Details
+
+This rule disallows the following link text values:
+
+- "click here"
+- "more info"
+- "read more"
+- "more"
+
+Comparison is case-insensitive and whitespace is normalized.
+
+Links with a valid `aria-label` or `aria-labelledby` attribute are exempt. A valid `aria-label` must be non-empty and must not itself be a disallowed text value.
+
+## Examples
+
+Examples of **incorrect** code for this rule:
+
+```gjs
+
+ Click here
+
+```
+
+```gjs
+
+ Read more
+
+```
+
+```gjs
+
+ More info
+
+```
+
+Examples of **correct** code for this rule:
+
+```gjs
+
+ About Us
+
+```
+
+```gjs
+
+ Documentation
+
+```
+
+```gjs
+
+ Click here
+
+```
+
+## Options
+
+| Name | Type | Default | Description |
+| ----------------- | ---------- | ------- | --------------------------------------------------------------------------- |
+| `allowEmptyLinks` | `boolean` | `false` | When `true`, allows links with no text content. |
+| `linkComponents` | `string[]` | `[]` | Additional component names treated as links (besides `` and ``). |
+
+## References
+
+- [WebAIM: Link Text and Appearance](https://webaim.org/techniques/hypertext/link_text)
+- [WCAG 2.4.4: Link Purpose (In Context)](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html)
+- [eslint-plugin-ember template-no-invalid-link-text](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-invalid-link-text.md)
diff --git a/lib/rules/template-no-invalid-link-text.js b/lib/rules/template-no-invalid-link-text.js
new file mode 100644
index 0000000000..58d9723665
--- /dev/null
+++ b/lib/rules/template-no-invalid-link-text.js
@@ -0,0 +1,189 @@
+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') {
+ 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 };
+}
+
+/**
+ * Check if the node has a valid aria-label or aria-labelledby that
+ * exempts it from link text validation.
+ */
+function hasValidAriaLabelOrLabelledby(node) {
+ const attrs = node.attributes || [];
+
+ // Check aria-labelledby
+ const ariaLabelledby = attrs.find((a) => a.name === 'aria-labelledby');
+ if (ariaLabelledby) {
+ if (ariaLabelledby.value && ariaLabelledby.value.type === 'GlimmerTextNode') {
+ const val = ariaLabelledby.value.chars.trim();
+ // Only valid if non-empty
+ return val.length > 0;
+ }
+ // Dynamic value — assume valid
+ if (
+ ariaLabelledby.value &&
+ (ariaLabelledby.value.type === 'GlimmerMustacheStatement' ||
+ ariaLabelledby.value.type === 'GlimmerConcatStatement')
+ ) {
+ return true;
+ }
+ // No value or empty — not valid
+ return false;
+ }
+
+ // Check aria-label
+ const ariaLabel = attrs.find((a) => a.name === 'aria-label');
+ if (ariaLabel) {
+ // Dynamic value — assume valid
+ if (
+ ariaLabel.value &&
+ (ariaLabel.value.type === 'GlimmerMustacheStatement' ||
+ ariaLabel.value.type === 'GlimmerConcatStatement')
+ ) {
+ return true;
+ }
+ if (ariaLabel.value && ariaLabel.value.type === 'GlimmerTextNode') {
+ const val = ariaLabel.value.chars.replaceAll(' ', ' ').toLowerCase().trim();
+ // aria-label itself must not be disallowed text
+ return val.length > 0 && !DISALLOWED_LINK_TEXTS.has(val);
+ }
+ return false;
+ }
+
+ return 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 linkTags = new Set(['a', 'LinkTo', ...customLinkComponents]);
+
+ function checkLinkContent(node, children) {
+ // Skip if has aria-hidden
+ const ariaHidden = (node.attributes || []).find((a) => a.name === 'aria-hidden');
+ if (ariaHidden?.value?.type === 'GlimmerTextNode' && ariaHidden.value.chars === 'true') {
+ return;
+ }
+
+ // Skip if has hidden attribute
+ if ((node.attributes || []).some((a) => a.name === 'hidden')) {
+ return;
+ }
+
+ // Check aria-label / aria-labelledby
+ if (hasValidAriaLabelOrLabelledby(node)) {
+ return;
+ }
+
+ let fullText = '';
+ let hasDynamic = false;
+
+ for (const child of children || []) {
+ const result = getTextContentResult(child);
+ fullText += result.text;
+ if (result.hasDynamic) {
+ hasDynamic = true;
+ }
+ }
+
+ // If there's dynamic content, skip (can't validate)
+ if (hasDynamic) {
+ return;
+ }
+
+ const normalized = fullText.trim().toLowerCase().replaceAll(/\s+/g, ' ');
+
+ // Empty link check
+ 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 {
+ GlimmerElementNode(node) {
+ if (!linkTags.has(node.tag)) {
+ return;
+ }
+
+ checkLinkContent(node, node.children);
+ },
+
+ GlimmerBlockStatement(node) {
+ if (
+ node.path &&
+ node.path.type === 'GlimmerPathExpression' &&
+ node.path.original === 'link-to'
+ ) {
+ checkLinkContent(node, node.program && node.program.body);
+ }
+ },
+ };
+ },
+};
diff --git a/tests/lib/rules/template-no-invalid-link-text.js b/tests/lib/rules/template-no-invalid-link-text.js
new file mode 100644
index 0000000000..c761c53a07
--- /dev/null
+++ b/tests/lib/rules/template-no-invalid-link-text.js
@@ -0,0 +1,293 @@
+const rule = require('../../../lib/rules/template-no-invalid-link-text');
+const RuleTester = require('eslint').RuleTester;
+
+const ruleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+ruleTester.run('template-no-invalid-link-text', rule, {
+ valid: [
+ 'About Us',
+ 'Contact Information',
+ 'Click here',
+
+ 'Click here to read more about this amazing adventure',
+ '{{#link-to}} click here to read more about our company{{/link-to}}',
+ 'Read more about ways semantic HTML can make your code more accessible.',
+ '{{foo}} more',
+ '',
+ '',
+ '',
+ '',
+ 'A link with translation',
+ 'A link with a variable as aria-label',
+ ],
+
+ invalid: [
+ {
+ code: 'Click here',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: 'More info',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: 'Read more',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+
+ {
+ code: 'click here',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: 'click here',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: '{{#link-to}}click here{{/link-to}}',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: ' ',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: `
+`,
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: 'Click here',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: 'Click here',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: 'Click here',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: `
+`,
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: '{{#link-to}}{{/link-to}}',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: `{{#link-to}}
+{{/link-to}}`,
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ code: 'click here',
+ output: null,
+ options: [{ linkComponents: ['MyLink'] }],
+ errors: [{ messageId: 'invalidText' }],
+ },
+ ],
+});
+
+const hbsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser/hbs'),
+ parserOptions: {
+ ecmaVersion: 2022,
+ sourceType: 'module',
+ },
+});
+
+hbsRuleTester.run('template-no-invalid-link-text', rule, {
+ valid: [
+ 'Click here to read more about this amazing adventure',
+ '{{#link-to}} click here to read more about our company{{/link-to}}',
+ 'Read more about ways semantic HTML can make your code more accessible.',
+ '{{foo}} more',
+ '',
+ '',
+ '',
+ '',
+ 'A link with translation',
+ 'A link with a variable as aria-label',
+ // allowEmptyLinks: true — empty links are valid
+ {
+ code: '',
+ options: [{ allowEmptyLinks: true }],
+ },
+ ],
+ invalid: [
+ {
+ code: 'click here',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: 'click here',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: '{{#link-to}}click here{{/link-to}}',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: ' ',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: `
+`,
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: 'Click here',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: 'Click here',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: 'Click here',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: `
+`,
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: '{{#link-to}}{{/link-to}}',
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: `{{#link-to}}
+{{/link-to}}`,
+ output: null,
+ errors: [
+ {
+ message:
+ 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ {
+ code: 'click here',
+ output: null,
+ options: [{ linkComponents: ['MyLink'] }],
+ errors: [
+ {
+ message:
+ 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
+ },
+ ],
+ },
+ ],
+});
From 659c1160f16374cde1ec97c785d46dfb4de6b533 Mon Sep 17 00:00:00 2001
From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com>
Date: Tue, 17 Mar 2026 09:43:11 -0400
Subject: [PATCH 2/2] PR Feedback
---
docs/rules/template-no-invalid-link-text.md | 2 +-
lib/rules/template-no-invalid-link-text.js | 130 +++++-----
.../rules/template-no-invalid-link-text.js | 237 ++++++++----------
3 files changed, 178 insertions(+), 191 deletions(-)
diff --git a/docs/rules/template-no-invalid-link-text.md b/docs/rules/template-no-invalid-link-text.md
index c08b40f2f8..ed301f458a 100644
--- a/docs/rules/template-no-invalid-link-text.md
+++ b/docs/rules/template-no-invalid-link-text.md
@@ -72,4 +72,4 @@ Examples of **correct** code for this rule:
- [WebAIM: Link Text and Appearance](https://webaim.org/techniques/hypertext/link_text)
- [WCAG 2.4.4: Link Purpose (In Context)](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html)
-- [eslint-plugin-ember template-no-invalid-link-text](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-no-invalid-link-text.md)
+- [ember-template-lint: no-invalid-link-text](https://github.com/ember-template-lint/ember-template-lint/blob/main/docs/rule/no-invalid-link-text.md)
diff --git a/lib/rules/template-no-invalid-link-text.js b/lib/rules/template-no-invalid-link-text.js
index 58d9723665..a09e48cc67 100644
--- a/lib/rules/template-no-invalid-link-text.js
+++ b/lib/rules/template-no-invalid-link-text.js
@@ -22,53 +22,49 @@ function getTextContentResult(node) {
return { text: '', hasDynamic: false };
}
+function isDynamicValue(value) {
+ return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement';
+}
+
/**
- * Check if the node has a valid aria-label or aria-labelledby that
- * exempts it from link text validation.
+ * 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 hasValidAriaLabelOrLabelledby(node) {
- const attrs = node.attributes || [];
-
- // Check aria-labelledby
+function checkAriaAttributes(attrs) {
const ariaLabelledby = attrs.find((a) => a.name === 'aria-labelledby');
if (ariaLabelledby) {
- if (ariaLabelledby.value && ariaLabelledby.value.type === 'GlimmerTextNode') {
- const val = ariaLabelledby.value.chars.trim();
- // Only valid if non-empty
- return val.length > 0;
+ if (isDynamicValue(ariaLabelledby.value)) {
+ return { skip: true };
}
- // Dynamic value — assume valid
- if (
- ariaLabelledby.value &&
- (ariaLabelledby.value.type === 'GlimmerMustacheStatement' ||
- ariaLabelledby.value.type === 'GlimmerConcatStatement')
- ) {
- return true;
+ if (ariaLabelledby.value?.type === 'GlimmerTextNode') {
+ if (ariaLabelledby.value.chars.trim().length > 0) {
+ return { skip: true }; // valid non-empty labelledby
+ }
}
- // No value or empty — not valid
- return false;
+ // empty aria-labelledby → fall through
+ return { skip: false };
}
- // Check aria-label
const ariaLabel = attrs.find((a) => a.name === 'aria-label');
if (ariaLabel) {
- // Dynamic value — assume valid
- if (
- ariaLabel.value &&
- (ariaLabel.value.type === 'GlimmerMustacheStatement' ||
- ariaLabel.value.type === 'GlimmerConcatStatement')
- ) {
- return true;
+ if (isDynamicValue(ariaLabel.value)) {
+ return { skip: true };
}
- if (ariaLabel.value && ariaLabel.value.type === 'GlimmerTextNode') {
+ if (ariaLabel.value?.type === 'GlimmerTextNode') {
const val = ariaLabel.value.chars.replaceAll(' ', ' ').toLowerCase().trim();
- // aria-label itself must not be disallowed text
- return val.length > 0 && !DISALLOWED_LINK_TEXTS.has(val);
+ 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 false;
}
- return false;
+ return { skip: false };
}
/** @type {import('eslint').Rule.RuleModule} */
@@ -108,28 +104,46 @@ module.exports = {
const options = context.options[0] || {};
const allowEmptyLinks = options.allowEmptyLinks || false;
const customLinkComponents = options.linkComponents || [];
- const linkTags = new Set(['a', 'LinkTo', ...customLinkComponents]);
+
+ const filename = context.filename ?? context.getFilename();
+ 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) {
- // Skip if has aria-hidden
- const ariaHidden = (node.attributes || []).find((a) => a.name === 'aria-hidden');
+ 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 has hidden attribute
- if ((node.attributes || []).some((a) => a.name === 'hidden')) {
+ // Skip if hidden attribute present
+ if (attrs.some((a) => a.name === 'hidden')) {
return;
}
- // Check aria-label / aria-labelledby
- if (hasValidAriaLabelOrLabelledby(node)) {
+ const ariaResult = checkAriaAttributes(attrs);
+ if (ariaResult.report) {
+ context.report({ node, messageId: 'invalidText', data: { text: ariaResult.text } });
+ return;
+ }
+ if (ariaResult.skip) {
return;
}
+ // Check text content
let fullText = '';
let hasDynamic = false;
-
for (const child of children || []) {
const result = getTextContentResult(child);
fullText += result.text;
@@ -138,50 +152,46 @@ module.exports = {
}
}
- // If there's dynamic content, skip (can't validate)
if (hasDynamic) {
- return;
+ return; // can't validate dynamic content
}
const normalized = fullText.trim().toLowerCase().replaceAll(/\s+/g, ' ');
- // Empty link check
if (!normalized.replaceAll(' ', '')) {
if (!allowEmptyLinks) {
- context.report({
- node,
- messageId: 'invalidText',
- data: { text: '(empty)' },
- });
+ context.report({ node, messageId: 'invalidText', data: { text: '(empty)' } });
}
return;
}
if (DISALLOWED_LINK_TEXTS.has(normalized)) {
- context.report({
- node,
- messageId: 'invalidText',
- data: { text: 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 &&
- node.path.type === 'GlimmerPathExpression' &&
- node.path.original === 'link-to'
- ) {
- checkLinkContent(node, node.program && node.program.body);
+ if (node.path?.type === 'GlimmerPathExpression' && node.path.original === 'link-to') {
+ checkLinkContent(node, node.program?.body);
}
},
};
diff --git a/tests/lib/rules/template-no-invalid-link-text.js b/tests/lib/rules/template-no-invalid-link-text.js
index c761c53a07..f6e74ce8a8 100644
--- a/tests/lib/rules/template-no-invalid-link-text.js
+++ b/tests/lib/rules/template-no-invalid-link-text.js
@@ -8,108 +8,140 @@ const ruleTester = new RuleTester({
ruleTester.run('template-no-invalid-link-text', rule, {
valid: [
- 'About Us',
- 'Contact Information',
- 'Click here',
-
- 'Click here to read more about this amazing adventure',
- '{{#link-to}} click here to read more about our company{{/link-to}}',
- 'Read more about ways semantic HTML can make your code more accessible.',
- '{{foo}} more',
- '',
- '',
- '',
- '',
- 'A link with translation',
- 'A link with a variable as aria-label',
+ { filename: 'test.gjs', code: 'About Us' },
+ {
+ filename: 'test.gjs',
+ code: 'Click here to read more about this amazing adventure',
+ },
+ {
+ filename: 'test.gjs',
+ code: '',
+ },
+ {
+ filename: 'test.gjs',
+ code: '',
+ },
+ {
+ filename: 'test.gjs',
+ code: '',
+ },
+ { filename: 'test.gjs', code: '' },
+ {
+ filename: 'test.gjs',
+ code: 'A link with a variable as aria-label',
+ },
+ // In GJS, LinkTo without an import from @ember/routing is not Ember's router link
+ { filename: 'test.gjs', code: 'click here' },
+ { filename: 'test.gjs', code: '' },
+ // Imported LinkTo with valid text
+ {
+ filename: 'test.gjs',
+ code: "import { LinkTo } from '@ember/routing'; About Us",
+ },
+ // allowEmptyLinks: true — empty is valid
+ {
+ filename: 'test.gjs',
+ code: '',
+ options: [{ allowEmptyLinks: true }],
+ },
+ // Dynamic content — can't validate
+ {
+ filename: 'test.gjs',
+ code: "import { LinkTo } from '@ember/routing'; {{foo}} more",
+ },
+ {
+ filename: 'test.gjs',
+ code: "import { LinkTo } from '@ember/routing'; A link with translation",
+ },
],
invalid: [
{
+ filename: 'test.gjs',
code: 'Click here',
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
+ filename: 'test.gjs',
code: 'More info',
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
+ filename: 'test.gjs',
code: 'Read more',
output: null,
errors: [{ messageId: 'invalidText' }],
},
-
- {
- code: 'click here',
- output: null,
- errors: [{ messageId: 'invalidText' }],
- },
- {
- code: 'click here',
- output: null,
- errors: [{ messageId: 'invalidText' }],
- },
{
- code: '{{#link-to}}click here{{/link-to}}',
+ // standalone "more" is disallowed
+ filename: 'test.gjs',
+ code: 'more',
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
+ filename: 'test.gjs',
code: '',
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
+ filename: 'test.gjs',
code: ' ',
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
- code: `
-`,
- output: null,
- errors: [{ messageId: 'invalidText' }],
- },
- {
+ filename: 'test.gjs',
code: 'Click here',
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
- code: 'Click here',
+ filename: 'test.gjs',
+ code: 'Click here',
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
- code: 'Click here',
+ // Imported LinkTo with disallowed text
+ filename: 'test.gjs',
+ code: "import { LinkTo } from '@ember/routing'; click here",
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
- code: '',
+ // Aliased LinkTo import must still be flagged
+ filename: 'test.gjs',
+ code: "import { LinkTo as RouterLink } from '@ember/routing'; click here",
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
- code: `
-`,
+ // Imported LinkTo — empty
+ filename: 'test.gjs',
+ code: "import { LinkTo } from '@ember/routing'; ",
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
- code: '{{#link-to}}{{/link-to}}',
+ // Nested element content
+ filename: 'test.gjs',
+ code: 'click here',
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
- code: `{{#link-to}}
-{{/link-to}}`,
+ // aria-label with disallowed text overrides content check
+ filename: 'test.gjs',
+ code: 'import { LinkTo } from \'@ember/routing\'; About Us',
output: null,
errors: [{ messageId: 'invalidText' }],
},
{
+ filename: 'test.gjs',
code: 'click here',
output: null,
options: [{ linkComponents: ['MyLink'] }],
@@ -126,7 +158,7 @@ const hbsRuleTester = new RuleTester({
},
});
-hbsRuleTester.run('template-no-invalid-link-text', rule, {
+hbsRuleTester.run('template-no-invalid-link-text (hbs)', rule, {
valid: [
'Click here to read more about this amazing adventure',
'{{#link-to}} click here to read more about our company{{/link-to}}',
@@ -139,155 +171,100 @@ hbsRuleTester.run('template-no-invalid-link-text', rule, {
'A link with translation',
'A link with a variable as aria-label',
// allowEmptyLinks: true — empty links are valid
- {
- code: '',
- options: [{ allowEmptyLinks: true }],
- },
+ { code: '', options: [{ allowEmptyLinks: true }] },
],
invalid: [
{
code: 'click here',
output: null,
- errors: [
- {
- message:
- 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ // standalone "more" is disallowed
+ code: 'more',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
},
{
code: 'click here',
output: null,
- errors: [
- {
- message:
- 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
},
{
code: '{{#link-to}}click here{{/link-to}}',
output: null,
- errors: [
- {
- message:
- 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
},
{
code: '',
output: null,
- errors: [
- {
- message:
- 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
},
{
code: ' ',
output: null,
- errors: [
- {
- message:
- 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
},
{
code: `
`,
output: null,
- errors: [
- {
- message:
- 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
},
{
code: 'Click here',
output: null,
- errors: [
- {
- message:
- 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
},
{
code: 'Click here',
output: null,
- errors: [
- {
- message:
- 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
},
{
code: 'Click here',
output: null,
- errors: [
- {
- message:
- 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ // aria-label with disallowed value on LinkTo (text content is valid but aria-label is not)
+ code: 'About Us',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
},
{
code: '',
output: null,
- errors: [
- {
- message:
- 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
},
{
- code: `
+ code: `
`,
output: null,
- errors: [
- {
- message:
- 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
},
{
code: '{{#link-to}}{{/link-to}}',
output: null,
- errors: [
- {
- message:
- 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
},
{
code: `{{#link-to}}
{{/link-to}}`,
output: null,
- errors: [
- {
- message:
- 'Link text "(empty)" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ // nested element content — text is in a child element
+ code: 'click here',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
},
{
code: 'click here',
output: null,
options: [{ linkComponents: ['MyLink'] }],
- errors: [
- {
- message:
- 'Link text "click here" is not descriptive. Use meaningful text that describes the link destination.',
- },
- ],
+ errors: [{ messageId: 'invalidText' }],
},
],
});