Skip to content

Commit 84bb067

Browse files
authored
feat(plugin-axe): aggregate categories
1 parent d107538 commit 84bb067

File tree

9 files changed

+375
-7
lines changed

9 files changed

+375
-7
lines changed

code-pushup.config.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import 'dotenv/config';
22
import {
3-
axeCoreConfig,
3+
configureAxePlugin,
44
configureCoveragePlugin,
55
configureEslintPlugin,
66
configureJsDocsPlugin,
@@ -23,5 +23,5 @@ export default mergeConfigs(
2323
configureTypescriptPlugin(),
2424
configureJsDocsPlugin(),
2525
await configureLighthousePlugin(TARGET_URL),
26-
axeCoreConfig(TARGET_URL),
26+
configureAxePlugin(TARGET_URL),
2727
);

code-pushup.preset.ts

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type {
55
CoreConfig,
66
PluginUrls,
77
} from './packages/models/src/index.js';
8-
import axePlugin from './packages/plugin-axe/src/index.js';
8+
import axePlugin, { axeCategories } from './packages/plugin-axe/src/index.js';
99
import coveragePlugin, {
1010
type CoveragePluginConfig,
1111
getNxCoveragePaths,
@@ -228,8 +228,10 @@ export async function configureLighthousePlugin(
228228
};
229229
}
230230

231-
export function axeCoreConfig(urls: PluginUrls): CoreConfig {
231+
export function configureAxePlugin(urls: PluginUrls): CoreConfig {
232+
const axe = axePlugin(urls);
232233
return {
233-
plugins: [axePlugin(urls)],
234+
plugins: [axe],
235+
categories: axeCategories(axe),
234236
};
235237
}

packages/plugin-axe/README.md

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,66 @@ The plugin organizes audits into category groups based on axe-core's accessibili
159159

160160
Use `npx code-pushup print-config --onlyPlugins=axe` to list all audits and groups for your configuration.
161161

162+
## Category integration
163+
164+
The plugin provides helpers to integrate Axe results into your categories.
165+
166+
### Auto-generate accessibility category
167+
168+
Use `axeCategories` to automatically create an accessibility category from all plugin groups:
169+
170+
```ts
171+
import axePlugin, { axeCategories } from '@code-pushup/axe-plugin';
172+
173+
const axe = axePlugin('https://example.com');
174+
175+
export default {
176+
plugins: [axe],
177+
categories: axeCategories(axe),
178+
};
179+
```
180+
181+
This configuration works with both single-URL and multi-URL configurations. For multi-URL setups, refs are automatically expanded for each URL with appropriate weights.
182+
183+
### Custom categories
184+
185+
For fine-grained control, provide your own categories with specific groups:
186+
187+
```ts
188+
import axePlugin, { axeCategories, axeGroupRef } from '@code-pushup/axe-plugin';
189+
190+
const axe = axePlugin(['https://example.com', 'https://example.com/about']);
191+
192+
export default {
193+
plugins: [axe],
194+
categories: axeCategories(axe, [
195+
{
196+
slug: 'axe-a11y',
197+
title: 'Axe Accessibility',
198+
refs: [axeGroupRef('aria', 2), axeGroupRef('color'), axeGroupRef('keyboard')],
199+
},
200+
]),
201+
};
202+
```
203+
204+
### Helper functions
205+
206+
| Function | Description |
207+
| --------------- | -------------------------------------- |
208+
| `axeCategories` | Auto-generates or expands categories |
209+
| `axeGroupRef` | Creates a category ref to an Axe group |
210+
| `axeAuditRef` | Creates a category ref to an Axe audit |
211+
212+
### Type safety
213+
214+
The `AxeGroupSlug` type is exported for discovering valid group slugs:
215+
216+
```ts
217+
import type { AxeGroupSlug } from '@code-pushup/axe-plugin';
218+
219+
const group: AxeGroupSlug = 'aria';
220+
```
221+
162222
## Resources
163223

164224
- **[Axe-core rules](https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md)** - Complete list of accessibility rules

packages/plugin-axe/src/index.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { axePlugin } from './lib/axe-plugin.js';
22

33
export default axePlugin;
4-
export type { AxePluginOptions } from './lib/config.js';
5-
export type { AxePreset } from './lib/config.js';
4+
5+
export type { AxePluginOptions, AxePreset } from './lib/config.js';
6+
export type { AxeGroupSlug } from './lib/groups.js';
7+
8+
export { axeAuditRef, axeGroupRef } from './lib/utils.js';
9+
export { axeCategories } from './lib/categories.js';
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
import type { CategoryConfig, Group, PluginConfig } from '@code-pushup/models';
2+
import {
3+
type PluginUrlContext,
4+
createCategoryRefs,
5+
expandCategoryRefs,
6+
removeIndex,
7+
shouldExpandForUrls,
8+
validateUrlContext,
9+
} from '@code-pushup/utils';
10+
import { AXE_PLUGIN_SLUG } from './constants.js';
11+
import { type AxeCategoryGroupSlug, isAxeGroupSlug } from './groups.js';
12+
13+
/**
14+
* Creates categories for the Axe plugin.
15+
*
16+
* @public
17+
* @param plugin - {@link PluginConfig} object with groups and context
18+
* @param categories - {@link CategoryConfig} optional user-defined categories
19+
* @returns {CategoryConfig[]} - expanded and aggregated categories
20+
*
21+
* @example
22+
* const axe = await axePlugin(urls);
23+
* const axeCoreConfig = {
24+
* plugins: [axe],
25+
* categories: axeCategories(axe),
26+
* };
27+
*/
28+
export function axeCategories(
29+
plugin: Pick<PluginConfig, 'groups' | 'context'>,
30+
categories?: CategoryConfig[],
31+
): CategoryConfig[] {
32+
if (!plugin.groups || plugin.groups.length === 0) {
33+
return categories ?? [];
34+
}
35+
validateUrlContext(plugin.context);
36+
if (!categories) {
37+
return createCategories(plugin.groups, plugin.context);
38+
}
39+
return expandCategories(categories, plugin.context);
40+
}
41+
42+
function createCategories(
43+
groups: Group[],
44+
context: PluginUrlContext,
45+
): CategoryConfig[] {
46+
return [createAggregatedCategory(groups, context)];
47+
}
48+
49+
function expandCategories(
50+
categories: CategoryConfig[],
51+
context: PluginUrlContext,
52+
): CategoryConfig[] {
53+
if (!shouldExpandForUrls(context.urlCount)) {
54+
return categories;
55+
}
56+
return categories.map(category =>
57+
expandAggregatedCategory(category, context),
58+
);
59+
}
60+
61+
export function createAggregatedCategory(
62+
groups: Group[],
63+
context: PluginUrlContext,
64+
): CategoryConfig {
65+
const refs = extractGroupSlugs(groups).flatMap(slug =>
66+
createCategoryRefs(slug, AXE_PLUGIN_SLUG, context),
67+
);
68+
return {
69+
slug: 'axe-a11y',
70+
title: 'Axe Accessibility',
71+
refs,
72+
};
73+
}
74+
75+
export function expandAggregatedCategory(
76+
category: CategoryConfig,
77+
context: PluginUrlContext,
78+
): CategoryConfig {
79+
return {
80+
...category,
81+
refs: category.refs.flatMap(ref =>
82+
ref.plugin === AXE_PLUGIN_SLUG ? expandCategoryRefs(ref, context) : [ref],
83+
),
84+
};
85+
}
86+
87+
export function extractGroupSlugs(groups: Group[]): AxeCategoryGroupSlug[] {
88+
const slugs = groups.map(({ slug }) => removeIndex(slug));
89+
return [...new Set(slugs)].filter(isAxeGroupSlug);
90+
}
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { describe, expect, it } from 'vitest';
2+
import type { CategoryConfig, PluginConfig } from '@code-pushup/models';
3+
import { axeCategories, extractGroupSlugs } from './categories.js';
4+
import { AXE_PLUGIN_SLUG } from './constants.js';
5+
import { axeGroupRef } from './utils.js';
6+
7+
describe('axeCategories', () => {
8+
const createMockPlugin = (
9+
overrides: Partial<Pick<PluginConfig, 'groups' | 'context'>> = {},
10+
): Pick<PluginConfig, 'groups' | 'context'> => ({
11+
groups: [
12+
{ slug: 'aria', title: 'ARIA', refs: [] },
13+
{ slug: 'color', title: 'Color & Contrast', refs: [] },
14+
],
15+
context: { urlCount: 1, weights: { 1: 1 } },
16+
...overrides,
17+
});
18+
19+
it('should create accessibility category with all groups', () => {
20+
expect(axeCategories(createMockPlugin())).toEqual([
21+
{
22+
slug: 'axe-a11y',
23+
title: 'Axe Accessibility',
24+
refs: [
25+
{ plugin: AXE_PLUGIN_SLUG, slug: 'aria', type: 'group', weight: 1 },
26+
{
27+
plugin: AXE_PLUGIN_SLUG,
28+
slug: 'color',
29+
type: 'group',
30+
weight: 1,
31+
},
32+
],
33+
},
34+
]);
35+
});
36+
37+
it('should expand refs for multi-URL', () => {
38+
const plugin = createMockPlugin({
39+
groups: [
40+
{ slug: 'aria-1', title: 'ARIA 1', refs: [] },
41+
{ slug: 'aria-2', title: 'ARIA 2', refs: [] },
42+
],
43+
context: { urlCount: 2, weights: { 1: 1, 2: 1 } },
44+
});
45+
46+
expect(axeCategories(plugin)).toEqual([
47+
{
48+
slug: 'axe-a11y',
49+
title: 'Axe Accessibility',
50+
refs: [
51+
{
52+
plugin: AXE_PLUGIN_SLUG,
53+
slug: 'aria-1',
54+
type: 'group',
55+
weight: 1,
56+
},
57+
{
58+
plugin: AXE_PLUGIN_SLUG,
59+
slug: 'aria-2',
60+
type: 'group',
61+
weight: 1,
62+
},
63+
],
64+
},
65+
]);
66+
});
67+
68+
it('should return empty array if plugin has no groups', () => {
69+
expect(axeCategories(createMockPlugin({ groups: [] }))).toEqual([]);
70+
});
71+
72+
it('should return categories unchanged for single URL', () => {
73+
const categories: CategoryConfig[] = [
74+
{
75+
slug: 'axe-a11y',
76+
title: 'Axe Accessibility',
77+
refs: [axeGroupRef('aria')],
78+
},
79+
];
80+
81+
expect(axeCategories(createMockPlugin(), categories)).toEqual(categories);
82+
});
83+
84+
it('should expand Axe refs and preserve non-Axe refs for multi-URL', () => {
85+
const categories: CategoryConfig[] = [
86+
{
87+
slug: 'axe-a11y',
88+
title: 'Axe Accessibility',
89+
refs: [
90+
axeGroupRef('aria'),
91+
{ plugin: 'lighthouse', type: 'group', slug: 'seo', weight: 1 },
92+
],
93+
},
94+
];
95+
96+
expect(
97+
axeCategories(
98+
createMockPlugin({ context: { urlCount: 2, weights: { 1: 1, 2: 1 } } }),
99+
categories,
100+
),
101+
).toEqual([
102+
{
103+
slug: 'axe-a11y',
104+
title: 'Axe Accessibility',
105+
refs: [
106+
{ plugin: AXE_PLUGIN_SLUG, slug: 'aria-1', type: 'group', weight: 1 },
107+
{ plugin: AXE_PLUGIN_SLUG, slug: 'aria-2', type: 'group', weight: 1 },
108+
{ plugin: 'lighthouse', type: 'group', slug: 'seo', weight: 1 },
109+
],
110+
},
111+
]);
112+
});
113+
114+
it('should throw for invalid context', () => {
115+
const plugin = createMockPlugin({
116+
context: { urlCount: 2, weights: { 1: 1 } },
117+
});
118+
119+
expect(() => axeCategories(plugin)).toThrow(
120+
'Invalid plugin context: weights count must match urlCount',
121+
);
122+
});
123+
});
124+
125+
describe('extractGroupSlugs', () => {
126+
it('should extract unique base slugs from groups', () => {
127+
expect(
128+
extractGroupSlugs([
129+
{ slug: 'aria-1', title: 'ARIA 1', refs: [] },
130+
{ slug: 'aria-2', title: 'ARIA 2', refs: [] },
131+
{ slug: 'color', title: 'Color & Contrast', refs: [] },
132+
]),
133+
).toEqual(['aria', 'color']);
134+
});
135+
136+
it('should filter out invalid group slugs', () => {
137+
expect(
138+
extractGroupSlugs([
139+
{ slug: 'aria', title: 'ARIA', refs: [] },
140+
{ slug: 'invalid-group', title: 'Invalid', refs: [] },
141+
]),
142+
).toEqual(['aria']);
143+
});
144+
});

packages/plugin-axe/src/lib/groups.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,10 @@ export const CATEGORY_GROUPS: Record<AxeCategoryGroupSlug, string> = {
7070
'time-and-media': 'Media',
7171
};
7272

73+
export function isAxeGroupSlug(slug: unknown): slug is AxeCategoryGroupSlug {
74+
return axeCategoryGroupSlugSchema.safeParse(slug).success;
75+
}
76+
7377
/* Combined exports */
7478
export const axeGroupSlugSchema = axeCategoryGroupSlugSchema;
7579
export type AxeGroupSlug = AxeCategoryGroupSlug;
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import type { CategoryRef } from '@code-pushup/models';
2+
import { AXE_PLUGIN_SLUG } from './constants.js';
3+
import type { AxeGroupSlug } from './groups.js';
4+
5+
export function axeGroupRef(groupSlug: AxeGroupSlug, weight = 1): CategoryRef {
6+
return {
7+
plugin: AXE_PLUGIN_SLUG,
8+
slug: groupSlug,
9+
type: 'group',
10+
weight,
11+
};
12+
}
13+
14+
export function axeAuditRef(auditSlug: string, weight = 1): CategoryRef {
15+
return {
16+
plugin: AXE_PLUGIN_SLUG,
17+
slug: auditSlug,
18+
type: 'audit',
19+
weight,
20+
};
21+
}

0 commit comments

Comments
 (0)