@diegovelasquezweb/a11y-engine 0.1.5 → 0.1.7
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/package.json +1 -1
- package/scripts/index.d.mts +103 -18
- package/scripts/index.mjs +195 -5
package/package.json
CHANGED
package/scripts/index.d.mts
CHANGED
|
@@ -1,3 +1,67 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Core types
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
|
|
5
|
+
export interface Finding {
|
|
6
|
+
id: string;
|
|
7
|
+
rule_id: string;
|
|
8
|
+
title: string;
|
|
9
|
+
severity: string;
|
|
10
|
+
wcag: string;
|
|
11
|
+
wcag_classification: string | null;
|
|
12
|
+
wcag_criterion_id: string | null;
|
|
13
|
+
category: string | null;
|
|
14
|
+
area: string;
|
|
15
|
+
url: string;
|
|
16
|
+
selector: string;
|
|
17
|
+
primary_selector: string;
|
|
18
|
+
impacted_users: string;
|
|
19
|
+
actual: string;
|
|
20
|
+
expected: string;
|
|
21
|
+
primary_failure_mode: string | null;
|
|
22
|
+
relationship_hint: string | null;
|
|
23
|
+
failure_checks: unknown[];
|
|
24
|
+
related_context: unknown[];
|
|
25
|
+
mdn: string | null;
|
|
26
|
+
fix_description: string | null;
|
|
27
|
+
fix_code: string | null;
|
|
28
|
+
fix_code_lang: string | null;
|
|
29
|
+
recommended_fix: string;
|
|
30
|
+
evidence: unknown[];
|
|
31
|
+
total_instances: number | null;
|
|
32
|
+
effort: string | null;
|
|
33
|
+
related_rules: string[];
|
|
34
|
+
screenshot_path: string | null;
|
|
35
|
+
false_positive_risk: string | null;
|
|
36
|
+
guardrails: Record<string, unknown> | null;
|
|
37
|
+
fix_difficulty_notes: string | string[] | null;
|
|
38
|
+
framework_notes: string | null;
|
|
39
|
+
cms_notes: string | null;
|
|
40
|
+
file_search_pattern: string | null;
|
|
41
|
+
ownership_status: string;
|
|
42
|
+
ownership_reason: string | null;
|
|
43
|
+
primary_source_scope: string[];
|
|
44
|
+
search_strategy: string;
|
|
45
|
+
managed_by_library: string | null;
|
|
46
|
+
component_hint: string | null;
|
|
47
|
+
verification_command: string | null;
|
|
48
|
+
verification_command_fallback: string | null;
|
|
49
|
+
check_data: Record<string, unknown> | null;
|
|
50
|
+
source?: string;
|
|
51
|
+
source_rule_id?: string | null;
|
|
52
|
+
pages_affected?: number | null;
|
|
53
|
+
affected_urls?: string[] | null;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface EnrichedFinding extends Finding {
|
|
57
|
+
ruleId: string;
|
|
58
|
+
sourceRuleId: string | null;
|
|
59
|
+
fixDescription: string | null;
|
|
60
|
+
fixCode: string | null;
|
|
61
|
+
falsePositiveRisk: string | null;
|
|
62
|
+
fixDifficultyNotes: string | string[] | null;
|
|
63
|
+
}
|
|
64
|
+
|
|
1
65
|
export interface SeverityTotals {
|
|
2
66
|
Critical: number;
|
|
3
67
|
Serious: number;
|
|
@@ -5,7 +69,7 @@ export interface SeverityTotals {
|
|
|
5
69
|
Minor: number;
|
|
6
70
|
}
|
|
7
71
|
|
|
8
|
-
export interface
|
|
72
|
+
export interface ComplianceScore {
|
|
9
73
|
score: number;
|
|
10
74
|
label: string;
|
|
11
75
|
wcagStatus: "Pass" | "Conditional Pass" | "Fail";
|
|
@@ -17,27 +81,48 @@ export interface PersonaGroup {
|
|
|
17
81
|
icon: string;
|
|
18
82
|
}
|
|
19
83
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
// Report types
|
|
86
|
+
// ---------------------------------------------------------------------------
|
|
87
|
+
|
|
88
|
+
export interface ScanPayload {
|
|
89
|
+
findings: Finding[] | Record<string, unknown>[];
|
|
90
|
+
metadata?: Record<string, unknown>;
|
|
25
91
|
}
|
|
26
92
|
|
|
27
|
-
export
|
|
93
|
+
export interface ReportOptions {
|
|
94
|
+
baseUrl?: string;
|
|
95
|
+
target?: string;
|
|
96
|
+
}
|
|
28
97
|
|
|
29
|
-
export
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
98
|
+
export interface PDFReport {
|
|
99
|
+
buffer: Buffer;
|
|
100
|
+
contentType: "application/pdf";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface ChecklistReport {
|
|
104
|
+
html: string;
|
|
105
|
+
contentType: "text/html";
|
|
106
|
+
}
|
|
34
107
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
// Public API
|
|
110
|
+
// ---------------------------------------------------------------------------
|
|
38
111
|
|
|
39
|
-
export function
|
|
112
|
+
export function getEnrichedFindings(findings: Finding[]): EnrichedFinding[];
|
|
113
|
+
export function getEnrichedFindings(findings: Record<string, unknown>[]): EnrichedFinding[];
|
|
40
114
|
|
|
41
|
-
export function
|
|
42
|
-
|
|
115
|
+
export function getComplianceScore(totals: SeverityTotals): ComplianceScore;
|
|
116
|
+
|
|
117
|
+
export function getPersonaGroups(
|
|
118
|
+
findings: EnrichedFinding[] | Record<string, unknown>[]
|
|
43
119
|
): Record<string, PersonaGroup>;
|
|
120
|
+
|
|
121
|
+
export function getPDFReport(
|
|
122
|
+
payload: ScanPayload,
|
|
123
|
+
options?: ReportOptions
|
|
124
|
+
): Promise<PDFReport>;
|
|
125
|
+
|
|
126
|
+
export function getChecklist(
|
|
127
|
+
options?: Pick<ReportOptions, "baseUrl">
|
|
128
|
+
): Promise<ChecklistReport>;
|
package/scripts/index.mjs
CHANGED
|
@@ -49,7 +49,7 @@ function getWcagReference() {
|
|
|
49
49
|
* Returns all engine asset data. Lazy-loaded and cached.
|
|
50
50
|
* @returns {{ intelligence: object, pa11yConfig: object, complianceConfig: object, wcagReference: object }}
|
|
51
51
|
*/
|
|
52
|
-
|
|
52
|
+
function getAssets() {
|
|
53
53
|
return {
|
|
54
54
|
intelligence: getIntelligence(),
|
|
55
55
|
pa11yConfig: getPa11yConfig(),
|
|
@@ -82,7 +82,7 @@ function normalizePa11yToken(value) {
|
|
|
82
82
|
* @param {object|null} checkData - The check_data object which may contain a `code` field.
|
|
83
83
|
* @returns {string} The canonical rule ID (e.g., "color-contrast").
|
|
84
84
|
*/
|
|
85
|
-
|
|
85
|
+
function mapPa11yRuleToCanonical(ruleId, sourceRuleId = null, checkData = null) {
|
|
86
86
|
const equivalenceMap = getPa11yConfig().equivalenceMap || {};
|
|
87
87
|
|
|
88
88
|
const checkCode = checkData && typeof checkData === "object" && typeof checkData.code === "string"
|
|
@@ -119,7 +119,7 @@ export function mapPa11yRuleToCanonical(ruleId, sourceRuleId = null, checkData =
|
|
|
119
119
|
* @param {object[]} findings - Array of findings with camelCase keys.
|
|
120
120
|
* @returns {object[]} Enriched findings.
|
|
121
121
|
*/
|
|
122
|
-
export function
|
|
122
|
+
export function getEnrichedFindings(findings) {
|
|
123
123
|
const rules = getIntelligence().rules || {};
|
|
124
124
|
|
|
125
125
|
return findings.map((finding) => {
|
|
@@ -168,7 +168,7 @@ export function enrichFindings(findings) {
|
|
|
168
168
|
* @param {{ Critical: number, Serious: number, Moderate: number, Minor: number }} totals
|
|
169
169
|
* @returns {{ score: number, label: string, wcagStatus: "Pass" | "Conditional Pass" | "Fail" }}
|
|
170
170
|
*/
|
|
171
|
-
export function
|
|
171
|
+
export function getComplianceScore(totals) {
|
|
172
172
|
const config = getComplianceConfig();
|
|
173
173
|
const penalties = config.complianceScore.penalties;
|
|
174
174
|
const thresholds = config.gradeThresholds;
|
|
@@ -206,7 +206,7 @@ export function computeScore(totals) {
|
|
|
206
206
|
* @param {object[]} findings - Array of findings with ruleId, wcagCriterionId, impactedUsers.
|
|
207
207
|
* @returns {Record<string, { label: string, count: number, icon: string }>}
|
|
208
208
|
*/
|
|
209
|
-
export function
|
|
209
|
+
export function getPersonaGroups(findings) {
|
|
210
210
|
const ref = getWcagReference();
|
|
211
211
|
const personaConfig = ref.personaConfig || {};
|
|
212
212
|
const personaMapping = ref.personaMapping || {};
|
|
@@ -260,3 +260,193 @@ export function computePersonaGroups(findings) {
|
|
|
260
260
|
|
|
261
261
|
return groups;
|
|
262
262
|
}
|
|
263
|
+
|
|
264
|
+
// ---------------------------------------------------------------------------
|
|
265
|
+
// Report generation
|
|
266
|
+
// ---------------------------------------------------------------------------
|
|
267
|
+
|
|
268
|
+
import {
|
|
269
|
+
normalizeFindings as normalizeForReports,
|
|
270
|
+
buildSummary,
|
|
271
|
+
computeComplianceScore,
|
|
272
|
+
scoreLabel,
|
|
273
|
+
buildPersonaSummary,
|
|
274
|
+
wcagOverallStatus,
|
|
275
|
+
} from "./reports/renderers/findings.mjs";
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Generates a PDF report buffer from raw scan findings.
|
|
279
|
+
* Requires Playwright (chromium) to render the PDF.
|
|
280
|
+
* @param {{ findings: object[], metadata?: object }} payload - Raw scan output (snake_case keys).
|
|
281
|
+
* @param {{ baseUrl?: string, target?: string }} [options={}]
|
|
282
|
+
* @returns {Promise<Buffer>} The PDF as a Node.js Buffer.
|
|
283
|
+
*/
|
|
284
|
+
export async function getPDFReport(payload, options = {}) {
|
|
285
|
+
const { chromium } = await import("playwright");
|
|
286
|
+
const {
|
|
287
|
+
buildPdfCoverPage,
|
|
288
|
+
buildPdfTableOfContents,
|
|
289
|
+
buildPdfExecutiveSummary,
|
|
290
|
+
buildPdfRiskSection,
|
|
291
|
+
buildPdfRemediationRoadmap,
|
|
292
|
+
buildPdfMethodologySection,
|
|
293
|
+
buildPdfIssueSummaryTable,
|
|
294
|
+
buildPdfNextSteps,
|
|
295
|
+
buildPdfAuditLimitations,
|
|
296
|
+
} = await import("./reports/renderers/pdf.mjs");
|
|
297
|
+
|
|
298
|
+
const args = { baseUrl: options.baseUrl || "", target: options.target || "WCAG 2.2 AA" };
|
|
299
|
+
const findings = normalizeForReports(payload).filter(
|
|
300
|
+
(f) => f.wcagClassification !== "AAA" && f.wcagClassification !== "Best Practice",
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
const totals = buildSummary(findings);
|
|
304
|
+
const score = computeComplianceScore(totals);
|
|
305
|
+
let siteHostname = args.baseUrl;
|
|
306
|
+
try { siteHostname = new URL(args.baseUrl.startsWith("http") ? args.baseUrl : `https://${args.baseUrl}`).hostname; } catch {}
|
|
307
|
+
const coverDate = new Date().toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
|
|
308
|
+
|
|
309
|
+
const html = `<!doctype html>
|
|
310
|
+
<html lang="en"><head><meta charset="utf-8"><title>Accessibility Audit — ${siteHostname}</title>
|
|
311
|
+
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
312
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=JetBrains+Mono:wght@400;500&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&display=swap" rel="stylesheet">
|
|
313
|
+
<style>@page{size:A4;margin:2cm}body{background:white;color:black;font-family:'Libre Baskerville',serif;font-size:11pt;line-height:1.6;margin:0;padding:0}h1,h2,h3,h4{font-family:'Inter',sans-serif;color:black;margin-top:1.5rem;margin-bottom:1rem}.cover-page{height:25.5cm;display:flex;flex-direction:column;page-break-after:always}.finding-entry{border-top:1pt solid black;padding-top:1.5rem;margin-top:2rem;page-break-inside:avoid}.severity-tag{font-weight:800;text-transform:uppercase;border:1.5pt solid black;padding:2pt 6pt;font-size:9pt;margin-bottom:1rem;display:inline-block}.remediation-box{background-color:#f3f4f6;border-left:4pt solid black;padding:1rem;margin:1rem 0;font-style:italic}pre{background:#f9fafb;border:1pt solid #ddd;padding:10pt;font-size:8pt;overflow:hidden;white-space:pre-wrap}.stats-table{width:100%;border-collapse:collapse;margin:2rem 0}.stats-table th,.stats-table td{border:1pt solid black;padding:10pt;text-align:left;font-size:9pt;font-family:'Inter',sans-serif}</style>
|
|
314
|
+
</head><body>
|
|
315
|
+
${buildPdfCoverPage({ siteHostname, target: args.target, score, wcagStatus: wcagOverallStatus(totals), coverDate })}
|
|
316
|
+
${buildPdfTableOfContents()}
|
|
317
|
+
${buildPdfExecutiveSummary(args, findings, totals)}
|
|
318
|
+
${buildPdfRiskSection(totals)}
|
|
319
|
+
${buildPdfRemediationRoadmap(findings)}
|
|
320
|
+
${buildPdfMethodologySection(args, findings)}
|
|
321
|
+
${buildPdfIssueSummaryTable(findings)}
|
|
322
|
+
${buildPdfNextSteps(findings, totals)}
|
|
323
|
+
${buildPdfAuditLimitations()}
|
|
324
|
+
</body></html>`;
|
|
325
|
+
|
|
326
|
+
const browser = await chromium.launch();
|
|
327
|
+
try {
|
|
328
|
+
const page = await browser.newPage();
|
|
329
|
+
await page.setContent(html, { waitUntil: "load" });
|
|
330
|
+
await page.evaluate(() => document.fonts.ready);
|
|
331
|
+
const pdfBuffer = await page.pdf({
|
|
332
|
+
format: "A4",
|
|
333
|
+
printBackground: true,
|
|
334
|
+
margin: { top: "1cm", right: "1cm", bottom: "1cm", left: "1cm" },
|
|
335
|
+
displayHeaderFooter: false,
|
|
336
|
+
});
|
|
337
|
+
return {
|
|
338
|
+
buffer: Buffer.from(pdfBuffer),
|
|
339
|
+
contentType: "application/pdf",
|
|
340
|
+
};
|
|
341
|
+
} finally {
|
|
342
|
+
await browser.close();
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* Generates a standalone manual accessibility checklist HTML string.
|
|
348
|
+
* Does not depend on scan results — reads from manual-checks.json asset.
|
|
349
|
+
* @param {{ baseUrl?: string }} [options={}]
|
|
350
|
+
* @returns {Promise<string>} The complete checklist HTML document.
|
|
351
|
+
*/
|
|
352
|
+
export async function getChecklist(options = {}) {
|
|
353
|
+
const { buildManualCheckCard } = await import("./reports/renderers/html.mjs");
|
|
354
|
+
const { escapeHtml } = await import("./reports/renderers/utils.mjs");
|
|
355
|
+
|
|
356
|
+
const manualChecks = loadAssetJson(ASSET_PATHS.reporting.manualChecks, "manual-checks.json");
|
|
357
|
+
const siteLabel = options.baseUrl || "your site";
|
|
358
|
+
const cards = manualChecks.map((c) => buildManualCheckCard(c)).join("\n");
|
|
359
|
+
|
|
360
|
+
const TOTAL = manualChecks.length;
|
|
361
|
+
const COUNT_A = manualChecks.filter((c) => c.level === "A").length;
|
|
362
|
+
const COUNT_AA = manualChecks.filter((c) => c.level === "AA").length;
|
|
363
|
+
const COUNT_AT = manualChecks.filter((c) => c.level === "AT").length;
|
|
364
|
+
|
|
365
|
+
const selectClasses =
|
|
366
|
+
"pl-4 pr-10 py-3 bg-white border border-slate-300 rounded-2xl text-sm font-bold text-slate-800 focus:outline-none focus:ring-4 focus:ring-amber-500/20 focus:border-amber-400 shadow-sm transition-all appearance-none cursor-pointer bg-[url('data:image/svg+xml;charset=utf-8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill%3D%22none%22%20viewBox%3D%220%200%2020%2020%22%3E%3Cpath%20stroke%3D%22%23374151%22%20stroke-linecap%3D%22round%22%20stroke-linejoin%3D%22round%22%20stroke-width%3D%221.5%22%3E%3Cpath%20d%3D%22m6%208%204%204%204-4%22%2F%3E%3C%2Fsvg%3E')] bg-[length:1.25rem_1.25rem] bg-[right_0.5rem_center] bg-no-repeat";
|
|
367
|
+
|
|
368
|
+
// Import the full checklist builder to reuse its buildHtml
|
|
369
|
+
// The checklist builder module has a main() that auto-runs, so we dynamically
|
|
370
|
+
// construct the same output using the renderer functions directly.
|
|
371
|
+
const html = `<!doctype html>
|
|
372
|
+
<html lang="en">
|
|
373
|
+
<head>
|
|
374
|
+
<meta charset="utf-8">
|
|
375
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
376
|
+
<title>Manual Accessibility Checklist — ${escapeHtml(siteLabel)}</title>
|
|
377
|
+
<script src="https://cdn.tailwindcss.com"><\/script>
|
|
378
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
379
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
380
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
381
|
+
<style>
|
|
382
|
+
:root { --amber: hsl(38, 92%, 50%); }
|
|
383
|
+
html { scroll-padding-top: 80px; }
|
|
384
|
+
body { background-color: #f8fafc; font-family: 'Inter', sans-serif; -webkit-font-smoothing: antialiased; }
|
|
385
|
+
.glass-header { background: rgba(255,255,255,0.85); backdrop-filter: blur(12px) saturate(180%); }
|
|
386
|
+
</style>
|
|
387
|
+
</head>
|
|
388
|
+
<body class="text-slate-900 min-h-screen">
|
|
389
|
+
<header class="fixed top-0 left-0 right-0 z-50 glass-header border-b border-slate-200/80 shadow-sm" id="navbar">
|
|
390
|
+
<nav aria-label="Checklist header">
|
|
391
|
+
<div class="max-w-4xl mx-auto px-4 h-16 flex justify-between items-center">
|
|
392
|
+
<div class="flex items-center gap-3">
|
|
393
|
+
<div class="px-3 h-10 rounded-lg bg-slate-900 text-white font-bold text-base font-mono flex items-center justify-center shadow-md">a11y</div>
|
|
394
|
+
<h1 class="text-xl font-bold">Manual <span class="text-slate-500">Checklist</span></h1>
|
|
395
|
+
</div>
|
|
396
|
+
<span class="text-sm text-slate-500 font-medium">${escapeHtml(siteLabel)}</span>
|
|
397
|
+
</div>
|
|
398
|
+
</nav>
|
|
399
|
+
</header>
|
|
400
|
+
<main id="main-content" class="max-w-4xl mx-auto px-4 pt-24 pb-20">
|
|
401
|
+
<div class="mb-12">
|
|
402
|
+
<h2 class="text-3xl font-extrabold mb-2">Manual Testing Checklist</h2>
|
|
403
|
+
<p class="text-slate-600 text-base leading-relaxed max-w-2xl">Automated scans catch ~30-40% of accessibility issues. This checklist covers the rest: keyboard navigation, screen reader behaviour, cognitive flow and more.</p>
|
|
404
|
+
</div>
|
|
405
|
+
<div class="flex flex-wrap items-center gap-3 mb-8">
|
|
406
|
+
<div class="flex items-center gap-1.5 text-sm font-semibold"><span class="inline-block w-3 h-3 rounded-full bg-slate-300"></span><span id="count-total">${TOTAL}</span> Total</div>
|
|
407
|
+
<div class="flex items-center gap-1.5 text-sm font-semibold text-emerald-600"><span class="inline-block w-3 h-3 rounded-full bg-emerald-400"></span><span id="count-pass">0</span> Pass</div>
|
|
408
|
+
<div class="flex items-center gap-1.5 text-sm font-semibold text-rose-600"><span class="inline-block w-3 h-3 rounded-full bg-rose-400"></span><span id="count-fail">0</span> Fail</div>
|
|
409
|
+
<div class="flex items-center gap-1.5 text-sm font-semibold text-amber-600"><span class="inline-block w-3 h-3 rounded-full bg-amber-400"></span><span id="count-na">0</span> N/A</div>
|
|
410
|
+
<div class="ml-auto flex items-center gap-2">
|
|
411
|
+
<select id="level-filter" class="${selectClasses}">
|
|
412
|
+
<option value="all">All levels</option>
|
|
413
|
+
<option value="A">Level A (${COUNT_A})</option>
|
|
414
|
+
<option value="AA">Level AA (${COUNT_AA})</option>
|
|
415
|
+
<option value="AT">Assistive Tech (${COUNT_AT})</option>
|
|
416
|
+
</select>
|
|
417
|
+
</div>
|
|
418
|
+
</div>
|
|
419
|
+
<div id="checklist-items" class="space-y-4">${cards}</div>
|
|
420
|
+
</main>
|
|
421
|
+
<script>
|
|
422
|
+
const items = document.querySelectorAll('[data-check]');
|
|
423
|
+
function updateProgress() {
|
|
424
|
+
let pass=0,fail=0,na=0;
|
|
425
|
+
items.forEach(el => { const s=el.dataset.status; if(s==='pass')pass++; else if(s==='fail')fail++; else if(s==='na')na++; });
|
|
426
|
+
document.getElementById('count-pass').textContent=pass;
|
|
427
|
+
document.getElementById('count-fail').textContent=fail;
|
|
428
|
+
document.getElementById('count-na').textContent=na;
|
|
429
|
+
}
|
|
430
|
+
document.getElementById('level-filter').addEventListener('change',e=>{
|
|
431
|
+
const v=e.target.value;
|
|
432
|
+
items.forEach(el=>{el.style.display=(v==='all'||el.dataset.level===v)?'':'none';});
|
|
433
|
+
});
|
|
434
|
+
items.forEach(el=>{
|
|
435
|
+
el.querySelectorAll('[data-action]').forEach(btn=>{
|
|
436
|
+
btn.addEventListener('click',()=>{
|
|
437
|
+
const action=btn.dataset.action;
|
|
438
|
+
el.dataset.status=el.dataset.status===action?'none':action;
|
|
439
|
+
updateProgress();
|
|
440
|
+
});
|
|
441
|
+
});
|
|
442
|
+
});
|
|
443
|
+
updateProgress();
|
|
444
|
+
<\/script>
|
|
445
|
+
</body>
|
|
446
|
+
</html>`;
|
|
447
|
+
|
|
448
|
+
return {
|
|
449
|
+
html,
|
|
450
|
+
contentType: "text/html",
|
|
451
|
+
};
|
|
452
|
+
}
|