Skip to content

Commit 44bc1af

Browse files
committed
feat(plugin-axe): add metadata transformations
1 parent d89741a commit 44bc1af

File tree

4 files changed

+340
-0
lines changed

4 files changed

+340
-0
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
export const AXE_PLUGIN_SLUG = 'axe';
2+
3+
export const AXE_PRESETS = [
4+
'wcag21aa',
5+
'wcag22aa',
6+
'best-practice',
7+
'all',
8+
] as const;
9+
10+
export type AxePreset = (typeof AXE_PRESETS)[number];
11+
12+
export const AXE_DEFAULT_PRESET: AxePreset = 'wcag21aa';
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { Audit } from '@code-pushup/models';
3+
import { loadAxeRules, transformRulesToAudits } from './transform.js';
4+
5+
describe('transformRulesToAudits', () => {
6+
describe('wcag21aa preset', () => {
7+
it('should return approximately 67 audits', () => {
8+
const audits = transformRulesToAudits(loadAxeRules('wcag21aa'));
9+
10+
expect(audits.length).toBeGreaterThanOrEqual(65);
11+
expect(audits.length).toBeLessThanOrEqual(70);
12+
});
13+
});
14+
15+
describe('wcag22aa preset', () => {
16+
it('should return approximately 68 audits', () => {
17+
const audits = transformRulesToAudits(loadAxeRules('wcag22aa'));
18+
19+
expect(audits.length).toBeGreaterThanOrEqual(66);
20+
expect(audits.length).toBeLessThanOrEqual(72);
21+
});
22+
});
23+
24+
describe('best-practice preset', () => {
25+
it('should return approximately 30 audits', () => {
26+
const audits = transformRulesToAudits(loadAxeRules('best-practice'));
27+
28+
expect(audits.length).toBeGreaterThanOrEqual(25);
29+
expect(audits.length).toBeLessThanOrEqual(35);
30+
});
31+
});
32+
33+
describe('all preset', () => {
34+
it('should return approximately 104 audits', () => {
35+
const audits = transformRulesToAudits(loadAxeRules('all'));
36+
37+
expect(audits.length).toBeGreaterThanOrEqual(100);
38+
expect(audits.length).toBeLessThanOrEqual(110);
39+
});
40+
});
41+
42+
describe('audit structure', () => {
43+
it('should have slug, title, description, and docsUrl', () => {
44+
const audit = transformRulesToAudits(
45+
loadAxeRules('wcag21aa'),
46+
)[0] as Audit;
47+
48+
expect(audit.slug).toBeTruthy();
49+
expect(audit.title).toBeTruthy();
50+
expect(audit.description).toBeTruthy();
51+
expect(audit.docsUrl).toMatch(/^https:\/\//);
52+
});
53+
});
54+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import type { RuleMetadata } from 'axe-core';
2+
import { describe, expect, it } from 'vitest';
3+
import { loadAxeRules, transformRulesToGroups } from './transform.js';
4+
5+
describe('transformRulesToGroups', () => {
6+
describe('wcag21aa preset', () => {
7+
it('should create WCAG 2.1 Level A and AA groups', () => {
8+
const groups = transformRulesToGroups(
9+
loadAxeRules('wcag21aa'),
10+
'wcag21aa',
11+
);
12+
13+
expect(groups.map(({ slug }) => slug)).toEqual([
14+
'wcag21-level-a',
15+
'wcag21-level-aa',
16+
]);
17+
expect(groups.map(({ title }) => title)).toEqual([
18+
'WCAG 2.1 Level A',
19+
'WCAG 2.1 Level AA',
20+
]);
21+
});
22+
23+
it('should have refs in WCAG groups', () => {
24+
transformRulesToGroups(loadAxeRules('wcag21aa'), 'wcag21aa').forEach(
25+
({ refs }) => {
26+
expect(refs.length).toBeGreaterThan(0);
27+
},
28+
);
29+
});
30+
});
31+
32+
describe('wcag22aa preset', () => {
33+
it('should create WCAG 2.2 Level A and AA groups', () => {
34+
const groups = transformRulesToGroups(
35+
loadAxeRules('wcag22aa'),
36+
'wcag22aa',
37+
);
38+
39+
expect(groups.map(({ slug }) => slug)).toEqual([
40+
'wcag22-level-a',
41+
'wcag22-level-aa',
42+
]);
43+
expect(groups.map(({ title }) => title)).toEqual([
44+
'WCAG 2.2 Level A',
45+
'WCAG 2.2 Level AA',
46+
]);
47+
});
48+
});
49+
50+
describe('best-practice preset', () => {
51+
it('should create multiple category groups', () => {
52+
expect(
53+
transformRulesToGroups(loadAxeRules('best-practice'), 'best-practice')
54+
.length,
55+
).toBeGreaterThan(5);
56+
});
57+
58+
it('should format category titles correctly', () => {
59+
const groups = transformRulesToGroups(
60+
loadAxeRules('best-practice'),
61+
'best-practice',
62+
);
63+
64+
expect(groups.find(({ slug }) => slug === 'aria')?.title).toBe('ARIA');
65+
expect(groups.find(({ slug }) => slug === 'name-role-value')?.title).toBe(
66+
'Names & Labels',
67+
);
68+
});
69+
70+
it('should format unknown category titles with title case', () => {
71+
const groups = transformRulesToGroups(
72+
[{ tags: ['cat.some-new-category', 'best-practice'] } as RuleMetadata],
73+
'best-practice',
74+
);
75+
76+
expect(
77+
groups.find(({ slug }) => slug === 'some-new-category')?.title,
78+
).toBe('Some New Category');
79+
});
80+
81+
it('should remove "cat." prefix from category slugs', () => {
82+
transformRulesToGroups(
83+
loadAxeRules('best-practice'),
84+
'best-practice',
85+
).forEach(({ slug }) => {
86+
expect(slug).not.toMatch(/^cat\./);
87+
});
88+
});
89+
});
90+
91+
describe('all preset', () => {
92+
it('should combine WCAG and category groups', () => {
93+
const groups = transformRulesToGroups(loadAxeRules('all'), 'all');
94+
95+
expect(groups.filter(({ slug }) => slug.startsWith('wcag'))).toHaveLength(
96+
2,
97+
);
98+
expect(
99+
groups.filter(({ slug }) => !slug.startsWith('wcag')).length,
100+
).toBeGreaterThan(5);
101+
});
102+
103+
it('should use WCAG 2.2 for all preset', () => {
104+
const groups = transformRulesToGroups(loadAxeRules('all'), 'all');
105+
106+
expect(groups.some(({ slug }) => slug === 'wcag22-level-a')).toBe(true);
107+
expect(groups.some(({ slug }) => slug === 'wcag22-level-aa')).toBe(true);
108+
});
109+
});
110+
111+
describe('group structure', () => {
112+
it('should have all refs with weight 1', () => {
113+
transformRulesToGroups(loadAxeRules('wcag21aa'), 'wcag21aa').forEach(
114+
({ refs }) => {
115+
refs.forEach(({ weight }) => {
116+
expect(weight).toBe(1);
117+
});
118+
},
119+
);
120+
});
121+
122+
it('should filter out empty groups', () => {
123+
transformRulesToGroups(loadAxeRules('all'), 'all').forEach(({ refs }) => {
124+
expect(refs.length).toBeGreaterThan(0);
125+
});
126+
});
127+
});
128+
});
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
import axe from 'axe-core';
2+
import type { Audit, Group } from '@code-pushup/models';
3+
import { capitalize } from '@code-pushup/utils';
4+
import type { AxePreset } from '../constants.js';
5+
6+
const WCAG_LEVEL_A_TAGS = ['wcag2a', 'wcag21a'];
7+
const WCAG_LEVEL_AA_TAGS_21 = ['wcag2aa', 'wcag21aa'];
8+
const WCAG_LEVEL_AA_TAGS_22 = ['wcag2aa', 'wcag21aa', 'wcag22aa'];
9+
10+
const CATEGORY_TITLES: Record<string, string> = {
11+
'cat.aria': 'ARIA',
12+
'cat.color': 'Color & Contrast',
13+
'cat.forms': 'Forms',
14+
'cat.keyboard': 'Keyboard',
15+
'cat.language': 'Language',
16+
'cat.name-role-value': 'Names & Labels',
17+
'cat.parsing': 'Parsing',
18+
'cat.semantics': 'Semantics',
19+
'cat.sensory-and-visual-cues': 'Visual Cues',
20+
'cat.structure': 'Structure',
21+
'cat.tables': 'Tables',
22+
'cat.text-alternatives': 'Text Alternatives',
23+
'cat.time-and-media': 'Media',
24+
};
25+
26+
export function loadAxeRules(preset: AxePreset): axe.RuleMetadata[] {
27+
const tags = getPresetTags(preset);
28+
return tags.length === 0 ? axe.getRules() : axe.getRules(tags);
29+
}
30+
31+
export function transformRulesToAudits(rules: axe.RuleMetadata[]): Audit[] {
32+
return rules.map(rule => ({
33+
slug: rule.ruleId,
34+
title: rule.help,
35+
description: rule.description,
36+
docsUrl: rule.helpUrl,
37+
}));
38+
}
39+
40+
export function transformRulesToGroups(
41+
rules: axe.RuleMetadata[],
42+
preset: AxePreset,
43+
): Group[] {
44+
const groups = (() => {
45+
switch (preset) {
46+
case 'wcag21aa':
47+
return createWcagGroups(rules, '2.1');
48+
case 'wcag22aa':
49+
return createWcagGroups(rules, '2.2');
50+
case 'best-practice':
51+
return createCategoryGroups(rules);
52+
case 'all':
53+
return [
54+
...createWcagGroups(rules, '2.2'),
55+
...createCategoryGroups(rules),
56+
];
57+
}
58+
})();
59+
60+
return groups.filter(({ refs }) => refs.length > 0);
61+
}
62+
63+
/**
64+
* Maps preset to corresponding axe-core tags.
65+
*
66+
* WCAG tags are non-cumulative - each rule has exactly one WCAG version tag.
67+
* To include all rules up to a version/level, multiple tags must be combined.
68+
*/
69+
function getPresetTags(preset: AxePreset): string[] {
70+
switch (preset) {
71+
case 'wcag21aa':
72+
return [...WCAG_LEVEL_A_TAGS, ...WCAG_LEVEL_AA_TAGS_21];
73+
case 'wcag22aa':
74+
return [...WCAG_LEVEL_A_TAGS, ...WCAG_LEVEL_AA_TAGS_22];
75+
case 'best-practice':
76+
return ['best-practice'];
77+
case 'all':
78+
return [];
79+
}
80+
}
81+
82+
function createGroup(slug: string, title: string, ruleIds: string[]): Group {
83+
return {
84+
slug,
85+
title,
86+
refs: ruleIds.map(ruleId => ({ slug: ruleId, weight: 1 })),
87+
};
88+
}
89+
90+
function createWcagGroups(
91+
rules: axe.RuleMetadata[],
92+
version: '2.1' | '2.2',
93+
): Group[] {
94+
const aTags = WCAG_LEVEL_A_TAGS;
95+
const aaTags =
96+
version === '2.1' ? WCAG_LEVEL_AA_TAGS_21 : WCAG_LEVEL_AA_TAGS_22;
97+
98+
const levelARuleIds = rules
99+
.filter(({ tags }) => tags.some(tag => aTags.includes(tag)))
100+
.map(({ ruleId }) => ruleId);
101+
102+
const levelAARuleIds = rules
103+
.filter(({ tags }) => tags.some(tag => aaTags.includes(tag)))
104+
.map(({ ruleId }) => ruleId);
105+
106+
const versionSlug = version.replace('.', '');
107+
108+
return [
109+
createGroup(
110+
`wcag${versionSlug}-level-a`,
111+
`WCAG ${version} Level A`,
112+
levelARuleIds,
113+
),
114+
createGroup(
115+
`wcag${versionSlug}-level-aa`,
116+
`WCAG ${version} Level AA`,
117+
levelAARuleIds,
118+
),
119+
];
120+
}
121+
122+
function createCategoryGroups(rules: axe.RuleMetadata[]): Group[] {
123+
const categoryTags = new Set(
124+
rules.flatMap(({ tags }) => tags.filter(tag => tag.startsWith('cat.'))),
125+
);
126+
127+
return Array.from(categoryTags).map(tag => {
128+
const slug = tag.replace('cat.', '');
129+
const title = formatCategoryTitle(tag, slug);
130+
const ruleIds = rules
131+
.filter(({ tags }) => tags.includes(tag))
132+
.map(({ ruleId }) => ruleId);
133+
134+
return createGroup(slug, title, ruleIds);
135+
});
136+
}
137+
138+
function formatCategoryTitle(tag: string, slug: string): string {
139+
if (CATEGORY_TITLES[tag]) {
140+
return CATEGORY_TITLES[tag];
141+
}
142+
return slug
143+
.split('-')
144+
.map(word => capitalize(word))
145+
.join(' ');
146+
}

0 commit comments

Comments
 (0)