initial wcag-ada
This commit is contained in:
parent
042b8cb83a
commit
d52cfe7de2
112 changed files with 9069 additions and 0 deletions
81
apps/wcag-ada/shared/src/constants.ts
Normal file
81
apps/wcag-ada/shared/src/constants.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
export const WCAG_TAGS = {
|
||||
WCAG_2_0_A: ['wcag2a', 'wcag20a'],
|
||||
WCAG_2_0_AA: ['wcag2aa', 'wcag20aa'],
|
||||
WCAG_2_0_AAA: ['wcag2aaa', 'wcag20aaa'],
|
||||
WCAG_2_1_A: ['wcag21a'],
|
||||
WCAG_2_1_AA: ['wcag21aa'],
|
||||
WCAG_2_1_AAA: ['wcag21aaa'],
|
||||
WCAG_2_2_A: ['wcag22a'],
|
||||
WCAG_2_2_AA: ['wcag22aa'],
|
||||
WCAG_2_2_AAA: ['wcag22aaa'],
|
||||
} as const;
|
||||
|
||||
export const IMPACT_SCORES = {
|
||||
critical: 100,
|
||||
serious: 75,
|
||||
moderate: 50,
|
||||
minor: 25,
|
||||
} as const;
|
||||
|
||||
export const COMPLIANCE_THRESHOLDS = {
|
||||
A: 0.95,
|
||||
AA: 0.98,
|
||||
AAA: 1.0,
|
||||
} as const;
|
||||
|
||||
export const DEFAULT_VIEWPORT = {
|
||||
width: 1280,
|
||||
height: 720,
|
||||
deviceScaleFactor: 1,
|
||||
isMobile: false,
|
||||
} as const;
|
||||
|
||||
export const MOBILE_VIEWPORT = {
|
||||
width: 375,
|
||||
height: 667,
|
||||
deviceScaleFactor: 2,
|
||||
isMobile: true,
|
||||
} as const;
|
||||
|
||||
export const SCAN_TIMEOUTS = {
|
||||
PAGE_LOAD: 30000,
|
||||
SCAN_EXECUTION: 60000,
|
||||
TOTAL: 120000,
|
||||
} as const;
|
||||
|
||||
export const RESOURCE_BLOCK_PATTERNS = [
|
||||
'*.gif',
|
||||
'*.jpg',
|
||||
'*.jpeg',
|
||||
'*.png',
|
||||
'*.svg',
|
||||
'*.webp',
|
||||
'*.woff',
|
||||
'*.woff2',
|
||||
'*.ttf',
|
||||
'*.eot',
|
||||
'fonts.googleapis.com',
|
||||
'fonts.gstatic.com',
|
||||
'googletagmanager.com',
|
||||
'google-analytics.com',
|
||||
'facebook.com',
|
||||
'twitter.com',
|
||||
'doubleclick.net',
|
||||
];
|
||||
|
||||
export const CRITICAL_WCAG_CRITERIA = [
|
||||
'1.1.1', // Non-text Content
|
||||
'1.3.1', // Info and Relationships
|
||||
'1.4.3', // Contrast (Minimum)
|
||||
'2.1.1', // Keyboard
|
||||
'2.1.2', // No Keyboard Trap
|
||||
'2.4.1', // Bypass Blocks
|
||||
'2.4.2', // Page Titled
|
||||
'2.4.3', // Focus Order
|
||||
'2.4.4', // Link Purpose
|
||||
'3.1.1', // Language of Page
|
||||
'3.3.1', // Error Identification
|
||||
'3.3.2', // Labels or Instructions
|
||||
'4.1.1', // Parsing
|
||||
'4.1.2', // Name, Role, Value
|
||||
] as const;
|
||||
3
apps/wcag-ada/shared/src/index.ts
Normal file
3
apps/wcag-ada/shared/src/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './types';
|
||||
export * from './constants';
|
||||
export * from './utils';
|
||||
201
apps/wcag-ada/shared/src/types.ts
Normal file
201
apps/wcag-ada/shared/src/types.ts
Normal file
|
|
@ -0,0 +1,201 @@
|
|||
import type { Result as AxeResult, ImpactValue, TagValue } from 'axe-core';
|
||||
|
||||
export interface WCAGLevel {
|
||||
level: 'A' | 'AA' | 'AAA';
|
||||
version: '2.0' | '2.1' | '2.2';
|
||||
}
|
||||
|
||||
export interface AccessibilityScanOptions {
|
||||
url: string;
|
||||
wcagLevel?: WCAGLevel;
|
||||
viewport?: ViewportSize;
|
||||
authenticate?: AuthenticationConfig;
|
||||
includeScreenshots?: boolean;
|
||||
customRules?: CustomRule[];
|
||||
scanSubpages?: boolean;
|
||||
maxDepth?: number;
|
||||
timeout?: number;
|
||||
waitForSelector?: string;
|
||||
excludeSelectors?: string[];
|
||||
}
|
||||
|
||||
export interface ViewportSize {
|
||||
width: number;
|
||||
height: number;
|
||||
deviceScaleFactor?: number;
|
||||
isMobile?: boolean;
|
||||
}
|
||||
|
||||
export interface AuthenticationConfig {
|
||||
type: 'basic' | 'form' | 'oauth' | 'custom';
|
||||
credentials?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
token?: string;
|
||||
};
|
||||
loginUrl?: string;
|
||||
selectors?: {
|
||||
username?: string;
|
||||
password?: string;
|
||||
submit?: string;
|
||||
};
|
||||
customScript?: string;
|
||||
}
|
||||
|
||||
export interface CustomRule {
|
||||
id: string;
|
||||
selector: string;
|
||||
tags: string[];
|
||||
description: string;
|
||||
help: string;
|
||||
helpUrl?: string;
|
||||
severity: ImpactValue;
|
||||
validator: (element: Element) => boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
export interface AccessibilityViolation {
|
||||
id: string;
|
||||
impact: ImpactValue;
|
||||
description: string;
|
||||
help: string;
|
||||
helpUrl: string;
|
||||
tags: TagValue[];
|
||||
nodes: ViolationNode[];
|
||||
wcagCriteria?: string[];
|
||||
fixSuggestion?: string;
|
||||
}
|
||||
|
||||
export interface ViolationNode {
|
||||
html: string;
|
||||
target: string[];
|
||||
xpath?: string;
|
||||
failureSummary?: string;
|
||||
screenshot?: string;
|
||||
relatedNodes?: RelatedNode[];
|
||||
}
|
||||
|
||||
export interface RelatedNode {
|
||||
html: string;
|
||||
target: string[];
|
||||
}
|
||||
|
||||
export interface AccessibilityScanResult {
|
||||
url: string;
|
||||
timestamp: Date;
|
||||
scanDuration: number;
|
||||
summary: ScanSummary;
|
||||
violations: AccessibilityViolation[];
|
||||
passes: AxeResult[];
|
||||
incomplete: AxeResult[];
|
||||
inapplicable: AxeResult[];
|
||||
pageMetadata: PageMetadata;
|
||||
wcagCompliance: WCAGCompliance;
|
||||
}
|
||||
|
||||
export interface ScanSummary {
|
||||
violationCount: number;
|
||||
passCount: number;
|
||||
incompleteCount: number;
|
||||
inapplicableCount: number;
|
||||
criticalIssues: number;
|
||||
seriousIssues: number;
|
||||
moderateIssues: number;
|
||||
minorIssues: number;
|
||||
score: number;
|
||||
}
|
||||
|
||||
export interface PageMetadata {
|
||||
title: string;
|
||||
url: string;
|
||||
viewport: ViewportSize;
|
||||
language?: string;
|
||||
doctype?: string;
|
||||
hasLandmarks: boolean;
|
||||
hasHeadings: boolean;
|
||||
imagesCount: number;
|
||||
formsCount: number;
|
||||
linksCount: number;
|
||||
}
|
||||
|
||||
export interface WCAGCompliance {
|
||||
level: WCAGLevel;
|
||||
isCompliant: boolean;
|
||||
criteriaResults: CriteriaResult[];
|
||||
overallScore: number;
|
||||
}
|
||||
|
||||
export interface CriteriaResult {
|
||||
criterion: string;
|
||||
level: 'A' | 'AA' | 'AAA';
|
||||
passed: boolean;
|
||||
violations: string[];
|
||||
}
|
||||
|
||||
export interface ScanJob {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
url: string;
|
||||
status: 'pending' | 'running' | 'completed' | 'failed';
|
||||
options: AccessibilityScanOptions;
|
||||
scheduledAt: Date;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
error?: string;
|
||||
resultId?: string;
|
||||
retryCount: number;
|
||||
}
|
||||
|
||||
export interface Website {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
userId: string;
|
||||
scanSchedule?: ScanSchedule;
|
||||
lastScanAt?: Date;
|
||||
complianceScore?: number;
|
||||
tags?: string[];
|
||||
active: boolean;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface ScanSchedule {
|
||||
frequency: 'manual' | 'hourly' | 'daily' | 'weekly' | 'monthly';
|
||||
dayOfWeek?: number;
|
||||
dayOfMonth?: number;
|
||||
hour?: number;
|
||||
timezone?: string;
|
||||
}
|
||||
|
||||
export interface ComplianceReport {
|
||||
id: string;
|
||||
websiteId: string;
|
||||
period: ReportPeriod;
|
||||
generatedAt: Date;
|
||||
summary: ReportSummary;
|
||||
trendsData: TrendData[];
|
||||
detailedResults: AccessibilityScanResult[];
|
||||
recommendations: string[];
|
||||
format: 'pdf' | 'html' | 'json' | 'csv';
|
||||
}
|
||||
|
||||
export interface ReportPeriod {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
export interface ReportSummary {
|
||||
averageScore: number;
|
||||
totalScans: number;
|
||||
improvementRate: number;
|
||||
criticalIssuesFixed: number;
|
||||
newIssuesFound: number;
|
||||
complianceLevel: WCAGLevel;
|
||||
}
|
||||
|
||||
export interface TrendData {
|
||||
date: Date;
|
||||
score: number;
|
||||
violationCount: number;
|
||||
passCount: number;
|
||||
}
|
||||
155
apps/wcag-ada/shared/src/utils.ts
Normal file
155
apps/wcag-ada/shared/src/utils.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import type { ImpactValue } from 'axe-core';
|
||||
import { IMPACT_SCORES, COMPLIANCE_THRESHOLDS, WCAG_TAGS } from './constants';
|
||||
import type { AccessibilityScanResult, ScanSummary, WCAGLevel } from './types';
|
||||
|
||||
export function calculateComplianceScore(result: AccessibilityScanResult): number {
|
||||
const { violations, passes } = result;
|
||||
const totalChecks = violations.length + passes.length;
|
||||
|
||||
if (totalChecks === 0) return 100;
|
||||
|
||||
let weightedViolations = 0;
|
||||
violations.forEach(violation => {
|
||||
const impactScore = IMPACT_SCORES[violation.impact as keyof typeof IMPACT_SCORES] || 50;
|
||||
weightedViolations += (impactScore / 100) * violation.nodes.length;
|
||||
});
|
||||
|
||||
const score = Math.max(0, 100 - (weightedViolations / totalChecks) * 100);
|
||||
return Math.round(score * 100) / 100;
|
||||
}
|
||||
|
||||
export function isCompliantWithLevel(score: number, level: WCAGLevel['level']): boolean {
|
||||
return score >= COMPLIANCE_THRESHOLDS[level] * 100;
|
||||
}
|
||||
|
||||
export function getWCAGTags(level: WCAGLevel): string[] {
|
||||
const key = `WCAG_${level.version.replace('.', '_')}_${level.level}` as keyof typeof WCAG_TAGS;
|
||||
return [...(WCAG_TAGS[key] || [])];
|
||||
}
|
||||
|
||||
export function summarizeViolations(violations: AccessibilityScanResult['violations']): Partial<ScanSummary> {
|
||||
const summary = {
|
||||
criticalIssues: 0,
|
||||
seriousIssues: 0,
|
||||
moderateIssues: 0,
|
||||
minorIssues: 0,
|
||||
};
|
||||
|
||||
violations.forEach(violation => {
|
||||
const count = violation.nodes.length;
|
||||
switch (violation.impact) {
|
||||
case 'critical':
|
||||
summary.criticalIssues += count;
|
||||
break;
|
||||
case 'serious':
|
||||
summary.seriousIssues += count;
|
||||
break;
|
||||
case 'moderate':
|
||||
summary.moderateIssues += count;
|
||||
break;
|
||||
case 'minor':
|
||||
summary.minorIssues += count;
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function generateFixSuggestion(violationId: string): string {
|
||||
const suggestions: Record<string, string> = {
|
||||
'color-contrast': 'Increase the contrast ratio between text and background colors to meet WCAG standards.',
|
||||
'image-alt': 'Add descriptive alt text to images. Use alt="" for decorative images.',
|
||||
'label': 'Add labels to form controls using <label> elements or aria-label attributes.',
|
||||
'button-name': 'Provide accessible names for buttons using text content, aria-label, or aria-labelledby.',
|
||||
'link-name': 'Ensure links have descriptive text that explains their purpose.',
|
||||
'heading-order': 'Use heading levels in sequential order without skipping levels.',
|
||||
'landmark-one-main': 'Ensure the page has one main landmark using <main> or role="main".',
|
||||
'page-has-heading-one': 'Add a single <h1> element to identify the main content of the page.',
|
||||
'meta-viewport': 'Avoid disabling zoom by not using maximum-scale=1.0 in viewport meta tag.',
|
||||
'duplicate-id': 'Ensure all id attributes are unique within the page.',
|
||||
'aria-required-attr': 'Add all required ARIA attributes for the role being used.',
|
||||
'aria-valid-attr': 'Use only valid ARIA attributes that are spelled correctly.',
|
||||
'html-has-lang': 'Add a lang attribute to the <html> element to specify the page language.',
|
||||
'document-title': 'Provide a descriptive <title> element in the document head.',
|
||||
'list': 'Ensure <ul> and <ol> elements only contain <li>, <script>, or <template> elements.',
|
||||
};
|
||||
|
||||
return suggestions[violationId] || 'Review the element and ensure it meets accessibility standards.';
|
||||
}
|
||||
|
||||
export function formatViolationTarget(target: string[]): string {
|
||||
return target.join(' > ');
|
||||
}
|
||||
|
||||
export function estimateFixTime(impact: ImpactValue, nodeCount: number): number {
|
||||
const baseTime: Record<ImpactValue, number> = {
|
||||
critical: 30,
|
||||
serious: 20,
|
||||
moderate: 15,
|
||||
minor: 10,
|
||||
};
|
||||
|
||||
return (baseTime[impact] || 15) * nodeCount;
|
||||
}
|
||||
|
||||
export function groupViolationsByWCAGCriteria(
|
||||
violations: AccessibilityScanResult['violations']
|
||||
): Record<string, AccessibilityScanResult['violations']> {
|
||||
const grouped: Record<string, AccessibilityScanResult['violations']> = {};
|
||||
|
||||
violations.forEach(violation => {
|
||||
violation.wcagCriteria?.forEach(criterion => {
|
||||
if (!grouped[criterion]) {
|
||||
grouped[criterion] = [];
|
||||
}
|
||||
grouped[criterion].push(violation);
|
||||
});
|
||||
});
|
||||
|
||||
return grouped;
|
||||
}
|
||||
|
||||
export function generateComplianceStatement(result: AccessibilityScanResult): string {
|
||||
const { wcagCompliance, summary } = result;
|
||||
const score = result.summary.score;
|
||||
const level = wcagCompliance.level;
|
||||
|
||||
if (wcagCompliance.isCompliant) {
|
||||
return `This page meets WCAG ${level.version} Level ${level.level} compliance standards with a score of ${score}%.`;
|
||||
}
|
||||
|
||||
const majorIssues = summary.criticalIssues + summary.seriousIssues;
|
||||
return `This page does not meet WCAG ${level.version} Level ${level.level} compliance. ` +
|
||||
`Found ${majorIssues} major accessibility issues that need to be addressed. ` +
|
||||
`Current compliance score: ${score}%.`;
|
||||
}
|
||||
|
||||
export function extractPageMetadata(html: string): Partial<AccessibilityScanResult['pageMetadata']> {
|
||||
const metadata: Partial<AccessibilityScanResult['pageMetadata']> = {};
|
||||
|
||||
// Extract title
|
||||
const titleMatch = html.match(/<title[^>]*>([^<]+)<\/title>/i);
|
||||
if (titleMatch) {
|
||||
metadata.title = titleMatch[1].trim();
|
||||
}
|
||||
|
||||
// Extract language
|
||||
const langMatch = html.match(/<html[^>]*lang=["']([^"']+)["']/i);
|
||||
if (langMatch) {
|
||||
metadata.language = langMatch[1];
|
||||
}
|
||||
|
||||
// Count elements
|
||||
metadata.imagesCount = (html.match(/<img[^>]*>/gi) || []).length;
|
||||
metadata.formsCount = (html.match(/<form[^>]*>/gi) || []).length;
|
||||
metadata.linksCount = (html.match(/<a[^>]*>/gi) || []).length;
|
||||
|
||||
// Check for landmarks
|
||||
metadata.hasLandmarks = /<(main|nav|aside|header|footer)[^>]*>|role=["'](main|navigation|complementary|banner|contentinfo)["']/i.test(html);
|
||||
|
||||
// Check for headings
|
||||
metadata.hasHeadings = /<h[1-6][^>]*>/i.test(html);
|
||||
|
||||
return metadata;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue