diff --git a/lib/rules/template-no-invalid-link-title.js b/lib/rules/template-no-invalid-link-title.js index 6b505ae34e..93cf517c23 100644 --- a/lib/rules/template-no-invalid-link-title.js +++ b/lib/rules/template-no-invalid-link-title.js @@ -45,21 +45,33 @@ module.exports = { }, create(context) { + const filename = context.filename; + const isStrictMode = filename.endsWith('.gjs') || filename.endsWith('.gts'); + + // In HBS, 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']); + if (!isStrictMode) { + linkTags.add('LinkTo'); + } + // eslint-disable-next-line complexity function checkElementNode(node) { - if (node.tag !== 'a' && node.tag !== 'LinkTo') { + if (!linkTags.has(node.tag)) { return; } + // Determine if this tag should be treated as for @title handling + const isLinkTo = node.tag === 'LinkTo' || importedLinkComponents.has(node.tag); const titleAttr = node.attributes.find( (attr) => attr.type === 'GlimmerAttrNode' && attr.name === 'title' ); - const titleArgAttr = - node.tag === 'LinkTo' - ? node.attributes.find( - (attr) => attr.type === 'GlimmerAttrNode' && attr.name === '@title' - ) - : null; + const titleArgAttr = isLinkTo + ? node.attributes.find((attr) => attr.type === 'GlimmerAttrNode' && attr.name === '@title') + : null; // Get title attribute text value let titleAttrValue; @@ -74,12 +86,12 @@ module.exports = { } // Collect all title values (lowercased, trimmed) - const titleValues = [titleAttrValue, node.tag === 'LinkTo' ? titleArgValue : null] + const titleValues = [titleAttrValue, isLinkTo ? titleArgValue : null] .filter((v) => typeof v === 'string') .map((v) => v.toLowerCase().trim()); // Error if both title and @title are specified on LinkTo - if (node.tag === 'LinkTo' && titleAttrValue !== undefined && titleArgValue !== undefined) { + if (isLinkTo && titleAttrValue !== undefined && titleArgValue !== undefined) { context.report({ node: titleAttr || node, messageId: 'noInvalidLinkTitle', @@ -150,6 +162,19 @@ module.exports = { } return { + ImportDeclaration(node) { + if (!isStrictMode) { + return; + } + 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: checkElementNode, GlimmerBlockStatement: checkBlockStatement, }; diff --git a/tests/lib/rules/template-no-invalid-link-title.js b/tests/lib/rules/template-no-invalid-link-title.js index cdb4ee2728..1eebd6f024 100644 --- a/tests/lib/rules/template-no-invalid-link-title.js +++ b/tests/lib/rules/template-no-invalid-link-title.js @@ -12,6 +12,19 @@ const ruleTester = new RuleTester({ ruleTester.run('template-no-invalid-link-title', rule, { valid: [ + // In GJS/GTS, is only a router link if explicitly imported. + // Without an import, it's a user-authored component and the rule shouldn't fire. + { + filename: 'test.gjs', + code: '', + }, + // With the import, the rule correctly treats it as the router LinkTo. + { + filename: 'test.gjs', + code: `import { LinkTo } from '@ember/routing'; + `, + }, + '', '', '', @@ -33,6 +46,14 @@ ruleTester.run('template-no-invalid-link-title', rule, { `, ], invalid: [ + // Imported in GJS/GTS: rule still applies + { + filename: 'test.gjs', + code: `import { LinkTo } from '@ember/routing'; + `, + output: null, + errors: [{ messageId: 'noInvalidLinkTitle' }], + }, { code: '', output: null,