@rgaaudit/mcp-server 0.2.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.js +417 -36
- package/dist/local-scanner.d.ts +66 -0
- package/dist/local-scanner.js +156 -0
- package/package.json +13 -2
package/dist/index.js
CHANGED
|
@@ -19,40 +19,127 @@
|
|
|
19
19
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
20
20
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
21
21
|
import { z } from 'zod';
|
|
22
|
+
import { isLocalUrl, scanLocal, fetchRgaaMappings } from './local-scanner.js';
|
|
22
23
|
const API_URL = process.env.RGAA_API_URL || 'https://app.rgaaudit.fr';
|
|
23
24
|
const API_KEY = process.env.RGAA_API_KEY || '';
|
|
24
25
|
const server = new McpServer({
|
|
25
26
|
name: 'rgaaudit',
|
|
26
|
-
version: '0.
|
|
27
|
+
version: '0.3.0',
|
|
27
28
|
});
|
|
29
|
+
// ── Cache d'audit (TTL 10 min, évite double consommation de quota) ──
|
|
30
|
+
const CACHE_TTL_MS = 10 * 60 * 1000;
|
|
31
|
+
const auditCache = new Map();
|
|
28
32
|
/**
|
|
29
|
-
*
|
|
33
|
+
* Nettoie les entrées expirées du cache
|
|
34
|
+
*/
|
|
35
|
+
function cleanCache() {
|
|
36
|
+
const now = Date.now();
|
|
37
|
+
for (const [key, entry] of auditCache) {
|
|
38
|
+
if (now - entry.timestamp > CACHE_TTL_MS)
|
|
39
|
+
auditCache.delete(key);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Scan local pour URLs localhost — utilise puppeteer-core + axe-core.
|
|
44
|
+
* Enrichit les résultats avec le mapping RGAA via /api/ci/map si l'API est disponible.
|
|
45
|
+
*
|
|
46
|
+
* @param url - URL locale à scanner
|
|
47
|
+
* @param options - details pour inclure les nœuds HTML
|
|
48
|
+
*/
|
|
49
|
+
async function callLocalScan(url, options) {
|
|
50
|
+
const localResult = await scanLocal(url);
|
|
51
|
+
// Enrichir avec le mapping RGAA si API disponible
|
|
52
|
+
const ruleIds = localResult.violations.map(v => v.ruleId);
|
|
53
|
+
const mapping = API_KEY ? await fetchRgaaMappings(ruleIds, API_URL, API_KEY) : null;
|
|
54
|
+
const violations = localResult.violations;
|
|
55
|
+
const score = violations.length === 0 ? 100 : Math.max(0, Math.round(100 - (violations.length * 3)));
|
|
56
|
+
const conformity = score >= 100 ? 'conforme' : score >= 50 ? 'partiellement_conforme' : 'non_conforme';
|
|
57
|
+
const threshold = options?.threshold ?? 0;
|
|
58
|
+
const result = {
|
|
59
|
+
url,
|
|
60
|
+
score,
|
|
61
|
+
conformity,
|
|
62
|
+
violations: violations.length,
|
|
63
|
+
pages: 1,
|
|
64
|
+
pass: threshold > 0 ? score >= threshold : true,
|
|
65
|
+
threshold: threshold > 0 ? threshold : null,
|
|
66
|
+
auditId: `local-${Date.now()}`,
|
|
67
|
+
auditUrl: url,
|
|
68
|
+
coverage: mapping?.coverage,
|
|
69
|
+
};
|
|
70
|
+
if (options?.details) {
|
|
71
|
+
result.violationDetails = violations.map(v => {
|
|
72
|
+
const m = mapping?.mappings[v.ruleId];
|
|
73
|
+
return {
|
|
74
|
+
ruleId: v.ruleId,
|
|
75
|
+
criteria: m?.criteria || null,
|
|
76
|
+
criteriaTitle: m?.title || null,
|
|
77
|
+
theme: m?.theme || null,
|
|
78
|
+
description: m?.help || v.description,
|
|
79
|
+
impact: m?.impact || v.impact,
|
|
80
|
+
nodesCount: v.nodesCount,
|
|
81
|
+
zone: null,
|
|
82
|
+
suggestion: m?.help || v.help,
|
|
83
|
+
nodes: v.nodes,
|
|
84
|
+
};
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
return result;
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Appelle POST /api/ci/scan (distant) ou scanLocal (localhost).
|
|
91
|
+
* Cache intelligent : réutilise l'auditId si un audit récent existe.
|
|
92
|
+
*
|
|
93
|
+
* @param url - URL à auditer
|
|
94
|
+
* @param options - details, threshold, auditId (pour réutiliser un audit existant)
|
|
30
95
|
*/
|
|
31
96
|
async function callScan(url, options) {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
97
|
+
cleanCache();
|
|
98
|
+
// Vérifier le cache
|
|
99
|
+
const cacheKey = url.toLowerCase().replace(/\/+$/, '');
|
|
100
|
+
const cached = auditCache.get(cacheKey);
|
|
101
|
+
if (cached && !options?.auditId) {
|
|
102
|
+
// Cache hit — retourner directement si on a déjà les détails ou si on n'en demande pas
|
|
103
|
+
if (!options?.details || cached.result.violationDetails) {
|
|
104
|
+
return cached.result;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
// Route : localhost → scan local, sinon → API distante
|
|
108
|
+
let result;
|
|
109
|
+
if (isLocalUrl(url)) {
|
|
110
|
+
result = await callLocalScan(url, options);
|
|
111
|
+
}
|
|
112
|
+
else {
|
|
113
|
+
if (!API_KEY) {
|
|
114
|
+
throw new Error('RGAA_API_KEY non configurée.\n\n' +
|
|
115
|
+
'1. Créez un compte sur https://app.rgaaudit.fr\n' +
|
|
116
|
+
'2. Allez dans Paramètres > API\n' +
|
|
117
|
+
'3. Générez une clé API\n' +
|
|
118
|
+
'4. Configurez : RGAA_API_KEY=rga_...');
|
|
119
|
+
}
|
|
120
|
+
const auditId = options?.auditId || cached?.result.auditId;
|
|
121
|
+
const res = await fetch(`${API_URL}/api/ci/scan`, {
|
|
122
|
+
method: 'POST',
|
|
123
|
+
headers: {
|
|
124
|
+
'Content-Type': 'application/json',
|
|
125
|
+
'X-API-Key': API_KEY,
|
|
126
|
+
},
|
|
127
|
+
body: JSON.stringify({
|
|
128
|
+
url,
|
|
129
|
+
details: options?.details ?? false,
|
|
130
|
+
threshold: options?.threshold ?? 0,
|
|
131
|
+
...(auditId ? { auditId } : {}),
|
|
132
|
+
}),
|
|
133
|
+
});
|
|
134
|
+
if (!res.ok) {
|
|
135
|
+
const body = await res.json().catch(() => ({}));
|
|
136
|
+
throw new Error(`API erreur ${res.status}: ${body.message || res.statusText}`);
|
|
137
|
+
}
|
|
138
|
+
result = await res.json();
|
|
139
|
+
}
|
|
140
|
+
// Mettre en cache
|
|
141
|
+
auditCache.set(cacheKey, { result, timestamp: Date.now() });
|
|
142
|
+
return result;
|
|
56
143
|
}
|
|
57
144
|
// ── Outil 1 : rgaa_audit ──
|
|
58
145
|
server.tool('rgaa_audit', 'Lancer un audit d\'accessibilité RGAA 4.1 complet sur une URL. Retourne les violations avec critères RGAA, impact, et suggestions de correction.', {
|
|
@@ -64,10 +151,20 @@ server.tool('rgaa_audit', 'Lancer un audit d\'accessibilité RGAA 4.1 complet su
|
|
|
64
151
|
const critique = violations.filter(v => v.impact === 'critical').length;
|
|
65
152
|
const serieux = violations.filter(v => v.impact === 'serious').length;
|
|
66
153
|
const modere = violations.filter(v => v.impact === 'moderate').length;
|
|
154
|
+
const uniqueViolations = result.violationsInSharedZones
|
|
155
|
+
? result.violations - result.violationsInSharedZones
|
|
156
|
+
: result.violations;
|
|
157
|
+
const sharedInfo = result.violationsInSharedZones
|
|
158
|
+
? ` (dont ${result.violationsInSharedZones} dans des zones partagées nav/header/footer)`
|
|
159
|
+
: '';
|
|
67
160
|
let text = `# Audit RGAA 4.1 — ${url}\n\n` +
|
|
68
161
|
`**Score** : ${result.score}% — ${result.conformity}\n` +
|
|
69
|
-
`**${result.violations} non-conformités
|
|
70
|
-
`- ${critique} critique(s), ${serieux} sérieuse(s), ${modere} modérée(s)\n
|
|
162
|
+
`**${result.violations} non-conformités**${sharedInfo} sur ${result.pages} pages\n` +
|
|
163
|
+
`- ${critique} critique(s), ${serieux} sérieuse(s), ${modere} modérée(s)\n`;
|
|
164
|
+
if (result.coverage) {
|
|
165
|
+
text += `- **Couverture RGAA** : ${result.coverage.percentage}% (${result.coverage.mappedCriteria}/${result.coverage.totalCriteria} critères testés)\n`;
|
|
166
|
+
}
|
|
167
|
+
text += '\n';
|
|
71
168
|
if (result.patterns && result.patterns.length > 0) {
|
|
72
169
|
text += `## Patterns de correction (${result.patterns.length})\n\n`;
|
|
73
170
|
for (const p of result.patterns) {
|
|
@@ -81,7 +178,7 @@ server.tool('rgaa_audit', 'Lancer un audit d\'accessibilité RGAA 4.1 complet su
|
|
|
81
178
|
text += `### ${i + 1}. ${v.criteriaTitle || v.description}\n`;
|
|
82
179
|
text += `- **Critère RGAA** : ${v.criteria || 'N/A'}${v.theme ? ` (${v.theme})` : ''}\n`;
|
|
83
180
|
text += `- **Impact** : ${v.impact}\n`;
|
|
84
|
-
text += `- **Éléments** : ${v.nodesCount} nœuds
|
|
181
|
+
text += `- **Éléments** : ${v.nodesCount} nœuds\n`;
|
|
85
182
|
if (v.zone)
|
|
86
183
|
text += `- **Zone** : ${v.zone}\n`;
|
|
87
184
|
if (v.suggestion)
|
|
@@ -104,13 +201,29 @@ server.tool('rgaa_score', 'Score de conformité RGAA rapide pour une URL. Retour
|
|
|
104
201
|
url: z.string().url().describe('URL du site web'),
|
|
105
202
|
}, async ({ url }) => {
|
|
106
203
|
try {
|
|
107
|
-
|
|
108
|
-
const
|
|
204
|
+
// Appeler avec details pour inclure un résumé des top violations
|
|
205
|
+
const result = await callScan(url, { details: true });
|
|
206
|
+
const violations = result.violationDetails || [];
|
|
207
|
+
// Top 5 violations triées par impact (critique > sérieux > modéré)
|
|
208
|
+
const impactOrder = { critical: 0, critique: 0, serious: 1, serieux: 1, moderate: 2, modere: 2, minor: 3, mineur: 3 };
|
|
209
|
+
const top5 = [...violations]
|
|
210
|
+
.sort((a, b) => (impactOrder[a.impact] ?? 9) - (impactOrder[b.impact] ?? 9))
|
|
211
|
+
.slice(0, 5);
|
|
212
|
+
let text = `**Score RGAA** : ${result.score}%\n` +
|
|
109
213
|
`**Conformité** : ${result.conformity}\n` +
|
|
110
214
|
`**Violations** : ${result.violations}\n` +
|
|
111
|
-
`**Pages analysées** : ${result.pages}\n
|
|
112
|
-
|
|
113
|
-
|
|
215
|
+
`**Pages analysées** : ${result.pages}\n`;
|
|
216
|
+
if (result.coverage) {
|
|
217
|
+
text += `**Couverture** : ${result.coverage.percentage}% des critères RGAA testés\n`;
|
|
218
|
+
}
|
|
219
|
+
text += `**URL** : ${url}\n`;
|
|
220
|
+
if (top5.length > 0) {
|
|
221
|
+
text += `\n**Top violations** :\n`;
|
|
222
|
+
for (const v of top5) {
|
|
223
|
+
text += `- [${v.impact}] ${v.criteria || ''} ${v.criteriaTitle || v.description} (${v.nodesCount} nœuds)\n`;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
text += `\n🔗 Détails : ${result.auditUrl}`;
|
|
114
227
|
return { content: [{ type: 'text', text }] };
|
|
115
228
|
}
|
|
116
229
|
catch (error) {
|
|
@@ -130,7 +243,7 @@ server.tool('rgaa_check', 'Vérifier un critère RGAA spécifique sur une URL. P
|
|
|
130
243
|
}
|
|
131
244
|
const text = `❌ Critère RGAA ${criteria} : **${matching.length} violation(s)** sur ${url}\n\n` +
|
|
132
245
|
matching.map(v => `- **${v.criteriaTitle || v.description}** (${v.impact})\n` +
|
|
133
|
-
` ${v.nodesCount} nœuds
|
|
246
|
+
` ${v.nodesCount} nœuds\n` +
|
|
134
247
|
(v.suggestion ? ` 💡 ${v.suggestion}\n` : '')).join('\n') +
|
|
135
248
|
`\n🔗 Détails : ${result.auditUrl}`;
|
|
136
249
|
return { content: [{ type: 'text', text }] };
|
|
@@ -171,7 +284,275 @@ server.tool('rgaa_explain', 'Explique un critère RGAA 4.1 avec sa description,
|
|
|
171
284
|
(info.exemple ? `## Exemple\n${info.exemple}\n` : '');
|
|
172
285
|
return { content: [{ type: 'text', text }] };
|
|
173
286
|
});
|
|
174
|
-
// ── Outil 5 :
|
|
287
|
+
// ── Outil 5 : rgaa_fix (local, pas d'API) ──
|
|
288
|
+
/**
|
|
289
|
+
* Base de corrections pour les règles axe-core / RGAA les plus courantes.
|
|
290
|
+
* Chaque entrée contient des exemples de code avant/après pour HTML, Vue et React.
|
|
291
|
+
*/
|
|
292
|
+
const RGAA_FIXES = {
|
|
293
|
+
'image-alt': {
|
|
294
|
+
rule: 'image-alt',
|
|
295
|
+
criteria: '1.1',
|
|
296
|
+
description: 'Image sans alternative textuelle',
|
|
297
|
+
fixes: {
|
|
298
|
+
html: '```html\n<!-- Avant -->\n<img src="photo.jpg">\n\n<!-- Après (informative) -->\n<img src="photo.jpg" alt="Description de l\'image">\n\n<!-- Après (décorative) -->\n<img src="decoration.svg" alt="" role="presentation">\n```',
|
|
299
|
+
vue: '```vue\n<!-- Avant -->\n<img :src="imageUrl" />\n\n<!-- Après -->\n<img :src="imageUrl" :alt="imageAlt" />\n\n<!-- Décorative -->\n<img :src="bgUrl" alt="" role="presentation" />\n```',
|
|
300
|
+
react: '```tsx\n// Avant\n<img src={imageUrl} />\n\n// Après\n<img src={imageUrl} alt="Description de l\'image" />\n\n// Décorative\n<img src={bgUrl} alt="" role="presentation" />\n```',
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
'color-contrast': {
|
|
304
|
+
rule: 'color-contrast',
|
|
305
|
+
criteria: '3.3',
|
|
306
|
+
description: 'Contraste insuffisant entre texte et fond',
|
|
307
|
+
fixes: {
|
|
308
|
+
html: '```css\n/* Avant — ratio 2.1:1 */\ncolor: #999; background: #fff;\n\n/* Après — ratio 7.4:1 */\ncolor: #333; background: #fff;\n\n/* Vérifier : https://webaim.org/resources/contrastchecker/ */\n/* Minimum : 4.5:1 (texte normal), 3:1 (texte ≥18pt ou 14pt gras) */\n```',
|
|
309
|
+
vue: '```vue\n<!-- Utiliser des tokens sémantiques au lieu de couleurs hardcodées -->\n<p class="text-foreground">Texte principal</p>\n<p class="text-muted-foreground">Texte secondaire</p>\n\n<!-- Vérifier dans tailwind.config.ts que les tokens respectent le ratio 4.5:1 -->\n```',
|
|
310
|
+
react: '```tsx\n// Utiliser des tokens CSS au lieu de couleurs inline\n<p className="text-foreground">Texte principal</p>\n<p className="text-muted-foreground">Texte secondaire</p>\n```',
|
|
311
|
+
},
|
|
312
|
+
},
|
|
313
|
+
'link-name': {
|
|
314
|
+
rule: 'link-name',
|
|
315
|
+
criteria: '6.1',
|
|
316
|
+
description: 'Lien sans intitulé accessible',
|
|
317
|
+
fixes: {
|
|
318
|
+
html: '```html\n<!-- Avant (lien icône sans texte) -->\n<a href="/menu"><svg>...</svg></a>\n\n<!-- Après — aria-label -->\n<a href="/menu" aria-label="Ouvrir le menu"><svg aria-hidden="true">...</svg></a>\n\n<!-- Après — texte sr-only -->\n<a href="/menu"><svg aria-hidden="true">...</svg><span class="sr-only">Ouvrir le menu</span></a>\n\n<!-- Après — SVG title -->\n<a href="/menu"><svg><title>Ouvrir le menu</title>...</svg></a>\n```',
|
|
319
|
+
vue: '```vue\n<!-- Avant -->\n<a :href="url"><Icon name="menu" /></a>\n\n<!-- Après -->\n<a :href="url" aria-label="Ouvrir le menu"><Icon name="menu" aria-hidden="true" /></a>\n\n<!-- Avec composant -->\n<NuxtLink :to="url" aria-label="Ouvrir le menu"><Icon name="menu" aria-hidden="true" /></NuxtLink>\n```',
|
|
320
|
+
react: '```tsx\n// Avant\n<a href={url}><MenuIcon /></a>\n\n// Après\n<a href={url} aria-label="Ouvrir le menu"><MenuIcon aria-hidden /></a>\n```',
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
'label': {
|
|
324
|
+
rule: 'label',
|
|
325
|
+
criteria: '11.1',
|
|
326
|
+
description: 'Champ de formulaire sans étiquette',
|
|
327
|
+
fixes: {
|
|
328
|
+
html: '```html\n<!-- Avant -->\n<input type="email" placeholder="Email">\n\n<!-- Après — label explicite -->\n<label for="email">Adresse email</label>\n<input id="email" type="email" placeholder="ex: nom@domaine.fr">\n\n<!-- Après — aria-label (si label visuel impossible) -->\n<input type="search" aria-label="Rechercher sur le site" placeholder="Rechercher...">\n```',
|
|
329
|
+
vue: '```vue\n<!-- Avant -->\n<Input type="email" placeholder="Email" />\n\n<!-- Après -->\n<div class="space-y-1">\n <Label for="email">Adresse email</Label>\n <Input id="email" type="email" placeholder="ex: nom@domaine.fr" />\n</div>\n```',
|
|
330
|
+
react: '```tsx\n// Avant\n<input type="email" placeholder="Email" />\n\n// Après\n<label htmlFor="email">Adresse email</label>\n<input id="email" type="email" placeholder="ex: nom@domaine.fr" />\n```',
|
|
331
|
+
},
|
|
332
|
+
},
|
|
333
|
+
'button-name': {
|
|
334
|
+
rule: 'button-name',
|
|
335
|
+
criteria: '11.9',
|
|
336
|
+
description: 'Bouton sans intitulé accessible',
|
|
337
|
+
fixes: {
|
|
338
|
+
html: '```html\n<!-- Avant -->\n<button><svg>...</svg></button>\n\n<!-- Après -->\n<button aria-label="Fermer"><svg aria-hidden="true">...</svg></button>\n\n<!-- Ou avec texte visible -->\n<button><svg aria-hidden="true">...</svg> Fermer</button>\n```',
|
|
339
|
+
vue: '```vue\n<!-- Avant -->\n<Button size="icon"><X /></Button>\n\n<!-- Après -->\n<Button size="icon" aria-label="Fermer"><X aria-hidden="true" /></Button>\n```',
|
|
340
|
+
react: '```tsx\n// Avant\n<button><XIcon /></button>\n\n// Après\n<button aria-label="Fermer"><XIcon aria-hidden /></button>\n```',
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
'html-has-lang': {
|
|
344
|
+
rule: 'html-has-lang',
|
|
345
|
+
criteria: '8.3',
|
|
346
|
+
description: 'Langue de la page non déclarée',
|
|
347
|
+
fixes: {
|
|
348
|
+
html: '```html\n<!-- Avant -->\n<html>\n\n<!-- Après -->\n<html lang="fr">\n```',
|
|
349
|
+
vue: '```typescript\n// nuxt.config.ts\nexport default defineNuxtConfig({\n app: {\n head: {\n htmlAttrs: { lang: \'fr\' },\n },\n },\n})\n```',
|
|
350
|
+
react: '```tsx\n// Avant\n<html>\n\n// Après\n<html lang="fr">\n\n// Next.js — layout.tsx\nexport default function RootLayout({ children }) {\n return <html lang="fr">{children}</html>\n}\n```',
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
'bypass': {
|
|
354
|
+
rule: 'bypass',
|
|
355
|
+
criteria: '12.7',
|
|
356
|
+
description: 'Pas de lien d\'évitement vers le contenu principal',
|
|
357
|
+
fixes: {
|
|
358
|
+
html: '```html\n<!-- Ajouter en tout premier dans <body> -->\n<a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-background focus:p-2 focus:rounded">\n Aller au contenu principal\n</a>\n\n<!-- Ajouter id sur le contenu principal -->\n<main id="main-content">...</main>\n```',
|
|
359
|
+
vue: '```vue\n<!-- layouts/default.vue ou app.vue -->\n<template>\n <a href="#main-content" class="sr-only focus:not-sr-only focus:absolute focus:top-2 focus:left-2 focus:z-50 focus:bg-background focus:p-2 focus:rounded">\n Aller au contenu principal\n </a>\n <header>...</header>\n <main id="main-content"><slot /></main>\n</template>\n```',
|
|
360
|
+
react: '```tsx\n// Layout.tsx\nexport default function Layout({ children }) {\n return (\n <>\n <a href="#main-content" className="sr-only focus:not-sr-only ...">\n Aller au contenu principal\n </a>\n <Header />\n <main id="main-content">{children}</main>\n </>\n )\n}\n```',
|
|
361
|
+
},
|
|
362
|
+
},
|
|
363
|
+
'landmark-one-main': {
|
|
364
|
+
rule: 'landmark-one-main',
|
|
365
|
+
criteria: '12.9',
|
|
366
|
+
description: 'Page sans élément <main>',
|
|
367
|
+
fixes: {
|
|
368
|
+
html: '```html\n<!-- Avant -->\n<body>\n <div class="content">...</div>\n</body>\n\n<!-- Après -->\n<body>\n <header>...</header>\n <nav>...</nav>\n <main>...</main>\n <footer>...</footer>\n</body>\n```',
|
|
369
|
+
vue: '```vue\n<!-- layouts/default.vue -->\n<template>\n <header><AppNav /></header>\n <main><slot /></main>\n <footer><AppFooter /></footer>\n</template>\n```',
|
|
370
|
+
react: '```tsx\n// Layout.tsx\nexport default function Layout({ children }) {\n return (\n <>\n <header><Nav /></header>\n <main>{children}</main>\n <footer><Footer /></footer>\n </>\n )\n}\n```',
|
|
371
|
+
},
|
|
372
|
+
},
|
|
373
|
+
'link-in-text-block': {
|
|
374
|
+
rule: 'link-in-text-block',
|
|
375
|
+
criteria: '10.6',
|
|
376
|
+
description: 'Liens non distinguables du texte environnant',
|
|
377
|
+
fixes: {
|
|
378
|
+
html: '```css\n/* Ajouter un soulignement ou un indicateur non-couleur */\na { text-decoration: underline; }\na:hover { text-decoration: none; border-bottom: 2px solid; }\n```',
|
|
379
|
+
vue: '```vue\n<!-- Tailwind -->\n<a href="/page" class="underline hover:no-underline hover:border-b-2 hover:border-current">\n Lien dans un paragraphe\n</a>\n```',
|
|
380
|
+
react: '```tsx\n<a href="/page" className="underline hover:no-underline hover:border-b-2">\n Lien dans un paragraphe\n</a>\n```',
|
|
381
|
+
},
|
|
382
|
+
},
|
|
383
|
+
'target-blank': {
|
|
384
|
+
rule: 'custom-new-window-warning',
|
|
385
|
+
criteria: '13.2',
|
|
386
|
+
description: 'Lien target="_blank" sans avertissement',
|
|
387
|
+
fixes: {
|
|
388
|
+
html: '```html\n<!-- Avant -->\n<a href="https://example.com" target="_blank">Site externe</a>\n\n<!-- Après — texte sr-only -->\n<a href="https://example.com" target="_blank" rel="noopener">\n Site externe <span class="sr-only">(nouvelle fenêtre)</span>\n</a>\n\n<!-- Après — aria-label -->\n<a href="https://example.com" target="_blank" rel="noopener"\n aria-label="Site externe (nouvelle fenêtre)">\n Site externe\n</a>\n```',
|
|
389
|
+
vue: '```vue\n<!-- Composant réutilisable -->\n<template>\n <a :href="href" target="_blank" rel="noopener">\n <slot />\n <ExternalLinkIcon class="inline h-3 w-3 ml-1" aria-hidden="true" />\n <span class="sr-only">(nouvelle fenêtre)</span>\n </a>\n</template>\n```',
|
|
390
|
+
react: '```tsx\n// Composant réutilisable\nfunction ExternalLink({ href, children }) {\n return (\n <a href={href} target="_blank" rel="noopener">\n {children}\n <ExternalLinkIcon className="inline h-3 w-3 ml-1" aria-hidden />\n <span className="sr-only">(nouvelle fenêtre)</span>\n </a>\n )\n}\n```',
|
|
391
|
+
},
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
server.tool('rgaa_fix', 'Retourne le code correctif pour une violation RGAA. Fournit des exemples avant/après en HTML, Vue et React.', {
|
|
395
|
+
ruleId: z.string().describe('ID de la règle axe-core (ex: image-alt, color-contrast, link-name, label, button-name, bypass, target-blank)'),
|
|
396
|
+
framework: z.enum(['html', 'vue', 'react']).default('html').describe('Framework cible pour les exemples de code'),
|
|
397
|
+
}, async ({ ruleId, framework }) => {
|
|
398
|
+
const fix = RGAA_FIXES[ruleId];
|
|
399
|
+
if (!fix) {
|
|
400
|
+
const available = Object.keys(RGAA_FIXES).join(', ');
|
|
401
|
+
return { content: [{ type: 'text', text: `Règle "${ruleId}" non trouvée dans la base de corrections.\n\nRègles disponibles : ${available}\n\nUtilisez \`rgaa_explain\` pour comprendre le critère RGAA associé.` }] };
|
|
402
|
+
}
|
|
403
|
+
const code = fix.fixes[framework] || fix.fixes['html'];
|
|
404
|
+
const text = `# Correction — ${fix.rule} (RGAA ${fix.criteria})\n\n` +
|
|
405
|
+
`**Problème** : ${fix.description}\n\n` +
|
|
406
|
+
`## Code correctif (${framework})\n\n${code}\n\n` +
|
|
407
|
+
(framework !== 'html' && fix.fixes['html'] ? `## Version HTML de référence\n\n${fix.fixes['html']}\n` : '');
|
|
408
|
+
return { content: [{ type: 'text', text }] };
|
|
409
|
+
});
|
|
410
|
+
// ── Outil 6 : rgaa_remediate ──
|
|
411
|
+
/**
|
|
412
|
+
* Règles pour lesquelles on sait générer un fix automatique.
|
|
413
|
+
* Mappées vers les clés de RGAA_FIXES.
|
|
414
|
+
*/
|
|
415
|
+
const FIXABLE_RULES = new Set(Object.keys(RGAA_FIXES));
|
|
416
|
+
/**
|
|
417
|
+
* Ordre de priorité des impacts pour le tri
|
|
418
|
+
*/
|
|
419
|
+
const IMPACT_PRIORITY = {
|
|
420
|
+
critical: 0, critique: 0,
|
|
421
|
+
serious: 1, serieux: 1,
|
|
422
|
+
moderate: 2, modere: 2,
|
|
423
|
+
minor: 3, mineur: 3,
|
|
424
|
+
};
|
|
425
|
+
server.tool('rgaa_remediate', 'Plan de remédiation autonome : audite, priorise les violations corrigeables, et retourne un plan d\'action structuré avec le code correctif pour chaque violation. L\'agent AI applique ensuite les fixes via ses outils (file system, browser). Sur localhost = re-audits gratuits. Sur site distant = chaque re-audit consomme le quota.', {
|
|
426
|
+
url: z.string().url().describe('URL du site à corriger'),
|
|
427
|
+
framework: z.enum(['html', 'vue', 'react']).default('html').describe('Framework cible pour le code correctif'),
|
|
428
|
+
targetScore: z.number().min(0).max(100).default(80).describe('Score cible à atteindre (défaut: 80%)'),
|
|
429
|
+
maxFixes: z.number().min(1).max(30).default(10).describe('Nombre max de violations à traiter (défaut: 10)'),
|
|
430
|
+
}, async ({ url, framework, targetScore, maxFixes }) => {
|
|
431
|
+
try {
|
|
432
|
+
const result = await callScan(url, { details: true });
|
|
433
|
+
const violations = result.violationDetails || [];
|
|
434
|
+
const isLocal = isLocalUrl(url);
|
|
435
|
+
if (violations.length === 0) {
|
|
436
|
+
return {
|
|
437
|
+
content: [{
|
|
438
|
+
type: 'text',
|
|
439
|
+
text: `# Remédiation RGAA — ${url}\n\n` +
|
|
440
|
+
`**Score actuel** : ${result.score}%\n` +
|
|
441
|
+
`**Aucune violation détectée.** Le site est conforme aux critères testés.\n\n` +
|
|
442
|
+
(result.coverage ? `Note : ${result.coverage.percentage}% des critères RGAA sont testés automatiquement. Les ${100 - result.coverage.percentage}% restants nécessitent un audit humain.` : ''),
|
|
443
|
+
}],
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
if (result.score >= targetScore) {
|
|
447
|
+
return {
|
|
448
|
+
content: [{
|
|
449
|
+
type: 'text',
|
|
450
|
+
text: `# Remédiation RGAA — ${url}\n\n` +
|
|
451
|
+
`**Score actuel** : ${result.score}% (cible : ${targetScore}%)\n` +
|
|
452
|
+
`**Score cible déjà atteint.** ${violations.length} violation(s) restante(s) de priorité basse.\n\n` +
|
|
453
|
+
`Pour aller plus loin, relancez avec un targetScore plus élevé.`,
|
|
454
|
+
}],
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
// Séparer violations corrigeables (on a un fix) vs manuelles
|
|
458
|
+
const fixable = [];
|
|
459
|
+
const manual = [];
|
|
460
|
+
for (const v of violations) {
|
|
461
|
+
if (FIXABLE_RULES.has(v.ruleId)) {
|
|
462
|
+
fixable.push(v);
|
|
463
|
+
}
|
|
464
|
+
else {
|
|
465
|
+
manual.push(v);
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
// Trier par impact (critique d'abord) puis par nombre de nœuds (plus d'impact)
|
|
469
|
+
fixable.sort((a, b) => {
|
|
470
|
+
const impactDiff = (IMPACT_PRIORITY[a.impact] ?? 9) - (IMPACT_PRIORITY[b.impact] ?? 9);
|
|
471
|
+
if (impactDiff !== 0)
|
|
472
|
+
return impactDiff;
|
|
473
|
+
return b.nodesCount - a.nodesCount;
|
|
474
|
+
});
|
|
475
|
+
// Limiter au max demandé
|
|
476
|
+
const toFix = fixable.slice(0, maxFixes);
|
|
477
|
+
// Construire le plan de remédiation
|
|
478
|
+
let text = `# Plan de remédiation RGAA — ${url}\n\n`;
|
|
479
|
+
text += `| Métrique | Valeur |\n|----------|--------|\n`;
|
|
480
|
+
text += `| Score actuel | ${result.score}% |\n`;
|
|
481
|
+
text += `| Score cible | ${targetScore}% |\n`;
|
|
482
|
+
text += `| Violations totales | ${violations.length} |\n`;
|
|
483
|
+
text += `| Corrigeables auto | ${fixable.length} |\n`;
|
|
484
|
+
text += `| Manuelles | ${manual.length} |\n`;
|
|
485
|
+
text += `| Fixes dans ce plan | ${toFix.length} |\n`;
|
|
486
|
+
if (isLocal) {
|
|
487
|
+
text += `| Mode | **Localhost** (re-audits gratuits) |\n`;
|
|
488
|
+
}
|
|
489
|
+
else {
|
|
490
|
+
text += `| Mode | **Distant** (re-audit = 1 quota) |\n`;
|
|
491
|
+
}
|
|
492
|
+
text += '\n';
|
|
493
|
+
// Score estimé après corrections
|
|
494
|
+
const estimatedGain = Math.min(30, toFix.length * 3);
|
|
495
|
+
const estimatedScore = Math.min(100, result.score + estimatedGain);
|
|
496
|
+
text += `**Score estimé après corrections** : ~${estimatedScore}% (+${estimatedGain} points)\n\n`;
|
|
497
|
+
text += `---\n\n## Corrections à appliquer (${toFix.length})\n\n`;
|
|
498
|
+
text += `Appliquez chaque fix dans l'ordre. Après le dernier fix, lancez \`rgaa_score\` pour mesurer l'amélioration.\n\n`;
|
|
499
|
+
for (const [i, v] of toFix.entries()) {
|
|
500
|
+
const fix = RGAA_FIXES[v.ruleId];
|
|
501
|
+
const code = fix.fixes[framework] || fix.fixes['html'];
|
|
502
|
+
text += `### Fix ${i + 1}/${toFix.length} — ${v.ruleId} (RGAA ${v.criteria || '?'})\n\n`;
|
|
503
|
+
text += `- **Problème** : ${fix.description}\n`;
|
|
504
|
+
text += `- **Impact** : ${v.impact}\n`;
|
|
505
|
+
text += `- **Éléments** : ${v.nodesCount} nœuds affectés\n`;
|
|
506
|
+
if (v.zone)
|
|
507
|
+
text += `- **Zone** : ${v.zone}\n`;
|
|
508
|
+
// Exemples de nœuds à corriger
|
|
509
|
+
if (v.nodes.length > 0) {
|
|
510
|
+
text += `- **Éléments à corriger** :\n`;
|
|
511
|
+
for (const n of v.nodes.slice(0, 3)) {
|
|
512
|
+
text += ` - \`${n.target}\`\n`;
|
|
513
|
+
}
|
|
514
|
+
if (v.nodes.length > 3)
|
|
515
|
+
text += ` - ... et ${v.nodes.length - 3} autres\n`;
|
|
516
|
+
}
|
|
517
|
+
text += `\n**Code correctif (${framework})** :\n\n${code}\n\n`;
|
|
518
|
+
if (v.suggestion && v.suggestion !== fix.description) {
|
|
519
|
+
text += `**Suggestion** : ${v.suggestion}\n\n`;
|
|
520
|
+
}
|
|
521
|
+
text += `---\n\n`;
|
|
522
|
+
}
|
|
523
|
+
// Violations manuelles (pas de fix automatique)
|
|
524
|
+
if (manual.length > 0) {
|
|
525
|
+
text += `## Violations manuelles (${manual.length})\n\n`;
|
|
526
|
+
text += `Ces violations n'ont pas de fix automatique. Elles nécessitent une analyse humaine :\n\n`;
|
|
527
|
+
const manualSorted = [...manual].sort((a, b) => (IMPACT_PRIORITY[a.impact] ?? 9) - (IMPACT_PRIORITY[b.impact] ?? 9));
|
|
528
|
+
for (const v of manualSorted.slice(0, 10)) {
|
|
529
|
+
text += `- **[${v.impact}]** ${v.criteria || ''} ${v.criteriaTitle || v.description} (${v.nodesCount} nœuds)\n`;
|
|
530
|
+
}
|
|
531
|
+
if (manual.length > 10)
|
|
532
|
+
text += `- ... et ${manual.length - 10} autres\n`;
|
|
533
|
+
text += '\n';
|
|
534
|
+
}
|
|
535
|
+
// Instructions pour l'agent
|
|
536
|
+
text += `## Instructions pour l'agent\n\n`;
|
|
537
|
+
text += `1. Pour chaque fix ci-dessus, identifiez le fichier source concerné\n`;
|
|
538
|
+
text += `2. Appliquez le code correctif adapté au composant\n`;
|
|
539
|
+
text += `3. Après toutes les corrections, exécutez :\n`;
|
|
540
|
+
text += ` - \`rgaa_score("${url}")\` pour mesurer l'amélioration\n`;
|
|
541
|
+
if (isLocal) {
|
|
542
|
+
text += ` - Re-audits **gratuits** (scan local, pas de quota)\n`;
|
|
543
|
+
}
|
|
544
|
+
else {
|
|
545
|
+
text += ` - Chaque re-audit **consomme 1 crédit** du quota utilisateur\n`;
|
|
546
|
+
}
|
|
547
|
+
text += `4. Si le score < ${targetScore}%, relancez \`rgaa_remediate\` pour le cycle suivant\n`;
|
|
548
|
+
text += `5. Vérifiez visuellement avec \`browser_screenshot\` que le rendu n'est pas cassé\n`;
|
|
549
|
+
return { content: [{ type: 'text', text }] };
|
|
550
|
+
}
|
|
551
|
+
catch (error) {
|
|
552
|
+
return { content: [{ type: 'text', text: `Erreur : ${error instanceof Error ? error.message : String(error)}` }], isError: true };
|
|
553
|
+
}
|
|
554
|
+
});
|
|
555
|
+
// ── Outil 7 : rgaa_report ──
|
|
175
556
|
server.tool('rgaa_report', 'Génère un rapport d\'audit RGAA structuré en JSON pour intégration CI/CD ou export.', {
|
|
176
557
|
url: z.string().url().describe('URL du site web'),
|
|
177
558
|
threshold: z.number().min(0).max(100).default(0).describe('Score minimum pour pass/fail (0 = pas de seuil)'),
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scanner local axe-core — Mode localhost
|
|
3
|
+
*
|
|
4
|
+
* Utilise puppeteer-core (Chrome existant) + @axe-core/puppeteer
|
|
5
|
+
* pour auditer des URLs localhost sans passer par l'API distante.
|
|
6
|
+
*
|
|
7
|
+
* Le mapping RGAA est ensuite récupéré via POST /api/ci/map.
|
|
8
|
+
*/
|
|
9
|
+
/** Résultat brut d'un scan local axe-core */
|
|
10
|
+
export interface LocalScanResult {
|
|
11
|
+
url: string;
|
|
12
|
+
violations: LocalViolation[];
|
|
13
|
+
passes: number;
|
|
14
|
+
incomplete: number;
|
|
15
|
+
timestamp: string;
|
|
16
|
+
}
|
|
17
|
+
export interface LocalViolation {
|
|
18
|
+
ruleId: string;
|
|
19
|
+
impact: string;
|
|
20
|
+
description: string;
|
|
21
|
+
help: string;
|
|
22
|
+
helpUrl: string;
|
|
23
|
+
nodesCount: number;
|
|
24
|
+
nodes: Array<{
|
|
25
|
+
html: string;
|
|
26
|
+
target: string;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
/** Mapping RGAA retourné par /api/ci/map */
|
|
30
|
+
export interface RgaaMapping {
|
|
31
|
+
criteria: string;
|
|
32
|
+
title: string;
|
|
33
|
+
theme: string | null;
|
|
34
|
+
impact: string | null;
|
|
35
|
+
help: string | null;
|
|
36
|
+
level: string | null;
|
|
37
|
+
example: string | null;
|
|
38
|
+
}
|
|
39
|
+
export interface MapResponse {
|
|
40
|
+
mappings: Record<string, RgaaMapping>;
|
|
41
|
+
coverage: {
|
|
42
|
+
mappedCriteria: number;
|
|
43
|
+
totalCriteria: number;
|
|
44
|
+
percentage: number;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Détecte si une URL est locale (localhost, 127.0.0.1, 0.0.0.0)
|
|
49
|
+
*/
|
|
50
|
+
export declare function isLocalUrl(url: string): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Lance un scan axe-core local sur une URL
|
|
53
|
+
*
|
|
54
|
+
* @param url - URL locale à scanner (localhost, 127.0.0.1)
|
|
55
|
+
* @returns Violations axe-core brutes
|
|
56
|
+
*/
|
|
57
|
+
export declare function scanLocal(url: string): Promise<LocalScanResult>;
|
|
58
|
+
/**
|
|
59
|
+
* Appelle POST /api/ci/map pour enrichir les violations avec le mapping RGAA
|
|
60
|
+
*
|
|
61
|
+
* @param ruleIds - Liste de ruleIds axe-core
|
|
62
|
+
* @param apiUrl - URL de l'API RGAAudit
|
|
63
|
+
* @param apiKey - Clé API
|
|
64
|
+
* @returns Mappings RGAA ou null si l'API est inaccessible
|
|
65
|
+
*/
|
|
66
|
+
export declare function fetchRgaaMappings(ruleIds: string[], apiUrl: string, apiKey: string): Promise<MapResponse | null>;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scanner local axe-core — Mode localhost
|
|
3
|
+
*
|
|
4
|
+
* Utilise puppeteer-core (Chrome existant) + @axe-core/puppeteer
|
|
5
|
+
* pour auditer des URLs localhost sans passer par l'API distante.
|
|
6
|
+
*
|
|
7
|
+
* Le mapping RGAA est ensuite récupéré via POST /api/ci/map.
|
|
8
|
+
*/
|
|
9
|
+
/**
|
|
10
|
+
* Détecte si une URL est locale (localhost, 127.0.0.1, 0.0.0.0)
|
|
11
|
+
*/
|
|
12
|
+
export function isLocalUrl(url) {
|
|
13
|
+
try {
|
|
14
|
+
const parsed = new URL(url);
|
|
15
|
+
return (parsed.hostname === 'localhost' ||
|
|
16
|
+
parsed.hostname === '127.0.0.1' ||
|
|
17
|
+
parsed.hostname === '0.0.0.0' ||
|
|
18
|
+
parsed.hostname === '::1');
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Trouve le chemin de Chrome/Chromium installé sur le système
|
|
26
|
+
*/
|
|
27
|
+
async function findChromePath() {
|
|
28
|
+
const { existsSync } = await import('fs');
|
|
29
|
+
const { join } = await import('path');
|
|
30
|
+
// Variable d'env explicite (priorité absolue, pas d'exécution shell)
|
|
31
|
+
if (process.env.CHROME_PATH && existsSync(process.env.CHROME_PATH)) {
|
|
32
|
+
return process.env.CHROME_PATH;
|
|
33
|
+
}
|
|
34
|
+
// Chemins connus par OS (pas d'exécution shell, pas d'injection possible)
|
|
35
|
+
const candidates = [];
|
|
36
|
+
// Windows — chemins standards via variables d'env système
|
|
37
|
+
const winDirs = [
|
|
38
|
+
process.env['PROGRAMFILES'],
|
|
39
|
+
process.env['PROGRAMFILES(X86)'],
|
|
40
|
+
process.env['LOCALAPPDATA'],
|
|
41
|
+
].filter(Boolean);
|
|
42
|
+
for (const dir of winDirs) {
|
|
43
|
+
candidates.push(join(dir, 'Google', 'Chrome', 'Application', 'chrome.exe'));
|
|
44
|
+
candidates.push(join(dir, 'Microsoft', 'Edge', 'Application', 'msedge.exe'));
|
|
45
|
+
}
|
|
46
|
+
// macOS
|
|
47
|
+
candidates.push('/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge');
|
|
48
|
+
// Linux
|
|
49
|
+
candidates.push('/usr/bin/google-chrome', '/usr/bin/google-chrome-stable', '/usr/bin/chromium', '/usr/bin/chromium-browser', '/snap/bin/chromium');
|
|
50
|
+
for (const p of candidates) {
|
|
51
|
+
if (existsSync(p))
|
|
52
|
+
return p;
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Lance un scan axe-core local sur une URL
|
|
58
|
+
*
|
|
59
|
+
* @param url - URL locale à scanner (localhost, 127.0.0.1)
|
|
60
|
+
* @returns Violations axe-core brutes
|
|
61
|
+
*/
|
|
62
|
+
export async function scanLocal(url) {
|
|
63
|
+
// Validation URL : seuls http/https autorisés, longueur limitée
|
|
64
|
+
const parsed = new URL(url);
|
|
65
|
+
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
|
66
|
+
throw new Error('Seuls les protocoles http et https sont autorisés');
|
|
67
|
+
}
|
|
68
|
+
if (url.length > 2048) {
|
|
69
|
+
throw new Error('URL trop longue (max 2048 caractères)');
|
|
70
|
+
}
|
|
71
|
+
const chromePath = await findChromePath();
|
|
72
|
+
if (!chromePath) {
|
|
73
|
+
throw new Error('Chrome/Chromium non trouvé sur ce système.\n\n' +
|
|
74
|
+
'Le scan localhost nécessite un navigateur Chromium installé.\n' +
|
|
75
|
+
'Installez Google Chrome ou Chromium, puis réessayez.\n\n' +
|
|
76
|
+
'Chemins recherchés :\n' +
|
|
77
|
+
'- Windows : Program Files\\Google\\Chrome\\Application\\chrome.exe\n' +
|
|
78
|
+
'- macOS : /Applications/Google Chrome.app\n' +
|
|
79
|
+
'- Linux : /usr/bin/google-chrome ou /usr/bin/chromium');
|
|
80
|
+
}
|
|
81
|
+
// Import dynamique pour ne pas crasher si les deps ne sont pas installées
|
|
82
|
+
const puppeteer = await import('puppeteer-core');
|
|
83
|
+
const { AxePuppeteer } = await import('@axe-core/puppeteer');
|
|
84
|
+
let browser = null;
|
|
85
|
+
try {
|
|
86
|
+
browser = await puppeteer.default.launch({
|
|
87
|
+
executablePath: chromePath,
|
|
88
|
+
headless: true,
|
|
89
|
+
args: [
|
|
90
|
+
'--no-sandbox',
|
|
91
|
+
'--disable-setuid-sandbox',
|
|
92
|
+
'--disable-dev-shm-usage',
|
|
93
|
+
'--disable-gpu',
|
|
94
|
+
'--disable-extensions',
|
|
95
|
+
],
|
|
96
|
+
});
|
|
97
|
+
const page = await browser.newPage();
|
|
98
|
+
await page.setViewport({ width: 1280, height: 720 });
|
|
99
|
+
// Naviguer avec timeout
|
|
100
|
+
await page.goto(url, { waitUntil: 'networkidle2', timeout: 30_000 });
|
|
101
|
+
// Lancer axe-core
|
|
102
|
+
const results = await new AxePuppeteer(page)
|
|
103
|
+
.withTags(['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa', 'best-practice'])
|
|
104
|
+
.analyze();
|
|
105
|
+
const violations = results.violations.map(v => ({
|
|
106
|
+
ruleId: v.id,
|
|
107
|
+
impact: v.impact || 'moderate',
|
|
108
|
+
description: v.description,
|
|
109
|
+
help: v.help,
|
|
110
|
+
helpUrl: v.helpUrl,
|
|
111
|
+
nodesCount: v.nodes.length,
|
|
112
|
+
nodes: v.nodes.slice(0, 5).map(n => ({
|
|
113
|
+
html: (n.html || '').slice(0, 200),
|
|
114
|
+
target: Array.isArray(n.target) ? n.target.join(' > ') : String(n.target),
|
|
115
|
+
})),
|
|
116
|
+
}));
|
|
117
|
+
return {
|
|
118
|
+
url,
|
|
119
|
+
violations,
|
|
120
|
+
passes: results.passes.length,
|
|
121
|
+
incomplete: results.incomplete.length,
|
|
122
|
+
timestamp: new Date().toISOString(),
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
if (browser)
|
|
127
|
+
await browser.close().catch(() => { });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
/**
|
|
131
|
+
* Appelle POST /api/ci/map pour enrichir les violations avec le mapping RGAA
|
|
132
|
+
*
|
|
133
|
+
* @param ruleIds - Liste de ruleIds axe-core
|
|
134
|
+
* @param apiUrl - URL de l'API RGAAudit
|
|
135
|
+
* @param apiKey - Clé API
|
|
136
|
+
* @returns Mappings RGAA ou null si l'API est inaccessible
|
|
137
|
+
*/
|
|
138
|
+
export async function fetchRgaaMappings(ruleIds, apiUrl, apiKey) {
|
|
139
|
+
try {
|
|
140
|
+
const res = await fetch(`${apiUrl}/api/ci/map`, {
|
|
141
|
+
method: 'POST',
|
|
142
|
+
headers: {
|
|
143
|
+
'Content-Type': 'application/json',
|
|
144
|
+
'X-API-Key': apiKey,
|
|
145
|
+
},
|
|
146
|
+
body: JSON.stringify({ ruleIds }),
|
|
147
|
+
});
|
|
148
|
+
if (!res.ok)
|
|
149
|
+
return null;
|
|
150
|
+
return res.json();
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// API inaccessible (offline, pas de connexion) → retour null
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rgaaudit/mcp-server",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "MCP Server RGAA 4.1 — Audit d'accessibilité pour Claude Code, Cursor, Copilot",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -18,7 +18,9 @@
|
|
|
18
18
|
"prepublishOnly": "npm run build"
|
|
19
19
|
},
|
|
20
20
|
"dependencies": {
|
|
21
|
+
"@axe-core/puppeteer": "^4.11.1",
|
|
21
22
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
23
|
+
"puppeteer-core": "^24.40.0",
|
|
22
24
|
"zod": "^3.25.3"
|
|
23
25
|
},
|
|
24
26
|
"devDependencies": {
|
|
@@ -28,7 +30,16 @@
|
|
|
28
30
|
"engines": {
|
|
29
31
|
"node": ">=18.0.0"
|
|
30
32
|
},
|
|
31
|
-
"keywords": [
|
|
33
|
+
"keywords": [
|
|
34
|
+
"mcp",
|
|
35
|
+
"rgaa",
|
|
36
|
+
"accessibility",
|
|
37
|
+
"a11y",
|
|
38
|
+
"wcag",
|
|
39
|
+
"audit",
|
|
40
|
+
"france",
|
|
41
|
+
"rgaa4"
|
|
42
|
+
],
|
|
32
43
|
"license": "MIT",
|
|
33
44
|
"repository": {
|
|
34
45
|
"type": "git",
|