@jay-framework/a11y-validator 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +5 -0
- package/dist/index.js +254 -0
- package/package.json +40 -0
- package/plugin.yaml +6 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { walkElements } from "@jay-framework/compiler-shared";
|
|
2
|
+
const INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set([
|
|
3
|
+
"a",
|
|
4
|
+
"button",
|
|
5
|
+
"input",
|
|
6
|
+
"select",
|
|
7
|
+
"textarea"
|
|
8
|
+
]);
|
|
9
|
+
const NON_INTERACTIVE_ELEMENTS = /* @__PURE__ */ new Set([
|
|
10
|
+
"div",
|
|
11
|
+
"span",
|
|
12
|
+
"p",
|
|
13
|
+
"section",
|
|
14
|
+
"article",
|
|
15
|
+
"header",
|
|
16
|
+
"footer",
|
|
17
|
+
"main",
|
|
18
|
+
"nav",
|
|
19
|
+
"aside",
|
|
20
|
+
"li",
|
|
21
|
+
"ul",
|
|
22
|
+
"ol"
|
|
23
|
+
]);
|
|
24
|
+
const VALID_ARIA_ROLES = /* @__PURE__ */ new Set([
|
|
25
|
+
"alert",
|
|
26
|
+
"alertdialog",
|
|
27
|
+
"application",
|
|
28
|
+
"article",
|
|
29
|
+
"banner",
|
|
30
|
+
"button",
|
|
31
|
+
"cell",
|
|
32
|
+
"checkbox",
|
|
33
|
+
"columnheader",
|
|
34
|
+
"combobox",
|
|
35
|
+
"complementary",
|
|
36
|
+
"contentinfo",
|
|
37
|
+
"definition",
|
|
38
|
+
"dialog",
|
|
39
|
+
"directory",
|
|
40
|
+
"document",
|
|
41
|
+
"feed",
|
|
42
|
+
"figure",
|
|
43
|
+
"form",
|
|
44
|
+
"grid",
|
|
45
|
+
"gridcell",
|
|
46
|
+
"group",
|
|
47
|
+
"heading",
|
|
48
|
+
"img",
|
|
49
|
+
"link",
|
|
50
|
+
"list",
|
|
51
|
+
"listbox",
|
|
52
|
+
"listitem",
|
|
53
|
+
"log",
|
|
54
|
+
"main",
|
|
55
|
+
"marquee",
|
|
56
|
+
"math",
|
|
57
|
+
"menu",
|
|
58
|
+
"menubar",
|
|
59
|
+
"menuitem",
|
|
60
|
+
"menuitemcheckbox",
|
|
61
|
+
"menuitemradio",
|
|
62
|
+
"meter",
|
|
63
|
+
"navigation",
|
|
64
|
+
"none",
|
|
65
|
+
"note",
|
|
66
|
+
"option",
|
|
67
|
+
"presentation",
|
|
68
|
+
"progressbar",
|
|
69
|
+
"radio",
|
|
70
|
+
"radiogroup",
|
|
71
|
+
"region",
|
|
72
|
+
"row",
|
|
73
|
+
"rowgroup",
|
|
74
|
+
"rowheader",
|
|
75
|
+
"scrollbar",
|
|
76
|
+
"search",
|
|
77
|
+
"searchbox",
|
|
78
|
+
"separator",
|
|
79
|
+
"slider",
|
|
80
|
+
"spinbutton",
|
|
81
|
+
"status",
|
|
82
|
+
"switch",
|
|
83
|
+
"tab",
|
|
84
|
+
"table",
|
|
85
|
+
"tablist",
|
|
86
|
+
"tabpanel",
|
|
87
|
+
"term",
|
|
88
|
+
"textbox",
|
|
89
|
+
"timer",
|
|
90
|
+
"toolbar",
|
|
91
|
+
"tooltip",
|
|
92
|
+
"tree",
|
|
93
|
+
"treegrid",
|
|
94
|
+
"treeitem"
|
|
95
|
+
]);
|
|
96
|
+
const LABELABLE_INPUTS = /* @__PURE__ */ new Set([
|
|
97
|
+
"text",
|
|
98
|
+
"password",
|
|
99
|
+
"email",
|
|
100
|
+
"tel",
|
|
101
|
+
"url",
|
|
102
|
+
"number",
|
|
103
|
+
"search",
|
|
104
|
+
"date",
|
|
105
|
+
"time",
|
|
106
|
+
"datetime-local",
|
|
107
|
+
"month",
|
|
108
|
+
"week",
|
|
109
|
+
"color",
|
|
110
|
+
"file",
|
|
111
|
+
"range"
|
|
112
|
+
]);
|
|
113
|
+
const validate = (ctx) => {
|
|
114
|
+
const findings = [];
|
|
115
|
+
const labelForIds = /* @__PURE__ */ new Set();
|
|
116
|
+
collectLabelForIds(ctx.body, labelForIds);
|
|
117
|
+
walkElements(ctx.body, ctx, (el) => {
|
|
118
|
+
const tag = el.rawTagName?.toLowerCase();
|
|
119
|
+
if (!tag)
|
|
120
|
+
return;
|
|
121
|
+
if (tag === "img") {
|
|
122
|
+
const alt = el.getAttribute?.("alt");
|
|
123
|
+
if (alt === void 0 || alt === null) {
|
|
124
|
+
findings.push({
|
|
125
|
+
severity: "error",
|
|
126
|
+
message: "Image missing alt attribute (WCAG 1.1.1)",
|
|
127
|
+
suggestion: 'Add an alt attribute. Use descriptive text for informative images, or alt="" for purely decorative images.',
|
|
128
|
+
element: "<img>",
|
|
129
|
+
attribute: "alt"
|
|
130
|
+
});
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
if (tag === "input") {
|
|
134
|
+
const type = (el.getAttribute?.("type") || "text").toLowerCase();
|
|
135
|
+
if (type === "hidden" || type === "submit" || type === "button" || type === "reset") {
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
if (!LABELABLE_INPUTS.has(type))
|
|
139
|
+
return;
|
|
140
|
+
checkLabel(el, tag, findings, labelForIds);
|
|
141
|
+
}
|
|
142
|
+
if (tag === "select" || tag === "textarea") {
|
|
143
|
+
checkLabel(el, tag, findings, labelForIds);
|
|
144
|
+
}
|
|
145
|
+
if (tag === "button") {
|
|
146
|
+
const text = el.textContent?.trim();
|
|
147
|
+
const ariaLabel = el.getAttribute?.("aria-label");
|
|
148
|
+
const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
|
|
149
|
+
const hasImg = el.querySelector?.("img[alt]");
|
|
150
|
+
if (!text && !ariaLabel && !ariaLabelledBy && !hasImg) {
|
|
151
|
+
findings.push({
|
|
152
|
+
severity: "error",
|
|
153
|
+
message: "Button has no accessible name (WCAG 4.1.2)",
|
|
154
|
+
suggestion: "Add text content, an aria-label, or an aria-labelledby attribute to the button.",
|
|
155
|
+
element: "<button>"
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (INTERACTIVE_ELEMENTS.has(tag) || el.getAttribute?.("role")) {
|
|
160
|
+
const tabindex = el.getAttribute?.("tabindex");
|
|
161
|
+
if (tabindex !== void 0 && tabindex !== null) {
|
|
162
|
+
const val = parseInt(tabindex, 10);
|
|
163
|
+
if (!isNaN(val) && val > 0) {
|
|
164
|
+
findings.push({
|
|
165
|
+
severity: "warning",
|
|
166
|
+
message: `Positive tabindex="${tabindex}" disrupts natural tab order (WCAG 2.4.3)`,
|
|
167
|
+
suggestion: 'Use tabindex="0" to add to natural tab order, or tabindex="-1" for programmatic focus. Avoid positive values — they override the DOM order and confuse keyboard users.',
|
|
168
|
+
element: `<${tag}>`,
|
|
169
|
+
attribute: "tabindex"
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (tag === "video" || tag === "audio") {
|
|
175
|
+
const autoplay = el.getAttribute?.("autoplay");
|
|
176
|
+
if (autoplay !== void 0 && autoplay !== null) {
|
|
177
|
+
const muted = el.getAttribute?.("muted");
|
|
178
|
+
if (muted === void 0 || muted === null) {
|
|
179
|
+
findings.push({
|
|
180
|
+
severity: "error",
|
|
181
|
+
message: `<${tag}> has autoplay without muted (WCAG 1.4.2)`,
|
|
182
|
+
suggestion: `Add the muted attribute to <${tag} autoplay>, or remove autoplay. Autoplaying audio is disruptive to screen reader users.`,
|
|
183
|
+
element: `<${tag}>`,
|
|
184
|
+
attribute: "autoplay"
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
const role = el.getAttribute?.("role");
|
|
190
|
+
if (role !== void 0 && role !== null) {
|
|
191
|
+
if (!VALID_ARIA_ROLES.has(role)) {
|
|
192
|
+
findings.push({
|
|
193
|
+
severity: "error",
|
|
194
|
+
message: `Invalid ARIA role="${role}" (WCAG 4.1.2)`,
|
|
195
|
+
suggestion: `"${role}" is not a valid WAI-ARIA role. Use a valid role such as "button", "link", "navigation", "dialog", etc.`,
|
|
196
|
+
element: `<${tag}>`,
|
|
197
|
+
attribute: "role"
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (NON_INTERACTIVE_ELEMENTS.has(tag)) {
|
|
202
|
+
const tabindex = el.getAttribute?.("tabindex");
|
|
203
|
+
if (tabindex !== void 0 && tabindex !== null) {
|
|
204
|
+
const val = parseInt(tabindex, 10);
|
|
205
|
+
if (!isNaN(val) && val >= 0 && !role) {
|
|
206
|
+
findings.push({
|
|
207
|
+
severity: "warning",
|
|
208
|
+
message: `<${tag}> is focusable via tabindex but has no role (WCAG 4.1.2)`,
|
|
209
|
+
suggestion: `Add a role attribute to indicate the element's purpose to screen readers. Example: <div tabindex="0" role="button"> or <span tabindex="0" role="link">.`,
|
|
210
|
+
element: `<${tag}>`,
|
|
211
|
+
attribute: "role"
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
return findings;
|
|
218
|
+
};
|
|
219
|
+
function checkLabel(el, tag, findings, labelForIds) {
|
|
220
|
+
const id = el.getAttribute?.("id");
|
|
221
|
+
const ariaLabel = el.getAttribute?.("aria-label");
|
|
222
|
+
const ariaLabelledBy = el.getAttribute?.("aria-labelledby");
|
|
223
|
+
if (ariaLabel || ariaLabelledBy)
|
|
224
|
+
return;
|
|
225
|
+
if (id && labelForIds.has(id))
|
|
226
|
+
return;
|
|
227
|
+
let parent = el.parentNode;
|
|
228
|
+
while (parent) {
|
|
229
|
+
if (parent.rawTagName?.toLowerCase() === "label")
|
|
230
|
+
return;
|
|
231
|
+
parent = parent.parentNode;
|
|
232
|
+
}
|
|
233
|
+
findings.push({
|
|
234
|
+
severity: "error",
|
|
235
|
+
message: `<${tag}> has no associated label (WCAG 1.3.1)`,
|
|
236
|
+
suggestion: `Add a <label for="${id || "inputId"}"> that references this ${tag}'s id, wrap it in a <label>, or add an aria-label attribute.`,
|
|
237
|
+
element: `<${tag}>`,
|
|
238
|
+
attribute: "id"
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
function collectLabelForIds(el, ids) {
|
|
242
|
+
if (el.rawTagName?.toLowerCase() === "label") {
|
|
243
|
+
const forId = el.getAttribute?.("for");
|
|
244
|
+
if (forId)
|
|
245
|
+
ids.add(forId);
|
|
246
|
+
}
|
|
247
|
+
for (const child of el.childNodes ?? []) {
|
|
248
|
+
if (child.nodeType === 1)
|
|
249
|
+
collectLabelForIds(child, ids);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
export {
|
|
253
|
+
validate
|
|
254
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@jay-framework/a11y-validator",
|
|
3
|
+
"version": "0.19.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Accessibility validation plugin for Jay Framework — checks jay-html templates for WCAG best practices",
|
|
6
|
+
"license": "Apache-2.0",
|
|
7
|
+
"main": "dist/index.js",
|
|
8
|
+
"files": [
|
|
9
|
+
"dist",
|
|
10
|
+
"plugin.yaml"
|
|
11
|
+
],
|
|
12
|
+
"exports": {
|
|
13
|
+
".": "./dist/index.js",
|
|
14
|
+
"./plugin.yaml": "./plugin.yaml"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "npm run clean && npm run build:server && npm run build:types && npm run validate",
|
|
18
|
+
"validate": "jay-stack-cli validate-plugin",
|
|
19
|
+
"build:server": "vite build --ssr",
|
|
20
|
+
"build:types": "tsup lib/index.ts --dts-only --format esm",
|
|
21
|
+
"build:check-types": "tsc",
|
|
22
|
+
"clean": "rimraf dist",
|
|
23
|
+
"confirm": "npm run clean && npm run build && npm run test",
|
|
24
|
+
"test": "vitest run"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@jay-framework/compiler-shared": "^0.19.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@jay-framework/dev-environment": "^0.19.0",
|
|
31
|
+
"@jay-framework/jay-stack-cli": "^0.19.0",
|
|
32
|
+
"@types/node": "^22.15.21",
|
|
33
|
+
"node-html-parser": "^6.1.0",
|
|
34
|
+
"rimraf": "^5.0.5",
|
|
35
|
+
"tsup": "^8.0.1",
|
|
36
|
+
"typescript": "^5.3.3",
|
|
37
|
+
"vite": "^5.0.11",
|
|
38
|
+
"vitest": "^1.2.1"
|
|
39
|
+
}
|
|
40
|
+
}
|