Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions code-pushup.config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import 'dotenv/config';
import {
axeCoreConfig,
configureAxePlugin,
configureCoveragePlugin,
configureEslintPlugin,
configureJsDocsPlugin,
Expand All @@ -23,5 +23,5 @@ export default mergeConfigs(
configureTypescriptPlugin(),
configureJsDocsPlugin(),
await configureLighthousePlugin(TARGET_URL),
axeCoreConfig(TARGET_URL),
configureAxePlugin(TARGET_URL),
);
8 changes: 5 additions & 3 deletions code-pushup.preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import type {
CoreConfig,
PluginUrls,
} from './packages/models/src/index.js';
import axePlugin from './packages/plugin-axe/src/index.js';
import axePlugin, { axeCategories } from './packages/plugin-axe/src/index.js';
import coveragePlugin, {
type CoveragePluginConfig,
getNxCoveragePaths,
Expand Down Expand Up @@ -228,8 +228,10 @@ export async function configureLighthousePlugin(
};
}

export function axeCoreConfig(urls: PluginUrls): CoreConfig {
export function configureAxePlugin(urls: PluginUrls): CoreConfig {
const axe = axePlugin(urls);
return {
plugins: [axePlugin(urls)],
plugins: [axe],
categories: axeCategories(axe),
};
}
60 changes: 60 additions & 0 deletions packages/plugin-axe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,66 @@ The plugin organizes audits into category groups based on axe-core's accessibili

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

## Category integration

The plugin provides helpers to integrate Axe results into your categories.

### Auto-generate accessibility category

Use `axeCategories` to automatically create an accessibility category from all plugin groups:

```ts
import axePlugin, { axeCategories } from '@code-pushup/axe-plugin';

const axe = axePlugin('https://example.com');

export default {
plugins: [axe],
categories: axeCategories(axe),
};
```

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.

### Custom categories

For fine-grained control, provide your own categories with specific groups:

```ts
import axePlugin, { axeCategories, axeGroupRef } from '@code-pushup/axe-plugin';

const axe = axePlugin(['https://example.com', 'https://example.com/about']);

export default {
plugins: [axe],
categories: axeCategories(axe, [
{
slug: 'axe-a11y',
title: 'Axe Accessibility',
refs: [axeGroupRef('aria', 2), axeGroupRef('color'), axeGroupRef('keyboard')],
},
]),
};
```

### Helper functions

| Function | Description |
| --------------- | -------------------------------------- |
| `axeCategories` | Auto-generates or expands categories |
| `axeGroupRef` | Creates a category ref to an Axe group |
| `axeAuditRef` | Creates a category ref to an Axe audit |

### Type safety

The `AxeGroupSlug` type is exported for discovering valid group slugs:

```ts
import type { AxeGroupSlug } from '@code-pushup/axe-plugin';

const group: AxeGroupSlug = 'aria';
```

## Resources

- **[Axe-core rules](https://github.com/dequelabs/axe-core/blob/develop/doc/rule-descriptions.md)** - Complete list of accessibility rules
Expand Down
8 changes: 6 additions & 2 deletions packages/plugin-axe/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { axePlugin } from './lib/axe-plugin.js';

export default axePlugin;
export type { AxePluginOptions } from './lib/config.js';
export type { AxePreset } from './lib/config.js';

export type { AxePluginOptions, AxePreset } from './lib/config.js';
export type { AxeGroupSlug } from './lib/groups.js';

export { axeAuditRef, axeGroupRef } from './lib/utils.js';
export { axeCategories } from './lib/categories.js';
90 changes: 90 additions & 0 deletions packages/plugin-axe/src/lib/categories.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { CategoryConfig, Group, PluginConfig } from '@code-pushup/models';
import {
type PluginUrlContext,
createCategoryRefs,
expandCategoryRefs,
removeIndex,
shouldExpandForUrls,
validateUrlContext,
} from '@code-pushup/utils';
import { AXE_PLUGIN_SLUG } from './constants.js';
import { type AxeCategoryGroupSlug, isAxeGroupSlug } from './groups.js';

/**
* Creates categories for the Axe plugin.
*
* @public
* @param plugin - {@link PluginConfig} object with groups and context
* @param categories - {@link CategoryConfig} optional user-defined categories
* @returns {CategoryConfig[]} - expanded and aggregated categories
*
* @example
* const axe = await axePlugin(urls);
* const axeCoreConfig = {
* plugins: [axe],
* categories: axeCategories(axe),
* };
*/
export function axeCategories(
plugin: Pick<PluginConfig, 'groups' | 'context'>,
categories?: CategoryConfig[],
): CategoryConfig[] {
if (!plugin.groups || plugin.groups.length === 0) {
return categories ?? [];
}
validateUrlContext(plugin.context);
if (!categories) {
return createCategories(plugin.groups, plugin.context);
}
return expandCategories(categories, plugin.context);
}

function createCategories(
groups: Group[],
context: PluginUrlContext,
): CategoryConfig[] {
return [createAggregatedCategory(groups, context)];
}

function expandCategories(
categories: CategoryConfig[],
context: PluginUrlContext,
): CategoryConfig[] {
if (!shouldExpandForUrls(context.urlCount)) {
return categories;
}
return categories.map(category =>
expandAggregatedCategory(category, context),
);
}

export function createAggregatedCategory(
groups: Group[],
context: PluginUrlContext,
): CategoryConfig {
const refs = extractGroupSlugs(groups).flatMap(slug =>
createCategoryRefs(slug, AXE_PLUGIN_SLUG, context),
);
return {
slug: 'axe-a11y',
title: 'Axe Accessibility',
refs,
};
}

export function expandAggregatedCategory(
category: CategoryConfig,
context: PluginUrlContext,
): CategoryConfig {
return {
...category,
refs: category.refs.flatMap(ref =>
ref.plugin === AXE_PLUGIN_SLUG ? expandCategoryRefs(ref, context) : [ref],
),
};
}

export function extractGroupSlugs(groups: Group[]): AxeCategoryGroupSlug[] {
const slugs = groups.map(({ slug }) => removeIndex(slug));
return [...new Set(slugs)].filter(isAxeGroupSlug);
}
144 changes: 144 additions & 0 deletions packages/plugin-axe/src/lib/categories.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, expect, it } from 'vitest';
import type { CategoryConfig, PluginConfig } from '@code-pushup/models';
import { axeCategories, extractGroupSlugs } from './categories.js';
import { AXE_PLUGIN_SLUG } from './constants.js';
import { axeGroupRef } from './utils.js';

describe('axeCategories', () => {
const createMockPlugin = (
overrides: Partial<Pick<PluginConfig, 'groups' | 'context'>> = {},
): Pick<PluginConfig, 'groups' | 'context'> => ({
groups: [
{ slug: 'aria', title: 'ARIA', refs: [] },
{ slug: 'color', title: 'Color & Contrast', refs: [] },
],
context: { urlCount: 1, weights: { 1: 1 } },
...overrides,
});

it('should create accessibility category with all groups', () => {
expect(axeCategories(createMockPlugin())).toEqual([
{
slug: 'axe-a11y',
title: 'Axe Accessibility',
refs: [
{ plugin: AXE_PLUGIN_SLUG, slug: 'aria', type: 'group', weight: 1 },
{
plugin: AXE_PLUGIN_SLUG,
slug: 'color',
type: 'group',
weight: 1,
},
],
},
]);
});

it('should expand refs for multi-URL', () => {
const plugin = createMockPlugin({
groups: [
{ slug: 'aria-1', title: 'ARIA 1', refs: [] },
{ slug: 'aria-2', title: 'ARIA 2', refs: [] },
],
context: { urlCount: 2, weights: { 1: 1, 2: 1 } },
});

expect(axeCategories(plugin)).toEqual([
{
slug: 'axe-a11y',
title: 'Axe Accessibility',
refs: [
{
plugin: AXE_PLUGIN_SLUG,
slug: 'aria-1',
type: 'group',
weight: 1,
},
{
plugin: AXE_PLUGIN_SLUG,
slug: 'aria-2',
type: 'group',
weight: 1,
},
],
},
]);
});

it('should return empty array if plugin has no groups', () => {
expect(axeCategories(createMockPlugin({ groups: [] }))).toEqual([]);
});

it('should return categories unchanged for single URL', () => {
const categories: CategoryConfig[] = [
{
slug: 'axe-a11y',
title: 'Axe Accessibility',
refs: [axeGroupRef('aria')],
},
];

expect(axeCategories(createMockPlugin(), categories)).toEqual(categories);
});

it('should expand Axe refs and preserve non-Axe refs for multi-URL', () => {
const categories: CategoryConfig[] = [
{
slug: 'axe-a11y',
title: 'Axe Accessibility',
refs: [
axeGroupRef('aria'),
{ plugin: 'lighthouse', type: 'group', slug: 'seo', weight: 1 },
],
},
];

expect(
axeCategories(
createMockPlugin({ context: { urlCount: 2, weights: { 1: 1, 2: 1 } } }),
categories,
),
).toEqual([
{
slug: 'axe-a11y',
title: 'Axe Accessibility',
refs: [
{ plugin: AXE_PLUGIN_SLUG, slug: 'aria-1', type: 'group', weight: 1 },
{ plugin: AXE_PLUGIN_SLUG, slug: 'aria-2', type: 'group', weight: 1 },
{ plugin: 'lighthouse', type: 'group', slug: 'seo', weight: 1 },
],
},
]);
});

it('should throw for invalid context', () => {
const plugin = createMockPlugin({
context: { urlCount: 2, weights: { 1: 1 } },
});

expect(() => axeCategories(plugin)).toThrow(
'Invalid plugin context: weights count must match urlCount',
);
});
});

describe('extractGroupSlugs', () => {
it('should extract unique base slugs from groups', () => {
expect(
extractGroupSlugs([
{ slug: 'aria-1', title: 'ARIA 1', refs: [] },
{ slug: 'aria-2', title: 'ARIA 2', refs: [] },
{ slug: 'color', title: 'Color & Contrast', refs: [] },
]),
).toEqual(['aria', 'color']);
});

it('should filter out invalid group slugs', () => {
expect(
extractGroupSlugs([
{ slug: 'aria', title: 'ARIA', refs: [] },
{ slug: 'invalid-group', title: 'Invalid', refs: [] },
]),
).toEqual(['aria']);
});
});
10 changes: 10 additions & 0 deletions packages/plugin-axe/src/lib/groups.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { z } from 'zod';
import { validate } from '@code-pushup/models';
import { axePresetSchema } from './config.js';

/* WCAG presets for rule loading */
Expand Down Expand Up @@ -70,6 +71,15 @@ export const CATEGORY_GROUPS: Record<AxeCategoryGroupSlug, string> = {
'time-and-media': 'Media',
};

export function isAxeGroupSlug(slug: unknown): slug is AxeCategoryGroupSlug {
try {
validate(axeCategoryGroupSlugSchema, slug);
return true;
} catch {
return false;
}
}
Comment thread
hanna-skryl marked this conversation as resolved.

/* Combined exports */
export const axeGroupSlugSchema = axeCategoryGroupSlugSchema;
export type AxeGroupSlug = AxeCategoryGroupSlug;
21 changes: 21 additions & 0 deletions packages/plugin-axe/src/lib/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { CategoryRef } from '@code-pushup/models';
import { AXE_PLUGIN_SLUG } from './constants.js';
import type { AxeGroupSlug } from './groups.js';

export function axeGroupRef(groupSlug: AxeGroupSlug, weight = 1): CategoryRef {
return {
plugin: AXE_PLUGIN_SLUG,
slug: groupSlug,
type: 'group',
weight,
};
}

export function axeAuditRef(auditSlug: string, weight = 1): CategoryRef {
return {
plugin: AXE_PLUGIN_SLUG,
slug: auditSlug,
type: 'audit',
weight,
};
}
Loading