Skip to content

Commit f7bd840

Browse files
committed
More tests
1 parent c6826f0 commit f7bd840

22 files changed

Lines changed: 743 additions & 198 deletions

lib/rules/template-no-autofocus-attribute.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,18 @@ module.exports = {
4848
});
4949
}
5050
},
51+
52+
// {{input autofocus=true}} and {{component "input" autofocus=true}}
53+
GlimmerMustacheStatement(node) {
54+
const pairs = node.hash?.pairs || [];
55+
const autofocusPair = pairs.find((p) => p.key === 'autofocus');
56+
if (autofocusPair) {
57+
context.report({
58+
node: autofocusPair,
59+
messageId: 'noAutofocus',
60+
});
61+
}
62+
},
5163
};
5264
},
5365
};

lib/rules/template-no-duplicate-landmark-elements.js

Lines changed: 47 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -46,9 +46,37 @@ module.exports = {
4646
'search',
4747
]);
4848

49+
// Sectioning elements that strip banner/contentinfo roles from header/footer
50+
const SECTIONING_ELEMENTS = new Set(['article', 'aside', 'main', 'nav', 'section']);
51+
const elementStack = [];
4952
const landmarks = new Map();
5053

54+
function isInsideSectioningElement() {
55+
return elementStack.some((tag) => SECTIONING_ELEMENTS.has(tag));
56+
}
57+
5158
return {
59+
GlimmerElementNode(node) {
60+
elementStack.push(node.tag);
61+
62+
const landmarkRole = getLandmarkRole(
63+
node,
64+
LANDMARK_ROLES,
65+
ELEMENT_TO_ROLE,
66+
isInsideSectioningElement()
67+
);
68+
if (landmarkRole) {
69+
if (!landmarks.has(landmarkRole)) {
70+
landmarks.set(landmarkRole, []);
71+
}
72+
landmarks.get(landmarkRole).push({ node, tag: node.tag });
73+
}
74+
},
75+
76+
'GlimmerElementNode:exit'() {
77+
elementStack.pop();
78+
},
79+
5280
'Program:exit'() {
5381
// Check for duplicates
5482
for (const [key, entries] of landmarks) {
@@ -110,31 +138,28 @@ module.exports = {
110138
}
111139
}
112140
},
113-
114-
GlimmerElementNode(node) {
115-
const landmarkRole = getLandmarkRole(node, LANDMARK_ROLES, ELEMENT_TO_ROLE);
116-
if (landmarkRole) {
117-
if (!landmarks.has(landmarkRole)) {
118-
landmarks.set(landmarkRole, []);
119-
}
120-
landmarks.get(landmarkRole).push({ node, tag: node.tag });
121-
}
122-
},
123141
};
124142
},
125143
};
126144

127145
function getLabel(node) {
128146
// Check aria-label
129147
const ariaLabel = node.attributes?.find((attr) => attr.name === 'aria-label');
130-
if (ariaLabel && ariaLabel.value?.type === 'GlimmerTextNode') {
131-
return ariaLabel.value.chars.trim();
148+
if (ariaLabel) {
149+
if (ariaLabel.value?.type === 'GlimmerTextNode') {
150+
return ariaLabel.value.chars.trim();
151+
}
152+
// Dynamic aria-label — treat as a unique label (can't statically determine duplicates)
153+
return `__dynamic:${ariaLabel.range?.[0] || Math.random()}`;
132154
}
133155

134156
// Check aria-labelledby - extract the ID value
135157
const ariaLabelledby = node.attributes?.find((attr) => attr.name === 'aria-labelledby');
136-
if (ariaLabelledby && ariaLabelledby.value?.type === 'GlimmerTextNode') {
137-
return `__labelledby:${ariaLabelledby.value.chars.trim()}`;
158+
if (ariaLabelledby) {
159+
if (ariaLabelledby.value?.type === 'GlimmerTextNode') {
160+
return `__labelledby:${ariaLabelledby.value.chars.trim()}`;
161+
}
162+
return `__dynamic:${ariaLabelledby.range?.[0] || Math.random()}`;
138163
}
139164

140165
return null;
@@ -148,13 +173,18 @@ function getRoleValue(node) {
148173
return null;
149174
}
150175

151-
function getLandmarkRole(node, LANDMARK_ROLES, ELEMENT_TO_ROLE) {
176+
function getLandmarkRole(node, LANDMARK_ROLES, ELEMENT_TO_ROLE, insideSectioning) {
152177
const role = getRoleValue(node);
153178
if (role && LANDMARK_ROLES.has(role)) {
154179
return role;
155180
}
156-
if (ELEMENT_TO_ROLE[node.tag]) {
157-
return ELEMENT_TO_ROLE[node.tag];
181+
const implicitRole = ELEMENT_TO_ROLE[node.tag];
182+
if (implicitRole) {
183+
// header and footer lose their landmark role when inside sectioning elements
184+
if (insideSectioning && (node.tag === 'header' || node.tag === 'footer')) {
185+
return null;
186+
}
187+
return implicitRole;
158188
}
159189
return null;
160190
}

lib/rules/template-no-inline-linkto.js

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ module.exports = {
3131
});
3232
}
3333
},
34+
35+
// {{link-to 'text' 'route'}} inline curly form
36+
GlimmerMustacheStatement(node) {
37+
if (node.path?.type === 'GlimmerPathExpression' && node.path.original === 'link-to') {
38+
context.report({
39+
node,
40+
messageId: 'noInlineLinkTo',
41+
});
42+
}
43+
},
3444
};
3545
},
3646
};

lib/rules/template-no-inline-styles.js

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,10 +39,10 @@ module.exports = {
3939
return;
4040
}
4141

42-
// If allowDynamicStyles is true, skip dynamic style values (MustacheStatement/ConcatStatement)
42+
// If allowDynamicStyles is true, skip purely dynamic style values (MustacheStatement only)
4343
if (allowDynamicStyles) {
4444
const valType = styleAttr.value?.type;
45-
if (valType === 'GlimmerMustacheStatement' || valType === 'GlimmerConcatStatement') {
45+
if (valType === 'GlimmerMustacheStatement') {
4646
return;
4747
}
4848
}

lib/rules/template-no-pointer-down-event-binding.js

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,22 @@ module.exports = {
2323
},
2424

2525
create(context) {
26+
const POINTER_DOWN_EVENTS = new Set(['pointerdown', 'mousedown']);
27+
const POINTER_DOWN_ATTRS = new Set(['onpointerdown', 'onmousedown']);
28+
2629
return {
2730
GlimmerElementNode(node) {
28-
// Check for onpointerdown attribute
29-
const pointerDownAttr = node.attributes?.find((a) => a.name === 'onpointerdown');
30-
if (pointerDownAttr) {
31-
context.report({
32-
node: pointerDownAttr,
33-
messageId: 'unexpected',
34-
});
31+
// Check for onpointerdown/onmousedown attributes
32+
for (const attr of node.attributes || []) {
33+
if (POINTER_DOWN_ATTRS.has(attr.name)) {
34+
context.report({
35+
node: attr,
36+
messageId: 'unexpected',
37+
});
38+
}
3539
}
3640

37-
// Check for {{on "pointerdown"}} modifier
41+
// Check for {{on "pointerdown"/"mousedown"}} and {{action ... on="mousedown"}} modifiers
3842
if (node.modifiers) {
3943
for (const modifier of node.modifiers) {
4044
if (
@@ -45,7 +49,25 @@ module.exports = {
4549
const eventParam = modifier.params[0];
4650
if (
4751
eventParam.type === 'GlimmerStringLiteral' &&
48-
eventParam.value === 'pointerdown'
52+
POINTER_DOWN_EVENTS.has(eventParam.value)
53+
) {
54+
context.report({
55+
node: modifier,
56+
messageId: 'unexpected',
57+
});
58+
}
59+
}
60+
61+
// Check {{action ... on="mousedown"/"pointerdown"}}
62+
if (
63+
modifier.path?.type === 'GlimmerPathExpression' &&
64+
modifier.path.original === 'action'
65+
) {
66+
const onPair = modifier.hash?.pairs?.find((p) => p.key === 'on');
67+
if (
68+
onPair &&
69+
onPair.value?.type === 'GlimmerStringLiteral' &&
70+
POINTER_DOWN_EVENTS.has(onPair.value.value)
4971
) {
5072
context.report({
5173
node: modifier,

lib/rules/template-no-unnecessary-component-helper.js

Lines changed: 80 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,15 @@
1+
function isComponentWithStringLiteral(node) {
2+
return (
3+
node.path &&
4+
node.path.type === 'GlimmerPathExpression' &&
5+
node.path.original === 'component' &&
6+
node.params &&
7+
node.params.length > 0 &&
8+
node.params[0].type === 'GlimmerStringLiteral' &&
9+
!(node.params[0].value || '').includes('@')
10+
);
11+
}
12+
113
/** @type {import('eslint').Rule.RuleModule} */
214
module.exports = {
315
meta: {
@@ -24,88 +36,88 @@ module.exports = {
2436

2537
create(context) {
2638
const sourceCode = context.sourceCode || context.getSourceCode();
39+
let inAttribute = 0;
2740

2841
return {
42+
GlimmerAttrNode() {
43+
inAttribute++;
44+
},
45+
'GlimmerAttrNode:exit'() {
46+
inAttribute--;
47+
},
48+
2949
GlimmerMustacheStatement(node) {
30-
if (
31-
node.path &&
32-
node.path.type === 'GlimmerPathExpression' &&
33-
node.path.original === 'component' &&
34-
node.params &&
35-
node.params.length > 0 &&
36-
node.params[0].type === 'GlimmerStringLiteral'
37-
) {
38-
const componentName = node.params[0].value || node.params[0].original;
39-
context.report({
40-
node,
41-
messageId: 'noUnnecessaryComponent',
42-
fix(fixer) {
43-
const text = sourceCode.getText(node);
44-
// Replace {{component "name" ...rest}} with {{name ...rest}}
45-
const restParams = node.params.slice(1);
46-
const hashPairs = node.hash?.pairs || [];
50+
if (inAttribute > 0) {
51+
return;
52+
}
53+
if (!isComponentWithStringLiteral(node)) {
54+
return;
55+
}
4756

48-
let replacement = `{{${componentName}`;
49-
for (const param of restParams) {
50-
replacement += ` ${sourceCode.getText(param)}`;
51-
}
52-
for (const pair of hashPairs) {
53-
replacement += ` ${sourceCode.getText(pair)}`;
54-
}
55-
replacement += '}}';
57+
const componentName = node.params[0].value || node.params[0].original;
58+
context.report({
59+
node,
60+
messageId: 'noUnnecessaryComponent',
61+
fix(fixer) {
62+
const restParams = node.params.slice(1);
63+
const hashPairs = node.hash?.pairs || [];
5664

57-
return fixer.replaceText(node, replacement);
58-
},
59-
});
60-
}
65+
let replacement = `{{${componentName}`;
66+
for (const param of restParams) {
67+
replacement += ` ${sourceCode.getText(param)}`;
68+
}
69+
for (const pair of hashPairs) {
70+
replacement += ` ${sourceCode.getText(pair)}`;
71+
}
72+
replacement += '}}';
73+
74+
return fixer.replaceText(node, replacement);
75+
},
76+
});
6177
},
6278

6379
GlimmerBlockStatement(node) {
64-
if (
65-
node.path &&
66-
node.path.type === 'GlimmerPathExpression' &&
67-
node.path.original === 'component' &&
68-
node.params &&
69-
node.params.length > 0 &&
70-
node.params[0].type === 'GlimmerStringLiteral'
71-
) {
72-
context.report({
73-
node,
74-
messageId: 'noUnnecessaryComponent',
75-
});
80+
if (inAttribute > 0) {
81+
return;
82+
}
83+
if (!isComponentWithStringLiteral(node)) {
84+
return;
7685
}
86+
87+
context.report({
88+
node,
89+
messageId: 'noUnnecessaryComponent',
90+
});
7791
},
7892

7993
GlimmerSubExpression(node) {
80-
if (
81-
node.path &&
82-
node.path.type === 'GlimmerPathExpression' &&
83-
node.path.original === 'component' &&
84-
node.params &&
85-
node.params.length > 0 &&
86-
node.params[0].type === 'GlimmerStringLiteral'
87-
) {
88-
const componentName = node.params[0].value || node.params[0].original;
89-
context.report({
90-
node,
91-
messageId: 'noUnnecessaryComponent',
92-
fix(fixer) {
93-
const restParams = node.params.slice(1);
94-
const hashPairs = node.hash?.pairs || [];
94+
if (inAttribute > 0) {
95+
return;
96+
}
97+
if (!isComponentWithStringLiteral(node)) {
98+
return;
99+
}
95100

96-
let replacement = `(${componentName}`;
97-
for (const param of restParams) {
98-
replacement += ` ${sourceCode.getText(param)}`;
99-
}
100-
for (const pair of hashPairs) {
101-
replacement += ` ${sourceCode.getText(pair)}`;
102-
}
103-
replacement += ')';
101+
const componentName = node.params[0].value || node.params[0].original;
102+
context.report({
103+
node,
104+
messageId: 'noUnnecessaryComponent',
105+
fix(fixer) {
106+
const restParams = node.params.slice(1);
107+
const hashPairs = node.hash?.pairs || [];
104108

105-
return fixer.replaceText(node, replacement);
106-
},
107-
});
108-
}
109+
let replacement = `(${componentName}`;
110+
for (const param of restParams) {
111+
replacement += ` ${sourceCode.getText(param)}`;
112+
}
113+
for (const pair of hashPairs) {
114+
replacement += ` ${sourceCode.getText(pair)}`;
115+
}
116+
replacement += ')';
117+
118+
return fixer.replaceText(node, replacement);
119+
},
120+
});
109121
},
110122
};
111123
},

0 commit comments

Comments
 (0)