diff --git a/code-pushup.config.ts b/code-pushup.config.ts index 9629a581d..6d2629f4a 100644 --- a/code-pushup.config.ts +++ b/code-pushup.config.ts @@ -1,6 +1,6 @@ import 'dotenv/config'; import { - axeCoreConfig, + configureAxePlugin, configureCoveragePlugin, configureEslintPlugin, configureJsDocsPlugin, @@ -23,5 +23,5 @@ export default mergeConfigs( configureTypescriptPlugin(), configureJsDocsPlugin(), await configureLighthousePlugin(TARGET_URL), - axeCoreConfig(TARGET_URL), + configureAxePlugin(TARGET_URL), ); diff --git a/code-pushup.preset.ts b/code-pushup.preset.ts index 99660378b..e232f74c4 100644 --- a/code-pushup.preset.ts +++ b/code-pushup.preset.ts @@ -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, @@ -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), }; } diff --git a/packages/plugin-axe/README.md b/packages/plugin-axe/README.md index 1bedb08ed..ccb6800fe 100644 --- a/packages/plugin-axe/README.md +++ b/packages/plugin-axe/README.md @@ -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 diff --git a/packages/plugin-axe/src/index.ts b/packages/plugin-axe/src/index.ts index f04d98f8e..87c707c6b 100644 --- a/packages/plugin-axe/src/index.ts +++ b/packages/plugin-axe/src/index.ts @@ -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'; diff --git a/packages/plugin-axe/src/lib/categories.ts b/packages/plugin-axe/src/lib/categories.ts new file mode 100644 index 000000000..1150693ec --- /dev/null +++ b/packages/plugin-axe/src/lib/categories.ts @@ -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, + 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); +} diff --git a/packages/plugin-axe/src/lib/categories.unit.test.ts b/packages/plugin-axe/src/lib/categories.unit.test.ts new file mode 100644 index 000000000..2ae7155d8 --- /dev/null +++ b/packages/plugin-axe/src/lib/categories.unit.test.ts @@ -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 => ({ + 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']); + }); +}); diff --git a/packages/plugin-axe/src/lib/groups.ts b/packages/plugin-axe/src/lib/groups.ts index 369489da2..ccbe59dab 100644 --- a/packages/plugin-axe/src/lib/groups.ts +++ b/packages/plugin-axe/src/lib/groups.ts @@ -70,6 +70,10 @@ export const CATEGORY_GROUPS: Record = { 'time-and-media': 'Media', }; +export function isAxeGroupSlug(slug: unknown): slug is AxeCategoryGroupSlug { + return axeCategoryGroupSlugSchema.safeParse(slug).success; +} + /* Combined exports */ export const axeGroupSlugSchema = axeCategoryGroupSlugSchema; export type AxeGroupSlug = AxeCategoryGroupSlug; diff --git a/packages/plugin-axe/src/lib/utils.ts b/packages/plugin-axe/src/lib/utils.ts new file mode 100644 index 000000000..15639fe13 --- /dev/null +++ b/packages/plugin-axe/src/lib/utils.ts @@ -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, + }; +} diff --git a/packages/plugin-axe/src/lib/utils.unit.test.ts b/packages/plugin-axe/src/lib/utils.unit.test.ts new file mode 100644 index 000000000..b9cb726bf --- /dev/null +++ b/packages/plugin-axe/src/lib/utils.unit.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest'; +import { AXE_PLUGIN_SLUG } from './constants.js'; +import { axeAuditRef, axeGroupRef } from './utils.js'; + +describe('axeGroupRef', () => { + it('should create a group reference with default weight', () => { + expect(axeGroupRef('aria')).toEqual({ + plugin: AXE_PLUGIN_SLUG, + slug: 'aria', + type: 'group', + weight: 1, + }); + }); + + it('should create a group reference with custom weight', () => { + expect(axeGroupRef('forms', 3)).toEqual({ + plugin: AXE_PLUGIN_SLUG, + slug: 'forms', + type: 'group', + weight: 3, + }); + }); +}); + +describe('axeAuditRef', () => { + it('should create an audit reference with default weight', () => { + expect(axeAuditRef('color-contrast')).toEqual({ + plugin: AXE_PLUGIN_SLUG, + slug: 'color-contrast', + type: 'audit', + weight: 1, + }); + }); + + it('should create an audit reference with custom weight', () => { + expect(axeAuditRef('button-name', 2)).toEqual({ + plugin: AXE_PLUGIN_SLUG, + slug: 'button-name', + type: 'audit', + weight: 2, + }); + }); +});