Skip to content

Commit c9d988f

Browse files
Merge pull request #2623 from NullVoxPopuli/nvp/template-lint-extract-rule-template-table-groups
Extract rule: template-table-groups
2 parents 498837e + 5d9dca2 commit c9d988f

4 files changed

Lines changed: 1198 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,7 @@ rules in templates can be disabled with eslint directives with mustache or html
197197
| [template-no-nested-interactive](docs/rules/template-no-nested-interactive.md) | disallow nested interactive elements | | | |
198198
| [template-no-nested-landmark](docs/rules/template-no-nested-landmark.md) | disallow nested landmark elements | | | |
199199
| [template-no-pointer-down-event-binding](docs/rules/template-no-pointer-down-event-binding.md) | disallow pointer down event bindings | | | |
200+
| [template-table-groups](docs/rules/template-table-groups.md) | require table elements to use table grouping elements | | | |
200201

201202
### Best Practices
202203

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
# ember/template-table-groups
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Requires table elements to use table grouping elements.
6+
7+
Tables should use `<thead>`, `<tbody>`, and `<tfoot>` elements to group related content. This improves accessibility for screen reader users and makes the table structure more semantic.
8+
9+
## Rule Details
10+
11+
This rule requires that `<table>` elements use grouping elements (`<thead>`, `<tbody>`, `<tfoot>`) instead of having `<tr>` elements as direct children.
12+
13+
## Examples
14+
15+
Examples of **incorrect** code for this rule:
16+
17+
```gjs
18+
<template>
19+
<table>
20+
<tr><td>Data</td></tr>
21+
</table>
22+
</template>
23+
```
24+
25+
```gjs
26+
<template>
27+
<table>
28+
<tr><th>Header</th></tr>
29+
<tr><td>Data</td></tr>
30+
</table>
31+
</template>
32+
```
33+
34+
Examples of **correct** code for this rule:
35+
36+
```gjs
37+
<template>
38+
<table>
39+
<thead>
40+
<tr><th>Header</th></tr>
41+
</thead>
42+
<tbody>
43+
<tr><td>Data</td></tr>
44+
</tbody>
45+
</table>
46+
</template>
47+
```
48+
49+
```gjs
50+
<template>
51+
<table>
52+
<tbody>
53+
<tr><td>Data</td></tr>
54+
</tbody>
55+
</table>
56+
</template>
57+
```
58+
59+
## Options
60+
61+
| Name | Type | Default | Description |
62+
| ----------------------------- | ---------- | ------- | ---------------------------------------------- |
63+
| `allowed-table-components` | `string[]` | `[]` | Component names treated as `<table>` elements. |
64+
| `allowed-caption-components` | `string[]` | `[]` | Component names treated as `<caption>`. |
65+
| `allowed-colgroup-components` | `string[]` | `[]` | Component names treated as `<colgroup>`. |
66+
| `allowed-thead-components` | `string[]` | `[]` | Component names treated as `<thead>`. |
67+
| `allowed-tbody-components` | `string[]` | `[]` | Component names treated as `<tbody>`. |
68+
| `allowed-tfoot-components` | `string[]` | `[]` | Component names treated as `<tfoot>`. |
69+
70+
## References
71+
72+
- [eslint-plugin-ember template-table-groups](https://github.com/ember-cli/eslint-plugin-ember/blob/master/docs/rules/template-table-groups.md)
73+
- [MDN - Table structure](https://developer.mozilla.org/en-US/docs/Learn/HTML/Tables/Advanced)

lib/rules/template-table-groups.js

Lines changed: 231 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,231 @@
1+
const ALLOWED_TABLE_CHILDREN = ['caption', 'colgroup', 'thead', 'tbody', 'tfoot'];
2+
const CONTROL_FLOW_START_MARK = 0;
3+
const CONTROL_FLOW_END_MARK = 1;
4+
5+
function dasherize(str) {
6+
return str
7+
.replaceAll('::', '/')
8+
.replaceAll(/([\da-z])([A-Z])/g, '$1-$2')
9+
.toLowerCase();
10+
}
11+
12+
function isControlFlowHelper(node) {
13+
if (node.type !== 'GlimmerBlockStatement' && node.type !== 'GlimmerMustacheStatement') {
14+
return false;
15+
}
16+
const name = node.path?.original;
17+
return ['if', 'unless', 'each', 'each-in', 'let', 'with'].includes(name);
18+
}
19+
20+
function isIfOrUnless(node) {
21+
const name = node.path?.original;
22+
return name === 'if' || name === 'unless';
23+
}
24+
25+
function getEffectiveChildren(children) {
26+
return (children || []).flatMap((child) => {
27+
if (isControlFlowHelper(child)) {
28+
if (isIfOrUnless(child) && child.program && child.inverse) {
29+
return [
30+
CONTROL_FLOW_START_MARK,
31+
...getEffectiveChildren(child.program?.body || child.children || []),
32+
CONTROL_FLOW_END_MARK,
33+
CONTROL_FLOW_START_MARK,
34+
...getEffectiveChildren(child.inverse?.body || []),
35+
CONTROL_FLOW_END_MARK,
36+
];
37+
}
38+
const body = child.program?.body || child.children || child.body?.body || [];
39+
return getEffectiveChildren(body);
40+
}
41+
return [child];
42+
});
43+
}
44+
45+
function isAllowedTableChild(child, internalTags) {
46+
switch (child.type) {
47+
case 'GlimmerElementNode': {
48+
const idx = ALLOWED_TABLE_CHILDREN.indexOf(child.tag);
49+
if (idx > -1) {
50+
return { allowed: true, indices: [idx] };
51+
}
52+
// Check @tagName attribute
53+
const tagNameAttr = child.attributes?.find((a) => a.name === '@tagName');
54+
if (tagNameAttr) {
55+
const val = tagNameAttr.value?.type === 'GlimmerTextNode' ? tagNameAttr.value.chars : null;
56+
const tIdx = ALLOWED_TABLE_CHILDREN.indexOf(val);
57+
return { allowed: tIdx > -1, indices: tIdx > -1 ? [tIdx] : [] };
58+
}
59+
// Check custom component mapping
60+
const dasherized = dasherize(child.tag);
61+
const possibleIndices = internalTags.get(dasherized) || [];
62+
if (possibleIndices.length > 0) {
63+
return { allowed: true, indices: possibleIndices };
64+
}
65+
return { allowed: false };
66+
}
67+
case 'GlimmerBlockStatement':
68+
case 'GlimmerMustacheStatement': {
69+
// Check tagName hash pair
70+
const tagNamePair = child.hash?.pairs?.find((p) => p.key === 'tagName');
71+
if (tagNamePair) {
72+
const val = tagNamePair.value?.value || tagNamePair.value?.chars;
73+
const idx = ALLOWED_TABLE_CHILDREN.indexOf(val);
74+
return { allowed: idx > -1, indices: idx > -1 ? [idx] : [] };
75+
}
76+
if (child.path?.original === 'yield') {
77+
return { allowed: true, indices: [] };
78+
}
79+
const possibleIndices = internalTags.get(child.path?.original) || [];
80+
if (possibleIndices.length > 0) {
81+
return { allowed: true, indices: possibleIndices };
82+
}
83+
return { allowed: false };
84+
}
85+
case 'GlimmerCommentStatement':
86+
case 'GlimmerMustacheCommentStatement': {
87+
return { allowed: true, indices: [] };
88+
}
89+
case 'GlimmerTextNode': {
90+
return { allowed: !/\S/.test(child.chars || ''), indices: [] };
91+
}
92+
default: {
93+
return { allowed: false };
94+
}
95+
}
96+
}
97+
98+
/** @type {import('eslint').Rule.RuleModule} */
99+
module.exports = {
100+
meta: {
101+
type: 'problem',
102+
docs: {
103+
description: 'require table elements to use table grouping elements',
104+
category: 'Accessibility',
105+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-table-groups.md',
106+
templateMode: 'both',
107+
},
108+
fixable: null,
109+
schema: [
110+
{
111+
type: 'object',
112+
properties: {
113+
'allowed-table-components': { type: 'array', items: { type: 'string' } },
114+
'allowed-caption-components': { type: 'array', items: { type: 'string' } },
115+
'allowed-colgroup-components': { type: 'array', items: { type: 'string' } },
116+
'allowed-thead-components': { type: 'array', items: { type: 'string' } },
117+
'allowed-tbody-components': { type: 'array', items: { type: 'string' } },
118+
'allowed-tfoot-components': { type: 'array', items: { type: 'string' } },
119+
},
120+
additionalProperties: false,
121+
},
122+
],
123+
messages: {
124+
missing: 'Tables must have a table group (thead, tbody or tfoot).',
125+
ordering:
126+
'Tables must have table groups in the correct order (caption, colgroup, thead, tbody then tfoot).',
127+
},
128+
originallyFrom: {
129+
name: 'ember-template-lint',
130+
rule: 'lib/rules/table-groups.js',
131+
docs: 'docs/rule/table-groups.md',
132+
tests: 'test/unit/rules/table-groups-test.js',
133+
},
134+
},
135+
136+
create(context) {
137+
const options = context.options[0] || {};
138+
const outerTags = new Set(options['allowed-table-components'] || []);
139+
const internalTags = new Map();
140+
141+
const componentKeys = [
142+
'allowed-caption-components',
143+
'allowed-colgroup-components',
144+
'allowed-thead-components',
145+
'allowed-tbody-components',
146+
'allowed-tfoot-components',
147+
];
148+
149+
for (const [index, key] of componentKeys.entries()) {
150+
if (options[key]) {
151+
for (const comp of options[key]) {
152+
if (!internalTags.has(comp)) {
153+
internalTags.set(comp, []);
154+
}
155+
internalTags.get(comp).push(index);
156+
}
157+
}
158+
}
159+
160+
function isTableElement(node) {
161+
if (node.tag === 'table') {
162+
return true;
163+
}
164+
if (outerTags.has(dasherize(node.tag))) {
165+
return true;
166+
}
167+
const tagNameAttr = node.attributes?.find((a) => a.name === '@tagName');
168+
if (tagNameAttr) {
169+
const val = tagNameAttr.value?.type === 'GlimmerTextNode' ? tagNameAttr.value.chars : null;
170+
return val === 'table';
171+
}
172+
return false;
173+
}
174+
175+
return {
176+
GlimmerElementNode(node) {
177+
if (!isTableElement(node)) {
178+
return;
179+
}
180+
181+
// Truly empty table (no content at all between tags) must have table groups
182+
if (!node.children || node.children.length === 0) {
183+
const sourceCode = context.getSourceCode();
184+
const text = sourceCode.getText(node);
185+
const openEnd = text.indexOf('>') + 1;
186+
const closeStart = text.lastIndexOf('</');
187+
if (closeStart >= 0 && closeStart <= openEnd) {
188+
context.report({ node, messageId: 'missing' });
189+
return;
190+
}
191+
}
192+
193+
const children = getEffectiveChildren(node.children);
194+
195+
let currentAllowedMinimumIndices = new Set([0]);
196+
const scopedIndices = [];
197+
198+
for (const child of children) {
199+
if (child === CONTROL_FLOW_START_MARK) {
200+
scopedIndices.push(currentAllowedMinimumIndices);
201+
currentAllowedMinimumIndices = new Set(currentAllowedMinimumIndices);
202+
continue;
203+
}
204+
if (child === CONTROL_FLOW_END_MARK) {
205+
currentAllowedMinimumIndices = scopedIndices.pop();
206+
continue;
207+
}
208+
209+
const { allowed, indices } = isAllowedTableChild(child, internalTags);
210+
if (!allowed) {
211+
context.report({ node, messageId: 'missing' });
212+
return;
213+
}
214+
215+
if (indices.length > 0) {
216+
const newAllowedMinimumIndices = new Set(
217+
[...currentAllowedMinimumIndices].flatMap((currentIndex) =>
218+
indices.filter((newIndex) => newIndex >= currentIndex)
219+
)
220+
);
221+
if (newAllowedMinimumIndices.size === 0) {
222+
context.report({ node, messageId: 'ordering' });
223+
return;
224+
}
225+
currentAllowedMinimumIndices = newAllowedMinimumIndices;
226+
}
227+
}
228+
},
229+
};
230+
},
231+
};

0 commit comments

Comments
 (0)