ultra11y 2.22.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/LICENSE +21 -0
- package/README.md +223 -0
- package/docker/Dockerfile +7 -0
- package/docker/package.json +9 -0
- package/docker/runner.mjs +23 -0
- package/package.json +73 -0
- package/scripts/bench.mjs +151 -0
- package/scripts/build-act-corpus.mjs +143 -0
- package/scripts/build-adjudication.mjs +503 -0
- package/scripts/build-pack-rgaa.mjs +397 -0
- package/scripts/build-standards.mjs +548 -0
- package/scripts/copy-bundle.mjs +39 -0
- package/scripts/import-pack.mjs +107 -0
- package/scripts/sync-engine.mjs +84 -0
- package/scripts/sync-version.mjs +43 -0
- package/scripts/ultra11y.mjs +52739 -0
- package/scripts/vendor/act-testcases.json +12297 -0
- package/scripts/vendor/rgaa/SOURCE.json +8 -0
- package/scripts/vendor/rgaa/criteres.json +4144 -0
- package/scripts/vendor/rgaa/glossaire.json +480 -0
- package/scripts/vendor/wcag-2.2-fr.json +82 -0
- package/scripts/vendor/wcag-2.2-sc-universe.json +950 -0
- package/scripts/vendor/wcag-2.2-sc.json +574 -0
- package/scripts/verify-rgaa-source.mjs +126 -0
- package/scripts/verify-skill-bundle.mjs +91 -0
- package/skills/review-a11y/SKILL.md +123 -0
- package/skills/review-a11y/scripts/ultra11y.mjs +52739 -0
- package/skills/ultra11y/SKILL.md +292 -0
- package/skills/ultra11y/references/act.md +163 -0
- package/skills/ultra11y/references/adjudication.md +599 -0
- package/skills/ultra11y/references/audit.md +100 -0
- package/skills/ultra11y/references/authoring.md +37 -0
- package/skills/ultra11y/references/automation.md +113 -0
- package/skills/ultra11y/references/correction.md +41 -0
- package/skills/ultra11y/references/criteria.md +129 -0
- package/skills/ultra11y/references/cross-file.md +82 -0
- package/skills/ultra11y/references/dynamic.md +152 -0
- package/skills/ultra11y/references/false-positives.md +81 -0
- package/skills/ultra11y/references/fix.md +46 -0
- package/skills/ultra11y/references/focus-and-logic.md +64 -0
- package/skills/ultra11y/references/forbidden-patterns.md +61 -0
- package/skills/ultra11y/references/forms-and-errors.md +147 -0
- package/skills/ultra11y/references/guidance.md +55 -0
- package/skills/ultra11y/references/judgment.md +90 -0
- package/skills/ultra11y/references/media-and-motion.md +116 -0
- package/skills/ultra11y/references/methodology.md +72 -0
- package/skills/ultra11y/references/naming.md +117 -0
- package/skills/ultra11y/references/orchestration.md +44 -0
- package/skills/ultra11y/references/packs.md +193 -0
- package/skills/ultra11y/references/prd.md +104 -0
- package/skills/ultra11y/references/rendered.md +110 -0
- package/skills/ultra11y/references/scale.md +49 -0
- package/skills/ultra11y/references/standards.md +97 -0
- package/skills/ultra11y/references/structure.md +138 -0
- package/skills/ultra11y/references/verify.md +88 -0
- package/skills/ultra11y/references/widgets.md +106 -0
- package/skills/ultra11y/scripts/ultra11y.mjs +52739 -0
- package/src/act.ts +441 -0
- package/src/adjudicate.ts +630 -0
- package/src/aria.ts +230 -0
- package/src/audit.ts +396 -0
- package/src/auditor.ts +399 -0
- package/src/autofill.ts +102 -0
- package/src/axe-map.ts +294 -0
- package/src/baseline.ts +75 -0
- package/src/capture.ts +182 -0
- package/src/check.ts +243 -0
- package/src/cli.ts +1927 -0
- package/src/color.ts +102 -0
- package/src/config.ts +182 -0
- package/src/crawl.ts +75 -0
- package/src/criteria.ts +183 -0
- package/src/data/adjudication.json +770 -0
- package/src/data/guidance/rgaa.json +2395 -0
- package/src/data/standards/rgaa.glossary.json +478 -0
- package/src/data/standards/rgaa.json +3454 -0
- package/src/data/wcag-universe.json +530 -0
- package/src/data/wcag.json +1178 -0
- package/src/discover.ts +203 -0
- package/src/fix/codemods.ts +76 -0
- package/src/fix/diff.ts +64 -0
- package/src/fix/edits.ts +126 -0
- package/src/fix.ts +215 -0
- package/src/gh.ts +214 -0
- package/src/glob.ts +206 -0
- package/src/graph/build.ts +114 -0
- package/src/graph/engine-resolve.ts +0 -0
- package/src/graph/graph.ts +126 -0
- package/src/graph/imports.ts +359 -0
- package/src/graph/resolve.ts +73 -0
- package/src/graph/tsconfig.ts +113 -0
- package/src/grounding.ts +119 -0
- package/src/guidance/index.ts +45 -0
- package/src/guidance/types.ts +36 -0
- package/src/init.ts +113 -0
- package/src/mcp/handlers.ts +388 -0
- package/src/mcp/http.ts +260 -0
- package/src/mcp/prompts.ts +183 -0
- package/src/mcp/protocol.ts +187 -0
- package/src/mcp/resources.ts +136 -0
- package/src/mcp/server.ts +228 -0
- package/src/mcp/stdio.ts +119 -0
- package/src/mcp/tools.ts +347 -0
- package/src/messages.ts +1011 -0
- package/src/name.ts +174 -0
- package/src/orchestrate-templates.ts +231 -0
- package/src/orchestrate.ts +186 -0
- package/src/output.ts +108 -0
- package/src/pack.ts +187 -0
- package/src/parse/html.ts +258 -0
- package/src/parse/jsx-ast.ts +67 -0
- package/src/parse/jsx-bridge.ts +247 -0
- package/src/parse/jsx.ts +19 -0
- package/src/parse/source.ts +75 -0
- package/src/prd.ts +406 -0
- package/src/project-lock.ts +42 -0
- package/src/render.ts +307 -0
- package/src/report.ts +399 -0
- package/src/rules/colors.ts +125 -0
- package/src/rules/cross-registry.ts +264 -0
- package/src/rules/cross-rule.ts +83 -0
- package/src/rules/forms.ts +590 -0
- package/src/rules/frames.ts +33 -0
- package/src/rules/headings.ts +227 -0
- package/src/rules/images.ts +200 -0
- package/src/rules/links.ts +193 -0
- package/src/rules/mandatory.ts +173 -0
- package/src/rules/multimedia.ts +77 -0
- package/src/rules/navigation.ts +190 -0
- package/src/rules/presentation.ts +75 -0
- package/src/rules/registry.ts +57 -0
- package/src/rules/rule.ts +122 -0
- package/src/rules/scripts-aria.ts +636 -0
- package/src/rules/tables.ts +264 -0
- package/src/rules/timing.ts +55 -0
- package/src/sample.ts +176 -0
- package/src/scan-local.ts +866 -0
- package/src/scan.ts +538 -0
- package/src/standards/derive.ts +195 -0
- package/src/standards/index.ts +31 -0
- package/src/standards/pack-rules.ts +111 -0
- package/src/standards/pack.ts +81 -0
- package/src/standards/registry.ts +100 -0
- package/src/standards/types.ts +202 -0
- package/src/standards/validate.ts +544 -0
- package/src/standards/vocabulary.ts +75 -0
- package/src/types.ts +355 -0
- package/src/util.ts +32 -0
- package/src/vendor/codeindex-engine.d.mts +1009 -0
- package/src/vendor/codeindex-engine.mjs +13701 -0
- package/src/vendor/engine.meta.json +9 -0
- package/src/verify.ts +275 -0
- package/src/wcag.ts +128 -0
package/src/messages.ts
ADDED
|
@@ -0,0 +1,1011 @@
|
|
|
1
|
+
// Language-neutral message catalog for rule findings (Phase 3 of the modular
|
|
2
|
+
// auditor/lang work). Every RuleFinding/CrossFinding (src/rules/*.ts) now carries a
|
|
3
|
+
// `msgId` (+ optional `params`) instead of a baked string; this catalog resolves it
|
|
4
|
+
// to fr/en prose at RENDER time — mirroring how src/wcag.ts resolves SC titles by
|
|
5
|
+
// lang. `toFinding()`/`crossToFinding()` (src/rules/rule.ts, cross-rule.ts) still
|
|
6
|
+
// BAKE the English resolution into `Finding.message`/`remediation` (canonical,
|
|
7
|
+
// AI-facing, back-compat with any consumer that only reads those fields) and
|
|
8
|
+
// additionally attach `msg: { id, params }` so a `--lang fr` render can resolve the
|
|
9
|
+
// authored French original instead.
|
|
10
|
+
//
|
|
11
|
+
// The fr strings below are the ORIGINAL author strings, moved here verbatim from the
|
|
12
|
+
// rule modules (not rewritten); the en strings are faithful technical translations
|
|
13
|
+
// written for this catalog. Params carry raw DATA (tag names, attribute values,
|
|
14
|
+
// counts, small enums) — never pre-rendered phrases — so each language's template
|
|
15
|
+
// controls its own wording/word order.
|
|
16
|
+
import type { Lang } from "./types.js";
|
|
17
|
+
|
|
18
|
+
export type MsgParams = Record<string, string | number>;
|
|
19
|
+
type Tpl = (p: MsgParams) => string;
|
|
20
|
+
interface MsgEntry {
|
|
21
|
+
message: Record<Lang, Tpl>;
|
|
22
|
+
remediation: Record<Lang, Tpl>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export const MSG_CATALOG: Record<string, MsgEntry> = {
|
|
26
|
+
// ---- Theme 1 — Images (src/rules/images.ts) ---------------------------------
|
|
27
|
+
"img-alt-missing": {
|
|
28
|
+
message: {
|
|
29
|
+
fr: (p) => `<${p.tag}> sans attribut alt ni nom accessible — alternative textuelle manquante.`,
|
|
30
|
+
en: (p) => `<${p.tag}> has no alt attribute or accessible name — missing text alternative.`,
|
|
31
|
+
},
|
|
32
|
+
remediation: {
|
|
33
|
+
fr: () => `Ajoutez alt="…" (description si l'image porte de l'information, alt="" si elle est décorative).`,
|
|
34
|
+
en: () => `Add alt="…" (a description if the image conveys information, alt="" if it is decorative).`,
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
"autocomplete-token-invalid": {
|
|
38
|
+
message: {
|
|
39
|
+
fr: (p) => `autocomplete="${p.value}" n'appartient pas au vocabulaire HTML — la finalité du champ n'est pas identifiable.`,
|
|
40
|
+
en: (p) => `autocomplete="${p.value}" is not in the HTML autofill vocabulary — the field's purpose is not identifiable.`,
|
|
41
|
+
},
|
|
42
|
+
remediation: {
|
|
43
|
+
fr: () => `Utilisez un token de la liste WCAG « Input Purposes » (par ex. given-name, email, tel, postal-code, current-password).`,
|
|
44
|
+
en: () => `Use a token from the WCAG "Input Purposes" list (e.g. given-name, email, tel, postal-code, current-password).`,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
"credential-entry-blocked.paste": {
|
|
48
|
+
message: {
|
|
49
|
+
fr: (p) => `Le collage est bloqué sur ce champ <input type="${p.type}"> — la saisie du secret devient un test de mémoire.`,
|
|
50
|
+
en: (p) => `Paste is blocked on this <input type="${p.type}"> — entering the secret becomes a memory test.`,
|
|
51
|
+
},
|
|
52
|
+
remediation: {
|
|
53
|
+
fr: () => `Retirez le gestionnaire qui annule l'événement paste : les gestionnaires de mots de passe doivent pouvoir remplir le champ.`,
|
|
54
|
+
en: () => `Remove the handler cancelling the paste event: password managers must be able to fill the field.`,
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
"credential-entry-blocked.autocomplete": {
|
|
58
|
+
message: {
|
|
59
|
+
fr: (p) => `autocomplete="off" sur un champ d'identification (<input type="${p.type}">) — le remplissage automatique est désactivé.`,
|
|
60
|
+
en: (p) => `autocomplete="off" on a credential field (<input type="${p.type}">) — automatic filling is disabled.`,
|
|
61
|
+
},
|
|
62
|
+
remediation: {
|
|
63
|
+
fr: () => `Déclarez la finalité réelle (autocomplete="username" / "current-password" / "one-time-code") au lieu de "off".`,
|
|
64
|
+
en: () => `Declare the real purpose (autocomplete="username" / "current-password" / "one-time-code") instead of "off".`,
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
"on-input-context-change": {
|
|
68
|
+
message: {
|
|
69
|
+
fr: (p) => `Changer la valeur de ce <${p.tag}> déclenche seul un changement de contexte (envoi/navigation).`,
|
|
70
|
+
en: (p) => `Changing this <${p.tag}>'s value alone triggers a change of context (submit/navigation).`,
|
|
71
|
+
},
|
|
72
|
+
remediation: {
|
|
73
|
+
fr: () => `Attendez une action explicite (bouton « Valider »), ou annoncez le changement avant qu'il ne survienne.`,
|
|
74
|
+
en: () => `Wait for an explicit action (a "Submit" button), or announce the change before it happens.`,
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
"label-in-name-mismatch": {
|
|
78
|
+
message: {
|
|
79
|
+
fr: (p) => `Le libellé visible « ${p.visible} » n'est pas contenu dans le nom accessible « ${p.name} » — la commande vocale échoue.`,
|
|
80
|
+
en: (p) => `The visible label "${p.visible}" is not contained in the accessible name "${p.name}" — voice control fails.`,
|
|
81
|
+
},
|
|
82
|
+
remediation: {
|
|
83
|
+
fr: (p) => `Faites commencer aria-label par le texte visible (« ${p.visible} … »), ou retirez aria-label et laissez le texte nommer le contrôle.`,
|
|
84
|
+
en: (p) => `Start aria-label with the visible text ("${p.visible} …"), or drop aria-label and let the text name the control.`,
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
"headers-attr-dangling": {
|
|
88
|
+
message: {
|
|
89
|
+
fr: (p) => `headers="${p.ids}" ne désigne aucune cellule d'en-tête de ce tableau — l'association n'existe pas.`,
|
|
90
|
+
en: (p) => `headers="${p.ids}" points at no header cell of this table — the association does not exist.`,
|
|
91
|
+
},
|
|
92
|
+
remediation: {
|
|
93
|
+
fr: () => `Faites pointer headers vers les id de <th> du MÊME tableau, ou utilisez scope="col"/"row".`,
|
|
94
|
+
en: () => `Point headers at the ids of <th> cells in the SAME table, or use scope="col"/"row".`,
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
"th-no-data-cells": {
|
|
98
|
+
message: {
|
|
99
|
+
fr: () => `Cet en-tête n'est référencé par aucune cellule de données alors que le tableau utilise headers — il n'en-tête rien.`,
|
|
100
|
+
en: () => `This header is referenced by no data cell although the table uses headers — it heads nothing.`,
|
|
101
|
+
},
|
|
102
|
+
remediation: {
|
|
103
|
+
fr: () => `Ajoutez son id au headers des cellules qu'il en-tête, ou retirez-le s'il ne sert qu'à la mise en forme.`,
|
|
104
|
+
en: () => `Add its id to the headers of the cells it heads, or remove it if it is purely presentational.`,
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
"dl-structure.foreign-child": {
|
|
108
|
+
message: {
|
|
109
|
+
fr: (p) => `<${p.tag}> comme enfant direct de <dl> — seuls <dt>, <dd> (et <div> les groupant) sont admis.`,
|
|
110
|
+
en: (p) => `<${p.tag}> as a direct child of <dl> — only <dt>, <dd> (and a grouping <div>) are allowed.`,
|
|
111
|
+
},
|
|
112
|
+
remediation: {
|
|
113
|
+
fr: () => `Déplacez le contenu dans un <dd>, ou sortez-le de la liste de définitions.`,
|
|
114
|
+
en: () => `Move the content into a <dd>, or take it out of the description list.`,
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
"dl-structure.orphan": {
|
|
118
|
+
message: {
|
|
119
|
+
fr: (p) => `<${p.tag}> hors de tout <dl> — la relation terme/description n'est pas exposée.`,
|
|
120
|
+
en: (p) => `<${p.tag}> outside any <dl> — the term/description relationship is not exposed.`,
|
|
121
|
+
},
|
|
122
|
+
remediation: {
|
|
123
|
+
fr: (p) => `Enveloppez les paires <dt>/<dd> dans un <dl> (le <${p.tag}> seul n'a aucune sémantique).`,
|
|
124
|
+
en: (p) => `Wrap the <dt>/<dd> pairs in a <dl> (a lone <${p.tag}> carries no semantics).`,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
"invalid-aria-attr": {
|
|
128
|
+
message: {
|
|
129
|
+
fr: (p) => `Attribut ${p.attr} inconnu de WAI-ARIA — il est ignoré par les navigateurs et n'expose rien.`,
|
|
130
|
+
en: (p) => `${p.attr} is not a WAI-ARIA attribute — browsers ignore it, so it exposes nothing.`,
|
|
131
|
+
},
|
|
132
|
+
remediation: {
|
|
133
|
+
fr: (p) => `Corrigez l'orthographe de ${p.attr} (par ex. aria-labelledby, aria-describedby) ou retirez l'attribut.`,
|
|
134
|
+
en: (p) => `Fix the spelling of ${p.attr} (e.g. aria-labelledby, aria-describedby) or remove the attribute.`,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
"invalid-aria-value": {
|
|
138
|
+
message: {
|
|
139
|
+
fr: (p) => `${p.attr}="${p.value}" n'est pas une valeur admise — l'état est traité comme absent.`,
|
|
140
|
+
en: (p) => `${p.attr}="${p.value}" is not an allowed value — the state is treated as absent.`,
|
|
141
|
+
},
|
|
142
|
+
remediation: {
|
|
143
|
+
fr: (p) => `Utilisez une valeur définie par WAI-ARIA pour ${p.attr} (par ex. true/false, ou un entier pour aria-level).`,
|
|
144
|
+
en: (p) => `Use a value WAI-ARIA defines for ${p.attr} (e.g. true/false, or an integer for aria-level).`,
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
"aria-required-attr": {
|
|
148
|
+
message: {
|
|
149
|
+
fr: (p) => `role="${p.role}" sans ${p.attrs} — l'état requis par ce rôle n'est pas exposé.`,
|
|
150
|
+
en: (p) => `role="${p.role}" without ${p.attrs} — the state this role requires is not exposed.`,
|
|
151
|
+
},
|
|
152
|
+
remediation: {
|
|
153
|
+
fr: (p) => `Ajoutez ${p.attrs} et tenez la valeur à jour, ou utilisez l'élément natif équivalent qui gère l'état seul.`,
|
|
154
|
+
en: (p) => `Add ${p.attrs} and keep it in sync, or use the native element, which manages the state on its own.`,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
"aria-required-parent": {
|
|
158
|
+
message: {
|
|
159
|
+
fr: (p) => `role="${p.role}" hors de son conteneur requis (${p.parents}) — le rôle n'est pas exposé.`,
|
|
160
|
+
en: (p) => `role="${p.role}" outside its required container (${p.parents}) — the role is not exposed.`,
|
|
161
|
+
},
|
|
162
|
+
remediation: {
|
|
163
|
+
fr: (p) => `Placez l'élément dans un conteneur ${p.parents}, ou reliez-le via aria-owns.`,
|
|
164
|
+
en: (p) => `Put the element inside a ${p.parents} container, or wire it up with aria-owns.`,
|
|
165
|
+
},
|
|
166
|
+
},
|
|
167
|
+
"aria-prohibited-attr": {
|
|
168
|
+
message: {
|
|
169
|
+
fr: (p) => `role="${p.role}" interdit un nom d'auteur, mais ${p.attrs} en fournit un — restitution imprévisible.`,
|
|
170
|
+
en: (p) => `role="${p.role}" prohibits an author-provided name, yet ${p.attrs} supplies one — restitution is unpredictable.`,
|
|
171
|
+
},
|
|
172
|
+
remediation: {
|
|
173
|
+
fr: (p) => `Retirez ${p.attrs}, ou donnez à l'élément un rôle qui accepte d'être nommé.`,
|
|
174
|
+
en: (p) => `Remove ${p.attrs}, or give the element a role that accepts a name.`,
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
"img-alt-missing.title-only": {
|
|
178
|
+
message: {
|
|
179
|
+
fr: (p) => `<${p.tag}> sans alt : le nom accessible vient du seul title, restitué de façon peu fiable (survol uniquement).`,
|
|
180
|
+
en: (p) => `<${p.tag}> has no alt: its accessible name comes from title alone, which is restituted unreliably (hover only).`,
|
|
181
|
+
},
|
|
182
|
+
remediation: {
|
|
183
|
+
fr: () => `Recommandation : déplacez le texte du title vers alt="…" (alt="" si l'image est décorative).`,
|
|
184
|
+
en: () => `Recommendation: move the title text into alt="…" (alt="" if the image is decorative).`,
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
"decorative-alt-misuse.empty-but-named": {
|
|
188
|
+
message: {
|
|
189
|
+
fr: () => `Image décorative (alt="") mais nommée par aria-label/title — incohérence décoratif/informatif.`,
|
|
190
|
+
en: () => `Decorative image (alt="") but named via aria-label/title — decorative/informative inconsistency.`,
|
|
191
|
+
},
|
|
192
|
+
remediation: {
|
|
193
|
+
fr: () => `Si l'image est décorative, retirez aria-label/title ; sinon donnez un alt descriptif.`,
|
|
194
|
+
en: () => `If the image is decorative, remove aria-label/title; otherwise give it a descriptive alt.`,
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
"decorative-alt-misuse.role-but-alt": {
|
|
198
|
+
message: {
|
|
199
|
+
fr: (p) => `Image en role="${p.role}" mais porteuse d'un alt non vide — déclarée décorative pourtant nommée.`,
|
|
200
|
+
en: (p) => `Image with role="${p.role}" but carrying a non-empty alt — declared decorative yet named.`,
|
|
201
|
+
},
|
|
202
|
+
remediation: {
|
|
203
|
+
fr: (p) => `Retirez role="${p.role}" si l'image est informative, ou videz l'alt si elle est décorative.`,
|
|
204
|
+
en: (p) => `Remove role="${p.role}" if the image is informative, or empty the alt if it is decorative.`,
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
"canvas-fallback-missing": {
|
|
208
|
+
message: {
|
|
209
|
+
fr: () => `<canvas> sans contenu alternatif ni nom accessible.`,
|
|
210
|
+
en: () => `<canvas> has no fallback content or accessible name.`,
|
|
211
|
+
},
|
|
212
|
+
remediation: {
|
|
213
|
+
fr: () => `Placez un contenu de repli entre <canvas>…</canvas> ou ajoutez role="img" + aria-label.`,
|
|
214
|
+
en: () => `Place fallback content between <canvas>…</canvas>, or add role="img" + aria-label.`,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
"input-image-alt-missing": {
|
|
218
|
+
message: {
|
|
219
|
+
fr: () => `<input type="image"> sans alt ni nom accessible — le bouton image n'a pas d'alternative textuelle.`,
|
|
220
|
+
en: () => `<input type="image"> has no alt or accessible name — the image button has no text alternative.`,
|
|
221
|
+
},
|
|
222
|
+
remediation: {
|
|
223
|
+
fr: () => `Ajoutez alt="…" décrivant l'action du bouton (ex. alt="Rechercher").`,
|
|
224
|
+
en: () => `Add alt="…" describing the button's action (e.g. alt="Search").`,
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
"object-embed-no-name": {
|
|
228
|
+
message: {
|
|
229
|
+
fr: (p) => `<${p.tag}> sans nom accessible ni contenu de repli — média embarqué sans alternative textuelle.`,
|
|
230
|
+
en: (p) => `<${p.tag}> has no accessible name or fallback content — embedded media with no text alternative.`,
|
|
231
|
+
},
|
|
232
|
+
remediation: {
|
|
233
|
+
fr: () => `Ajoutez aria-label/title décrivant le contenu (et un contenu de repli textuel dans <object>…</object>), ou aria-hidden="true" si décoratif.`,
|
|
234
|
+
en: () => `Add aria-label/title describing the content (and text fallback content inside <object>…</object>), or aria-hidden="true" if decorative.`,
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
"chart-no-accessible-name": {
|
|
238
|
+
message: {
|
|
239
|
+
fr: (p) => `Graphique (<${p.tag}> ${p.cls}) sans nom accessible — alternative textuelle manquante.`,
|
|
240
|
+
en: (p) => `Chart (<${p.tag}> ${p.cls}) has no accessible name — missing text alternative.`,
|
|
241
|
+
},
|
|
242
|
+
remediation: {
|
|
243
|
+
fr: () => `Enveloppez le graphique dans <div role="img" aria-label="…"> décrivant la tendance (ou ajoutez un <title>/aria-label).`,
|
|
244
|
+
en: () => `Wrap the chart in <div role="img" aria-label="…"> describing the trend (or add a <title>/aria-label).`,
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
// ---- Theme 2 — Frames (src/rules/frames.ts) ---------------------------------
|
|
249
|
+
"iframe-title-missing": {
|
|
250
|
+
message: {
|
|
251
|
+
fr: () => `<iframe> sans attribut title — le cadre n'a pas de titre.`,
|
|
252
|
+
en: () => `<iframe> has no title attribute — the frame has no title.`,
|
|
253
|
+
},
|
|
254
|
+
remediation: {
|
|
255
|
+
fr: () => `Ajoutez un title décrivant le contenu du cadre, ex. title="Carte de localisation".`,
|
|
256
|
+
en: () => `Add a title describing the frame's content, e.g. title="Location map".`,
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
// ---- Theme 3 — Colours (src/rules/colors.ts) --------------------------------
|
|
261
|
+
"contrast-literal": {
|
|
262
|
+
message: {
|
|
263
|
+
fr: (p) =>
|
|
264
|
+
`Contraste insuffisant : ratio ${p.ratio}:1 entre le texte et son fond (minimum ${p.min}:1 pour du texte ${p.textSize === "large" ? "large" : "normal"}).`,
|
|
265
|
+
en: (p) =>
|
|
266
|
+
`Insufficient contrast: ${p.ratio}:1 ratio between the text and its background (minimum ${p.min}:1 for ${p.textSize === "large" ? "large" : "normal"} text).`,
|
|
267
|
+
},
|
|
268
|
+
remediation: {
|
|
269
|
+
fr: (p) => `Assombrissez le texte ou éclaircissez le fond pour atteindre au moins ${p.min}:1 (contraste calculé sur des couleurs inline littérales).`,
|
|
270
|
+
en: (p) => `Darken the text or lighten the background to reach at least ${p.min}:1 (contrast computed from literal inline colours).`,
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
// ---- Theme 5 — Data tables (src/rules/tables.ts) ----------------------------
|
|
275
|
+
"data-table-no-headers.no-th": {
|
|
276
|
+
message: {
|
|
277
|
+
fr: () => `Tableau de données sans <th> — en-têtes de colonne/ligne non déclarés.`,
|
|
278
|
+
en: () => `Data table with no <th> — column/row headers are not declared.`,
|
|
279
|
+
},
|
|
280
|
+
remediation: {
|
|
281
|
+
fr: () => `Déclarez les en-têtes avec <th>, et associez-les via scope="col"/"row" (ou headers/id).`,
|
|
282
|
+
en: () => `Declare headers with <th>, and associate them via scope="col"/"row" (or headers/id).`,
|
|
283
|
+
},
|
|
284
|
+
},
|
|
285
|
+
"data-table-no-headers.no-assoc": {
|
|
286
|
+
message: {
|
|
287
|
+
fr: () => `Tableau de données avec <th> mais sans scope/headers — association cellule↔en-tête absente.`,
|
|
288
|
+
en: () => `Data table with <th> but no scope/headers — the cell↔header association is missing.`,
|
|
289
|
+
},
|
|
290
|
+
remediation: {
|
|
291
|
+
fr: () => `Ajoutez scope="col"/"row" sur les <th> (ou headers="…"/id sur cellules complexes).`,
|
|
292
|
+
en: () => `Add scope="col"/"row" on the <th> elements (or headers="…"/id for complex cells).`,
|
|
293
|
+
},
|
|
294
|
+
},
|
|
295
|
+
"table-caption-missing": {
|
|
296
|
+
message: {
|
|
297
|
+
fr: () => `Tableau de données sans <caption> ni nom accessible — titre du tableau absent.`,
|
|
298
|
+
en: () => `Data table with no <caption> or accessible name — the table has no title.`,
|
|
299
|
+
},
|
|
300
|
+
remediation: {
|
|
301
|
+
fr: () => `Ajoutez un <caption> en première position du <table> (ou aria-label/aria-labelledby).`,
|
|
302
|
+
en: () => `Add a <caption> as the first child of the <table> (or aria-label/aria-labelledby).`,
|
|
303
|
+
},
|
|
304
|
+
},
|
|
305
|
+
"layout-table-data-markup": {
|
|
306
|
+
message: {
|
|
307
|
+
fr: (p) => `Tableau de mise en forme (role="${p.role}") utilisant du balisage de données (th/caption/scope).`,
|
|
308
|
+
en: (p) => `Layout table (role="${p.role}") using data markup (th/caption/scope).`,
|
|
309
|
+
},
|
|
310
|
+
remediation: {
|
|
311
|
+
fr: () => `Retirez th/caption/scope/headers d'un tableau de présentation, ou faites-en un vrai tableau de données.`,
|
|
312
|
+
en: () => `Remove th/caption/scope/headers from a presentation table, or turn it into a real data table.`,
|
|
313
|
+
},
|
|
314
|
+
},
|
|
315
|
+
"sortable-header-no-aria-sort": {
|
|
316
|
+
message: {
|
|
317
|
+
fr: () => `En-tête de colonne triable sans aria-sort — l'état de tri (croissant/décroissant) n'est pas restitué.`,
|
|
318
|
+
en: () => `Sortable column header with no aria-sort — the sort state (ascending/descending) is not conveyed.`,
|
|
319
|
+
},
|
|
320
|
+
remediation: {
|
|
321
|
+
fr: () => `Ajoutez aria-sort="none|ascending|descending" sur le <th> trié, et masquez le glyphe de tri (aria-hidden="true").`,
|
|
322
|
+
en: () => `Add aria-sort="none|ascending|descending" on the sorted <th>, and hide the sort glyph (aria-hidden="true").`,
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
"table-empty-data-cell": {
|
|
326
|
+
message: {
|
|
327
|
+
fr: () => `Cellule de données vide (ou réduite à « - ») : restituée « vide » par les lecteurs d'écran.`,
|
|
328
|
+
en: () => `Empty data cell (or reduced to "-"): announced as "blank" by screen readers.`,
|
|
329
|
+
},
|
|
330
|
+
remediation: {
|
|
331
|
+
fr: () => `Ajoutez un texte masqué (classe sr-only) précisant l'absence de valeur, ex. « Non renseigné ».`,
|
|
332
|
+
en: () => `Insert hidden text (an sr-only class) stating the absence of a value, e.g. "Not provided".`,
|
|
333
|
+
},
|
|
334
|
+
},
|
|
335
|
+
|
|
336
|
+
// ---- Theme 6/7 — Links & buttons (src/rules/links.ts) -----------------------
|
|
337
|
+
"link-empty-name": {
|
|
338
|
+
message: {
|
|
339
|
+
fr: () => `Lien sans intitulé — aucun nom accessible.`,
|
|
340
|
+
en: () => `Link with no label — no accessible name.`,
|
|
341
|
+
},
|
|
342
|
+
remediation: {
|
|
343
|
+
fr: () => `Donnez un intitulé textuel au lien (texte visible, ou aria-label si vraiment nécessaire).`,
|
|
344
|
+
en: () => `Give the link a text label (visible text, or aria-label if truly necessary).`,
|
|
345
|
+
},
|
|
346
|
+
},
|
|
347
|
+
"button-empty-name": {
|
|
348
|
+
message: {
|
|
349
|
+
fr: () => `Bouton sans intitulé — aucun nom accessible.`,
|
|
350
|
+
en: () => `Button with no label — no accessible name.`,
|
|
351
|
+
},
|
|
352
|
+
remediation: {
|
|
353
|
+
fr: () => `Donnez un intitulé au bouton (texte, value, ou aria-label).`,
|
|
354
|
+
en: () => `Give the button a label (text, value, or aria-label).`,
|
|
355
|
+
},
|
|
356
|
+
},
|
|
357
|
+
"icon-only-control-unnamed": {
|
|
358
|
+
message: {
|
|
359
|
+
fr: (p) => `${p.kind === "link" ? "Lien" : "Bouton"} avec une icône seule (img/svg/i) sans nom accessible.`,
|
|
360
|
+
en: (p) => `${p.kind === "link" ? "Link" : "Button"} with an icon only (img/svg/i) and no accessible name.`,
|
|
361
|
+
},
|
|
362
|
+
remediation: {
|
|
363
|
+
fr: () => `Ajoutez un alt/aria-label sur l'icône ou un texte masqué visuellement (classe visually-hidden).`,
|
|
364
|
+
en: () => `Add an alt/aria-label on the icon, or a visually-hidden text (visually-hidden class).`,
|
|
365
|
+
},
|
|
366
|
+
},
|
|
367
|
+
"control-name-title-only": {
|
|
368
|
+
message: {
|
|
369
|
+
fr: (p) =>
|
|
370
|
+
`${p.kind === "link" ? "Lien" : p.kind === "field" ? "Champ de formulaire" : "Bouton"} dont le seul nom accessible vient de l'attribut title — title n'est pas restitué de façon fiable (survol uniquement).`,
|
|
371
|
+
en: (p) =>
|
|
372
|
+
`${p.kind === "link" ? "Link" : p.kind === "field" ? "Form field" : "Button"} whose only accessible name comes from the title attribute — title is not reliably conveyed (hover only).`,
|
|
373
|
+
},
|
|
374
|
+
remediation: {
|
|
375
|
+
fr: () => `Donnez un intitulé via texte visible ou aria-label ; réservez title à une information complémentaire.`,
|
|
376
|
+
en: () => `Give it a label via visible text or aria-label; reserve title for supplementary information.`,
|
|
377
|
+
},
|
|
378
|
+
},
|
|
379
|
+
|
|
380
|
+
// ---- Theme 7 — Scripts / ARIA (src/rules/scripts-aria.ts) -------------------
|
|
381
|
+
"invalid-aria-role": {
|
|
382
|
+
message: {
|
|
383
|
+
fr: (p) => `Rôle ARIA invalide : "${p.roles}" n'est pas un rôle WAI-ARIA valide.`,
|
|
384
|
+
en: (p) => `Invalid ARIA role: "${p.roles}" is not a valid WAI-ARIA role.`,
|
|
385
|
+
},
|
|
386
|
+
remediation: {
|
|
387
|
+
fr: () => `Utilisez un rôle ARIA valide ou un élément HTML natif équivalent.`,
|
|
388
|
+
en: () => `Use a valid ARIA role, or an equivalent native HTML element.`,
|
|
389
|
+
},
|
|
390
|
+
},
|
|
391
|
+
"aria-ref-missing-id": {
|
|
392
|
+
message: {
|
|
393
|
+
fr: (p) => `${p.attr} référence un id inexistant : ${p.ids}.`,
|
|
394
|
+
en: (p) => `${p.attr} references a non-existent id: ${p.ids}.`,
|
|
395
|
+
},
|
|
396
|
+
remediation: {
|
|
397
|
+
fr: () => `Corrigez la référence ou ajoutez l'élément cible avec l'id attendu.`,
|
|
398
|
+
en: () => `Fix the reference, or add the target element with the expected id.`,
|
|
399
|
+
},
|
|
400
|
+
},
|
|
401
|
+
"redundant-aria": {
|
|
402
|
+
message: {
|
|
403
|
+
fr: (p) => `role="${p.role}" est redondant : <${p.tag}> a déjà ce rôle implicite.`,
|
|
404
|
+
en: (p) => `role="${p.role}" is redundant: <${p.tag}> already has this implicit role.`,
|
|
405
|
+
},
|
|
406
|
+
remediation: {
|
|
407
|
+
fr: () => `Retirez l'attribut role redondant et laissez la sémantique native.`,
|
|
408
|
+
en: () => `Remove the redundant role attribute and let the native semantics apply.`,
|
|
409
|
+
},
|
|
410
|
+
},
|
|
411
|
+
"clickable-noninteractive": {
|
|
412
|
+
message: {
|
|
413
|
+
fr: (p) =>
|
|
414
|
+
`<${p.tag}> avec onClick mais ${p.focusIssue === "no-tabindex" ? "non focalisable (tabindex absent)" : "sans rôle interactif"} — contrôle non accessible au clavier/AT.`,
|
|
415
|
+
en: (p) =>
|
|
416
|
+
`<${p.tag}> has onClick but ${p.focusIssue === "no-tabindex" ? "is not focusable (no tabindex)" : "has no interactive role"} — control not accessible via keyboard/AT.`,
|
|
417
|
+
},
|
|
418
|
+
remediation: {
|
|
419
|
+
fr: () => `Utilisez <button>/<a> natif, ou ajoutez role + tabindex="0" + gestion clavier (Enter/Espace).`,
|
|
420
|
+
en: () => `Use a native <button>/<a>, or add role + tabindex="0" + keyboard handling (Enter/Space).`,
|
|
421
|
+
},
|
|
422
|
+
},
|
|
423
|
+
"aria-required-children": {
|
|
424
|
+
message: {
|
|
425
|
+
fr: (p) => `role="${p.role}" sans enfant requis (${p.req}) — structure ARIA incomplète.`,
|
|
426
|
+
en: (p) => `role="${p.role}" with no required child (${p.req}) — incomplete ARIA structure.`,
|
|
427
|
+
},
|
|
428
|
+
remediation: {
|
|
429
|
+
fr: () => `Ajoutez les éléments enfants au rôle approprié, ou utilisez les éléments HTML natifs.`,
|
|
430
|
+
en: () => `Add child elements with the appropriate role, or use native HTML elements.`,
|
|
431
|
+
},
|
|
432
|
+
},
|
|
433
|
+
"aria-hidden-focusable": {
|
|
434
|
+
message: {
|
|
435
|
+
fr: () => `aria-hidden="true" sur (ou contenant) un élément focalisable — piège pour les technologies d'assistance.`,
|
|
436
|
+
en: () => `aria-hidden="true" on (or containing) a focusable element — a trap for assistive technology.`,
|
|
437
|
+
},
|
|
438
|
+
remediation: {
|
|
439
|
+
fr: () => `Retirez aria-hidden, ou rendez l'élément non focalisable (tabindex="-1", disabled).`,
|
|
440
|
+
en: () => `Remove aria-hidden, or make the element non-focusable (tabindex="-1", disabled).`,
|
|
441
|
+
},
|
|
442
|
+
},
|
|
443
|
+
"nested-interactive": {
|
|
444
|
+
message: {
|
|
445
|
+
fr: (p) => `Élément interactif <${p.tag}> imbriqué dans un autre élément interactif — non restitué correctement.`,
|
|
446
|
+
en: (p) => `Interactive element <${p.tag}> nested inside another interactive element — not conveyed correctly.`,
|
|
447
|
+
},
|
|
448
|
+
remediation: {
|
|
449
|
+
fr: () => `Ne pas imbriquer des contrôles interactifs (lien/bouton) ; mettez-les côte à côte.`,
|
|
450
|
+
en: () => `Do not nest interactive controls (link/button); place them side by side instead.`,
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
"live-region-conflict.invalid-value": {
|
|
454
|
+
message: {
|
|
455
|
+
fr: (p) => `aria-live="${p.value}" invalide — seules les valeurs off, polite, assertive sont restituées.`,
|
|
456
|
+
en: (p) => `aria-live="${p.value}" is invalid — only the values off, polite, assertive are honoured.`,
|
|
457
|
+
},
|
|
458
|
+
remediation: {
|
|
459
|
+
fr: () => `Utilisez aria-live="polite" (associé à aria-atomic="true") — ou role="alert" pour une erreur ; toute autre valeur est ignorée.`,
|
|
460
|
+
en: () => `Use aria-live="polite" (paired with aria-atomic="true") — or role="alert" for an error; any other value is ignored.`,
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
"live-region-conflict.mismatch": {
|
|
464
|
+
message: {
|
|
465
|
+
fr: (p) => `role="${p.role}" implique aria-live="${p.want}" mais aria-live="${p.live}" — message de statut restitué de façon incohérente.`,
|
|
466
|
+
en: (p) => `role="${p.role}" implies aria-live="${p.want}" but aria-live="${p.live}" — status message conveyed inconsistently.`,
|
|
467
|
+
},
|
|
468
|
+
remediation: {
|
|
469
|
+
fr: (p) =>
|
|
470
|
+
p.want === "assertive"
|
|
471
|
+
? `Laissez role="${p.role}" gérer la restitution (retirez aria-live) ou utilisez aria-live="assertive".`
|
|
472
|
+
: `Alignez aria-live sur "${p.want}" (avec aria-atomic="true"), cohérent avec role="${p.role}", ou retirez-le.`,
|
|
473
|
+
en: (p) =>
|
|
474
|
+
p.want === "assertive"
|
|
475
|
+
? `Let role="${p.role}" drive the announcement (remove aria-live), or use aria-live="assertive".`
|
|
476
|
+
: `Align aria-live with "${p.want}" (with aria-atomic="true"), consistent with role="${p.role}", or remove it.`,
|
|
477
|
+
},
|
|
478
|
+
},
|
|
479
|
+
"status-message-not-assertive": {
|
|
480
|
+
message: {
|
|
481
|
+
fr: () => `Conteneur d'erreur/alerte en aria-live="polite" — un message d'erreur doit être restitué de façon assertive.`,
|
|
482
|
+
en: () => `Error/alert container with aria-live="polite" — an error message must be conveyed assertively.`,
|
|
483
|
+
},
|
|
484
|
+
remediation: {
|
|
485
|
+
fr: () =>
|
|
486
|
+
`Utilisez role="alert" seul (restitution assertive immédiate) plutôt qu'aria-live="polite" ; ne déplacez pas aussi le focus vers ce conteneur — soit un mécanisme aria-live/role="alert", soit un déplacement du focus, jamais les deux.`,
|
|
487
|
+
en: () =>
|
|
488
|
+
`Use role="alert" alone (immediate assertive restitution) rather than aria-live="polite"; do not also move focus to that container — a live region OR a focus move, never both.`,
|
|
489
|
+
},
|
|
490
|
+
},
|
|
491
|
+
|
|
492
|
+
// ---- Theme 8 — Mandatory elements (src/rules/mandatory.ts) ------------------
|
|
493
|
+
"html-lang-missing": {
|
|
494
|
+
message: {
|
|
495
|
+
fr: () => `<html> sans attribut lang — la langue par défaut de la page n'est pas déclarée.`,
|
|
496
|
+
en: () => `<html> has no lang attribute — the page's default language is not declared.`,
|
|
497
|
+
},
|
|
498
|
+
remediation: {
|
|
499
|
+
fr: () => `Ajoutez lang sur <html>, ex. <html lang="fr">.`,
|
|
500
|
+
en: () => `Add lang on <html>, e.g. <html lang="en">.`,
|
|
501
|
+
},
|
|
502
|
+
},
|
|
503
|
+
"title-missing-empty": {
|
|
504
|
+
message: {
|
|
505
|
+
fr: (p) => (p.titleState === "empty" ? `<title> vide — le titre de la page est absent de contenu.` : `<title> absent — la page n'a pas de titre.`),
|
|
506
|
+
en: (p) => (p.titleState === "empty" ? `<title> is empty — the page title has no content.` : `<title> is missing — the page has no title.`),
|
|
507
|
+
},
|
|
508
|
+
remediation: {
|
|
509
|
+
fr: () => `Ajoutez un <title> non vide et pertinent dans <head>.`,
|
|
510
|
+
en: () => `Add a non-empty, relevant <title> in <head>.`,
|
|
511
|
+
},
|
|
512
|
+
},
|
|
513
|
+
"duplicate-id": {
|
|
514
|
+
message: {
|
|
515
|
+
fr: (p) => `id="${p.id}" dupliqué — un id doit être unique dans la page (HTML invalide).`,
|
|
516
|
+
en: (p) => `id="${p.id}" duplicated — an id must be unique in the page (invalid HTML).`,
|
|
517
|
+
},
|
|
518
|
+
remediation: {
|
|
519
|
+
fr: () => `Donnez un id unique à chaque élément ; ajustez les références (label[for], aria-*).`,
|
|
520
|
+
en: () => `Give each element a unique id; adjust references accordingly (label[for], aria-*).`,
|
|
521
|
+
},
|
|
522
|
+
},
|
|
523
|
+
"inline-lang-change-missing": {
|
|
524
|
+
message: {
|
|
525
|
+
fr: (p) => `Attribut lang vide sur <${p.tag}> — changement de langue mal indiqué.`,
|
|
526
|
+
en: (p) => `Empty lang attribute on <${p.tag}> — the language change is not properly indicated.`,
|
|
527
|
+
},
|
|
528
|
+
remediation: {
|
|
529
|
+
fr: () => `Renseignez un code de langue valide (ex. lang="en") ou retirez l'attribut.`,
|
|
530
|
+
en: () => `Provide a valid language code (e.g. lang="en") or remove the attribute.`,
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
"lang-invalid": {
|
|
534
|
+
message: {
|
|
535
|
+
fr: (p) => `Code de langue invalide lang="${p.lang}" sur <${p.tag}> — n'est pas un code BCP 47 valide.`,
|
|
536
|
+
en: (p) => `Invalid language code lang="${p.lang}" on <${p.tag}> — not a valid BCP 47 code.`,
|
|
537
|
+
},
|
|
538
|
+
remediation: {
|
|
539
|
+
fr: () => `Utilisez un code de langue valide (ex. "fr", "en", "fr-CA").`,
|
|
540
|
+
en: () => `Use a valid language code (e.g. "en", "fr", "en-US").`,
|
|
541
|
+
},
|
|
542
|
+
},
|
|
543
|
+
|
|
544
|
+
// ---- Theme 9 — Headings (src/rules/headings.ts) -----------------------------
|
|
545
|
+
"heading-order-skip": {
|
|
546
|
+
message: {
|
|
547
|
+
fr: (p) => `Saut de niveau de titre : <h${p.level}> après <h${p.prev}> (niveau h${p.expected} attendu).`,
|
|
548
|
+
en: (p) => `Heading level skip: <h${p.level}> after <h${p.prev}> (level h${p.expected} expected).`,
|
|
549
|
+
},
|
|
550
|
+
remediation: {
|
|
551
|
+
fr: () => `Ne sautez pas de niveau : enchaînez les titres sans omettre de palier.`,
|
|
552
|
+
en: () => `Do not skip a level: chain heading levels without omitting a step.`,
|
|
553
|
+
},
|
|
554
|
+
},
|
|
555
|
+
// ADVISORY (non-normative): "start the page at h1" is not required by HTML, WCAG, or
|
|
556
|
+
// SEO — a single, well-structured heading hierarchy (covered normatively by
|
|
557
|
+
// heading-order-skip) is what matters. Worded as a recommendation, not a constat.
|
|
558
|
+
"h1-missing": {
|
|
559
|
+
message: {
|
|
560
|
+
fr: () =>
|
|
561
|
+
`Recommandation : la page ne commence par aucun <h1>. Un <h1> décrivant le contenu principal aide au repérage, mais démarrer à h1 n'est requis ni par HTML, ni par l'accessibilité, ni par le SEO.`,
|
|
562
|
+
en: () =>
|
|
563
|
+
`Recommendation: the page starts with no <h1>. An <h1> naming the main content aids orientation, but starting at h1 is not required by HTML, accessibility, or SEO.`,
|
|
564
|
+
},
|
|
565
|
+
remediation: {
|
|
566
|
+
fr: () => `Envisagez d'ajouter un <h1> décrivant le contenu principal ; la hiérarchie des titres (sans saut de niveau) reste l'exigence normative.`,
|
|
567
|
+
en: () => `Consider adding an <h1> describing the page's main content; a heading hierarchy with no level skip stays the normative requirement.`,
|
|
568
|
+
},
|
|
569
|
+
},
|
|
570
|
+
// ADVISORY (non-normative): "a single h1 per page" is not a rule in HTML, accessibility,
|
|
571
|
+
// or SEO. Multiple h1 are valid (HTML5 sectioning) — worded as a recommendation.
|
|
572
|
+
"h1-multiple": {
|
|
573
|
+
message: {
|
|
574
|
+
fr: (p) =>
|
|
575
|
+
`Recommandation : plusieurs <h1> dans la page (${p.count}). Un titre de niveau 1 unique clarifie souvent la structure, mais « un seul <h1> par page » n'est une règle ni en HTML, ni en accessibilité, ni en SEO.`,
|
|
576
|
+
en: (p) =>
|
|
577
|
+
`Recommendation: multiple <h1> in the page (${p.count}). A single level-1 heading often clarifies structure, but "one h1 per page" is not a rule in HTML, accessibility, or SEO.`,
|
|
578
|
+
},
|
|
579
|
+
remediation: {
|
|
580
|
+
fr: () => `Envisagez de conserver un unique <h1> et de hiérarchiser le reste avec h2…h6 ; ce n'est pas une non-conformité.`,
|
|
581
|
+
en: () => `Consider keeping a single <h1> and structuring the rest with h2…h6; this is not a non-conformity.`,
|
|
582
|
+
},
|
|
583
|
+
},
|
|
584
|
+
"list-structure.invalid-child": {
|
|
585
|
+
message: {
|
|
586
|
+
fr: (p) => `<${p.childTag}> enfant direct de <${p.parentTag}> — une liste ne doit contenir que des <li>.`,
|
|
587
|
+
en: (p) => `<${p.childTag}> is a direct child of <${p.parentTag}> — a list must only contain <li>.`,
|
|
588
|
+
},
|
|
589
|
+
remediation: {
|
|
590
|
+
fr: (p) => `Enveloppez le contenu dans des <li>, ou utilisez un autre élément que <${p.parentTag}>.`,
|
|
591
|
+
en: (p) => `Wrap the content in <li> elements, or use a different element than <${p.parentTag}>.`,
|
|
592
|
+
},
|
|
593
|
+
},
|
|
594
|
+
"list-structure.li-outside-list": {
|
|
595
|
+
message: {
|
|
596
|
+
fr: (p) => `<li> hors d'une liste (<${p.parentTag}> parent) — structure de liste invalide.`,
|
|
597
|
+
en: (p) => `<li> outside a list (<${p.parentTag}> parent) — invalid list structure.`,
|
|
598
|
+
},
|
|
599
|
+
remediation: {
|
|
600
|
+
fr: () => `Placez chaque <li> directement dans un <ul>, <ol> ou <menu>.`,
|
|
601
|
+
en: () => `Place each <li> directly inside a <ul>, <ol> or <menu>.`,
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
"empty-heading": {
|
|
605
|
+
message: {
|
|
606
|
+
fr: (p) => `Titre <${p.tag}> de niveau ${p.level} vide — un titre sans intitulé désoriente la navigation au clavier/lecteur d'écran.`,
|
|
607
|
+
en: (p) => `Empty level-${p.level} <${p.tag}> heading — a heading with no label disorients keyboard/screen-reader navigation.`,
|
|
608
|
+
},
|
|
609
|
+
remediation: {
|
|
610
|
+
fr: () => `Donnez un intitulé textuel au titre, ou retirez-le s'il est purement décoratif.`,
|
|
611
|
+
en: () => `Give the heading a text label, or remove it if it is purely decorative.`,
|
|
612
|
+
},
|
|
613
|
+
},
|
|
614
|
+
|
|
615
|
+
// ---- Theme 10 — Presentation / zoom (src/rules/presentation.ts) -------------
|
|
616
|
+
"meta-viewport-zoom-block": {
|
|
617
|
+
message: {
|
|
618
|
+
fr: (p) =>
|
|
619
|
+
`<meta viewport> bloque le zoom (${p.blockedBy === "user-scalable" ? "user-scalable=no" : `maximum-scale=${p.maxScale}`}) — agrandissement à 200% empêché.`,
|
|
620
|
+
en: (p) =>
|
|
621
|
+
`<meta viewport> blocks zoom (${p.blockedBy === "user-scalable" ? "user-scalable=no" : `maximum-scale=${p.maxScale}`}) — 200% zoom is prevented.`,
|
|
622
|
+
},
|
|
623
|
+
remediation: {
|
|
624
|
+
fr: () => `Retirez user-scalable=no et maximum-scale (ou maximum-scale ≥ 2) du content du viewport.`,
|
|
625
|
+
en: () => `Remove user-scalable=no and maximum-scale (or set maximum-scale ≥ 2) from the viewport content.`,
|
|
626
|
+
},
|
|
627
|
+
},
|
|
628
|
+
"css-generated-content-informative": {
|
|
629
|
+
message: {
|
|
630
|
+
fr: (p) => `Contenu généré en CSS porteur de texte (content: "${p.text}") : invisible pour les technologies d'assistance.`,
|
|
631
|
+
en: (p) => `CSS generated content carrying text (content: "${p.text}"): invisible to assistive technologies.`,
|
|
632
|
+
},
|
|
633
|
+
remediation: {
|
|
634
|
+
fr: () => `Déplacez ce texte informatif dans le DOM ; réservez content aux éléments purement décoratifs.`,
|
|
635
|
+
en: () => `Move this informative text into the DOM; keep content for purely decorative elements.`,
|
|
636
|
+
},
|
|
637
|
+
},
|
|
638
|
+
|
|
639
|
+
// ---- Theme 11 — Forms (src/rules/forms.ts) ----------------------------------
|
|
640
|
+
"control-label-missing": {
|
|
641
|
+
message: {
|
|
642
|
+
fr: (p) => `Champ de formulaire <${p.tag}> sans étiquette — aucun label associé.`,
|
|
643
|
+
en: (p) => `Form field <${p.tag}> has no label — no associated label.`,
|
|
644
|
+
},
|
|
645
|
+
remediation: {
|
|
646
|
+
fr: () => `Associez un <label for="…"> (ou enveloppez le champ d'un <label>, ou aria-label/aria-labelledby).`,
|
|
647
|
+
en: () => `Associate a <label for="…"> (or wrap the field in a <label>, or use aria-label/aria-labelledby).`,
|
|
648
|
+
},
|
|
649
|
+
},
|
|
650
|
+
"placeholder-as-label": {
|
|
651
|
+
message: {
|
|
652
|
+
fr: (p) => `placeholder="${p.value}" utilisé comme seule étiquette — le placeholder n'est pas un label.`,
|
|
653
|
+
en: (p) => `placeholder="${p.value}" used as the only label — a placeholder is not a label.`,
|
|
654
|
+
},
|
|
655
|
+
remediation: {
|
|
656
|
+
fr: () => `Ajoutez un <label> réel ; le placeholder ne doit que compléter, pas remplacer l'étiquette.`,
|
|
657
|
+
en: () => `Add a real <label>; the placeholder should only supplement, not replace, the label.`,
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
"fieldset-legend-missing": {
|
|
661
|
+
message: {
|
|
662
|
+
fr: () => `<fieldset> sans <legend> (ou légende vide) — regroupement de champs sans légende.`,
|
|
663
|
+
en: () => `<fieldset> has no <legend> (or an empty one) — a field group with no legend.`,
|
|
664
|
+
},
|
|
665
|
+
remediation: {
|
|
666
|
+
fr: () => `Ajoutez un <legend> non vide en premier enfant du <fieldset>.`,
|
|
667
|
+
en: () => `Add a non-empty <legend> as the first child of the <fieldset>.`,
|
|
668
|
+
},
|
|
669
|
+
},
|
|
670
|
+
"form-field-multiple-labels": {
|
|
671
|
+
message: {
|
|
672
|
+
fr: (p) => `Champ <${p.tag}> référencé par ${p.count} <label for="${p.id}"> — étiquettes multiples ambiguës.`,
|
|
673
|
+
en: (p) => `Field <${p.tag}> referenced by ${p.count} <label for="${p.id}"> — ambiguous multiple labels.`,
|
|
674
|
+
},
|
|
675
|
+
remediation: {
|
|
676
|
+
fr: () => `Un seul <label> doit cibler le champ ; fusionnez ou retirez les étiquettes superflues.`,
|
|
677
|
+
en: () => `A single <label> should target the field; merge or remove the superfluous labels.`,
|
|
678
|
+
},
|
|
679
|
+
},
|
|
680
|
+
"select-has-option": {
|
|
681
|
+
message: {
|
|
682
|
+
fr: () => `<select> sans aucune <option> — liste de choix vide.`,
|
|
683
|
+
en: () => `<select> has no <option> at all — empty choice list.`,
|
|
684
|
+
},
|
|
685
|
+
remediation: {
|
|
686
|
+
fr: () => `Ajoutez des <option> (et un <optgroup>/option par défaut si pertinent).`,
|
|
687
|
+
en: () => `Add <option> elements (and an <optgroup>/default option if relevant).`,
|
|
688
|
+
},
|
|
689
|
+
},
|
|
690
|
+
"label-for-dangling": {
|
|
691
|
+
message: {
|
|
692
|
+
fr: (p) => `<label for="${p.id}"> ne cible aucun élément — aucun champ n'a id="${p.id}".`,
|
|
693
|
+
en: (p) => `<label for="${p.id}"> targets no element — no field has id="${p.id}".`,
|
|
694
|
+
},
|
|
695
|
+
remediation: {
|
|
696
|
+
fr: (p) => `Donnez au champ id="${p.id}", corrigez l'attribut for, ou enveloppez le champ dans le <label>.`,
|
|
697
|
+
en: (p) => `Give the field id="${p.id}", fix the for attribute, or wrap the field inside the <label>.`,
|
|
698
|
+
},
|
|
699
|
+
},
|
|
700
|
+
"aria-invalid-no-description": {
|
|
701
|
+
message: {
|
|
702
|
+
fr: (p) => `<${p.tag}> a aria-invalid="true" mais aucun aria-describedby/aria-errormessage — l'erreur est signalée sans être reliée à son message.`,
|
|
703
|
+
en: (p) => `<${p.tag}> has aria-invalid="true" but no aria-describedby/aria-errormessage — the error is signalled without being linked to its message.`,
|
|
704
|
+
},
|
|
705
|
+
remediation: {
|
|
706
|
+
fr: () => `Reliez le message d'erreur au champ via aria-describedby (ou aria-errormessage) pointant vers le texte d'erreur.`,
|
|
707
|
+
en: () => `Link the error message to the field via aria-describedby (or aria-errormessage) pointing at the error text.`,
|
|
708
|
+
},
|
|
709
|
+
},
|
|
710
|
+
"error-not-associated": {
|
|
711
|
+
message: {
|
|
712
|
+
fr: (p) => `Message d'erreur (id="${p.id}") relié à aucun champ — aucun aria-describedby/aria-errormessage ne le référence.`,
|
|
713
|
+
en: (p) => `Error message (id="${p.id}") linked to no field — no aria-describedby/aria-errormessage references it.`,
|
|
714
|
+
},
|
|
715
|
+
remediation: {
|
|
716
|
+
fr: (p) =>
|
|
717
|
+
`Sur le champ concerné, ajoutez aria-describedby="${p.id}" (ou aria-errormessage) et aria-invalid="true" ; restituez l'erreur via role="alert" (pas aria-live="polite"), sans à la fois déplacer le focus et recourir à aria-live/role="alert".`,
|
|
718
|
+
en: (p) =>
|
|
719
|
+
`On the relevant field, add aria-describedby="${p.id}" (or aria-errormessage) and aria-invalid="true"; announce the error via role="alert" (not aria-live="polite"), without both moving focus and using a live region.`,
|
|
720
|
+
},
|
|
721
|
+
},
|
|
722
|
+
"field-purpose-incomplete.autocomplete": {
|
|
723
|
+
message: {
|
|
724
|
+
fr: (p) =>
|
|
725
|
+
`Champ d'identification (${p.type === "email" || p.type === "tel" ? `type="${p.type}"` : "name/id"}) sans autocomplete — objet du champ non exposé.`,
|
|
726
|
+
en: (p) =>
|
|
727
|
+
`Identification field (${p.type === "email" || p.type === "tel" ? `type="${p.type}"` : "name/id"}) with no autocomplete — the field's purpose is not exposed.`,
|
|
728
|
+
},
|
|
729
|
+
remediation: {
|
|
730
|
+
fr: () => `Ajoutez un autocomplete approprié (ex. email, tel, name, postal-code, street-address) — WCAG 1.3.5.`,
|
|
731
|
+
en: () => `Add an appropriate autocomplete (e.g. email, tel, name, postal-code, street-address) — WCAG 1.3.5.`,
|
|
732
|
+
},
|
|
733
|
+
},
|
|
734
|
+
"field-purpose-incomplete.aria-required": {
|
|
735
|
+
message: {
|
|
736
|
+
fr: (p) => `Widget personnalisé (role="${p.role}") requis sans aria-required — l'état requis n'est pas restitué.`,
|
|
737
|
+
en: (p) => `Custom widget (role="${p.role}") is required but has no aria-required — the required state is not conveyed.`,
|
|
738
|
+
},
|
|
739
|
+
remediation: {
|
|
740
|
+
fr: () => `Ajoutez aria-required="true" sur le widget personnalisé requis.`,
|
|
741
|
+
en: () => `Add aria-required="true" on the required custom widget.`,
|
|
742
|
+
},
|
|
743
|
+
},
|
|
744
|
+
"disabled-context-content.fieldset": {
|
|
745
|
+
message: {
|
|
746
|
+
fr: () => `<fieldset disabled> encapsulant des champs en lecture seule — le contenu risque d'être ignoré par les technologies d'assistance.`,
|
|
747
|
+
en: () => `<fieldset disabled> wrapping read-only fields — the content may be ignored by assistive technologies.`,
|
|
748
|
+
},
|
|
749
|
+
remediation: {
|
|
750
|
+
fr: () => `Remplacez le conteneur désactivé par un <div> (ou aria-disabled="true" + gestion clavier en JS) afin que le contenu reste restitué.`,
|
|
751
|
+
en: () => `Replace the disabled container with a <div> (or aria-disabled="true" + JS keyboard handling) so the content stays exposed.`,
|
|
752
|
+
},
|
|
753
|
+
},
|
|
754
|
+
"disabled-context-content.inert": {
|
|
755
|
+
message: {
|
|
756
|
+
fr: () => `Conteneur [inert] encapsulant des champs de formulaire — leur contenu est retiré de l'arbre d'accessibilité.`,
|
|
757
|
+
en: () => `[inert] container wrapping form fields — their content is taken out of the accessibility tree.`,
|
|
758
|
+
},
|
|
759
|
+
remediation: {
|
|
760
|
+
fr: () => `Retirez [inert] du conteneur en lecture seule (ou aria-disabled="true" + JS) pour que le contenu reste restitué.`,
|
|
761
|
+
en: () => `Take [inert] off the read-only container (or use aria-disabled="true" + JS) so the content stays exposed.`,
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
"radio-checkbox-group-ungrouped": {
|
|
765
|
+
message: {
|
|
766
|
+
fr: (p) => `${p.count} champs ${p.type} partageant name="${p.name}" hors de tout <fieldset>/role="radiogroup"/role="group" — regroupement absent.`,
|
|
767
|
+
en: (p) => `${p.count} ${p.type} fields sharing name="${p.name}" outside any <fieldset>/role="radiogroup"/role="group" — grouping is missing.`,
|
|
768
|
+
},
|
|
769
|
+
remediation: {
|
|
770
|
+
fr: () => `Regroupez ces champs de même nature dans un <fieldset> avec <legend> (ou un role="radiogroup"/role="group" nommé).`,
|
|
771
|
+
en: () => `Group these same-nature fields in a <fieldset> with a <legend> (or a named role="radiogroup"/role="group").`,
|
|
772
|
+
},
|
|
773
|
+
},
|
|
774
|
+
"date-fields-ungrouped": {
|
|
775
|
+
message: {
|
|
776
|
+
fr: (p) => `${p.count} champs de date adjacents hors de tout <fieldset>/role="group" — champs de même nature non regroupés.`,
|
|
777
|
+
en: (p) => `${p.count} adjacent date fields outside any <fieldset>/role="group" — same-nature fields left ungrouped.`,
|
|
778
|
+
},
|
|
779
|
+
remediation: {
|
|
780
|
+
fr: () => `Regroupez les champs de date (jour/mois/année, début/fin) dans un <fieldset> avec une <legend> décrivant la période.`,
|
|
781
|
+
en: () => `Group the date fields (day/month/year, start/end) in a <fieldset> with a <legend> describing the period.`,
|
|
782
|
+
},
|
|
783
|
+
},
|
|
784
|
+
|
|
785
|
+
// ---- Theme 12 — Navigation & landmarks (src/rules/navigation.ts) ------------
|
|
786
|
+
"skip-link-target-missing": {
|
|
787
|
+
message: {
|
|
788
|
+
fr: (p) => `Lien interne href="${p.href}" — la cible #${p.id} n'existe pas dans la page (lien d'évitement/ancre cassé).`,
|
|
789
|
+
en: (p) => `Internal link href="${p.href}" — target #${p.id} does not exist in the page (broken skip link/anchor).`,
|
|
790
|
+
},
|
|
791
|
+
remediation: {
|
|
792
|
+
fr: (p) => `Ajoutez un élément avec id="${p.id}" (ex. <main id="${p.id}">) ou corrigez l'ancre.`,
|
|
793
|
+
en: (p) => `Add an element with id="${p.id}" (e.g. <main id="${p.id}">) or fix the anchor.`,
|
|
794
|
+
},
|
|
795
|
+
},
|
|
796
|
+
"positive-tabindex": {
|
|
797
|
+
message: {
|
|
798
|
+
fr: (p) => `tabindex="${p.value}" positif — force un ordre de tabulation incohérent avec l'ordre du DOM.`,
|
|
799
|
+
en: (p) => `Positive tabindex="${p.value}" — forces a tab order inconsistent with DOM order.`,
|
|
800
|
+
},
|
|
801
|
+
remediation: {
|
|
802
|
+
fr: () => `Utilisez tabindex="0" (ou pas de tabindex) et ordonnez via le DOM ; réservez les valeurs >0.`,
|
|
803
|
+
en: () => `Use tabindex="0" (or no tabindex) and order via the DOM; reserve values >0.`,
|
|
804
|
+
},
|
|
805
|
+
},
|
|
806
|
+
"missing-main-landmark": {
|
|
807
|
+
message: {
|
|
808
|
+
fr: () => `Aucun repère <main> dans la page — le contenu principal n'est pas identifié (et la cible d'un lien d'évitement manque).`,
|
|
809
|
+
en: () => `No <main> landmark in the page — the main content is not identified (and a skip link's target is missing).`,
|
|
810
|
+
},
|
|
811
|
+
remediation: {
|
|
812
|
+
fr: () => `Enveloppez le contenu principal dans un <main id="content"> (unique par page).`,
|
|
813
|
+
en: () => `Wrap the main content in a <main id="content"> (unique per page).`,
|
|
814
|
+
},
|
|
815
|
+
},
|
|
816
|
+
"multiple-main-landmark": {
|
|
817
|
+
message: {
|
|
818
|
+
fr: (p) => `Plusieurs repères <main> dans la page (${p.count}) — un seul contenu principal est autorisé.`,
|
|
819
|
+
en: (p) => `Multiple <main> landmarks in the page (${p.count}) — only one main content area is allowed.`,
|
|
820
|
+
},
|
|
821
|
+
remediation: {
|
|
822
|
+
fr: () => `Conservez un unique <main>/role="main" ; structurez le reste avec <section>/<aside>.`,
|
|
823
|
+
en: () => `Keep a single <main>/role="main"; structure the rest with <section>/<aside>.`,
|
|
824
|
+
},
|
|
825
|
+
},
|
|
826
|
+
"nav-landmark-missing": {
|
|
827
|
+
message: {
|
|
828
|
+
fr: (p) => `Groupe de ${p.count} liens de navigation dans <${p.region}> sans repère de navigation (<nav> ou role="navigation") dans la page.`,
|
|
829
|
+
en: (p) => `A cluster of ${p.count} navigation links in <${p.region}> with no navigation landmark (<nav> or role="navigation") in the page.`,
|
|
830
|
+
},
|
|
831
|
+
remediation: {
|
|
832
|
+
fr: () => `Encapsulez les liens de navigation dans un <nav> (ou role="navigation") pour exposer un repère de navigation.`,
|
|
833
|
+
en: () => `Wrap the navigation links in a <nav> (or role="navigation") to expose a navigation landmark.`,
|
|
834
|
+
},
|
|
835
|
+
},
|
|
836
|
+
"nav-landmark-unnamed": {
|
|
837
|
+
message: {
|
|
838
|
+
fr: () => `Plusieurs repères de navigation dans la page ; celui-ci n'a ni aria-label ni aria-labelledby pour le distinguer.`,
|
|
839
|
+
en: () => `Several navigation landmarks in the page; this one has neither aria-label nor aria-labelledby to tell them apart.`,
|
|
840
|
+
},
|
|
841
|
+
remediation: {
|
|
842
|
+
fr: () => `Donnez à chaque <nav> un aria-label (ou aria-labelledby) distinct, ex. « Menu principal » / « Fil d'Ariane ».`,
|
|
843
|
+
en: () => `Give each <nav> a distinct aria-label (or aria-labelledby), e.g. "Primary" / "Breadcrumb".`,
|
|
844
|
+
},
|
|
845
|
+
},
|
|
846
|
+
|
|
847
|
+
// ---- Theme 4/13 — Multimedia (src/rules/multimedia.ts) ----------------------
|
|
848
|
+
"autoplay-media.muted-video": {
|
|
849
|
+
message: {
|
|
850
|
+
fr: () => `<video autoplay> démarre automatiquement — contenu en mouvement non contrôlé.`,
|
|
851
|
+
en: () => `<video autoplay> starts automatically — uncontrolled moving content.`,
|
|
852
|
+
},
|
|
853
|
+
remediation: {
|
|
854
|
+
fr: () => `Évitez l'autoplay ou fournissez un contrôle pause/stop accessible (et controls).`,
|
|
855
|
+
en: () => `Avoid autoplay, or provide an accessible pause/stop control (and controls).`,
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
"autoplay-media.audible": {
|
|
859
|
+
message: {
|
|
860
|
+
fr: (p) => `<${p.tag} autoplay> démarre automatiquement ${p.tag === "audio" ? "du son" : "une vidéo sonore"} — non contrôlable par l'utilisateur.`,
|
|
861
|
+
en: (p) => `<${p.tag} autoplay> automatically starts ${p.tag === "audio" ? "sound" : "a video with sound"} — not controllable by the user.`,
|
|
862
|
+
},
|
|
863
|
+
remediation: {
|
|
864
|
+
fr: () => `Retirez autoplay, ou ajoutez un contrôle de lecture (controls + pause/stop) facilement accessible.`,
|
|
865
|
+
en: () => `Remove autoplay, or add an easily accessible playback control (controls + pause/stop).`,
|
|
866
|
+
},
|
|
867
|
+
},
|
|
868
|
+
"media-no-track": {
|
|
869
|
+
message: {
|
|
870
|
+
fr: () => `<video> sans élément <track> — aucune piste de sous-titres/légendes synchronisées.`,
|
|
871
|
+
en: () => `<video> has no <track> element — no synchronized captions/subtitles track.`,
|
|
872
|
+
},
|
|
873
|
+
remediation: {
|
|
874
|
+
fr: () => `Ajoutez <track kind="captions" src="…" srclang="fr" label="Français"> (ou subtitles) pour le média synchronisé.`,
|
|
875
|
+
en: () => `Add <track kind="captions" src="…" srclang="en" label="English"> (or subtitles) for the synchronized media.`,
|
|
876
|
+
},
|
|
877
|
+
},
|
|
878
|
+
|
|
879
|
+
// ---- Theme 13 — Timing & moving content (src/rules/timing.ts) ---------------
|
|
880
|
+
"meta-refresh-redirect": {
|
|
881
|
+
message: {
|
|
882
|
+
fr: (p) =>
|
|
883
|
+
`<meta http-equiv="refresh" content="${p.content}"> impose un délai de ${p.seconds}s — rafraîchissement/redirection temporisé non contrôlable.`,
|
|
884
|
+
en: (p) => `<meta http-equiv="refresh" content="${p.content}"> imposes a ${p.seconds}s delay — an uncontrollable timed refresh/redirect.`,
|
|
885
|
+
},
|
|
886
|
+
remediation: {
|
|
887
|
+
fr: () => `Supprimez le meta refresh temporisé, ou laissez l'utilisateur contrôler/désactiver/prolonger le délai.`,
|
|
888
|
+
en: () => `Remove the timed meta refresh, or let the user control/disable/extend the delay.`,
|
|
889
|
+
},
|
|
890
|
+
},
|
|
891
|
+
"blink-marquee": {
|
|
892
|
+
message: {
|
|
893
|
+
fr: (p) => `<${p.tag}> — contenu en mouvement/clignotant automatique sans mécanisme de pause.`,
|
|
894
|
+
en: (p) => `<${p.tag}> — automatic moving/blinking content with no pause mechanism.`,
|
|
895
|
+
},
|
|
896
|
+
remediation: {
|
|
897
|
+
fr: (p) => `Remplacez <${p.tag}> par du contenu statique, ou fournissez un contrôle pause/stop/masquer.`,
|
|
898
|
+
en: (p) => `Replace <${p.tag}> with static content, or provide a pause/stop/hide control.`,
|
|
899
|
+
},
|
|
900
|
+
},
|
|
901
|
+
|
|
902
|
+
// ---- Cross-file rules (src/rules/cross-registry.ts) -------------------------
|
|
903
|
+
"cross-icon-only-unnamed": {
|
|
904
|
+
message: {
|
|
905
|
+
fr: (p) => `<${p.tag}> rend un contrôle à icône seule mais est utilisé sans nom accessible (aucun aria-label/title/texte passé).`,
|
|
906
|
+
en: (p) => `<${p.tag}> renders an icon-only control but is used with no accessible name (no aria-label/title/text passed).`,
|
|
907
|
+
},
|
|
908
|
+
remediation: {
|
|
909
|
+
fr: (p) => `Passez un nom au composant à cet endroit, p. ex. <${p.tag} aria-label="…" /> (le composant ${p.defName} rend une icône sans texte).`,
|
|
910
|
+
en: (p) => `Pass a name to the component at this usage, e.g. <${p.tag} aria-label="…" /> (the component ${p.defName} renders an icon with no text).`,
|
|
911
|
+
},
|
|
912
|
+
},
|
|
913
|
+
"cross-prop-drilled-name-lost": {
|
|
914
|
+
message: {
|
|
915
|
+
fr: (p) => `<${p.tag} ${p.passed}="…"> mais ${p.defName} ne transmet pas ce nom au contrôle rendu — le nom accessible est perdu.`,
|
|
916
|
+
en: (p) => `<${p.tag} ${p.passed}="…"> but ${p.defName} does not forward this name to the rendered control — the accessible name is lost.`,
|
|
917
|
+
},
|
|
918
|
+
remediation: {
|
|
919
|
+
fr: (p) => `Dans ${p.defName}, transmettez ${p.passed} (ou {...props}) au <button>/<a> rendu, ou nommez le contrôle directement.`,
|
|
920
|
+
en: (p) => `In ${p.defName}, forward ${p.passed} (or {...props}) to the rendered <button>/<a>, or name the control directly.`,
|
|
921
|
+
},
|
|
922
|
+
},
|
|
923
|
+
|
|
924
|
+
// ---- Dynamic tier (src/scan.ts mergeDynamic) ---------------------------------
|
|
925
|
+
// `scan --merge` folds axe-core/probe findings into a static AuditResult. Their
|
|
926
|
+
// MESSAGE is either ultra11y's own authored prose (the 320px reflow check) or the
|
|
927
|
+
// rendering engine's own text (axe-core's English violation help, or a probe's own
|
|
928
|
+
// detail string) — never ultra11y prose in the latter case. Only the REMEDIATION
|
|
929
|
+
// is always ultra11y-authored. Both entries below give these findings a `msg.id`
|
|
930
|
+
// (set in mergeDynamic) so a LATER `report`/`prd --lang` re-render can still
|
|
931
|
+
// resolve the OTHER language, instead of staying stuck in whatever language was
|
|
932
|
+
// baked at merge/scan time.
|
|
933
|
+
"dyn-reflow": {
|
|
934
|
+
message: {
|
|
935
|
+
fr: () => `Défilement horizontal à 320px de large — le contenu ne se redistribue pas (reflow).`,
|
|
936
|
+
en: () => `Horizontal scrolling at 320px width — content does not reflow.`,
|
|
937
|
+
},
|
|
938
|
+
remediation: {
|
|
939
|
+
fr: () => `Vérifié au rendu par axe-core ; corrigez l'élément cité.`,
|
|
940
|
+
en: () => `Verified at render time by axe-core; fix the cited element.`,
|
|
941
|
+
},
|
|
942
|
+
},
|
|
943
|
+
"dyn-remediation": {
|
|
944
|
+
// Covers axe-core findings and the residual-criteria probes (focus-visible,
|
|
945
|
+
// text-spacing, hover, reflow-zoom). Their message is authored by the engine
|
|
946
|
+
// itself, not by ultra11y — it passes through UNCHANGED in both languages (never
|
|
947
|
+
// translated; axe-core only ever speaks English), carried verbatim via `params.
|
|
948
|
+
// message`. This is the documented "axe's own English messages stay as-is".
|
|
949
|
+
message: {
|
|
950
|
+
fr: (p) => String(p.message ?? ""),
|
|
951
|
+
en: (p) => String(p.message ?? ""),
|
|
952
|
+
},
|
|
953
|
+
remediation: {
|
|
954
|
+
fr: () => `Vérifié au rendu par axe-core ; corrigez l'élément cité.`,
|
|
955
|
+
en: () => `Verified at render time by axe-core; fix the cited element.`,
|
|
956
|
+
},
|
|
957
|
+
},
|
|
958
|
+
};
|
|
959
|
+
|
|
960
|
+
// ---- Cross-file RelatedSite notes (src/rules/cross-registry.ts) -------------------
|
|
961
|
+
// Mirrors MSG_CATALOG's fr/en split, but for `Finding.related.note` — the short label
|
|
962
|
+
// describing the OTHER site (e.g. a component definition) a cross-file finding points
|
|
963
|
+
// at. Unlike message/remediation these carry no params, so entries are plain strings.
|
|
964
|
+
// The fr strings are the ORIGINAL author strings, moved here verbatim; the en strings
|
|
965
|
+
// are faithful technical translations written for this catalog.
|
|
966
|
+
export const NOTE_CATALOG: Record<string, Record<Lang, string>> = {
|
|
967
|
+
"related.icon-component-def": {
|
|
968
|
+
fr: "définition du composant à icône seule",
|
|
969
|
+
en: "icon-only component definition",
|
|
970
|
+
},
|
|
971
|
+
"related.name-drop-def": {
|
|
972
|
+
fr: "contrôle qui ne reçoit pas le nom passé",
|
|
973
|
+
en: "control that does not receive the passed name",
|
|
974
|
+
},
|
|
975
|
+
};
|
|
976
|
+
|
|
977
|
+
function entryFor(id: string | undefined): MsgEntry | undefined {
|
|
978
|
+
return id === undefined ? undefined : MSG_CATALOG[id];
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
/** Resolve a finding's message by lang: the finding's own localized `i18n` pair first
|
|
982
|
+
* (declarative pack rules, which cannot register into the compiled catalog), then the
|
|
983
|
+
* catalog template when `f.msg` is present and known, else the baked `f.message`
|
|
984
|
+
* (back-compat with old JSON / unknown ids). */
|
|
985
|
+
export function resolveMessage(
|
|
986
|
+
f: { message: string; msg?: { id: string; params?: MsgParams }; i18n?: { message: Partial<Record<Lang, string>> } },
|
|
987
|
+
lang: Lang,
|
|
988
|
+
): string {
|
|
989
|
+
const localized = f.i18n?.message?.[lang];
|
|
990
|
+
if (localized) return localized;
|
|
991
|
+
const entry = entryFor(f.msg?.id);
|
|
992
|
+
return entry ? entry.message[lang](f.msg?.params ?? {}) : f.message;
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** Resolve a finding's remediation by lang — mirrors resolveMessage. */
|
|
996
|
+
export function resolveRemediation(
|
|
997
|
+
f: { remediation: string; msg?: { id: string; params?: MsgParams }; i18n?: { remediation: Partial<Record<Lang, string>> } },
|
|
998
|
+
lang: Lang,
|
|
999
|
+
): string {
|
|
1000
|
+
const localized = f.i18n?.remediation?.[lang];
|
|
1001
|
+
if (localized) return localized;
|
|
1002
|
+
const entry = entryFor(f.msg?.id);
|
|
1003
|
+
return entry ? entry.remediation[lang](f.msg?.params ?? {}) : f.remediation;
|
|
1004
|
+
}
|
|
1005
|
+
|
|
1006
|
+
/** Resolve a cross-file RelatedSite's note by lang: NOTE_CATALOG entry when `noteId` is
|
|
1007
|
+
* present and known, else the baked `note` (back-compat with old JSON / unknown ids). */
|
|
1008
|
+
export function resolveNote(related: { note: string; noteId?: string }, lang: Lang): string {
|
|
1009
|
+
const entry = related.noteId ? NOTE_CATALOG[related.noteId] : undefined;
|
|
1010
|
+
return entry ? entry[lang] : related.note;
|
|
1011
|
+
}
|