Repository navigation
Expand file tree
/
Copy pathverifier.mjs
More file actions
460 lines (419 loc) · 20.4 KB
/
Copy pathverifier.mjs
File metadata and controls
460 lines (419 loc) · 20.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
import { readFileSync, existsSync, readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { dirname, join, resolve } from 'node:path'
/**
* Vérifie que le site de ResonLab reste servable et que ses deux langues
* restent alignées.
*
* Le piège que cette suite empêche : traduire une page, puis modifier la
* française seule. Les deux divergent en silence, et la moitié des lecteurs
* voit une version périmée sans que rien ne le signale.
*
* On ne peut pas comparer une traduction mot à mot — on compare la structure :
* autant de sections, autant de cartes, autant de titres. Une section ajoutée
* d'un seul côté se voit immédiatement.
*
* Elle contrôle aussi ce qui casse un site statique sans prévenir : un lien
* mort, une image absente, une ressource chargée depuis un autre serveur.
*/
const RACINE = resolve(dirname(fileURLToPath(import.meta.url)))
const DOCS = RACINE
/**
* Lit une page en normalisant les fins de ligne.
*
* Sans cela, la suite passe en local et échoue sur un runner Windows, qui
* extrait les fichiers en CRLF : elle n'annonce pas une erreur mais un
* comptage faux. Leçon déjà payée ailleurs dans la maison.
*/
const lireNormalise = (relatif) =>
readFileSync(join(RACINE, relatif), 'utf-8').replaceAll('\r\n', '\n')
let echecs = 0
const echec = (message) => {
console.log(` ÉCHEC : ${message}`)
echecs += 1
}
/* ── 0. Aucun caractère de contrôle dans ce fichier ──────────────────────── */
/**
* **Un caractère invisible a déjà tué deux contrôles de ce fichier.**
*
* Les motifs `sections` et `boutons` s'écrivaient `/<section\b/g` — sauf que le
* fichier contenait un vrai octet 0x08, le retour arrière, à la place de la
* séquence `\b`. Le motif cherchait donc « <section suivi d'un retour arrière »,
* ce qu'aucune page ne contiendra jamais. Les deux comptages rendaient zéro des
* deux côtés, donc toujours égaux : **la vérification affichait OK sans
* regarder quoi que ce soit**, et sur toutes les pages, depuis toujours.
*
* Trouvé en ajoutant une section d'un seul côté et en constatant que rien
* n'échouait. Aucune relecture ne pouvait le voir : le caractère ne s'affiche
* pas.
*
* Ce contrôle-ci passe donc avant les autres, et il regarde ce fichier
* lui-même. C'est la troisième fois qu'un octet de contrôle finit dans une
* source de la maison.
*/
{
const source = readFileSync(join(RACINE, 'verifier.mjs'), 'utf-8')
// Tabulation, saut de ligne et retour chariot sont légitimes ; le reste non.
const invisibles = [...source].filter((c) => {
const code = c.charCodeAt(0)
return (code < 32 && code !== 9 && code !== 10 && code !== 13) || code === 127
})
if (invisibles.length > 0) {
const codes = [...new Set(invisibles.map((c) => '0x' + c.charCodeAt(0).toString(16)))]
echec(
`verifier.mjs contient ${invisibles.length} caractère(s) de contrôle (${codes.join(', ')}) — ` +
'un motif écrit avec un octet invisible ne correspond jamais, et le contrôle dit OK sans rien regarder'
)
}
}
/* ── 1. Chaque page a sa jumelle, et elles se ressemblent ────────────────── */
/**
* Les paires de pages, française et anglaise.
*
* Ce contrôle ne portait que sur l'accueil, et c'était un piège : les pages
* ajoutées ensuite — la feuille de route, les conditions — pouvaient diverger
* sans que rien ne le signale, alors que la suite continuait d'afficher
* « deux langues alignées ». Une vérification qui ne regarde qu'une partie de
* ce qu'elle prétend couvrir est pire qu'une absence de vérification.
*/
const PAIRES = [
['index.html', 'en/index.html'],
['suite.html', 'en/roadmap.html'],
['conditions.html', 'en/terms.html'],
// La page découverte est produite par `traduire-decouvrir.mjs`. Elle est donc
// alignée par construction — jusqu'au jour où quelqu'un éditera la page
// anglaise à la main plutôt que de relancer le script, et où la prochaine
// génération écrasera son travail sans prévenir.
['decouvrir.html', 'en/discover.html'],
// Produite par `traduire-telecharger.mjs`, même remarque que ci-dessus.
['telecharger.html', 'en/download.html']
]
for (const [cheminFr, cheminEn] of PAIRES) {
if (!existsSync(join(DOCS, cheminEn))) {
echec(`${cheminEn} est absent`)
continue
}
const fr = lireNormalise(cheminFr)
const en = lireNormalise(cheminEn)
const compter = (html, motif) => (html.match(motif) ?? []).length
const structures = [
['sections', /<section\b/g],
['cartes', /class="card reveal/g],
['applications', /class="app reveal/g],
['titres de section', /<h2 class="titre/g],
['titres de carte', /<h3>/g],
['boutons', /class="btn\b/g],
['paragraphes d’introduction', /class="intro reveal/g]
]
for (const [nom, motif] of structures) {
const a = compter(fr, motif)
const b = compter(en, motif)
if (a !== b) echec(`${cheminFr} → ${cheminEn} · ${nom} : ${a} en français, ${b} en anglais`)
}
// Le CSS et le JavaScript doivent rester identiques : la version anglaise
// est fabriquée par substitution de texte, elle n'a aucune raison d'avoir sa
// propre mise en forme. S'ils diffèrent, c'est qu'on a édité la page traduite
// à la main — et la prochaine génération l'écrasera.
const style = (html) => html.slice(html.indexOf('<style>'), html.indexOf('</style>'))
if (style(fr) !== style(en)) {
echec(`${cheminEn} : le CSS diffère du français — page éditée à la main`)
}
if (!/<html lang="en">/.test(en)) echec(`${cheminEn} ne déclare pas lang="en"`)
// Un mot français resté dans la page anglaise est le symptôme le plus
// fréquent d'une substitution oubliée.
const corps = en.slice(en.indexOf('<body'))
const sansCommentaires = corps.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^\s*\/\/.*$/gm, '')
for (const mot of [' Nos principes', 'Logiciels libres', 'Découvrir<', 'Valais, Suisse', 'La suite<', 'PUBLIÉ<']) {
if (sansCommentaires.includes(mot)) {
echec(`${cheminEn} : texte français resté — « ${mot.trim()} »`)
}
}
}
/* ── 1 ter. Aucune substitution oubliée sur la page découverte ───────────── */
/**
* **Le contrôle se déduit de la table de traduction, il ne l'énumère pas.**
*
* Le relevé du français résiduel ci-dessus repose sur une liste de mots écrite
* à la main. Elle a rendu service, mais c'est une liste figée : elle ne connaît
* que ce qu'on a pensé à y mettre le jour où on l'a écrite. Tout texte ajouté
* ensuite passe dessous — et c'est exactement ce qui est arrivé aux ancres des
* guides et au numéro de version d'un badge. **Avant d'écrire une valeur à la
* main, se demander qui la met à jour quand elle change.** Ici, personne.
*
* `traduire-decouvrir.mjs` porte déjà la réponse : chaque couple qu'il contient
* dit « ce texte français devient ce texte anglais ». Si le français d'un couple
* se retrouve encore dans la page anglaise, c'est qu'une substitution n'a pas
* mordu — et le script, lui, ne peut pas le voir, puisqu'il s'arrête seulement
* quand une chaîne est **introuvable**, jamais quand elle survit à côté.
*
* La table grandit avec la page : le contrôle grandit avec elle, sans que
* personne ait à y penser.
*/
/**
* Les pages fabriquées par substitution, et leur table.
*
* **Ce contrôle ne visait qu'une page**, et c'était le piège qu'il dénonce
* lui-même une ligne plus haut : la page suivante fabriquée de la même façon
* serait passée dessous, sous un « site cohérent » qui ne l'aurait jamais
* regardée. Ajouter une page ici est désormais la seule chose à faire.
*/
const TRADUITES = [
['traduire-decouvrir.mjs', 'en/discover.html', 20],
['traduire-telecharger.mjs', 'en/download.html', 12]
]
for (const [nomScript, pageAnglaise, minimumExamines] of TRADUITES) {
const script = readFileSync(join(RACINE, nomScript), 'utf-8')
const en = readFileSync(join(RACINE, pageAnglaise), 'utf-8').replaceAll('\r\n', '\n')
// Le premier membre de chaque couple `[français, anglais]`, quel que soit le
// guillemet employé. On ne garde que les couples porteurs de texte affiché :
// les substitutions de chemins et d'attributs n'ont rien à faire ici.
const couples = [
...script.matchAll(/\[\s*(['"`])((?:[^\\]|\\.)*?)\1\s*,\s*(['"`])((?:[^\\]|\\.)*?)\3\s*\]/gs)
]
/**
* **Les échappements du source ne sont pas dans la page.**
*
* Un couple écrit entre guillemets doubles porte ses guillemets intérieurs
* échappés — `class=\"apercu-note\"` — alors que la page contient un
* guillemet nu. Comparés tels quels, ces couples-là ne pouvaient **jamais**
* mordre : le contrôle les examinait, les comptait dans son total, et ne
* pouvait rien y trouver.
*
* **Trouvé en le sabotant, pas en le relisant** : un texte français laissé
* dans une note d'aperçu passait au vert, alors que le même sabotage sur une
* entrée à guillemets simples échouait correctement. Un contrôle qui mord sur
* une partie de ce qu'il examine donne exactement la même confiance qu'un
* contrôle complet — et c'est là qu'il est dangereux.
*/
const denuder = (texte) =>
texte.replaceAll('\\"', '"').replaceAll("\\'", "'").replaceAll('\\\\', '\\')
let examines = 0
for (const couple of couples) {
const francais = denuder(couple[2])
const anglais = denuder(couple[4])
// Une substitution qui ne change pas le texte — un chemin, un attribut —
// ne prouve rien : le français y « survit » légitimement.
if (francais === anglais) continue
/**
* On écarte ce qui ne porte pas de texte affiché — un chemin, un attribut.
*
* **Le premier filtre était trop fin et personne ne l'aurait vu.** Il
* exigeait un mot de quatre lettres suivi d'un espace, ce que
* « Pas d'adressage » n'a pas : le couple était écarté sans bruit, et le
* contrôle affichait OK sur un titre resté en français. Un filtre trop
* large produit des faux échecs qu'on remarque ; un filtre trop étroit
* produit des trous qu'on ne remarque jamais.
*
* On se contente donc de deux exclusions franches : une substitution de
* chemin, et une chaîne sans la moindre lettre.
*/
if (/^(href|src|class|id)=/.test(francais)) continue
if (!/[A-Za-zÀ-ÿ]{3,}/.test(francais.replace(/<[^>]*>/g, ''))) continue
// Un fragment dont l'anglais contient le français en entier ne peut pas
// servir de témoin : il serait présent des deux côtés par construction.
if (anglais.includes(francais)) continue
examines += 1
if (en.includes(francais)) {
echec(
`${pageAnglaise} : substitution oubliée — le texte français subsiste : ` +
`« ${francais.trim().slice(0, 70)}… »`
)
}
}
// Un contrôle qui n'examine rien dirait OK. Si le motif de lecture de la
// table cesse un jour de correspondre, on veut le savoir, pas le supposer.
if (examines < minimumExamines) {
echec(
`seulement ${examines} substitution(s) examinée(s) dans ${nomScript} — ` +
'le motif ne correspond plus, ce contrôle ne regarde plus rien'
)
}
}
/* ── 1 bis. Aucun numéro de version écrit à la main ──────────────────────── */
// **Cette page annonçait « PUBLIÉ — 0.1.0 » le jour où la 0.2.0 est sortie**,
// dans les deux langues, et rien ne pouvait le voir : un numéro de version
// recopié à la main dans une page vit loin de ce qui le fait changer, et il ne
// change donc jamais. C'est de la documentation qui ment, et la maison préfère
// une vérification qui échoue.
//
// La réponse n'est pas de mieux se souvenir : c'est de ne plus l'écrire. Le
// badge dit qu'une application est publiée, les boutons visent
// `releases/latest`, et **la page des releases est seule à porter le numéro** —
// elle, elle est produite par le workflow qui construit la version.
//
// Ce contrôle interdit de le réintroduire. Sans lui, la prochaine main qui
// trouve le badge un peu sec y remettra un numéro, et il pourrira pareil.
for (const chemin of ['index.html', 'en/index.html']) {
const html = lireNormalise(chemin)
for (const [, badge] of html.matchAll(/<span class="etat">([^<]*)<\/span>/g)) {
if (/\d+\.\d+\.\d+/.test(badge)) {
echec(
`${chemin} : le badge « ${badge.trim()} » porte un numéro de version écrit ` +
`à la main — il périmera sans que rien ne le signale. La page des releases le porte déjà.`
)
}
}
}
/* ── 1 quater. La date « mis à jour » ne ment pas ─────────────────────────── */
/**
* **La feuille de route porte une date, et elle doit valoir quelque chose.**
*
* Défaut réel, trouvé à la main le 15 août 2026 : la page annonçait « Ohmnia est
* en cours de traduction, écran par écran » et « la consultation mobile de
* Scenika sera une page servie par Nexika ». Les deux étaient livrés. La page
* affichait pourtant « MIS À JOUR LE 10 AOÛT » — le marqueur existait, personne
* ne le tenait, et **rien ne le regardait**.
*
* Aucune vérification ne pouvait voir la fausseté : ces phrases décrivent
* l'état d'*autres dépôts*, et celui-ci est publié seul. Le contrôle des deux
* langues, lui, les trouvait parfaitement alignées — elles mentaient des deux
* côtés.
*
* **Ce qui est vérifiable, c'est la cohérence entre la date et le fichier.** Si
* quelqu'un modifie la feuille de route sans toucher à sa date, la page se
* prétend plus ancienne qu'elle n'est, et c'est le signe qu'une affirmation a
* bougé sans être datée. Git le sait : on compare la date affichée à celle du
* dernier commit qui a touché le fichier.
*
* Ce n'est pas une vérification de vérité — elle ne saura jamais si « en cours »
* est encore exact. C'est une vérification d'**honnêteté du marqueur** : tant
* que la date suit les modifications, un lecteur peut juger de l'âge de ce
* qu'il lit. Une date périmée se dénonce elle-même ; une affirmation périmée,
* non.
*/
{
const MOIS = {
janvier: 1, février: 2, mars: 3, avril: 4, mai: 5, juin: 6, juillet: 7,
août: 8, septembre: 9, octobre: 10, novembre: 11, décembre: 12,
january: 1, february: 2, march: 3, april: 4, may: 5, june: 6, july: 7,
august: 8, september: 9, october: 10, november: 11, december: 12
}
const dateAffichee = (html) => {
const ligne = html.match(/<p class="etat-ligne">([^<]+)<\/p>/)
if (!ligne) return null
const m = ligne[1].match(/(\d{1,2})\s+([A-Za-zÀ-ÿ]+)\s+(\d{4})/)
if (!m) return null
const mois = MOIS[m[2].toLowerCase()]
if (!mois) return null
return `${m[3]}-${String(mois).padStart(2, '0')}-${String(Number(m[1])).padStart(2, '0')}`
}
const PAGES_DATEES = ['suite.html', 'en/roadmap.html']
const dates = PAGES_DATEES.map((f) => ({ fichier: f, date: dateAffichee(lireNormalise(f)) }))
for (const { fichier, date } of dates) {
if (!date) echec(`${fichier} : aucune date « mis à jour » lisible — le contrôle ne regarde rien`)
}
// Les deux langues datent la même feuille de route : elles ne peuvent pas
// avoir été revues à des jours différents.
if (dates.every((d) => d.date) && dates[0].date !== dates[1].date) {
echec(
`les deux feuilles de route n'affichent pas la même date — ` +
dates.map((d) => `${d.fichier} : ${d.date}`).join(' · ')
)
}
// La date doit couvrir la dernière modification du fichier. Sans git — une
// archive, un export — on le dit au lieu de faire semblant de vérifier.
let git = null
try {
git = (await import('node:child_process')).execFileSync
} catch {
git = null
}
for (const { fichier, date } of dates) {
if (!date || !git) continue
let dernierCommit = null
try {
dernierCommit = git('git', ['log', '-1', '--format=%cs', '--', fichier], {
cwd: RACINE,
encoding: 'utf-8'
}).trim()
} catch {
dernierCommit = null
}
if (!dernierCommit) {
console.log(` (pas d'historique git pour ${fichier} : fraîcheur non vérifiée)`)
continue
}
if (date < dernierCommit) {
echec(
`${fichier} annonce « mis à jour le ${date} » alors que son dernier commit date du ` +
`${dernierCommit} — la page a changé sans que sa date suive`
)
}
}
}
/* ── 1 bis. La famille est au complet partout où on l'énumère ────────────── */
/**
* **Une page qui présente la maison doit les présenter toutes.**
*
* Défaut réel, signalé par l'utilisateur : Lumika manquait de « La suite » et
* de la page des conditions, dans les deux langues. Rien ne pouvait le voir —
* cette suite compare le français et l'anglais, et une application absente
* **des deux côtés** les laisse parfaitement alignés. C'est la troisième forme
* de panne : une vérification qui échoue très bien, mais qui ne regarde pas ce
* qui compte. Le LISEZ-MOI annonçait « les conditions des cinq » ; la page en
* listait quatre.
*
* **La liste des marques est relevée dans `logos/`, jamais écrite ici.** Une
* liste figée serait exactement le défaut qu'on corrige : la sixième
* application manquerait au contrôle censé la réclamer. C'est la leçon déjà
* payée sur la liste d'ancres et sur les marques de `fabriquer-icones.mjs`.
*
* **La règle s'ajuste au lieu d'imposer un tableau de pages.** Une page qui
* nomme au moins trois des marques est une page qui énumère la famille : elle
* doit alors les nommer toutes. Une page qui n'en cite qu'une ou deux parle
* d'autre chose et n'est pas concernée — et une page nouvelle est couverte
* sans qu'on ait à penser à l'inscrire quelque part.
*/
const MARQUES = readdirSync(join(RACINE, 'logos'))
.filter((f) => f.endsWith('.svg'))
.map((f) => f.replace(/\.svg$/, ''))
.filter((nom) => nom !== 'resonlab')
if (MARQUES.length < 2) {
echec(`logos/ ne donne que ${MARQUES.length} marque(s) : le contrôle de la famille ne regarde rien`)
}
/** Au-delà de ce nombre de marques citées, la page énumère la famille. */
const SEUIL_ENUMERATION = 3
/* ── 2. Aucun lien mort, aucune ressource externe ────────────────────────── */
const pages = [
...readdirSync(DOCS).filter((f) => f.endsWith('.html')).map((f) => ['.', f]),
...(existsSync(join(DOCS, 'en'))
? readdirSync(join(DOCS, 'en')).filter((f) => f.endsWith('.html')).map((f) => ['en', f])
: [])
]
for (const [dossier, fichier] of pages) {
const html = readFileSync(join(DOCS, dossier, fichier), 'utf-8')
const etiquette = `${dossier === '.' ? '' : dossier + '/'}${fichier}`
for (const [, cible] of html.matchAll(/href="([^"#:]+\.html)"/g)) {
if (!existsSync(resolve(DOCS, dossier, cible))) echec(`lien mort dans ${etiquette} → ${cible}`)
}
for (const [, cible] of html.matchAll(/src="([^"]+\.(?:png|jpg|svg))"/g)) {
if (!existsSync(resolve(DOCS, dossier, cible))) echec(`image absente dans ${etiquette} → ${cible}`)
}
for (const [, ancre] of html.matchAll(/href="#([^"]+)"/g)) {
if (!html.includes(`id="${ancre}"`)) echec(`ancre morte dans ${etiquette} → #${ancre}`)
}
// La famille au complet, dès que la page l'énumère.
const citees = MARQUES.filter((marque) => new RegExp(marque, 'i').test(html))
if (citees.length >= SEUIL_ENUMERATION && citees.length < MARQUES.length) {
const absentes = MARQUES.filter((m) => !citees.includes(m))
echec(
`${etiquette} présente la maison mais oublie ${absentes.join(', ')} — ` +
`${citees.length} marque(s) sur ${MARQUES.length}`
)
}
// GitHub Pages doit pouvoir servir ces fichiers seuls : rien ne doit être
// chargé depuis un autre serveur, ni police, ni script, ni feuille de style.
if (/<script[^>]+src=/.test(html)) echec(`${etiquette} charge un script externe`)
if (/<link[^>]*rel="stylesheet"/.test(html)) echec(`${etiquette} charge une feuille de style externe`)
for (const [, url] of html.matchAll(/(?:src|href)="(https?:[^"]+)"/g)) {
const permis = url.startsWith('https://github.com/ResonLab') ||
url.startsWith('https://resonlab.github.io')
if (!permis) echec(`${etiquette} référence une ressource externe : ${url}`)
}
}
console.log(
echecs === 0
? `SITE COHÉRENT (${pages.length} pages, deux langues alignées)`
: `${echecs} PROBLÈME(S) SUR LE SITE`
)
process.exit(echecs === 0 ? 0 : 1)