@blamejs/core 0.7.105 → 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 +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
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.7.x
|
|
10
10
|
|
|
11
|
+
- **0.7.106** (2026-05-06) — `b.guardHtml.wcag` — WCAG 2.2 audit-only accessibility scanner. Pure static analysis (no rendering, no JS execution) emitting structured findings the operator wires into a CI gate / audit log / dev warning. **`b.guardHtml.wcag.audit(html, { level, ignore, allowedRoles, allowedAutocomplete, ... })`** returns `{ findings: [{ sc, level, severity, element, line, column, message, remediation }], summary: { error, warning, info }, score, totalFindings, scopeUrl, scannedAt }`. Page-level checks: `<html lang>` (3.1.1), `<title>` (2.4.2), skip-link (2.4.1). Element-level checks: `<img alt>` (1.1.1), input labels (3.3.2), input names (4.1.2), button text (4.1.2), anchor accessible name (2.4.4), heading order + empty heading (1.3.1). **`b.guardHtml.wcag.aria.audit(html)`** — WAI-ARIA 1.2 validation: unknown role values, missing required ARIA properties (e.g. `role="checkbox"` without `aria-checked`), aria-* values outside the spec value set (`aria-checked` ∉ `{true, false, mixed}`), unresolved `aria-labelledby` / `aria-controls` / `aria-describedby` references, and `aria-hidden="true"` on interactive elements. Operators with custom design systems extend the role registry via `allowedRoles`. **`b.guardHtml.wcag.tables.audit(html)`** — Table semantics: `<table>` without `<caption>` (data tables only — layout tables with `role="presentation"` skip the check), `<th>` without scope or with invalid scope value, `<tr>` outside table-context wrappers. **`b.guardHtml.wcag.forms.audit(html)`** — Form-specific: `<fieldset>` without `<legend>` (1.3.1), input `autocomplete=` value against the HTML 5.3 token registry (1.3.5), password input with `autocomplete="off"` (3.3.8 blocks password managers), input/email without autocomplete (3.3.7 redundant entry), `<textarea>` without label. Conformance level filter (`A` / `AA` / `AAA`); `ignore: ["1.4.3"]` for SC-by-SC opt-out; `skipAria` / `skipTables` / `skipForms` for module-level opt-out. Heuristic score (1 - weighted-violations / heuristic-max) for quick gauge. 51 test cases.
|
|
12
|
+
|
|
11
13
|
- **0.7.105** (2026-05-06) — `b.compliance.sanctions` — sanctions-list screening primitive. Operators handling KYC / payment / customer-onboarding flows screen names against the U.S. Treasury OFAC SDN list, EU CSL, UK HMT consolidated list, UN 1267 list, or operator-defined lists. The framework owns indexing + match algorithm; the operator owns the daily fetch + format-specific parsing (the framework intentionally does not vendor the list — it changes daily and has legal-distribution implications). **`b.compliance.sanctions.create({ entries, algorithm, fuzzy, ... })`** returns a screener with `screen(input)` (single record), `screenBulk(inputs)` (batch), `snapshot()` (rule-version digest for audit trails), `reload(newEntries)` (atomic index swap with diff), `entryById(id)` (lookup), and `size()`. Three match strategies: `exact` (fastest, no fuzz), `jaro-winkler` (default, threshold 0.85), `levenshtein` (edit-distance with cap). Match output: `{ match: bool, hits: [{ entryId, name, matchedOn, score, reason, listed, programs }], algorithm, ruleVersion, screenedAt }`. **`b.compliance.sanctions.fuzzy`** — pure algorithmic core: `normalize` (Unicode diacritic strip + lowercase + whitespace collapse), `tokenize`, `levenshtein` (cap + early-exit), `jaro` / `jaroWinkler`, `tokenSetSimilarity` (order-invariant bag-of-tokens), `substringContains` (token-bounded), `initialsMatch`. **`b.compliance.sanctions.aliases.expand(name, opts)`** — alias-expansion helper covering nicknames (Bill ↔ William, Mike ↔ Michael), transliteration variants (Mohamed ↔ Mohammed), reverse-order forms (Smith John / Smith, John), and initials (J. Smith). 32 built-in name pairs plus operator-extensible `extraPairs`. **`b.compliance.sanctions.fetcher.create({ screener, fetch, intervalMs, onRefreshed, onError })`** — periodic refresh worker that runs the operator's `fetch` callback, validates a non-empty result, and atomically reloads the screener via `screener.reload`. Audit emissions on every refresh state (`compliance.sanctions.refresh.started` / `completed` / `skipped` / `failed`). **Parser shims** for the canonical public list formats: `parseOfacCsvRow` / `parseOfacAliasRow` / `mergeAliases` (OFAC SDN), `parseEuCslEntry` (EU Consolidated Sanctions List XML), `parseUn1267Entry` (UN Security Council XML). Audit emissions: `compliance.sanctions.screened` (every screen call), `compliance.sanctions.matched` (when hits > 0). Test coverage: 39 cases across normalize / tokenize / Levenshtein / Jaro-Winkler / token-set / substring / initials / screen exact + jw + levenshtein / type filter / bulk / snapshot / reload / alias expansion / fetcher tick + failure modes.
|
|
12
14
|
|
|
13
15
|
- **0.7.104** (2026-05-06) — `b.dsr` Data Subject Rights workflow primitive (~2000 LoC). End-to-end coordinator for GDPR Article 15-22 / CCPA / CPRA / LGPD / PIPEDA / UK-GDPR data-subject requests. **`b.dsr.create({ ticketStore, posture, identityResolver, sources, ... })`** returns a workflow instance with full ticket lifecycle: `submit(input)` resolves subject identity via the operator-supplied `identityResolver`, computes a posture-aware deadline (gdpr 30d / ccpa 45d / lgpd-br 15d / pipl-cn 15d / pipeda-ca 30d / appi-jp 30d / pdpa-sg 30d / uk-gdpr 30d), and persists a pending ticket. `process(ticketId, opts)` orchestrates per-source `query` (for access / portability / rectification) or `erase` (for erasure) callbacks; partial source failures land the ticket in `partially_completed` state with per-source error capture. `cancel` / `reject` (with required reason per GDPR) advance to terminal states. `expireOverdue()` sweep marks deadline-overdue tickets as `expired`. Seven request types: `access` / `erasure` / `portability` / `rectification` / `restriction` / `object` / `automated-decision`. **Verification ladder** (`minimal` / `secondary` / `strong`) per GDPR Art. 12(6) — minimum required level by request type with operator override; erasure / portability / rectification require `secondary` by default. **Receipt builder** (`buildReceipt(ticketId)`) — emits a canonical `blamejs.dsr.receipt/1` JSON envelope for completed/cancelled/rejected/expired tickets with optional operator-side `receiptSigner` hook for cryptographic attestation. **Portability bundle builder** (`buildPortabilityBundle(ticket)`) — `blamejs.dsr.portability/1` JSON shape with per-source data for access / portability requests. **Two ticket-store backends** ship: `memoryTicketStore()` for development / tests, `dbTicketStore({ db, table })` for production (auto-provisions a SQLite table with subject_email + status indexes, includes a `purgeExpired()` retention sweep). Audit emissions on every state transition (`dsr.ticket.submitted` / `in_progress` / `completed` / `partial` / `cancelled` / `rejected` / `expired` plus per-source `dsr.source.queried` / `erased` / `failed`). Test coverage: 38 cases across submit / process / cancel / reject / list / expire / portability / verification ladder / receipt / store backends.
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* WCAG 2.2 audit-only scanner for HTML.
|
|
4
|
+
*
|
|
5
|
+
* Scans operator-supplied HTML for accessibility violations against
|
|
6
|
+
* the WCAG 2.2 Recommendation (https://www.w3.org/TR/WCAG22/) without
|
|
7
|
+
* modifying the document. Returns a structured report of findings the
|
|
8
|
+
* operator wires into a CI gate, audit log, or development warning.
|
|
9
|
+
*
|
|
10
|
+
* var audit = b.guardHtml.wcag.audit(htmlString, {
|
|
11
|
+
* level: "AA", // | "A" | "AAA"
|
|
12
|
+
* ignore: ["1.4.3"], // SCs the operator's deployment opts out of
|
|
13
|
+
* });
|
|
14
|
+
*
|
|
15
|
+
* Design constraints:
|
|
16
|
+
* - PURE static analysis — no rendering, no JS execution.
|
|
17
|
+
* - The scanner is conservative: it flags clear violations (missing
|
|
18
|
+
* alt text, empty heading text) and high-confidence patterns
|
|
19
|
+
* (out-of-order headings, form fields without label / aria-label /
|
|
20
|
+
* aria-labelledby). It does NOT attempt color-contrast checks,
|
|
21
|
+
* focus-indicator checks, or text-spacing checks (require rendered
|
|
22
|
+
* styles or runtime CSS).
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
var validateOpts = require("./validate-opts");
|
|
26
|
+
var lazyRequire = require("./lazy-require");
|
|
27
|
+
var { defineClass } = require("./framework-error");
|
|
28
|
+
|
|
29
|
+
var GuardHtmlWcagError = defineClass("GuardHtmlWcagError", { alwaysPermanent: true });
|
|
30
|
+
|
|
31
|
+
var observability = lazyRequire(function () { return require("./observability"); });
|
|
32
|
+
var aria = require("./guard-html-wcag-aria");
|
|
33
|
+
var tables = require("./guard-html-wcag-tables");
|
|
34
|
+
var forms = require("./guard-html-wcag-forms");
|
|
35
|
+
var tagwalk = require("./guard-html-wcag-tagwalk");
|
|
36
|
+
|
|
37
|
+
var SC_REGISTRY = Object.freeze({
|
|
38
|
+
"1.1.1": { level: "A", name: "Non-text Content" },
|
|
39
|
+
"1.3.1": { level: "A", name: "Info and Relationships" },
|
|
40
|
+
"2.4.1": { level: "A", name: "Bypass Blocks" },
|
|
41
|
+
"2.4.2": { level: "A", name: "Page Titled" },
|
|
42
|
+
"2.4.4": { level: "A", name: "Link Purpose (In Context)" },
|
|
43
|
+
"3.1.1": { level: "A", name: "Language of Page" },
|
|
44
|
+
"3.3.2": { level: "A", name: "Labels or Instructions" },
|
|
45
|
+
"4.1.1": { level: "A", name: "Parsing (deprecated in 2.2; retained for back-compat)" },
|
|
46
|
+
"4.1.2": { level: "A", name: "Name, Role, Value" },
|
|
47
|
+
"2.4.11": { level: "AA", name: "Focus Not Obscured (Minimum)" },
|
|
48
|
+
"2.4.13": { level: "AAA", name: "Focus Appearance" },
|
|
49
|
+
"2.5.7": { level: "AA", name: "Dragging Movements" },
|
|
50
|
+
"2.5.8": { level: "AA", name: "Target Size (Minimum)" },
|
|
51
|
+
"3.2.6": { level: "A", name: "Consistent Help" },
|
|
52
|
+
"3.3.7": { level: "A", name: "Redundant Entry" },
|
|
53
|
+
"3.3.8": { level: "AA", name: "Accessible Authentication (Minimum)" },
|
|
54
|
+
"3.3.9": { level: "AAA", name: "Accessible Authentication (Enhanced)" },
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
var VALID_LEVELS = Object.freeze(["A", "AA", "AAA"]);
|
|
58
|
+
|
|
59
|
+
function _meetsLevel(scLevel, requestedLevel) {
|
|
60
|
+
if (requestedLevel === "AAA") return true;
|
|
61
|
+
if (requestedLevel === "AA") return scLevel === "A" || scLevel === "AA";
|
|
62
|
+
return scLevel === "A";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function _newReport(scopeUrl) {
|
|
66
|
+
return {
|
|
67
|
+
findings: [],
|
|
68
|
+
summary: { error: 0, warning: 0, info: 0 },
|
|
69
|
+
totalFindings: 0,
|
|
70
|
+
scopeUrl: scopeUrl || null,
|
|
71
|
+
scannedAt: Date.now(),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function _addFinding(report, finding) {
|
|
76
|
+
report.findings.push(finding);
|
|
77
|
+
report.totalFindings += 1;
|
|
78
|
+
report.summary[finding.severity] = (report.summary[finding.severity] || 0) + 1;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
var _TAG_RE = tagwalk.TAG_RE;
|
|
82
|
+
var _parseAttrs = tagwalk.parseAttrs;
|
|
83
|
+
var _lineColAt = tagwalk.lineColAt;
|
|
84
|
+
|
|
85
|
+
function _innerText(html, tagOpenEnd, tagName) {
|
|
86
|
+
var lower = tagName.toLowerCase();
|
|
87
|
+
var closeRe = new RegExp("</\\s*" + lower + "\\s*>", "i"); // allow:dynamic-regex — `lower` is a tag name from the framework's static SC registry, not operator input; `\\s*` and tag name are RegExp-safe (no special chars)
|
|
88
|
+
closeRe.lastIndex = tagOpenEnd;
|
|
89
|
+
var m = closeRe.exec(html);
|
|
90
|
+
if (!m) return "";
|
|
91
|
+
var raw = html.slice(tagOpenEnd, m.index);
|
|
92
|
+
return raw.replace(/<[^>]+>/g, "").replace(/&[a-z]+;|&#\d+;/gi, "").trim();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---- Per-element checks ----
|
|
96
|
+
|
|
97
|
+
function _checkImgAlt(html, attrs, tagName, offset, report, opts) {
|
|
98
|
+
if (tagName !== "img") return;
|
|
99
|
+
if (opts.ignore.indexOf("1.1.1") !== -1) return;
|
|
100
|
+
if ("alt" in attrs) return;
|
|
101
|
+
var pos = _lineColAt(html, offset);
|
|
102
|
+
_addFinding(report, {
|
|
103
|
+
sc: "1.1.1",
|
|
104
|
+
level: "A",
|
|
105
|
+
severity: "error",
|
|
106
|
+
element: "img",
|
|
107
|
+
line: pos.line,
|
|
108
|
+
column: pos.column,
|
|
109
|
+
message: "img element missing alt attribute (use alt=\"\" for purely decorative images)",
|
|
110
|
+
remediation: "Add alt=\"<descriptive text>\" or alt=\"\" if purely decorative",
|
|
111
|
+
});
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function _checkInputLabel(html, attrs, tagName, offset, report, opts, ctx) {
|
|
115
|
+
if (tagName !== "input") return;
|
|
116
|
+
if (opts.ignore.indexOf("3.3.2") !== -1) return;
|
|
117
|
+
var inputType = (attrs.type || "text").toLowerCase();
|
|
118
|
+
if (["hidden", "submit", "button", "image", "reset"].indexOf(inputType) !== -1) return;
|
|
119
|
+
var hasLabel = "aria-label" in attrs ||
|
|
120
|
+
"aria-labelledby" in attrs ||
|
|
121
|
+
"title" in attrs ||
|
|
122
|
+
(attrs.id && ctx.labelledIds[attrs.id]);
|
|
123
|
+
if (!hasLabel) {
|
|
124
|
+
var pos = _lineColAt(html, offset);
|
|
125
|
+
_addFinding(report, {
|
|
126
|
+
sc: "3.3.2",
|
|
127
|
+
level: "A",
|
|
128
|
+
severity: "error",
|
|
129
|
+
element: "input",
|
|
130
|
+
line: pos.line,
|
|
131
|
+
column: pos.column,
|
|
132
|
+
message: "Form input has no associated label (no aria-label, aria-labelledby, title, or matching <label for=...>)",
|
|
133
|
+
remediation: "Add <label for=\"<id>\">...</label> or aria-label=\"<text>\" attribute",
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
if (!("name" in attrs) && opts.ignore.indexOf("4.1.2") === -1) {
|
|
137
|
+
var pos2 = _lineColAt(html, offset);
|
|
138
|
+
_addFinding(report, {
|
|
139
|
+
sc: "4.1.2",
|
|
140
|
+
level: "A",
|
|
141
|
+
severity: "warning",
|
|
142
|
+
element: "input",
|
|
143
|
+
line: pos2.line,
|
|
144
|
+
column: pos2.column,
|
|
145
|
+
message: "input element has no name attribute (required for form submission + assistive-tech identification)",
|
|
146
|
+
remediation: "Add name=\"<field-name>\" attribute",
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function _checkAnchorScheduled(html, attrs, tagName, offset, report, opts) {
|
|
152
|
+
if (tagName !== "a") return null;
|
|
153
|
+
if (opts.ignore.indexOf("2.4.4") !== -1) return null;
|
|
154
|
+
if (!("href" in attrs)) return null;
|
|
155
|
+
var hasAccessibleName = "aria-label" in attrs ||
|
|
156
|
+
"aria-labelledby" in attrs ||
|
|
157
|
+
"title" in attrs;
|
|
158
|
+
return { offset: offset, attrs: attrs, hasAccessibleName: hasAccessibleName };
|
|
159
|
+
// void the unused vars so the linter is happy
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _checkButtonText(html, tagOpenEnd, attrs, offset, report, opts) {
|
|
163
|
+
if (opts.ignore.indexOf("4.1.2") !== -1) return;
|
|
164
|
+
var inner = _innerText(html, tagOpenEnd, "button");
|
|
165
|
+
if (inner.length > 0) return;
|
|
166
|
+
if ("aria-label" in attrs || "aria-labelledby" in attrs ||
|
|
167
|
+
"title" in attrs) return;
|
|
168
|
+
var pos = _lineColAt(html, offset);
|
|
169
|
+
_addFinding(report, {
|
|
170
|
+
sc: "4.1.2",
|
|
171
|
+
level: "A",
|
|
172
|
+
severity: "error",
|
|
173
|
+
element: "button",
|
|
174
|
+
line: pos.line,
|
|
175
|
+
column: pos.column,
|
|
176
|
+
message: "button has no visible text and no aria-label / title (assistive tech reads it as \"button\" with no purpose)",
|
|
177
|
+
remediation: "Add visible text content, aria-label=\"<purpose>\", or aria-labelledby=\"<idref>\"",
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function _checkHeadingOrder(html, attrs, tagName, offset, report, opts, ctx) {
|
|
182
|
+
if (!/^h[1-6]$/.test(tagName)) return;
|
|
183
|
+
if (opts.ignore.indexOf("1.3.1") !== -1) return;
|
|
184
|
+
var level = parseInt(tagName.charAt(1), 10); // allow:raw-byte-literal — base-10 parse radix
|
|
185
|
+
if (ctx.headingLevels.length === 0) {
|
|
186
|
+
if (level !== 1) {
|
|
187
|
+
var pos = _lineColAt(html, offset);
|
|
188
|
+
_addFinding(report, {
|
|
189
|
+
sc: "1.3.1",
|
|
190
|
+
level: "A",
|
|
191
|
+
severity: "warning",
|
|
192
|
+
element: tagName,
|
|
193
|
+
line: pos.line,
|
|
194
|
+
column: pos.column,
|
|
195
|
+
message: "First heading on the page is " + tagName + " (expected h1; missing/late h1 hurts navigation for screen readers)",
|
|
196
|
+
remediation: "Promote the first heading to h1, or insert an h1 above it",
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
} else {
|
|
200
|
+
var lastLevel = ctx.headingLevels[ctx.headingLevels.length - 1];
|
|
201
|
+
if (level > lastLevel + 1) {
|
|
202
|
+
var pos2 = _lineColAt(html, offset);
|
|
203
|
+
_addFinding(report, {
|
|
204
|
+
sc: "1.3.1",
|
|
205
|
+
level: "A",
|
|
206
|
+
severity: "warning",
|
|
207
|
+
element: tagName,
|
|
208
|
+
line: pos2.line,
|
|
209
|
+
column: pos2.column,
|
|
210
|
+
message: "Heading skips levels (" + tagName + " follows h" + lastLevel +
|
|
211
|
+
"; intermediate level skipped)",
|
|
212
|
+
remediation: "Insert intermediate heading or demote " + tagName + " to h" + (lastLevel + 1),
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
var inner = _innerText(html, ctx.lastTagEndOffset, tagName);
|
|
217
|
+
if (inner.length === 0) {
|
|
218
|
+
var pos3 = _lineColAt(html, offset);
|
|
219
|
+
_addFinding(report, {
|
|
220
|
+
sc: "1.3.1",
|
|
221
|
+
level: "A",
|
|
222
|
+
severity: "error",
|
|
223
|
+
element: tagName,
|
|
224
|
+
line: pos3.line,
|
|
225
|
+
column: pos3.column,
|
|
226
|
+
message: "Empty heading element (" + tagName + " has no text content)",
|
|
227
|
+
remediation: "Add the heading text or remove the empty heading element",
|
|
228
|
+
});
|
|
229
|
+
}
|
|
230
|
+
ctx.headingLevels.push(level);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ---- Page-level checks ----
|
|
234
|
+
|
|
235
|
+
function _checkHtmlLang(html, report, opts) {
|
|
236
|
+
if (opts.ignore.indexOf("3.1.1") !== -1) return;
|
|
237
|
+
var m = /<html\b([^>]*)>/i.exec(html);
|
|
238
|
+
if (!m) return;
|
|
239
|
+
var attrs = _parseAttrs(m[1]);
|
|
240
|
+
if (!attrs.lang || !attrs.lang.trim()) {
|
|
241
|
+
var pos = _lineColAt(html, m.index);
|
|
242
|
+
_addFinding(report, {
|
|
243
|
+
sc: "3.1.1",
|
|
244
|
+
level: "A",
|
|
245
|
+
severity: "error",
|
|
246
|
+
element: "html",
|
|
247
|
+
line: pos.line,
|
|
248
|
+
column: pos.column,
|
|
249
|
+
message: "html element missing lang attribute (assistive tech can't pick the right voice / pronunciation)",
|
|
250
|
+
remediation: "Add lang=\"<BCP47-tag>\" e.g. lang=\"en\" or lang=\"en-US\"",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function _checkPageTitle(html, report, opts) {
|
|
256
|
+
if (opts.ignore.indexOf("2.4.2") !== -1) return;
|
|
257
|
+
if (!/<head\b/i.test(html)) return;
|
|
258
|
+
var m = /<title\b[^>]*>([^]*?)<\/title>/i.exec(html);
|
|
259
|
+
if (!m) {
|
|
260
|
+
var pos = _lineColAt(html, html.search(/<head\b/i));
|
|
261
|
+
_addFinding(report, {
|
|
262
|
+
sc: "2.4.2",
|
|
263
|
+
level: "A",
|
|
264
|
+
severity: "error",
|
|
265
|
+
element: "title",
|
|
266
|
+
line: pos.line,
|
|
267
|
+
column: pos.column,
|
|
268
|
+
message: "Page has no <title> element",
|
|
269
|
+
remediation: "Add <title>Descriptive page title</title> inside <head>",
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
var title = m[1].replace(/<[^>]+>/g, "").trim();
|
|
274
|
+
if (title.length === 0) {
|
|
275
|
+
var pos2 = _lineColAt(html, m.index);
|
|
276
|
+
_addFinding(report, {
|
|
277
|
+
sc: "2.4.2",
|
|
278
|
+
level: "A",
|
|
279
|
+
severity: "error",
|
|
280
|
+
element: "title",
|
|
281
|
+
line: pos2.line,
|
|
282
|
+
column: pos2.column,
|
|
283
|
+
message: "<title> element is empty",
|
|
284
|
+
remediation: "Add descriptive text inside <title>",
|
|
285
|
+
});
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
if (title.length < 4 || /^untitled/i.test(title)) {
|
|
289
|
+
var pos3 = _lineColAt(html, m.index);
|
|
290
|
+
_addFinding(report, {
|
|
291
|
+
sc: "2.4.2",
|
|
292
|
+
level: "A",
|
|
293
|
+
severity: "warning",
|
|
294
|
+
element: "title",
|
|
295
|
+
line: pos3.line,
|
|
296
|
+
column: pos3.column,
|
|
297
|
+
message: "<title> is too short or generic (\"" + title + "\")",
|
|
298
|
+
remediation: "Use a descriptive page title that distinguishes the page from siblings",
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function _checkSkipLink(html, report, opts) {
|
|
304
|
+
if (opts.ignore.indexOf("2.4.1") !== -1) return;
|
|
305
|
+
if (!/<body\b/i.test(html)) return;
|
|
306
|
+
if (/<a[^>]+href=["']#[a-zA-Z][^"']*["'][^>]*>\s*(skip|jump)\b/i.test(html)) return;
|
|
307
|
+
var bodyMatch = /<body\b[^>]*>/i.exec(html);
|
|
308
|
+
var pos = _lineColAt(html, bodyMatch ? bodyMatch.index : 0);
|
|
309
|
+
_addFinding(report, {
|
|
310
|
+
sc: "2.4.1",
|
|
311
|
+
level: "A",
|
|
312
|
+
severity: "info",
|
|
313
|
+
element: "body",
|
|
314
|
+
line: pos.line,
|
|
315
|
+
column: pos.column,
|
|
316
|
+
message: "No \"skip to content\" link detected at the start of the page",
|
|
317
|
+
remediation: "Add <a href=\"#main\" class=\"sr-only\">Skip to content</a> as the first focusable element in <body>",
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function _checkAnchors(html, scheduled, report) {
|
|
322
|
+
for (var i = 0; i < scheduled.length; i++) {
|
|
323
|
+
var s = scheduled[i];
|
|
324
|
+
var tagOpen = html.indexOf(">", s.offset);
|
|
325
|
+
if (tagOpen === -1) continue;
|
|
326
|
+
var inner = _innerText(html, tagOpen + 1, "a");
|
|
327
|
+
var visibleText = inner.length > 0;
|
|
328
|
+
if (visibleText || s.hasAccessibleName) continue;
|
|
329
|
+
var pos = _lineColAt(html, s.offset);
|
|
330
|
+
_addFinding(report, {
|
|
331
|
+
sc: "2.4.4",
|
|
332
|
+
level: "A",
|
|
333
|
+
severity: "error",
|
|
334
|
+
element: "a",
|
|
335
|
+
line: pos.line,
|
|
336
|
+
column: pos.column,
|
|
337
|
+
message: "Link has no accessible name (no visible text, aria-label, aria-labelledby, or title)",
|
|
338
|
+
remediation: "Add link text, aria-label=\"<purpose>\", or aria-labelledby=\"<idref>\"",
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// ---- Main audit ----
|
|
344
|
+
|
|
345
|
+
function audit(html, opts) {
|
|
346
|
+
opts = opts || {};
|
|
347
|
+
validateOpts(opts, [
|
|
348
|
+
"level", "ignore", "checkAll", "scopeUrl",
|
|
349
|
+
"skipAria", "allowedRoles", "skipTables",
|
|
350
|
+
"skipForms", "allowedAutocomplete",
|
|
351
|
+
], "guardHtml.wcag.audit");
|
|
352
|
+
if (typeof html !== "string") {
|
|
353
|
+
throw new GuardHtmlWcagError("guard-html-wcag/bad-input",
|
|
354
|
+
"audit: html must be a string");
|
|
355
|
+
}
|
|
356
|
+
var level = opts.level || "AA";
|
|
357
|
+
if (VALID_LEVELS.indexOf(level) === -1) {
|
|
358
|
+
throw new GuardHtmlWcagError("guard-html-wcag/bad-level",
|
|
359
|
+
"audit: level must be one of " + VALID_LEVELS.join(", "));
|
|
360
|
+
}
|
|
361
|
+
var ignore = Array.isArray(opts.ignore) ? opts.ignore : [];
|
|
362
|
+
for (var i = 0; i < ignore.length; i++) {
|
|
363
|
+
if (typeof ignore[i] !== "string") {
|
|
364
|
+
throw new GuardHtmlWcagError("guard-html-wcag/bad-ignore",
|
|
365
|
+
"audit: ignore[" + i + "] must be a string SC like \"1.4.3\"");
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
var report = _newReport(opts.scopeUrl);
|
|
369
|
+
var scanOpts = { level: level, ignore: ignore };
|
|
370
|
+
|
|
371
|
+
// Page-level checks
|
|
372
|
+
_checkHtmlLang(html, report, scanOpts);
|
|
373
|
+
_checkPageTitle(html, report, scanOpts);
|
|
374
|
+
_checkSkipLink(html, report, scanOpts);
|
|
375
|
+
|
|
376
|
+
// Per-element walker
|
|
377
|
+
var ctx = {
|
|
378
|
+
headingLevels: [],
|
|
379
|
+
labelledIds: Object.create(null),
|
|
380
|
+
lastTagEndOffset: 0,
|
|
381
|
+
scheduledAnchors: [],
|
|
382
|
+
};
|
|
383
|
+
var labelRe = /<label\b[^>]*\bfor\s*=\s*["']([^"']+)["'][^>]*>/gi;
|
|
384
|
+
var lm;
|
|
385
|
+
while ((lm = labelRe.exec(html))) {
|
|
386
|
+
ctx.labelledIds[lm[1]] = true;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function _scLevelEligible(sc) {
|
|
390
|
+
var sce = SC_REGISTRY[sc];
|
|
391
|
+
if (!sce) return true;
|
|
392
|
+
return _meetsLevel(sce.level, level);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
_TAG_RE.lastIndex = 0;
|
|
396
|
+
var m;
|
|
397
|
+
while ((m = _TAG_RE.exec(html))) {
|
|
398
|
+
// Skip closing tags (m[0] starts with "</"); only run per-element
|
|
399
|
+
// checks on opening tags so we don't double-fire on each pair.
|
|
400
|
+
if (m[0].charAt(1) === "/") continue;
|
|
401
|
+
var tagName = m[1].toLowerCase();
|
|
402
|
+
var attrs = _parseAttrs(m[2]);
|
|
403
|
+
var offset = m.index;
|
|
404
|
+
var endOffset = m.index + m[0].length;
|
|
405
|
+
ctx.lastTagEndOffset = endOffset;
|
|
406
|
+
|
|
407
|
+
if (_scLevelEligible("1.1.1")) _checkImgAlt(html, attrs, tagName, offset, report, scanOpts);
|
|
408
|
+
if (_scLevelEligible("3.3.2") || _scLevelEligible("4.1.2")) _checkInputLabel(html, attrs, tagName, offset, report, scanOpts, ctx);
|
|
409
|
+
if (_scLevelEligible("2.4.4")) {
|
|
410
|
+
var sched = _checkAnchorScheduled(html, attrs, tagName, offset, report, scanOpts);
|
|
411
|
+
if (sched) ctx.scheduledAnchors.push(sched);
|
|
412
|
+
}
|
|
413
|
+
if (_scLevelEligible("4.1.2") && tagName === "button") {
|
|
414
|
+
_checkButtonText(html, endOffset, attrs, offset, report, scanOpts);
|
|
415
|
+
}
|
|
416
|
+
if (_scLevelEligible("1.3.1")) _checkHeadingOrder(html, attrs, tagName, offset, report, scanOpts, ctx);
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
_checkAnchors(html, ctx.scheduledAnchors, report);
|
|
420
|
+
|
|
421
|
+
// ARIA validation pass — fold its findings into the report.
|
|
422
|
+
if (opts.skipAria !== true) {
|
|
423
|
+
var ariaFindings = aria.audit(html, {
|
|
424
|
+
allowedRoles: opts.allowedRoles,
|
|
425
|
+
scopeUrl: opts.scopeUrl,
|
|
426
|
+
});
|
|
427
|
+
for (var ai = 0; ai < ariaFindings.length; ai++) {
|
|
428
|
+
_addFinding(report, ariaFindings[ai]);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
// Table-semantics pass — caption / scope / layout-table heuristics.
|
|
432
|
+
if (opts.skipTables !== true) {
|
|
433
|
+
var tableFindings = tables.audit(html, { scopeUrl: opts.scopeUrl });
|
|
434
|
+
for (var ti = 0; ti < tableFindings.length; ti++) {
|
|
435
|
+
_addFinding(report, tableFindings[ti]);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
// Forms pass — fieldset/legend / autocomplete / textarea labelling.
|
|
439
|
+
if (opts.skipForms !== true) {
|
|
440
|
+
var formFindings = forms.audit(html, {
|
|
441
|
+
allowedAutocomplete: opts.allowedAutocomplete,
|
|
442
|
+
scopeUrl: opts.scopeUrl,
|
|
443
|
+
});
|
|
444
|
+
for (var fi = 0; fi < formFindings.length; fi++) {
|
|
445
|
+
_addFinding(report, formFindings[fi]);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Heuristic score: 1 - weighted-violations / heuristic-max
|
|
450
|
+
var weighted = report.summary.error * 3 + report.summary.warning * 1.5 + // allow:raw-byte-literal — severity weights for heuristic score
|
|
451
|
+
report.summary.info * 0.5; // allow:raw-byte-literal — severity weights for heuristic score
|
|
452
|
+
var maxFor = Math.max(50, weighted * 2); // allow:raw-byte-literal — heuristic-score floor
|
|
453
|
+
report.score = Math.max(0, 1 - weighted / maxFor);
|
|
454
|
+
|
|
455
|
+
try { observability().safeEvent("guard-html.wcag.audited", 1, {
|
|
456
|
+
level: level, errors: String(report.summary.error),
|
|
457
|
+
}); } catch (_e) { /* drop-silent */ }
|
|
458
|
+
|
|
459
|
+
return report;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
module.exports = {
|
|
463
|
+
audit: audit,
|
|
464
|
+
aria: aria,
|
|
465
|
+
tables: tables,
|
|
466
|
+
forms: forms,
|
|
467
|
+
SC_REGISTRY: SC_REGISTRY,
|
|
468
|
+
VALID_LEVELS: VALID_LEVELS,
|
|
469
|
+
GuardHtmlWcagError: GuardHtmlWcagError,
|
|
470
|
+
};
|
package/lib/guard-html.js
CHANGED
|
@@ -118,6 +118,7 @@
|
|
|
118
118
|
*/
|
|
119
119
|
|
|
120
120
|
var codepointClass = require("./codepoint-class");
|
|
121
|
+
var guardHtmlWcag = require("./guard-html-wcag");
|
|
121
122
|
var lazyRequire = require("./lazy-require");
|
|
122
123
|
var gateContract = require("./gate-contract");
|
|
123
124
|
var C = require("./constants");
|
|
@@ -972,5 +973,8 @@ module.exports = {
|
|
|
972
973
|
DANGEROUS_SCHEMES: DANGEROUS_SCHEMES,
|
|
973
974
|
CLOBBER_GLOBALS: CLOBBER_GLOBALS,
|
|
974
975
|
CLOBBER_PRONE_TAGS: CLOBBER_PRONE_TAGS,
|
|
976
|
+
// WCAG 2.2 audit-only mode (b.guardHtml.wcag.audit) — accessibility
|
|
977
|
+
// scanner that emits violations without modifying HTML.
|
|
978
|
+
wcag: guardHtmlWcag,
|
|
975
979
|
GuardHtmlError: GuardHtmlError,
|
|
976
980
|
};
|
package/package.json
CHANGED
package/sbom.cyclonedx.json
CHANGED
|
@@ -2,10 +2,10 @@
|
|
|
2
2
|
"$schema": "http://cyclonedx.org/schema/bom-1.5.schema.json",
|
|
3
3
|
"bomFormat": "CycloneDX",
|
|
4
4
|
"specVersion": "1.5",
|
|
5
|
-
"serialNumber": "urn:uuid:
|
|
5
|
+
"serialNumber": "urn:uuid:7d9604c0-8d11-4b2c-bc59-69f6fcf1acf6",
|
|
6
6
|
"version": 1,
|
|
7
7
|
"metadata": {
|
|
8
|
-
"timestamp": "2026-05-06T11:
|
|
8
|
+
"timestamp": "2026-05-06T11:52:08.561Z",
|
|
9
9
|
"lifecycles": [
|
|
10
10
|
{
|
|
11
11
|
"phase": "build"
|
|
@@ -19,14 +19,14 @@
|
|
|
19
19
|
}
|
|
20
20
|
],
|
|
21
21
|
"component": {
|
|
22
|
-
"bom-ref": "@blamejs/core@0.7.
|
|
22
|
+
"bom-ref": "@blamejs/core@0.7.106",
|
|
23
23
|
"type": "library",
|
|
24
24
|
"name": "blamejs",
|
|
25
|
-
"version": "0.7.
|
|
25
|
+
"version": "0.7.106",
|
|
26
26
|
"scope": "required",
|
|
27
27
|
"author": "blamejs contributors",
|
|
28
28
|
"description": "The Node framework that owns its stack.",
|
|
29
|
-
"purl": "pkg:npm/%40blamejs/core@0.7.
|
|
29
|
+
"purl": "pkg:npm/%40blamejs/core@0.7.106",
|
|
30
30
|
"properties": [],
|
|
31
31
|
"externalReferences": [
|
|
32
32
|
{
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
"components": [],
|
|
55
55
|
"dependencies": [
|
|
56
56
|
{
|
|
57
|
-
"ref": "@blamejs/core@0.7.
|
|
57
|
+
"ref": "@blamejs/core@0.7.106",
|
|
58
58
|
"dependsOn": []
|
|
59
59
|
}
|
|
60
60
|
]
|