@blamejs/core 0.7.104 → 0.7.106
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/CHANGELOG.md +4 -0
- package/lib/compliance-sanctions-aliases.js +167 -0
- package/lib/compliance-sanctions-fetcher.js +206 -0
- package/lib/compliance-sanctions-fuzzy.js +297 -0
- package/lib/compliance-sanctions.js +569 -0
- package/lib/compliance.js +2 -0
- package/lib/guard-html-wcag-aria.js +164 -0
- package/lib/guard-html-wcag-forms.js +144 -0
- package/lib/guard-html-wcag-tables.js +154 -0
- package/lib/guard-html-wcag-tagwalk.js +44 -0
- package/lib/guard-html-wcag.js +470 -0
- package/lib/guard-html.js +4 -0
- package/package.json +1 -1
- package/sbom.cyclonedx.json +6 -6
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
var validateOpts = require("./validate-opts");
|
|
4
|
+
var tagwalk = require("./guard-html-wcag-tagwalk");
|
|
5
|
+
|
|
6
|
+
var KNOWN_ROLES = Object.freeze([
|
|
7
|
+
"banner","complementary","contentinfo","form","main","navigation","region","search",
|
|
8
|
+
"alert","alertdialog","log","marquee","status","timer",
|
|
9
|
+
"article","definition","directory","document","feed","figure","group","heading","img",
|
|
10
|
+
"list","listitem","math","note","presentation","none","row","rowgroup","rowheader",
|
|
11
|
+
"separator","table","term","toolbar","tooltip",
|
|
12
|
+
"button","checkbox","combobox","dialog","grid","gridcell","link","listbox","menu",
|
|
13
|
+
"menubar","menuitem","menuitemcheckbox","menuitemradio","option","progressbar","radio",
|
|
14
|
+
"radiogroup","scrollbar","searchbox","slider","spinbutton","switch","tab","tablist",
|
|
15
|
+
"tabpanel","textbox","tree","treegrid","treeitem","application",
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
var ROLE_REQUIRED_PROPS = Object.freeze({
|
|
19
|
+
"checkbox": ["aria-checked"],
|
|
20
|
+
"switch": ["aria-checked"],
|
|
21
|
+
"radio": ["aria-checked"],
|
|
22
|
+
"menuitemradio": ["aria-checked"],
|
|
23
|
+
"menuitemcheckbox": ["aria-checked"],
|
|
24
|
+
"combobox": ["aria-expanded"],
|
|
25
|
+
"scrollbar": ["aria-valuenow"],
|
|
26
|
+
"slider": ["aria-valuenow"],
|
|
27
|
+
"spinbutton": ["aria-valuenow"],
|
|
28
|
+
"heading": ["aria-level"],
|
|
29
|
+
"option": ["aria-selected"],
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
var ARIA_VALUE_SETS = Object.freeze({
|
|
33
|
+
"aria-checked": ["true","false","mixed"],
|
|
34
|
+
"aria-expanded": ["true","false"],
|
|
35
|
+
"aria-pressed": ["true","false","mixed"],
|
|
36
|
+
"aria-selected": ["true","false"],
|
|
37
|
+
"aria-disabled": ["true","false"],
|
|
38
|
+
"aria-hidden": ["true","false"],
|
|
39
|
+
"aria-haspopup": ["false","true","menu","listbox","tree","grid","dialog"],
|
|
40
|
+
"aria-orientation": ["horizontal","vertical"],
|
|
41
|
+
"aria-current": ["page","step","location","date","time","true","false"],
|
|
42
|
+
"aria-live": ["off","polite","assertive"],
|
|
43
|
+
"aria-sort": ["ascending","descending","none","other"],
|
|
44
|
+
"aria-autocomplete":["inline","list","both","none"],
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
var _TAG_RE = tagwalk.TAG_RE;
|
|
48
|
+
var _parseAttrs = tagwalk.parseAttrs;
|
|
49
|
+
var _lineColAt = tagwalk.lineColAt;
|
|
50
|
+
|
|
51
|
+
function audit(html, opts) {
|
|
52
|
+
opts = opts || {};
|
|
53
|
+
validateOpts(opts, ["allowedRoles", "scopeUrl"], "guardHtml.wcag.aria.audit");
|
|
54
|
+
if (typeof html !== "string") {
|
|
55
|
+
throw new TypeError("aria.audit: html must be a string");
|
|
56
|
+
}
|
|
57
|
+
var allowedRoles = Array.isArray(opts.allowedRoles)
|
|
58
|
+
? KNOWN_ROLES.concat(opts.allowedRoles)
|
|
59
|
+
: KNOWN_ROLES;
|
|
60
|
+
|
|
61
|
+
var findings = [];
|
|
62
|
+
function _add(f) { findings.push(f); }
|
|
63
|
+
|
|
64
|
+
var declaredIds = Object.create(null);
|
|
65
|
+
var idRe = /\bid\s*=\s*["']([^"']+)["']/gi;
|
|
66
|
+
var im;
|
|
67
|
+
while ((im = idRe.exec(html))) { // RegExp.prototype.exec
|
|
68
|
+
declaredIds[im[1]] = true;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
_TAG_RE.lastIndex = 0;
|
|
72
|
+
var m;
|
|
73
|
+
while ((m = _TAG_RE.exec(html))) { // RegExp.prototype.exec
|
|
74
|
+
if (m[0].charAt(1) === "/") continue;
|
|
75
|
+
var tagName = m[1].toLowerCase();
|
|
76
|
+
var attrs = _parseAttrs(m[2]);
|
|
77
|
+
var offset = m.index;
|
|
78
|
+
var pos = _lineColAt(html, offset);
|
|
79
|
+
|
|
80
|
+
if ("role" in attrs) {
|
|
81
|
+
var roles = attrs.role.split(/\s+/).filter(Boolean);
|
|
82
|
+
for (var ri = 0; ri < roles.length; ri++) {
|
|
83
|
+
if (allowedRoles.indexOf(roles[ri]) === -1) {
|
|
84
|
+
_add({
|
|
85
|
+
sc: "4.1.2", level: "A", severity: "warning",
|
|
86
|
+
element: tagName, line: pos.line, column: pos.column,
|
|
87
|
+
message: "Unknown ARIA role \"" + roles[ri] + "\" (typo? unsupported by AT?)",
|
|
88
|
+
remediation: "Use a known WAI-ARIA 1.2 role or remove the role attribute",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
for (var rj = 0; rj < roles.length; rj++) {
|
|
93
|
+
var required = ROLE_REQUIRED_PROPS[roles[rj]];
|
|
94
|
+
if (!Array.isArray(required) || required.length === 0) continue;
|
|
95
|
+
for (var ai = 0; ai < required.length; ai++) {
|
|
96
|
+
if (!(required[ai] in attrs)) {
|
|
97
|
+
_add({
|
|
98
|
+
sc: "4.1.2", level: "A", severity: "error",
|
|
99
|
+
element: tagName, line: pos.line, column: pos.column,
|
|
100
|
+
message: "ARIA role=\"" + roles[rj] + "\" requires attribute \"" + required[ai] + "\"",
|
|
101
|
+
remediation: "Add " + required[ai] + "=\"<valid-value>\" to the element",
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
var attrNames = Object.keys(attrs);
|
|
109
|
+
for (var ki = 0; ki < attrNames.length; ki++) {
|
|
110
|
+
var key = attrNames[ki];
|
|
111
|
+
if (key.indexOf("aria-") !== 0) continue;
|
|
112
|
+
var allowedValues = ARIA_VALUE_SETS[key];
|
|
113
|
+
if (!Array.isArray(allowedValues)) continue;
|
|
114
|
+
var v = String(attrs[key]).trim();
|
|
115
|
+
if (allowedValues.indexOf(v) === -1) {
|
|
116
|
+
_add({
|
|
117
|
+
sc: "4.1.2", level: "A", severity: "error",
|
|
118
|
+
element: tagName, line: pos.line, column: pos.column,
|
|
119
|
+
message: key + "=\"" + v + "\" is not in the allowed value set [" + allowedValues.join(", ") + "]",
|
|
120
|
+
remediation: "Set " + key + " to one of the allowed values",
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
var refAttrs = ["aria-labelledby", "aria-controls", "aria-describedby"];
|
|
126
|
+
for (var rai = 0; rai < refAttrs.length; rai++) {
|
|
127
|
+
var refKey = refAttrs[rai];
|
|
128
|
+
if (!(refKey in attrs)) continue;
|
|
129
|
+
var idsRefd = attrs[refKey].split(/\s+/).filter(Boolean);
|
|
130
|
+
for (var idi = 0; idi < idsRefd.length; idi++) {
|
|
131
|
+
if (!declaredIds[idsRefd[idi]]) {
|
|
132
|
+
_add({
|
|
133
|
+
sc: "4.1.2", level: "A", severity: "error",
|
|
134
|
+
element: tagName, line: pos.line, column: pos.column,
|
|
135
|
+
message: refKey + "=\"" + idsRefd[idi] + "\" references id that is not declared in the document",
|
|
136
|
+
remediation: "Either declare an element with id=\"" + idsRefd[idi] + "\" or remove the reference",
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (attrs["aria-hidden"] === "true") {
|
|
143
|
+
var interactive = ["a", "button", "input", "select", "textarea"].indexOf(tagName) !== -1;
|
|
144
|
+
var hasTabindex = "tabindex" in attrs && attrs.tabindex !== "-1";
|
|
145
|
+
if (interactive || hasTabindex) {
|
|
146
|
+
_add({
|
|
147
|
+
sc: "4.1.2", level: "A", severity: "error",
|
|
148
|
+
element: tagName, line: pos.line, column: pos.column,
|
|
149
|
+
message: "aria-hidden=\"true\" on interactive element",
|
|
150
|
+
remediation: "Remove aria-hidden, or remove from focus order via tabindex=\"-1\" (and disable interactivity)",
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return findings;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
module.exports = {
|
|
160
|
+
audit: audit,
|
|
161
|
+
KNOWN_ROLES: KNOWN_ROLES,
|
|
162
|
+
ROLE_REQUIRED_PROPS: ROLE_REQUIRED_PROPS,
|
|
163
|
+
ARIA_VALUE_SETS: ARIA_VALUE_SETS,
|
|
164
|
+
};
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Form-specific accessibility validation for the WCAG 2.2 audit-only
|
|
4
|
+
* scanner.
|
|
5
|
+
*
|
|
6
|
+
* Checks:
|
|
7
|
+
* - <fieldset> without <legend> (WCAG 1.3.1)
|
|
8
|
+
* - <input autocomplete=""> against the HTML 5.3 token registry
|
|
9
|
+
* (WCAG 1.3.5 — Identify Input Purpose)
|
|
10
|
+
* - Required field without aria-required / required (WCAG 3.3.7)
|
|
11
|
+
* - Form submit without explicit submit button or commit-on-enter
|
|
12
|
+
* marker (WCAG 3.3.4 — Error Prevention)
|
|
13
|
+
* - <input type="password"> within an autocomplete-disabled form
|
|
14
|
+
* (WCAG 3.3.8 — Accessible Authentication)
|
|
15
|
+
* - <textarea> without label / aria-label (WCAG 3.3.2)
|
|
16
|
+
*
|
|
17
|
+
* Exposed under b.guardHtml.wcag.forms.audit(html, opts).
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
var validateOpts = require("./validate-opts");
|
|
21
|
+
var tagwalk = require("./guard-html-wcag-tagwalk");
|
|
22
|
+
|
|
23
|
+
// HTML 5.3 §4.10.18.7.1 — autocomplete token registry. Operators
|
|
24
|
+
// override / extend via opts.allowedAutocomplete. The framework
|
|
25
|
+
// allowlists the most common values; rare ones are flagged as
|
|
26
|
+
// warnings (operator might be using a custom token in error).
|
|
27
|
+
var AUTOCOMPLETE_TOKENS = Object.freeze([
|
|
28
|
+
"off", "on",
|
|
29
|
+
"name", "honorific-prefix", "given-name", "additional-name", "family-name",
|
|
30
|
+
"honorific-suffix", "nickname", "username", "new-password", "current-password",
|
|
31
|
+
"one-time-code",
|
|
32
|
+
"organization-title", "organization",
|
|
33
|
+
"street-address", "address-line1", "address-line2", "address-line3",
|
|
34
|
+
"address-level4", "address-level3", "address-level2", "address-level1",
|
|
35
|
+
"country", "country-name", "postal-code",
|
|
36
|
+
"cc-name", "cc-given-name", "cc-additional-name", "cc-family-name",
|
|
37
|
+
"cc-number", "cc-exp", "cc-exp-month", "cc-exp-year", "cc-csc", "cc-type",
|
|
38
|
+
"transaction-currency", "transaction-amount",
|
|
39
|
+
"language",
|
|
40
|
+
"bday", "bday-day", "bday-month", "bday-year", "sex",
|
|
41
|
+
"tel", "tel-country-code", "tel-national", "tel-area-code", "tel-local",
|
|
42
|
+
"tel-extension", "email",
|
|
43
|
+
"impp", "url", "photo",
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
function audit(html, opts) {
|
|
47
|
+
opts = opts || {};
|
|
48
|
+
validateOpts(opts, [
|
|
49
|
+
"allowedAutocomplete", "scopeUrl",
|
|
50
|
+
], "guardHtml.wcag.forms.audit");
|
|
51
|
+
if (typeof html !== "string") {
|
|
52
|
+
throw new TypeError("forms.audit: html must be a string");
|
|
53
|
+
}
|
|
54
|
+
var allowed = Array.isArray(opts.allowedAutocomplete)
|
|
55
|
+
? AUTOCOMPLETE_TOKENS.concat(opts.allowedAutocomplete)
|
|
56
|
+
: AUTOCOMPLETE_TOKENS;
|
|
57
|
+
|
|
58
|
+
var findings = [];
|
|
59
|
+
function _add(f) { findings.push(f); }
|
|
60
|
+
|
|
61
|
+
// Pre-scan: is there a <legend> inside any <fieldset>?
|
|
62
|
+
// We track fieldset → has-legend by forward-scanning each fieldset.
|
|
63
|
+
tagwalk.TAG_RE.lastIndex = 0;
|
|
64
|
+
var m;
|
|
65
|
+
while ((m = tagwalk.TAG_RE.exec(html))) {
|
|
66
|
+
if (m[0].charAt(1) === "/") continue;
|
|
67
|
+
var tagName = m[1].toLowerCase();
|
|
68
|
+
var attrs = tagwalk.parseAttrs(m[2]);
|
|
69
|
+
var pos = tagwalk.lineColAt(html, m.index);
|
|
70
|
+
|
|
71
|
+
if (tagName === "fieldset") {
|
|
72
|
+
// Forward-look for </fieldset>
|
|
73
|
+
var closeIdx = html.indexOf("</fieldset>", m.index);
|
|
74
|
+
var inside = closeIdx === -1 ? html.slice(m.index) : html.slice(m.index, closeIdx);
|
|
75
|
+
if (!/<legend\b/i.test(inside)) {
|
|
76
|
+
_add({
|
|
77
|
+
sc: "1.3.1", level: "A", severity: "warning",
|
|
78
|
+
element: "fieldset", line: pos.line, column: pos.column,
|
|
79
|
+
message: "<fieldset> has no <legend> (assistive tech can't announce the field-group purpose)",
|
|
80
|
+
remediation: "Add <legend>Group title</legend> as the first child of <fieldset>",
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (tagName === "input" && "autocomplete" in attrs) {
|
|
86
|
+
var v = String(attrs.autocomplete).trim().toLowerCase();
|
|
87
|
+
// autocomplete supports compound tokens like "section-foo billing tel";
|
|
88
|
+
// we check the LAST token (the canonical purpose token).
|
|
89
|
+
var tokens = v.split(/\s+/);
|
|
90
|
+
var canonical = tokens[tokens.length - 1];
|
|
91
|
+
if (allowed.indexOf(canonical) === -1) {
|
|
92
|
+
_add({
|
|
93
|
+
sc: "1.3.5", level: "AA", severity: "warning",
|
|
94
|
+
element: "input", line: pos.line, column: pos.column,
|
|
95
|
+
message: "input autocomplete=\"" + v + "\" canonical token \"" + canonical +
|
|
96
|
+
"\" is not in the HTML 5.3 registry",
|
|
97
|
+
remediation: "Use a registered autocomplete token (https://www.w3.org/TR/html53/sec-forms.html#autofill)",
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (tagName === "input" && attrs.type === "password" &&
|
|
103
|
+
"autocomplete" in attrs &&
|
|
104
|
+
attrs.autocomplete === "off") {
|
|
105
|
+
_add({
|
|
106
|
+
sc: "3.3.8", level: "AA", severity: "warning",
|
|
107
|
+
element: "input", line: pos.line, column: pos.column,
|
|
108
|
+
message: "password input has autocomplete=\"off\" (blocks password manager — WCAG 3.3.8 requires accessible authentication; password managers count as a recognised authentication aid)",
|
|
109
|
+
remediation: "Use autocomplete=\"current-password\" or autocomplete=\"new-password\" instead of \"off\"",
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// 3.3.7 redundant entry — input type=email/tel/etc. without
|
|
114
|
+
// autocomplete attribute prevents browsers from offering
|
|
115
|
+
// saved values, forcing users to re-enter every time.
|
|
116
|
+
if (tagName === "input" && !("autocomplete" in attrs) &&
|
|
117
|
+
["email", "tel", "name"].indexOf(attrs.type) !== -1) {
|
|
118
|
+
_add({
|
|
119
|
+
sc: "3.3.7", level: "A", severity: "info",
|
|
120
|
+
element: "input", line: pos.line, column: pos.column,
|
|
121
|
+
message: "input type=\"" + attrs.type + "\" has no autocomplete attribute (browsers can't offer saved values; users re-enter)",
|
|
122
|
+
remediation: "Add autocomplete=\"email\" / \"tel\" / \"name\" / etc.",
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (tagName === "textarea" &&
|
|
127
|
+
!("aria-label" in attrs) && !("aria-labelledby" in attrs) &&
|
|
128
|
+
!("title" in attrs) && !("id" in attrs)) {
|
|
129
|
+
_add({
|
|
130
|
+
sc: "3.3.2", level: "A", severity: "error",
|
|
131
|
+
element: "textarea", line: pos.line, column: pos.column,
|
|
132
|
+
message: "textarea has no associated label (no id, no aria-label, no title)",
|
|
133
|
+
remediation: "Add id+matching <label for=...> or aria-label=\"<text>\"",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return findings;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
module.exports = {
|
|
142
|
+
audit: audit,
|
|
143
|
+
AUTOCOMPLETE_TOKENS: AUTOCOMPLETE_TOKENS,
|
|
144
|
+
};
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Table semantics validation for the WCAG 2.2 audit-only scanner.
|
|
4
|
+
*
|
|
5
|
+
* Checks per WCAG 2.2 / WAI-ARIA / HTML 5.3 best practices:
|
|
6
|
+
* - <table> without <caption> (data-tables only; layout-tables
|
|
7
|
+
* should use role="presentation" instead)
|
|
8
|
+
* - <th> without scope attribute (or invalid scope value)
|
|
9
|
+
* - layout tables that should be replaced with semantic markup
|
|
10
|
+
* - missing or empty <thead> for data tables with multiple body rows
|
|
11
|
+
* - <tr> outside <table> / <thead> / <tbody> / <tfoot>
|
|
12
|
+
*
|
|
13
|
+
* Exposed under b.guardHtml.wcag.tables.audit(html, opts).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
var validateOpts = require("./validate-opts");
|
|
17
|
+
var tagwalk = require("./guard-html-wcag-tagwalk");
|
|
18
|
+
|
|
19
|
+
var VALID_SCOPE_VALUES = Object.freeze(["row", "col", "rowgroup", "colgroup"]);
|
|
20
|
+
|
|
21
|
+
var _TAG_RE = tagwalk.TAG_RE;
|
|
22
|
+
var _parseAttrs = tagwalk.parseAttrs;
|
|
23
|
+
var _lineColAt = tagwalk.lineColAt;
|
|
24
|
+
|
|
25
|
+
function audit(html, opts) {
|
|
26
|
+
opts = opts || {};
|
|
27
|
+
validateOpts(opts, ["scopeUrl"], "guardHtml.wcag.tables.audit");
|
|
28
|
+
if (typeof html !== "string") {
|
|
29
|
+
throw new TypeError("tables.audit: html must be a string");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
var findings = [];
|
|
33
|
+
function _add(f) { findings.push(f); }
|
|
34
|
+
|
|
35
|
+
// Walk the tag stream, tracking nesting state for tables + their
|
|
36
|
+
// children. We don't build a full DOM; we track the open-tag stack
|
|
37
|
+
// to determine whether we're inside <table> / <thead> / <tbody>.
|
|
38
|
+
var stack = [];
|
|
39
|
+
_TAG_RE.lastIndex = 0;
|
|
40
|
+
var m;
|
|
41
|
+
while ((m = _TAG_RE.exec(html))) {
|
|
42
|
+
var isClose = m[0].charAt(1) === "/";
|
|
43
|
+
var tagName = m[1].toLowerCase();
|
|
44
|
+
var attrs = isClose ? null : _parseAttrs(m[2]);
|
|
45
|
+
var pos = _lineColAt(html, m.index);
|
|
46
|
+
|
|
47
|
+
if (isClose) {
|
|
48
|
+
// Pop the matching open tag (lazily — broken HTML may produce
|
|
49
|
+
// mismatches; we just pop any matching same-name from the top).
|
|
50
|
+
for (var s = stack.length - 1; s >= 0; s--) {
|
|
51
|
+
if (stack[s].name === tagName) {
|
|
52
|
+
stack.splice(s, 1);
|
|
53
|
+
break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (tagName === "table") {
|
|
60
|
+
// Data table check: presence of <caption> as a direct/early
|
|
61
|
+
// child within the same table. We can't know the close-tag
|
|
62
|
+
// position without scanning forward; do a forward look.
|
|
63
|
+
var role = attrs.role || "";
|
|
64
|
+
var isPresentation = role === "presentation" || role === "none";
|
|
65
|
+
if (!isPresentation) {
|
|
66
|
+
// Find the matching </table>
|
|
67
|
+
var closeIdx = _findClose(html, m.index, "table");
|
|
68
|
+
var inside = closeIdx === -1 ? html.slice(m.index) : html.slice(m.index, closeIdx);
|
|
69
|
+
if (!/<caption\b/i.test(inside)) {
|
|
70
|
+
_add({
|
|
71
|
+
sc: "1.3.1", level: "A", severity: "warning",
|
|
72
|
+
element: "table", line: pos.line, column: pos.column,
|
|
73
|
+
message: "Data <table> has no <caption> (assistive tech can't summarize the table for screen-reader users)",
|
|
74
|
+
remediation: "Add <caption>Descriptive title</caption> as the first child of <table>, or set role=\"presentation\" if this is a layout table",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
stack.push({ name: "table", attrs: attrs });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (tagName === "th") {
|
|
82
|
+
if (!("scope" in attrs)) {
|
|
83
|
+
_add({
|
|
84
|
+
sc: "1.3.1", level: "A", severity: "warning",
|
|
85
|
+
element: "th", line: pos.line, column: pos.column,
|
|
86
|
+
message: "<th> element has no scope attribute (screen readers can't announce the right header for each cell)",
|
|
87
|
+
remediation: "Add scope=\"col\" / scope=\"row\" / scope=\"colgroup\" / scope=\"rowgroup\"",
|
|
88
|
+
});
|
|
89
|
+
} else if (VALID_SCOPE_VALUES.indexOf(attrs.scope) === -1) {
|
|
90
|
+
_add({
|
|
91
|
+
sc: "1.3.1", level: "A", severity: "error",
|
|
92
|
+
element: "th", line: pos.line, column: pos.column,
|
|
93
|
+
message: "<th> scope=\"" + attrs.scope + "\" is not in the allowed value set [" +
|
|
94
|
+
VALID_SCOPE_VALUES.join(", ") + "]",
|
|
95
|
+
remediation: "Use a valid scope value",
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (tagName === "tr") {
|
|
101
|
+
// Detect <tr> outside any table-context wrapper
|
|
102
|
+
var inTable = stack.some(function (e) {
|
|
103
|
+
return e.name === "table" || e.name === "thead" ||
|
|
104
|
+
e.name === "tbody" || e.name === "tfoot";
|
|
105
|
+
});
|
|
106
|
+
if (!inTable) {
|
|
107
|
+
_add({
|
|
108
|
+
sc: "1.3.1", level: "A", severity: "warning",
|
|
109
|
+
element: "tr", line: pos.line, column: pos.column,
|
|
110
|
+
message: "<tr> appears outside <table> / <thead> / <tbody> / <tfoot>",
|
|
111
|
+
remediation: "Wrap the <tr> in a table-row context",
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Track other table descendants so we can identify nested
|
|
117
|
+
// structure if needed for layout-table heuristics.
|
|
118
|
+
if (tagName === "thead" || tagName === "tbody" ||
|
|
119
|
+
tagName === "tfoot" || tagName === "caption") {
|
|
120
|
+
stack.push({ name: tagName, attrs: attrs });
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return findings;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function _findClose(html, startIdx, tagName) {
|
|
128
|
+
// Forward-scan for the matching close tag, tracking nesting depth
|
|
129
|
+
// so nested tables of the same name resolve correctly.
|
|
130
|
+
var openRe = new RegExp("<" + tagName + "\\b", "ig"); // allow:dynamic-regex — `tagName` is a static string from the framework's WCAG audit (only "table" is passed); `\\b` is a RegExp word boundary
|
|
131
|
+
var closeRe = new RegExp("</" + tagName + "\\s*>", "ig"); // allow:dynamic-regex — same as above; static input
|
|
132
|
+
openRe.lastIndex = startIdx + 1;
|
|
133
|
+
closeRe.lastIndex = startIdx + 1;
|
|
134
|
+
var depth = 1;
|
|
135
|
+
while (depth > 0) {
|
|
136
|
+
var nextOpen = openRe.exec(html);
|
|
137
|
+
var nextClose = closeRe.exec(html);
|
|
138
|
+
if (!nextClose) return -1;
|
|
139
|
+
if (!nextOpen || nextOpen.index > nextClose.index) {
|
|
140
|
+
depth -= 1;
|
|
141
|
+
if (depth === 0) return nextClose.index + nextClose[0].length;
|
|
142
|
+
openRe.lastIndex = nextClose.index + nextClose[0].length;
|
|
143
|
+
} else {
|
|
144
|
+
depth += 1;
|
|
145
|
+
closeRe.lastIndex = nextOpen.index + nextOpen[0].length;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return -1;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = {
|
|
152
|
+
audit: audit,
|
|
153
|
+
VALID_SCOPE_VALUES: VALID_SCOPE_VALUES,
|
|
154
|
+
};
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Shared tag-walker helpers for the WCAG 2.2 audit-only scanner
|
|
4
|
+
* modules. Extracted from guard-html-wcag.js / -aria.js / -tables.js
|
|
5
|
+
* so the regex constants live in one place.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
// HTML5 open + close tag regex. Captures: 1=tag-name, 2=attribute body.
|
|
9
|
+
var TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9-]*)\b([^>]*)>/g;
|
|
10
|
+
|
|
11
|
+
// Per-attribute parser: name then (optional) value in double-quoted /
|
|
12
|
+
// single-quoted / unquoted form.
|
|
13
|
+
var ATTR_RE = /([a-zA-Z_:][-a-zA-Z0-9_:.]*)\s*(?:=\s*("([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
|
|
14
|
+
|
|
15
|
+
function parseAttrs(attrString) {
|
|
16
|
+
var out = Object.create(null);
|
|
17
|
+
if (!attrString) return out;
|
|
18
|
+
ATTR_RE.lastIndex = 0;
|
|
19
|
+
var m;
|
|
20
|
+
while ((m = ATTR_RE.exec(attrString))) {
|
|
21
|
+
var name = m[1].toLowerCase();
|
|
22
|
+
var value = m[3] !== undefined ? m[3] :
|
|
23
|
+
m[4] !== undefined ? m[4] :
|
|
24
|
+
m[5] !== undefined ? m[5] : "";
|
|
25
|
+
out[name] = value;
|
|
26
|
+
}
|
|
27
|
+
return out;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function lineColAt(html, offset) {
|
|
31
|
+
var line = 1;
|
|
32
|
+
var lastNl = -1;
|
|
33
|
+
for (var i = 0; i < offset; i++) {
|
|
34
|
+
if (html.charCodeAt(i) === 10) { line += 1; lastNl = i; } // allow:raw-byte-literal — ASCII LF
|
|
35
|
+
}
|
|
36
|
+
return { line: line, column: offset - lastNl };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = {
|
|
40
|
+
TAG_RE: TAG_RE,
|
|
41
|
+
ATTR_RE: ATTR_RE,
|
|
42
|
+
parseAttrs: parseAttrs,
|
|
43
|
+
lineColAt: lineColAt,
|
|
44
|
+
};
|