@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.
Files changed (67) hide show
  1. package/LICENSE +544 -0
  2. package/README.md +318 -0
  3. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorate.js +9 -0
  4. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateMetadata.js +6 -0
  5. package/dist/_virtual/_@oxc-project_runtime@0.142.0/helpers/esm/decorateParam.js +8 -0
  6. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorate.js +9 -0
  7. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateMetadata.js +6 -0
  8. package/dist/_virtual/_@oxc-project_runtime@0.143.0/helpers/esm/decorateParam.js +8 -0
  9. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorate.js +9 -0
  10. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateMetadata.js +6 -0
  11. package/dist/_virtual/_@oxc-project_runtime@0.146.0/helpers/esm/decorateParam.js +8 -0
  12. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorate.js +9 -0
  13. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateMetadata.js +6 -0
  14. package/dist/_virtual/_@oxc-project_runtime@0.147.0/helpers/esm/decorateParam.js +8 -0
  15. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorate.js +9 -0
  16. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateMetadata.js +6 -0
  17. package/dist/_virtual/_@oxc-project_runtime@0.148.0/helpers/esm/decorateParam.js +8 -0
  18. package/dist/index.js +47 -0
  19. package/dist/nodefony/command/CardCommand.js +70 -0
  20. package/dist/nodefony/config/config.js +200 -0
  21. package/dist/nodefony/config/defineModuleConfig.js +36 -0
  22. package/dist/nodefony/controllers/DevkitController.js +60 -0
  23. package/dist/nodefony/controllers/McpController.js +223 -0
  24. package/dist/nodefony/controllers/OAuthMetadataController.js +89 -0
  25. package/dist/nodefony/interfaces/IDevkitService.js +1 -0
  26. package/dist/nodefony/interfaces/index.js +1 -0
  27. package/dist/nodefony/service/DevkitService.js +198 -0
  28. package/dist/nodefony/src/card.js +2 -0
  29. package/dist/nodefony/src/errors/DevkitError.js +21 -0
  30. package/dist/nodefony/src/mcp/guard.js +51 -0
  31. package/dist/nodefony/src/mcp/protocol.js +127 -0
  32. package/dist/nodefony/src/mcp/server.js +133 -0
  33. package/dist/nodefony/src/mcp/tools.js +163 -0
  34. package/dist/types/index.d.ts +52 -0
  35. package/dist/types/nodefony/command/CardCommand.d.ts +33 -0
  36. package/dist/types/nodefony/config/config.d.ts +25 -0
  37. package/dist/types/nodefony/config/defineModuleConfig.d.ts +27 -0
  38. package/dist/types/nodefony/controllers/DevkitController.d.ts +37 -0
  39. package/dist/types/nodefony/controllers/McpController.d.ts +70 -0
  40. package/dist/types/nodefony/controllers/OAuthMetadataController.d.ts +39 -0
  41. package/dist/types/nodefony/interfaces/IDevkitService.d.ts +69 -0
  42. package/dist/types/nodefony/interfaces/index.d.ts +1 -0
  43. package/dist/types/nodefony/service/DevkitService.d.ts +143 -0
  44. package/dist/types/nodefony/src/card.d.ts +18 -0
  45. package/dist/types/nodefony/src/errors/DevkitError.d.ts +14 -0
  46. package/dist/types/nodefony/src/mcp/guard.d.ts +66 -0
  47. package/dist/types/nodefony/src/mcp/protocol.d.ts +139 -0
  48. package/dist/types/nodefony/src/mcp/server.d.ts +48 -0
  49. package/dist/types/nodefony/src/mcp/tools.d.ts +81 -0
  50. package/docs/index.md +358 -0
  51. package/package.json +77 -0
  52. package/skills/nodefony-add-crud/SKILL.md +199 -0
  53. package/skills/nodefony-add-realtime-channel/SKILL.md +95 -0
  54. package/skills/nodefony-add-service/SKILL.md +90 -0
  55. package/skills/nodefony-browser/SKILL.md +416 -0
  56. package/skills/nodefony-browser/references/socket.md +115 -0
  57. package/skills/nodefony-browser/references/sondes.md +175 -0
  58. package/skills/nodefony-browser/scripts/audit.mjs +169 -0
  59. package/skills/nodefony-browser/scripts/inspect.mjs +903 -0
  60. package/skills/nodefony-browser/scripts/lib/browser.mjs +357 -0
  61. package/skills/nodefony-browser/scripts/lib/probes.mjs +501 -0
  62. package/skills/nodefony-browser/scripts/lib/wcag.mjs +153 -0
  63. package/skills/nodefony-browser/scripts/socket.mjs +354 -0
  64. package/skills/nodefony-browser/scripts/watch.mjs +125 -0
  65. package/skills/nodefony-migrate-schema/SKILL.md +359 -0
  66. package/skills/nodefony-migrate-schema/references/verdicts.md +139 -0
  67. 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
+ }