@nodefony/devkit 10.0.0-alpha.1
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/LICENSE +544 -0
- package/README.md +318 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +8 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
- package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
- package/dist/index.js +47 -0
- package/dist/nodefony/command/CardCommand.js +70 -0
- package/dist/nodefony/config/config.js +200 -0
- package/dist/nodefony/config/defineModuleConfig.js +36 -0
- package/dist/nodefony/controllers/DevkitController.js +60 -0
- package/dist/nodefony/controllers/McpController.js +223 -0
- package/dist/nodefony/controllers/OAuthMetadataController.js +89 -0
- package/dist/nodefony/interfaces/IDevkitService.js +1 -0
- package/dist/nodefony/interfaces/index.js +1 -0
- package/dist/nodefony/service/DevkitService.js +198 -0
- package/dist/nodefony/src/card.js +2 -0
- package/dist/nodefony/src/errors/DevkitError.js +21 -0
- package/dist/nodefony/src/mcp/guard.js +51 -0
- package/dist/nodefony/src/mcp/protocol.js +127 -0
- package/dist/nodefony/src/mcp/server.js +133 -0
- package/dist/nodefony/src/mcp/tools.js +163 -0
- package/dist/types/index.d.ts +52 -0
- package/dist/types/nodefony/command/CardCommand.d.ts +33 -0
- package/dist/types/nodefony/config/config.d.ts +25 -0
- package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
- package/dist/types/nodefony/controllers/DevkitController.d.ts +37 -0
- package/dist/types/nodefony/controllers/McpController.d.ts +70 -0
- package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +39 -0
- package/dist/types/nodefony/interfaces/IDevkitService.d.ts +69 -0
- package/dist/types/nodefony/interfaces/index.d.ts +1 -0
- package/dist/types/nodefony/service/DevkitService.d.ts +143 -0
- package/dist/types/nodefony/src/card.d.ts +18 -0
- package/dist/types/nodefony/src/errors/DevkitError.d.ts +14 -0
- package/dist/types/nodefony/src/mcp/guard.d.ts +66 -0
- package/dist/types/nodefony/src/mcp/protocol.d.ts +139 -0
- package/dist/types/nodefony/src/mcp/server.d.ts +48 -0
- package/dist/types/nodefony/src/mcp/tools.d.ts +81 -0
- package/docs/index.md +358 -0
- package/package.json +77 -0
- package/skills/nodefony-add-crud/SKILL.md +199 -0
- package/skills/nodefony-add-realtime-channel/SKILL.md +95 -0
- package/skills/nodefony-add-service/SKILL.md +90 -0
- package/skills/nodefony-browser/SKILL.md +416 -0
- package/skills/nodefony-browser/references/socket.md +115 -0
- package/skills/nodefony-browser/references/sondes.md +175 -0
- package/skills/nodefony-browser/scripts/audit.mjs +169 -0
- package/skills/nodefony-browser/scripts/inspect.mjs +903 -0
- package/skills/nodefony-browser/scripts/lib/browser.mjs +357 -0
- package/skills/nodefony-browser/scripts/lib/probes.mjs +501 -0
- package/skills/nodefony-browser/scripts/lib/wcag.mjs +153 -0
- package/skills/nodefony-browser/scripts/socket.mjs +354 -0
- package/skills/nodefony-browser/scripts/watch.mjs +125 -0
- package/skills/nodefony-migrate-schema/SKILL.md +359 -0
- package/skills/nodefony-migrate-schema/references/verdicts.md +139 -0
- package/skills/nodefony-protect-route/SKILL.md +195 -0
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Grammaire de la ligne de commande des sondes — fonctions PURES, sans
|
|
3
|
+
* navigateur, sans réseau, sans état.
|
|
4
|
+
*
|
|
5
|
+
* Extraites des scripts pour être éprouvées par des tests qui tournent
|
|
6
|
+
* partout : une allowlist de familles qui laisse passer un nom inconnu, ou un
|
|
7
|
+
* découpage de sélecteurs qui avale une entrée malformée, produit une mesure
|
|
8
|
+
* FAUSSE en silence — exactement la classe de bug qu'une sonde ne peut pas se
|
|
9
|
+
* permettre.
|
|
10
|
+
*/
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Les familles de sondes activables — l'allowlist, et sa documentation.
|
|
15
|
+
*
|
|
16
|
+
* Un nom absent d'ici est REFUSÉ (code 64), jamais ignoré : une famille
|
|
17
|
+
* fautée en silence ferait croire qu'on a mesuré ce qu'on n'a pas mesuré.
|
|
18
|
+
*/
|
|
19
|
+
export const FAMILIES = Object.freeze({
|
|
20
|
+
a11y: "accessibilité — étiquettes, noms accessibles, titres, cibles, arbre ARIA",
|
|
21
|
+
axe: "audit WCAG complet par axe-core — une centaine de règles, dont le contraste de tout le texte",
|
|
22
|
+
rendu:
|
|
23
|
+
"rendu — débordement horizontal, éléments hors viewport, polices réellement chargées",
|
|
24
|
+
reseau: "réseau — requêtes, échecs, ressources lourdes, temps de réponse",
|
|
25
|
+
perf: "temps de rendu — TTFB, FCP, LCP, CLS, tâches longues",
|
|
26
|
+
stockage: "cookies (attributs, jamais les valeurs) et Web Storage",
|
|
27
|
+
responsive: "débordement horizontal à plusieurs largeurs d'écran",
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Analyse la liste de familles demandée (`NF_BROWSER_FAMILIES`).
|
|
32
|
+
*
|
|
33
|
+
* `Object.hasOwn` et non `in` : `"toString" in FAMILLES` est vrai par la chaîne
|
|
34
|
+
* de prototypes, et une « famille » toString serait acceptée sans exister.
|
|
35
|
+
*
|
|
36
|
+
* @param {string|undefined} raw - valeur brute (`"a11y,perf"`, `"toutes"`, vide).
|
|
37
|
+
* @param {string[]} [fallback] - familles retenues quand rien n'est demandé.
|
|
38
|
+
* @returns {{ kept: string[], unknown: string[] }} les familles valides,
|
|
39
|
+
* et celles qui n'existent pas — à refuser, jamais à ignorer.
|
|
40
|
+
*/
|
|
41
|
+
export function parseFamilies(raw, fallback = []) {
|
|
42
|
+
const requested = String(raw ?? "").trim();
|
|
43
|
+
if (!requested) return { kept: [...fallback], unknown: [] };
|
|
44
|
+
if (requested === "toutes") {
|
|
45
|
+
return { kept: Object.keys(FAMILIES), unknown: [] };
|
|
46
|
+
}
|
|
47
|
+
const kept = [];
|
|
48
|
+
const unknown = [];
|
|
49
|
+
for (const name of requested
|
|
50
|
+
.split(",")
|
|
51
|
+
.map((n) => n.trim())
|
|
52
|
+
.filter(Boolean)) {
|
|
53
|
+
if (Object.hasOwn(FAMILIES, name)) {
|
|
54
|
+
if (!kept.includes(name)) kept.push(name);
|
|
55
|
+
} else {
|
|
56
|
+
unknown.push(name);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return { kept, unknown };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Analyse les sondes de style (`NF_BROWSER_PROBES`, forme `libellé=sélecteur`).
|
|
64
|
+
*
|
|
65
|
+
* Les entrées malformées sont RENDUES, pas avalées : une sonde qu'on croit
|
|
66
|
+
* poser et qui n'existe pas est une mesure qui manque sans bruit.
|
|
67
|
+
*
|
|
68
|
+
* @param {string|undefined} raw - entrées séparées par des virgules.
|
|
69
|
+
* @returns {{ probes: { label: string, sel: string }[], rejected: string[] }}
|
|
70
|
+
*/
|
|
71
|
+
export function parseProbes(raw) {
|
|
72
|
+
const probes = [];
|
|
73
|
+
const rejected = [];
|
|
74
|
+
for (const chunk of String(raw ?? "")
|
|
75
|
+
.split(",")
|
|
76
|
+
.map((p) => p.trim())
|
|
77
|
+
.filter(Boolean)) {
|
|
78
|
+
const i = chunk.indexOf("=");
|
|
79
|
+
const label = i > 0 ? chunk.slice(0, i).trim() : "";
|
|
80
|
+
const sel = i > 0 ? chunk.slice(i + 1).trim() : "";
|
|
81
|
+
if (label && sel) probes.push({ label, sel });
|
|
82
|
+
else rejected.push(chunk);
|
|
83
|
+
}
|
|
84
|
+
return { probes, rejected };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Verbes qui prennent une VALEUR après la cible. Les autres n'en ont aucune. */
|
|
88
|
+
const VERBS_WITH_VALUE = new Set(["saisir"]);
|
|
89
|
+
|
|
90
|
+
/** Verbes reconnus d'une action, le premier étant celui par défaut. */
|
|
91
|
+
export const ACTION_VERBS = [
|
|
92
|
+
"clic",
|
|
93
|
+
"double",
|
|
94
|
+
"droit",
|
|
95
|
+
"survol",
|
|
96
|
+
"saisir",
|
|
97
|
+
"touche",
|
|
98
|
+
"voir",
|
|
99
|
+
"defiler",
|
|
100
|
+
"attendre",
|
|
101
|
+
];
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Analyse une séquence d'actions (`NF_BROWSER_ACTIONS`).
|
|
105
|
+
*
|
|
106
|
+
* Grammaire : `verbe:cible[=valeur]`, séquence séparée par `|`, verbe facultatif
|
|
107
|
+
* (`clic` par défaut).
|
|
108
|
+
*
|
|
109
|
+
* ⚠️ Le signe `=` appartient AUSSI à la grammaire des sélecteurs CSS —
|
|
110
|
+
* `[data-active=true]`, `input[type=search]`, `[aria-expanded="false"]`. Couper
|
|
111
|
+
* la cible au premier `=` rencontré, ce que faisait la version précédente,
|
|
112
|
+
* amputait ces sélecteurs : la cible devenait `[data-active` et la sonde
|
|
113
|
+
* s'arrêtait en annonçant un élément introuvable, ce qui envoyait chercher un
|
|
114
|
+
* défaut dans la page. D'où deux règles :
|
|
115
|
+
*
|
|
116
|
+
* 1. seuls les verbes qui PRENNENT une valeur (`saisir`) découpent sur `=` ;
|
|
117
|
+
* pour tous les autres, la cible est le reste entier ;
|
|
118
|
+
* 2. même pour ceux-là, la coupure ignore les `=` situés à l'intérieur de
|
|
119
|
+
* crochets — `saisir:input[name=q]=bonjour` saisit bien « bonjour » dans
|
|
120
|
+
* `input[name=q]`.
|
|
121
|
+
*
|
|
122
|
+
* @param raw - la valeur de la variable d'environnement, ou une chaîne vide.
|
|
123
|
+
* @returns la liste des actions `{ verbe, cible, valeur }`, dans l'ordre.
|
|
124
|
+
*/
|
|
125
|
+
export function parseActions(raw) {
|
|
126
|
+
const verbs = ACTION_VERBS.join("|");
|
|
127
|
+
const header = new RegExp(`^(${verbs}):([\\s\\S]*)$`);
|
|
128
|
+
return String(raw ?? "")
|
|
129
|
+
.split("|")
|
|
130
|
+
.map((a) => a.trim())
|
|
131
|
+
.filter(Boolean)
|
|
132
|
+
.map((entry) => {
|
|
133
|
+
const m = header.exec(entry);
|
|
134
|
+
const verb = m ? m[1] : "clic";
|
|
135
|
+
const rest = m ? m[2] : entry;
|
|
136
|
+
if (!VERBS_WITH_VALUE.has(verb)) {
|
|
137
|
+
return { verb, target: rest.trim(), value: "" };
|
|
138
|
+
}
|
|
139
|
+
// Dernier `=` HORS crochets : la valeur est ce qui suit.
|
|
140
|
+
let depth = 0;
|
|
141
|
+
let cut = -1;
|
|
142
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
143
|
+
const c = rest[i];
|
|
144
|
+
if (c === "[") depth += 1;
|
|
145
|
+
else if (c === "]") depth = Math.max(0, depth - 1);
|
|
146
|
+
else if (c === "=" && depth === 0) cut = i;
|
|
147
|
+
}
|
|
148
|
+
return cut === -1
|
|
149
|
+
? { verb, target: rest.trim(), value: "" }
|
|
150
|
+
: {
|
|
151
|
+
verb,
|
|
152
|
+
target: rest.slice(0, cut).trim(),
|
|
153
|
+
value: rest.slice(cut + 1),
|
|
154
|
+
};
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Analyse le schéma de couleurs demandé (`NF_BROWSER_COLOR_SCHEME`).
|
|
160
|
+
*
|
|
161
|
+
* Les valeurs sont celles de la MÉDIA QUERY standard, jamais le vocabulaire
|
|
162
|
+
* d'une bibliothèque : `prefers-color-scheme` est ce que tout moteur de rendu
|
|
163
|
+
* comprend, quelle que soit la trousse d'interface au-dessus.
|
|
164
|
+
*
|
|
165
|
+
* Une valeur inconnue est REFUSÉE : silencieusement ignorée, elle ferait
|
|
166
|
+
* mesurer le thème par défaut en croyant tenir l'autre — le faux vert dont un
|
|
167
|
+
* défaut visible dans UN SEUL thème est le cas d'école.
|
|
168
|
+
*
|
|
169
|
+
* @param {string|undefined} raw - valeur brute (`"light"`, `"dark"`, vide).
|
|
170
|
+
* @returns {{ schema: "light"|"dark"|"no-preference"|null, invalid: string|null }}
|
|
171
|
+
* `schema` à null quand rien n'est demandé (on ne force rien).
|
|
172
|
+
*/
|
|
173
|
+
export function parseColorScheme(raw) {
|
|
174
|
+
const v = String(raw ?? "")
|
|
175
|
+
.trim()
|
|
176
|
+
.toLowerCase();
|
|
177
|
+
if (!v) return { schema: null, invalid: null };
|
|
178
|
+
if (v === "light" || v === "dark" || v === "no-preference")
|
|
179
|
+
return { schema: v, invalid: null };
|
|
180
|
+
return { schema: null, invalid: v };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Analyse les entrées de stockage à poser avant chargement (`NF_BROWSER_STORAGE`).
|
|
185
|
+
*
|
|
186
|
+
* Pourquoi ce détour plutôt qu'un réglage de thème tout fait : une application
|
|
187
|
+
* qui MÉMORISE son thème ne suit plus `prefers-color-scheme`, et la clé qu'elle
|
|
188
|
+
* emploie lui appartient. Chaque trousse d'interface a la sienne, et elles
|
|
189
|
+
* changent d'une version à l'autre ; en coder une rendrait la sonde juste pour
|
|
190
|
+
* cette trousse-là et faussement rassurante pour toutes les autres. La
|
|
191
|
+
* précision vit donc dans l'ARGUMENT, jamais dans le code — c'est celui qui
|
|
192
|
+
* connaît son application qui donne la clé.
|
|
193
|
+
*
|
|
194
|
+
* Forme `clé=valeur`, séparées par des virgules. Une valeur peut contenir `=`
|
|
195
|
+
* (jeton, JSON) : seul le PREMIER `=` sépare.
|
|
196
|
+
*
|
|
197
|
+
* @param {string|undefined} raw - entrées séparées par des virgules.
|
|
198
|
+
* @returns {{ entries: { key: string, value: string }[], rejected: string[] }}
|
|
199
|
+
*/
|
|
200
|
+
export function parseStorage(raw) {
|
|
201
|
+
const entries = [];
|
|
202
|
+
const rejected = [];
|
|
203
|
+
for (const chunk of String(raw ?? "")
|
|
204
|
+
.split(",")
|
|
205
|
+
.map((p) => p.trim())
|
|
206
|
+
.filter(Boolean)) {
|
|
207
|
+
const i = chunk.indexOf("=");
|
|
208
|
+
const key = i > 0 ? chunk.slice(0, i).trim() : "";
|
|
209
|
+
const value = i > 0 ? chunk.slice(i + 1).trim() : "";
|
|
210
|
+
if (key && value) entries.push({ key, value });
|
|
211
|
+
else rejected.push(chunk);
|
|
212
|
+
}
|
|
213
|
+
return { entries, rejected };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Les deux réglages qui dépendent de l'ENDROIT d'où la sonde s'exécute.
|
|
218
|
+
*
|
|
219
|
+
* Le navigateur tourne soit sur la machine du développeur, soit dans un
|
|
220
|
+
* conteneur — et les deux ne voient pas le même monde : `127.0.0.1` désigne le
|
|
221
|
+
* CONTENEUR lui-même quand on y est enfermé, et le dossier de sortie n'est un
|
|
222
|
+
* volume monté que là-bas.
|
|
223
|
+
*
|
|
224
|
+
* Le verdict est INJECTÉ, jamais déduit d'une plateforme ni lu ici : c'est ce
|
|
225
|
+
* qui rend cette fonction éprouvable des deux côtés sans conteneur, et ce qui
|
|
226
|
+
* évite de faire dire à `process.platform` une chose qu'il ne sait pas. Une
|
|
227
|
+
* capacité se constate ; c'est l'appelant qui constate, cette fonction décide.
|
|
228
|
+
*
|
|
229
|
+
* @param {{inContainer: boolean, base?: string, out?: string}} stage -
|
|
230
|
+
* le constat, et les valeurs explicites qui l'emportent toujours.
|
|
231
|
+
* @returns {{ base: string, out: string }} origine à joindre, dossier de sortie.
|
|
232
|
+
*/
|
|
233
|
+
export function environmentDefaults({ inContainer, base, out } = {}) {
|
|
234
|
+
return {
|
|
235
|
+
base:
|
|
236
|
+
base ||
|
|
237
|
+
(inContainer
|
|
238
|
+
? "https://host.docker.internal:5152"
|
|
239
|
+
: "https://127.0.0.1:5152"),
|
|
240
|
+
out: out || (inContainer ? "/output" : "tmp/browser"),
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Nom du fichier d'état d'authentification, DÉRIVÉ de l'identifiant.
|
|
246
|
+
*
|
|
247
|
+
* Un état sauvegardé est réutilisé pour éviter de rejouer le parcours de
|
|
248
|
+
* connexion à chaque sonde. Tant qu'il porte un nom unique, il est repris quel
|
|
249
|
+
* que soit l'utilisateur demandé : on réclame une mesure sous un compte de
|
|
250
|
+
* moindre privilège et l'on obtient celle de l'administrateur, sans un mot.
|
|
251
|
+
* Vécu ici — un canal refusé au compte demandé s'ouvrait sous l'identité de la
|
|
252
|
+
* sonde précédente. Une session appartient à quelqu'un ; son fichier le dit.
|
|
253
|
+
*
|
|
254
|
+
* Deux parties, deux rôles : un fragment LISIBLE, pour qu'un humain reconnaisse
|
|
255
|
+
* ses fichiers dans le dossier de sortie, et une EMPREINTE de l'identifiant
|
|
256
|
+
* complet, parce que deux identifiants distincts peuvent s'assainir en un même
|
|
257
|
+
* fragment (`a@b` et `a-b`) — et une collision de nom rouvrirait exactement le
|
|
258
|
+
* trou qu'on ferme.
|
|
259
|
+
*
|
|
260
|
+
* @param {string|undefined} login - l'identifiant de connexion demandé.
|
|
261
|
+
* @returns {string} le nom de fichier, sans dossier.
|
|
262
|
+
*/
|
|
263
|
+
export function authStateName(login) {
|
|
264
|
+
const raw = String(login ?? "");
|
|
265
|
+
const readable =
|
|
266
|
+
raw.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, 40) || "anonyme";
|
|
267
|
+
const fingerprint = createHash("sha256")
|
|
268
|
+
.update(raw)
|
|
269
|
+
.digest("hex")
|
|
270
|
+
.slice(0, 8);
|
|
271
|
+
return `.auth-state-${readable}-${fingerprint}.json`;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Analyse les largeurs d'écran de la famille `responsive` (`NF_BROWSER_WIDTHS`).
|
|
276
|
+
*
|
|
277
|
+
* Bornes 240–4000 : en deçà aucun navigateur réel, au-delà on ne mesure plus un
|
|
278
|
+
* écran mais un mur d'affichage — et un zéro ou un négatif ferait échouer le
|
|
279
|
+
* redimensionnement avec un message qui n'incrimine pas la vraie cause.
|
|
280
|
+
*
|
|
281
|
+
* @param {string|undefined} raw - largeurs en pixels, séparées par des virgules.
|
|
282
|
+
* @returns {{ widths: number[], invalidWidths: string[] }}
|
|
283
|
+
*/
|
|
284
|
+
export function parseWidths(raw) {
|
|
285
|
+
const widths = [];
|
|
286
|
+
const invalidWidths = [];
|
|
287
|
+
for (const chunk of String(raw ?? "")
|
|
288
|
+
.split(",")
|
|
289
|
+
.map((p) => p.trim())
|
|
290
|
+
.filter(Boolean)) {
|
|
291
|
+
const n = Number(chunk);
|
|
292
|
+
if (Number.isInteger(n) && n >= 240 && n <= 4000) {
|
|
293
|
+
if (!widths.includes(n)) widths.push(n);
|
|
294
|
+
} else {
|
|
295
|
+
invalidWidths.push(chunk);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return { widths, invalidWidths };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Agrège les verdicts des familles mesurées en un verdict de page.
|
|
303
|
+
*
|
|
304
|
+
* « OK » seulement si TOUT est OK : un verdict global qui moyenne cache
|
|
305
|
+
* précisément l'alerte qu'on cherchait.
|
|
306
|
+
*
|
|
307
|
+
* @param {string[]} verdicts - les verdicts des familles actives.
|
|
308
|
+
* @returns {"OK"|"ALERTE"} l'état le plus défavorable rencontré.
|
|
309
|
+
*/
|
|
310
|
+
export function verdictGlobal(verdicts) {
|
|
311
|
+
return verdicts.every((v) => v === "OK") ? "OK" : "ALERTE";
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/**
|
|
315
|
+
* Médiane d'une série de mesures — la statistique d'un RTT, jamais la moyenne.
|
|
316
|
+
*
|
|
317
|
+
* Une moyenne est déplacée par un seul aller-retour aberrant (GC, réveil de
|
|
318
|
+
* connexion) ; la médiane dit ce qu'un appel TYPIQUE coûte.
|
|
319
|
+
*
|
|
320
|
+
* @param {number[]} values - mesures en millisecondes.
|
|
321
|
+
* @returns {number|null} la médiane, ou null si la série est vide.
|
|
322
|
+
*/
|
|
323
|
+
export function median(values) {
|
|
324
|
+
if (!Array.isArray(values) || values.length === 0) return null;
|
|
325
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
326
|
+
const m = Math.floor(sorted.length / 2);
|
|
327
|
+
return sorted.length % 2 === 1 ? sorted[m] : (sorted[m - 1] + sorted[m]) / 2;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Résume un rapport axe-core en un bloc lisible — le tri éditorial, pas la mesure.
|
|
331
|
+
*
|
|
332
|
+
* Rendue à part et PURE pour être éprouvée sans navigateur : c'est la seule
|
|
333
|
+
* partie qu'on écrit soi-même, donc la seule qui puisse être fausse.
|
|
334
|
+
*
|
|
335
|
+
* @param {{violations: object[], passes?: object[], incomplete?: object[]}} report
|
|
336
|
+
* ce que rend `axe.run()`.
|
|
337
|
+
* @returns {object} verdict, comptes par gravité, et les manquements les plus
|
|
338
|
+
* graves avec un exemple de cible chacun.
|
|
339
|
+
*/
|
|
340
|
+
export function summarizeAxe(report) {
|
|
341
|
+
const violations = report.violations ?? [];
|
|
342
|
+
const bySeverity = { critical: 0, serious: 0, moderate: 0, minor: 0 };
|
|
343
|
+
for (const v of violations)
|
|
344
|
+
if (Object.hasOwn(bySeverity, v.impact ?? "")) bySeverity[v.impact] += 1;
|
|
345
|
+
const rank = { critical: 0, serious: 1, moderate: 2, minor: 3 };
|
|
346
|
+
const sortedBySeverity = [...violations].sort(
|
|
347
|
+
(a, b) => (rank[a.impact] ?? 9) - (rank[b.impact] ?? 9),
|
|
348
|
+
);
|
|
349
|
+
return {
|
|
350
|
+
// Un manquement AVÉRÉ vaut alerte ; `incomplete` ne suffit pas — ce sont
|
|
351
|
+
// les cas qu'axe refuse de trancher seul (fond en image, par exemple), pas
|
|
352
|
+
// des défauts. Les compter comme tels ferait crier la sonde à tort.
|
|
353
|
+
verdict: violations.length === 0 ? "OK" : "ALERTE",
|
|
354
|
+
engine: `axe-core ${report.testEngine?.version ?? "?"}`,
|
|
355
|
+
rulesRun:
|
|
356
|
+
violations.length +
|
|
357
|
+
(report.passes?.length ?? 0) +
|
|
358
|
+
(report.incomplete?.length ?? 0),
|
|
359
|
+
passed: report.passes?.length ?? 0,
|
|
360
|
+
failures: { total: violations.length, bySeverity },
|
|
361
|
+
// À trancher à la main — axe dit qu'il ne peut pas conclure, il ne dit pas
|
|
362
|
+
// que c'est bon.
|
|
363
|
+
toReview: (report.incomplete ?? []).slice(0, 5).map((v) => ({
|
|
364
|
+
rule: v.id,
|
|
365
|
+
description: v.help,
|
|
366
|
+
targets: v.nodes.length,
|
|
367
|
+
})),
|
|
368
|
+
worst: sortedBySeverity.slice(0, 8).map((v) => ({
|
|
369
|
+
rule: v.id,
|
|
370
|
+
severity: v.impact,
|
|
371
|
+
description: v.help,
|
|
372
|
+
criteria: (v.tags ?? []).filter((t) => /^wcag\d|^best-practice$/.test(t)),
|
|
373
|
+
targets: v.nodes.length,
|
|
374
|
+
// Jusqu'à CINQ cibles par règle, pas une seule : une même règle couvre
|
|
375
|
+
// des défauts DISTINCTS à des endroits distincts — huit contrastes ratés
|
|
376
|
+
// dans huit composants différents ne se corrigent pas d'un seul geste.
|
|
377
|
+
// N'en montrer qu'un ferait croire le travail fini après le premier.
|
|
378
|
+
examples: v.nodes.slice(0, 5).map((n) => ({
|
|
379
|
+
target: n.target?.join(" ") ?? "",
|
|
380
|
+
// Le « pourquoi » calculé par axe : contraste mesuré, rôle attendu…
|
|
381
|
+
reason: (
|
|
382
|
+
n.any?.[0]?.message ??
|
|
383
|
+
n.all?.[0]?.message ??
|
|
384
|
+
n.failureSummary ??
|
|
385
|
+
""
|
|
386
|
+
)
|
|
387
|
+
.replace(/\s+/g, " ")
|
|
388
|
+
.slice(0, 200),
|
|
389
|
+
snippet: (n.html ?? "").slice(0, 120),
|
|
390
|
+
})),
|
|
391
|
+
otherTargets: Math.max(0, v.nodes.length - 5),
|
|
392
|
+
documentation: v.helpUrl,
|
|
393
|
+
})),
|
|
394
|
+
};
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Résume un rapport Lighthouse — le tri éditorial, pas la mesure.
|
|
399
|
+
*
|
|
400
|
+
* Un rapport brut pèse près d'un mégaoctet : le rendre tel quel, c'est garantir
|
|
401
|
+
* que personne ne le lise. On en tire les scores par catégorie et les audits
|
|
402
|
+
* RATÉS, du plus lourd au plus léger, avec ce qui les explique.
|
|
403
|
+
*
|
|
404
|
+
* Deux pièges de lecture, encodés ici :
|
|
405
|
+
* • un audit dont le score est `null` n'a PAS échoué — il ne s'applique pas
|
|
406
|
+
* (rien à mesurer) ou n'est qu'informatif. Le compter en échec ferait crier
|
|
407
|
+
* le rapport sur des pages saines ;
|
|
408
|
+
* • un score de catégorie est une moyenne pondérée : il peut rester flatteur
|
|
409
|
+
* alors qu'un audit important est au plus bas. On rend donc les DEUX.
|
|
410
|
+
*
|
|
411
|
+
* @param {object} lhr - le rapport (`runnerResult.lhr`).
|
|
412
|
+
* @param {number} [threshold] - score en deçà duquel un audit est retenu (0–1).
|
|
413
|
+
* @returns {object} scores par catégorie, audits ratés, et le décor de mesure.
|
|
414
|
+
*/
|
|
415
|
+
export function summarizeLighthouse(lhr, threshold = 0.9) {
|
|
416
|
+
const audits = lhr?.audits ?? {};
|
|
417
|
+
const categories = Object.values(lhr?.categories ?? {});
|
|
418
|
+
const scores = {};
|
|
419
|
+
for (const c of categories) {
|
|
420
|
+
// `null` se distingue de 0 : « pas de score » n'est pas « score nul ».
|
|
421
|
+
scores[c.id] =
|
|
422
|
+
c.score === null || c.score === undefined
|
|
423
|
+
? null
|
|
424
|
+
: +(c.score * 100).toFixed(0);
|
|
425
|
+
}
|
|
426
|
+
const failed = [];
|
|
427
|
+
for (const c of categories) {
|
|
428
|
+
for (const ref of c.auditRefs ?? []) {
|
|
429
|
+
const a = audits[ref.id];
|
|
430
|
+
if (!a || a.score === null || a.score === undefined) continue;
|
|
431
|
+
if (a.score >= threshold) continue;
|
|
432
|
+
failed.push({
|
|
433
|
+
category: c.id,
|
|
434
|
+
audit: ref.id,
|
|
435
|
+
title: a.title,
|
|
436
|
+
score: +(a.score * 100).toFixed(0),
|
|
437
|
+
// Le poids dans la note : un audit à 0 qui pèse 0 ne coûte rien, et
|
|
438
|
+
// c'est ce qui explique un score élevé malgré des rouges.
|
|
439
|
+
weight: ref.weight ?? 0,
|
|
440
|
+
value: a.displayValue ?? null,
|
|
441
|
+
details: (a.description ?? "")
|
|
442
|
+
.replace(/\s*\[.*?\]\(.*?\)/gu, "")
|
|
443
|
+
.trim()
|
|
444
|
+
.slice(0, 180),
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
// Trié par poids décroissant puis score croissant : ce qui coûte le plus, en
|
|
449
|
+
// premier — un tri par score seul remonterait des broutilles sans influence.
|
|
450
|
+
failed.sort((a, b) => b.weight - a.weight || a.score - b.score);
|
|
451
|
+
return {
|
|
452
|
+
verdict: failed.length === 0 ? "OK" : "ALERTE",
|
|
453
|
+
engine: `lighthouse ${lhr?.lighthouseVersion ?? "?"}`,
|
|
454
|
+
url: lhr?.finalDisplayedUrl ?? lhr?.finalUrl ?? null,
|
|
455
|
+
// Le DÉCOR fait partie de la mesure : un score de performance n'a aucun
|
|
456
|
+
// sens sans savoir quel appareil et quel réseau ont été simulés.
|
|
457
|
+
stage: {
|
|
458
|
+
device: lhr?.configSettings?.formFactor ?? "?",
|
|
459
|
+
throttling: lhr?.configSettings?.throttlingMethod ?? "?",
|
|
460
|
+
},
|
|
461
|
+
scores,
|
|
462
|
+
// Les catégories SANS score sont dites : elles n'ont pas été évaluées, ce
|
|
463
|
+
// qui n'est pas la même chose qu'un score parfait.
|
|
464
|
+
unscored: Object.entries(scores)
|
|
465
|
+
.filter(([, v]) => v === null)
|
|
466
|
+
.map(([k]) => k),
|
|
467
|
+
failedAudits: { total: failed.length, examples: failed.slice(0, 12) },
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* L'ordre dans lequel essayer les navigateurs — et pourquoi celui-là.
|
|
473
|
+
*
|
|
474
|
+
* ⚠️ Ne pas confondre avec le CANAL d'un socket applicatif (`NF_BROWSER_CHANNEL`),
|
|
475
|
+
* qui désigne tout autre chose : le mot « canal » est celui du pilote pour
|
|
476
|
+
* nommer une variante de navigateur, et le réutiliser ici a déjà provoqué une
|
|
477
|
+
* collision — un nom de canal temps réel interprété comme un navigateur.
|
|
478
|
+
*
|
|
479
|
+
* Le but est de **ne rien télécharger quand ce n'est pas nécessaire**. Un poste
|
|
480
|
+
* de développement a presque toujours un navigateur ; sous Windows, Edge est
|
|
481
|
+
* même préinstallé. Exiger cent mégaoctets avant de pouvoir regarder un écran
|
|
482
|
+
* est une barrière que rien ne justifie.
|
|
483
|
+
*
|
|
484
|
+
* L'ordre place quand même `chromium` en tête : c'est celui que le pilote
|
|
485
|
+
* installe et dont il connaît la version, donc le plus reproductible. Les
|
|
486
|
+
* navigateurs du système sont un repli — parfaitement bon pour REGARDER, moins
|
|
487
|
+
* pour COMPARER une mesure dans le temps, puisque leur version bouge sans
|
|
488
|
+
* prévenir. C'est la même distinction que local / conteneur.
|
|
489
|
+
*
|
|
490
|
+
* Un navigateur demandé EXPLICITEMENT n'est jamais complété par un repli : se
|
|
491
|
+
* rabattre en silence sur un autre navigateur que celui exigé rendrait une
|
|
492
|
+
* mesure attribuée au mauvais moteur.
|
|
493
|
+
*
|
|
494
|
+
* @param {string|undefined} explicit - navigateur imposé (`NF_BROWSER_ENGINE`).
|
|
495
|
+
* @returns {string[]} les navigateurs à essayer, dans l'ordre.
|
|
496
|
+
*/
|
|
497
|
+
export function browserOrder(explicit) {
|
|
498
|
+
const v = String(explicit ?? "").trim();
|
|
499
|
+
if (v) return [v];
|
|
500
|
+
return ["chromium", "chrome", "msedge"];
|
|
501
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calculs WCAG 2.x — luminance, contraste, seuils — en fonctions PURES.
|
|
3
|
+
*
|
|
4
|
+
* Une seule source pour deux mondes : ces fonctions sont importées par les
|
|
5
|
+
* tests (Node) et INJECTÉES par leur code source dans la page mesurée (la
|
|
6
|
+
* sonde compose `String(fn)` dans l'expression qu'elle fait évaluer au
|
|
7
|
+
* navigateur). C'est pourquoi chacune est AUTOSUFFISANTE — pas d'import, pas
|
|
8
|
+
* d'état de module, pas de fermeture : une référence au module ne survivrait
|
|
9
|
+
* pas au voyage vers la page, et l'erreur n'apparaîtrait qu'à l'exécution,
|
|
10
|
+
* dans le navigateur, où personne ne la lit.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Décompose une couleur CSS en canaux 0–255 et son alpha.
|
|
15
|
+
*
|
|
16
|
+
* Deux notations coexistent dans ce que rend `getComputedStyle`, et elles
|
|
17
|
+
* n'ont PAS la même échelle : l'héritée `rgb(0, 87, 156)` compte en 0–255,
|
|
18
|
+
* la moderne `color(srgb 0 0.34 0.61 / 0.13)` en 0–1. Les lire avec la même
|
|
19
|
+
* expression régulière donne une couleur presque noire là où il y a du bleu —
|
|
20
|
+
* un contraste faux, et des échecs inventés qui noient les vrais.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} color - couleur telle que rendue par `getComputedStyle`.
|
|
23
|
+
* @returns {{ r: number, g: number, b: number, a: number }} canaux 0–255 et
|
|
24
|
+
* alpha 0–1 ; noir opaque si la notation est illisible.
|
|
25
|
+
*/
|
|
26
|
+
export function parseColor(color) {
|
|
27
|
+
const s = String(color).trim();
|
|
28
|
+
const numbers = s.match(/-?\d*\.?\d+(?:e-?\d+)?%?/gi);
|
|
29
|
+
if (!numbers || numbers.length < 3) return { r: 0, g: 0, b: 0, a: 1 };
|
|
30
|
+
// `color(srgb …)` et `color(display-p3 …)` : canaux en 0–1. Le pourcentage
|
|
31
|
+
// est explicite dans les deux familles et se ramène toujours à 0–1.
|
|
32
|
+
const modern = /^color\(/i.test(s);
|
|
33
|
+
const channel = (v) => {
|
|
34
|
+
if (v.endsWith("%")) return (parseFloat(v) / 100) * 255;
|
|
35
|
+
const n = parseFloat(v);
|
|
36
|
+
return modern ? n * 255 : n;
|
|
37
|
+
};
|
|
38
|
+
const [r, g, b] = numbers.slice(0, 3).map(channel);
|
|
39
|
+
const rawAlpha = numbers[3];
|
|
40
|
+
const a =
|
|
41
|
+
rawAlpha === undefined
|
|
42
|
+
? 1
|
|
43
|
+
: rawAlpha.endsWith("%")
|
|
44
|
+
? parseFloat(rawAlpha) / 100
|
|
45
|
+
: parseFloat(rawAlpha);
|
|
46
|
+
const clamp = (n) => Math.min(255, Math.max(0, n));
|
|
47
|
+
return {
|
|
48
|
+
r: clamp(r),
|
|
49
|
+
g: clamp(g),
|
|
50
|
+
b: clamp(b),
|
|
51
|
+
a: Number.isFinite(a) ? Math.min(1, Math.max(0, a)) : 1,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compose une couleur semi-transparente sur celle qui se trouve dessous.
|
|
57
|
+
*
|
|
58
|
+
* Sans cette étape, le contraste d'un texte posé sur un voile à 13 % est
|
|
59
|
+
* calculé contre le voile SEUL — c'est-à-dire contre une couleur que
|
|
60
|
+
* personne ne voit. C'est ainsi qu'un aplat pâle passe pour très sombre.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} over - la couche du dessus (éventuellement transparente).
|
|
63
|
+
* @param {string} under - ce qu'il y a derrière (supposé opaque).
|
|
64
|
+
* @returns {string} une couleur `rgb()` opaque, telle qu'elle est PERÇUE.
|
|
65
|
+
*/
|
|
66
|
+
export function compose(over, under) {
|
|
67
|
+
const h = parseColor(over);
|
|
68
|
+
if (h.a >= 1)
|
|
69
|
+
return `rgb(${Math.round(h.r)}, ${Math.round(h.g)}, ${Math.round(h.b)})`;
|
|
70
|
+
const b = parseColor(under);
|
|
71
|
+
const blend = (x, y) => Math.round(x * h.a + y * (1 - h.a));
|
|
72
|
+
return `rgb(${blend(h.r, b.r)}, ${blend(h.g, b.g)}, ${blend(h.b, b.b)})`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Luminance relative d'une couleur CSS — définition WCAG 2.x.
|
|
77
|
+
*
|
|
78
|
+
* L'alpha est ignoré ici À DESSEIN : une couleur translucide doit avoir été
|
|
79
|
+
* composée sur son fond AVANT (`composer`), sans quoi la luminance décrit une
|
|
80
|
+
* couleur que l'œil ne rencontre jamais.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} color - couleur telle que rendue par `getComputedStyle`.
|
|
83
|
+
* @returns {number} luminance entre 0 (noir) et 1 (blanc).
|
|
84
|
+
*/
|
|
85
|
+
export function srgbLuminance(color) {
|
|
86
|
+
const { r, g, b } = parseColor(color);
|
|
87
|
+
const [lr, lg, lb] = [r, g, b].map((v) => {
|
|
88
|
+
const s = v / 255;
|
|
89
|
+
return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4;
|
|
90
|
+
});
|
|
91
|
+
return 0.2126 * lr + 0.7152 * lg + 0.0722 * lb;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Rapport de contraste WCAG entre deux couleurs CSS, arrondi à 2 décimales.
|
|
96
|
+
*
|
|
97
|
+
* @param {string} a - une couleur (l'ordre est indifférent).
|
|
98
|
+
* @param {string} b - l'autre couleur.
|
|
99
|
+
* @returns {number} rapport entre 1 (identiques) et 21 (noir sur blanc).
|
|
100
|
+
*/
|
|
101
|
+
export function contrastRatio(a, b) {
|
|
102
|
+
const [x, y] = [srgbLuminance(a), srgbLuminance(b)].sort((p, q) => q - p);
|
|
103
|
+
return +((x + 0.05) / (y + 0.05)).toFixed(2);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Un texte est-il « large » au sens WCAG — c'est la POLICE qui décide du seuil.
|
|
108
|
+
*
|
|
109
|
+
* WCAG appelle large un texte d'au moins 24 px, ou 18,66 px en gras. Rendre un
|
|
110
|
+
* contraste sans trancher cette question laisse le lecteur choisir son seuil au
|
|
111
|
+
* hasard entre 3:1 et 4,5:1 — c'est-à-dire ne rien conclure.
|
|
112
|
+
*
|
|
113
|
+
* @param {number} px - taille de police calculée, en pixels.
|
|
114
|
+
* @param {boolean} bold - graisse calculée ≥ 700.
|
|
115
|
+
* @returns {boolean} vrai si les seuils « texte large » s'appliquent.
|
|
116
|
+
*/
|
|
117
|
+
export function isLargeText(px, bold) {
|
|
118
|
+
return px >= 24 || (bold === true && px >= 18.66);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Verdict WCAG d'un contraste, compte tenu de la police qui l'affiche.
|
|
123
|
+
*
|
|
124
|
+
* @param {number} ratio - rapport de contraste mesuré.
|
|
125
|
+
* @param {number} px - taille de police calculée, en pixels.
|
|
126
|
+
* @param {boolean} bold - graisse calculée ≥ 700.
|
|
127
|
+
* @returns {"AAA"|"AA"|"ÉCHEC"} le niveau atteint.
|
|
128
|
+
*/
|
|
129
|
+
export function verdictWcag(ratio, px, bold) {
|
|
130
|
+
const isLarge = isLargeText(px, bold);
|
|
131
|
+
if (ratio >= (isLarge ? 4.5 : 7)) return "AAA";
|
|
132
|
+
if (ratio >= (isLarge ? 3 : 4.5)) return "AA";
|
|
133
|
+
return "ÉCHEC";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Le code source des quatre fonctions, prêt à être injecté dans une page.
|
|
138
|
+
*
|
|
139
|
+
* @returns {string} déclarations de fonctions concaténées, évaluables telles
|
|
140
|
+
* quelles dans la portée où la sonde compose son expression.
|
|
141
|
+
*/
|
|
142
|
+
export function sourceWcag() {
|
|
143
|
+
return [
|
|
144
|
+
parseColor,
|
|
145
|
+
compose,
|
|
146
|
+
srgbLuminance,
|
|
147
|
+
contrastRatio,
|
|
148
|
+
isLargeText,
|
|
149
|
+
verdictWcag,
|
|
150
|
+
]
|
|
151
|
+
.map(String)
|
|
152
|
+
.join("\n");
|
|
153
|
+
}
|