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..ed301f458a
--- /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)
+- [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
new file mode 100644
index 0000000000..a09e48cc67
--- /dev/null
+++ b/lib/rules/template-no-invalid-link-text.js
@@ -0,0 +1,199 @@
+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 };
+}
+
+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 ?? 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) {
+ 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;
+ }
+
+ // Check text content
+ let fullText = '';
+ let hasDynamic = false;
+ for (const child of children || []) {
+ const result = getTextContentResult(child);
+ fullText += result.text;
+ if (result.hasDynamic) {
+ hasDynamic = true;
+ }
+ }
+
+ if (hasDynamic) {
+ return; // can't validate dynamic content
+ }
+
+ 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);
+ }
+ },
+ };
+ },
+};
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..f6e74ce8a8
--- /dev/null
+++ b/tests/lib/rules/template-no-invalid-link-text.js
@@ -0,0 +1,270 @@
+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: [
+ { 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' }],
+ },
+ {
+ // 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' }],
+ },
+ {
+ filename: 'test.gjs',
+ code: 'Click here',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ filename: 'test.gjs',
+ code: 'Click here',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ // Imported LinkTo with disallowed text
+ filename: 'test.gjs',
+ code: "import { LinkTo } from '@ember/routing'; click here",
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ // 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' }],
+ },
+ {
+ // Imported LinkTo — empty
+ filename: 'test.gjs',
+ code: "import { LinkTo } from '@ember/routing'; ",
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ // Nested element content
+ filename: 'test.gjs',
+ code: 'click here',
+ output: null,
+ errors: [{ messageId: 'invalidText' }],
+ },
+ {
+ // 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'] }],
+ 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 (hbs)', 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: [{ messageId: 'invalidText' }],
+ },
+ {
+ // standalone "more" is disallowed
+ code: 'more',
+ 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' }],
+ },
+ {
+ // 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: [{ 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' }],
+ },
+ {
+ // 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: [{ messageId: 'invalidText' }],
+ },
+ ],
+});