@diegovelasquezweb/a11y-engine 0.1.2 → 0.1.4

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.
@@ -0,0 +1,262 @@
1
+ /**
2
+ * @file index.mjs
3
+ * @description Public programmatic API for @diegovelasquezweb/a11y-engine.
4
+ * Consumers can import functions directly instead of reading JSON files from disk.
5
+ */
6
+
7
+ import { ASSET_PATHS, loadAssetJson } from "./core/asset-loader.mjs";
8
+
9
+ // ---------------------------------------------------------------------------
10
+ // Lazy-loaded asset cache
11
+ // ---------------------------------------------------------------------------
12
+
13
+ let _intelligence = null;
14
+ let _pa11yConfig = null;
15
+ let _complianceConfig = null;
16
+ let _wcagReference = null;
17
+
18
+ function getIntelligence() {
19
+ if (!_intelligence) _intelligence = loadAssetJson(ASSET_PATHS.remediation.intelligence, "intelligence.json");
20
+ return _intelligence;
21
+ }
22
+
23
+ function getPa11yConfig() {
24
+ if (!_pa11yConfig) {
25
+ try {
26
+ _pa11yConfig = loadAssetJson(ASSET_PATHS.engine.pa11yConfig, "pa11y-config.json");
27
+ } catch {
28
+ _pa11yConfig = { equivalenceMap: {}, ignoreByPrinciple: [], impactMap: {} };
29
+ }
30
+ }
31
+ return _pa11yConfig;
32
+ }
33
+
34
+ function getComplianceConfig() {
35
+ if (!_complianceConfig) _complianceConfig = loadAssetJson(ASSET_PATHS.reporting.complianceConfig, "compliance-config.json");
36
+ return _complianceConfig;
37
+ }
38
+
39
+ function getWcagReference() {
40
+ if (!_wcagReference) _wcagReference = loadAssetJson(ASSET_PATHS.reporting.wcagReference, "wcag-reference.json");
41
+ return _wcagReference;
42
+ }
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // Assets access
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Returns all engine asset data. Lazy-loaded and cached.
50
+ * @returns {{ intelligence: object, pa11yConfig: object, complianceConfig: object, wcagReference: object }}
51
+ */
52
+ export function getAssets() {
53
+ return {
54
+ intelligence: getIntelligence(),
55
+ pa11yConfig: getPa11yConfig(),
56
+ complianceConfig: getComplianceConfig(),
57
+ wcagReference: getWcagReference(),
58
+ };
59
+ }
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Pa11y rule canonicalization
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Normalizes a pa11y code token for comparison.
67
+ * @param {string} value
68
+ * @returns {string}
69
+ */
70
+ function normalizePa11yToken(value) {
71
+ return value
72
+ .toLowerCase()
73
+ .replace(/^wcag2a{1,3}\./, "")
74
+ .replace(/^pa11y-/, "")
75
+ .replace(/[^a-z0-9]/g, "");
76
+ }
77
+
78
+ /**
79
+ * Maps a pa11y rule ID to its canonical axe-equivalent ID.
80
+ * @param {string} ruleId - The current rule ID (may already be canonical or a pa11y slug).
81
+ * @param {string|null} sourceRuleId - The original pa11y code if available.
82
+ * @param {object|null} checkData - The check_data object which may contain a `code` field.
83
+ * @returns {string} The canonical rule ID (e.g., "color-contrast").
84
+ */
85
+ export function mapPa11yRuleToCanonical(ruleId, sourceRuleId = null, checkData = null) {
86
+ const equivalenceMap = getPa11yConfig().equivalenceMap || {};
87
+
88
+ const checkCode = checkData && typeof checkData === "object" && typeof checkData.code === "string"
89
+ ? checkData.code
90
+ : null;
91
+
92
+ const codeCandidates = [sourceRuleId, checkCode, ruleId]
93
+ .filter((v) => typeof v === "string" && v.length > 0)
94
+ .map(normalizePa11yToken);
95
+
96
+ const patterns = Object.entries(equivalenceMap).map(([pattern, canonical]) => ({
97
+ pattern: normalizePa11yToken(pattern),
98
+ canonical,
99
+ }));
100
+
101
+ for (const code of codeCandidates) {
102
+ for (const entry of patterns) {
103
+ if (code.startsWith(entry.pattern)) {
104
+ return entry.canonical;
105
+ }
106
+ }
107
+ }
108
+
109
+ return ruleId;
110
+ }
111
+
112
+ // ---------------------------------------------------------------------------
113
+ // Finding enrichment
114
+ // ---------------------------------------------------------------------------
115
+
116
+ /**
117
+ * Enriches findings with intelligence data (fix descriptions, fix code, category).
118
+ * Each finding should have at minimum: { ruleId, sourceRuleId?, checkData?, fixDescription?, fixCode? }
119
+ * @param {object[]} findings - Array of findings with camelCase keys.
120
+ * @returns {object[]} Enriched findings.
121
+ */
122
+ export function enrichFindings(findings) {
123
+ const rules = getIntelligence().rules || {};
124
+
125
+ return findings.map((finding) => {
126
+ const canonical = mapPa11yRuleToCanonical(
127
+ finding.ruleId || finding.rule_id || "",
128
+ finding.sourceRuleId || finding.source_rule_id || null,
129
+ finding.checkData || finding.check_data || null,
130
+ );
131
+
132
+ const normalized = {
133
+ ...finding,
134
+ ruleId: canonical,
135
+ rule_id: canonical,
136
+ sourceRuleId: finding.sourceRuleId || finding.source_rule_id || finding.ruleId || finding.rule_id || null,
137
+ };
138
+
139
+ if (normalized.fixDescription || normalized.fix_description ||
140
+ normalized.fixCode || normalized.fix_code) {
141
+ return normalized;
142
+ }
143
+
144
+ const info = rules[canonical];
145
+ if (!info) return normalized;
146
+
147
+ return {
148
+ ...normalized,
149
+ category: normalized.category ?? info.category ?? null,
150
+ fixDescription: info.fix?.description ?? null,
151
+ fix_description: info.fix?.description ?? null,
152
+ fixCode: info.fix?.code ?? null,
153
+ fix_code: info.fix?.code ?? null,
154
+ falsePositiveRisk: normalized.falsePositiveRisk ?? normalized.false_positive_risk ?? info.false_positive_risk ?? null,
155
+ false_positive_risk: normalized.false_positive_risk ?? info.false_positive_risk ?? null,
156
+ fixDifficultyNotes: normalized.fixDifficultyNotes ?? normalized.fix_difficulty_notes ?? info.fix_difficulty_notes ?? null,
157
+ fix_difficulty_notes: normalized.fix_difficulty_notes ?? info.fix_difficulty_notes ?? null,
158
+ };
159
+ });
160
+ }
161
+
162
+ // ---------------------------------------------------------------------------
163
+ // Score computation
164
+ // ---------------------------------------------------------------------------
165
+
166
+ /**
167
+ * Computes compliance score, grade label, and WCAG pass/fail status.
168
+ * @param {{ Critical: number, Serious: number, Moderate: number, Minor: number }} totals
169
+ * @returns {{ score: number, label: string, wcagStatus: "Pass" | "Conditional Pass" | "Fail" }}
170
+ */
171
+ export function computeScore(totals) {
172
+ const config = getComplianceConfig();
173
+ const penalties = config.complianceScore.penalties;
174
+ const thresholds = config.gradeThresholds;
175
+
176
+ const rawScore =
177
+ config.complianceScore.baseScore -
178
+ totals.Critical * (penalties.Critical ?? 15) -
179
+ totals.Serious * (penalties.Serious ?? 5) -
180
+ totals.Moderate * (penalties.Moderate ?? 2) -
181
+ totals.Minor * (penalties.Minor ?? 0.5);
182
+
183
+ const score = Math.max(0, Math.min(100, Math.round(rawScore)));
184
+
185
+ let label = "Critical";
186
+ for (const threshold of thresholds) {
187
+ if (score >= threshold.min) {
188
+ label = threshold.label;
189
+ break;
190
+ }
191
+ }
192
+
193
+ let wcagStatus = "Pass";
194
+ if (totals.Critical > 0 || totals.Serious > 0) wcagStatus = "Fail";
195
+ else if (totals.Moderate > 0 || totals.Minor > 0) wcagStatus = "Conditional Pass";
196
+
197
+ return { score, label, wcagStatus };
198
+ }
199
+
200
+ // ---------------------------------------------------------------------------
201
+ // Persona grouping
202
+ // ---------------------------------------------------------------------------
203
+
204
+ /**
205
+ * Groups findings by accessibility persona (screen reader, keyboard, cognitive, etc.).
206
+ * @param {object[]} findings - Array of findings with ruleId, wcagCriterionId, impactedUsers.
207
+ * @returns {Record<string, { label: string, count: number, icon: string }>}
208
+ */
209
+ export function computePersonaGroups(findings) {
210
+ const ref = getWcagReference();
211
+ const personaConfig = ref.personaConfig || {};
212
+ const personaMapping = ref.personaMapping || {};
213
+ const wcagCriterionMap = ref.wcagCriterionMap || {};
214
+
215
+ const groups = {};
216
+ for (const [key, config] of Object.entries(personaConfig)) {
217
+ groups[key] = { label: config.label, count: 0, icon: key };
218
+ }
219
+
220
+ const criterionToPersonas = {};
221
+ for (const [personaKey, mapping] of Object.entries(personaMapping)) {
222
+ for (const rule of mapping.rules) {
223
+ const criterion = wcagCriterionMap[rule];
224
+ if (criterion) {
225
+ if (!criterionToPersonas[criterion]) criterionToPersonas[criterion] = new Set();
226
+ criterionToPersonas[criterion].add(personaKey);
227
+ }
228
+ }
229
+ }
230
+
231
+ for (const f of findings) {
232
+ const ruleId = (f.ruleId || f.rule_id || "").toLowerCase();
233
+ const wcagCriterionId = f.wcagCriterionId || f.wcag_criterion_id || "";
234
+ const users = (f.impactedUsers || f.impacted_users || "").toLowerCase();
235
+ const matchedPersonas = new Set();
236
+
237
+ for (const [personaKey, mapping] of Object.entries(personaMapping)) {
238
+ if (!groups[personaKey]) continue;
239
+ const matchesRule = mapping.rules.some((r) => ruleId === r.toLowerCase());
240
+ if (matchesRule) {
241
+ matchedPersonas.add(personaKey);
242
+ groups[personaKey].count++;
243
+ continue;
244
+ }
245
+ if (wcagCriterionId && criterionToPersonas[wcagCriterionId]?.has(personaKey)) {
246
+ matchedPersonas.add(personaKey);
247
+ groups[personaKey].count++;
248
+ continue;
249
+ }
250
+ }
251
+
252
+ if (matchedPersonas.size === 0 && users) {
253
+ for (const [personaKey, mapping] of Object.entries(personaMapping)) {
254
+ if (!groups[personaKey] || matchedPersonas.has(personaKey)) continue;
255
+ const matchesKeyword = mapping.keywords.some((kw) => users.includes(kw.toLowerCase()));
256
+ if (matchesKeyword) groups[personaKey].count++;
257
+ }
258
+ }
259
+ }
260
+
261
+ return groups;
262
+ }