@holmdigital/engine 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,59 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Holm Digital AB
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
22
+
23
+ ================================================================================
24
+
25
+ THIRD PARTY NOTICES
26
+
27
+ This project includes code or libraries from third parties.
28
+
29
+ 1. axe-core
30
+ License: Mozilla Public License 2.0 (MPL-2.0)
31
+ Source: https://github.com/dequelabs/axe-core
32
+
33
+ This software is used unmodified as a dependency.
34
+ The Source Code Form of axe-core is subject to the terms of the Mozilla Public License, v. 2.0.
35
+ If a copy of the MPL was not distributed with this file, You can obtain one at http://mozilla.org/MPL/2.0/.
36
+
37
+ 2. puppeteer
38
+ License: Apache License 2.0
39
+ Source: https://github.com/puppeteer/puppeteer
40
+
41
+ Licensed under the Apache License, Version 2.0 (the "License");
42
+ you may not use this file except in compliance with the License.
43
+ You may obtain a copy of the License at
44
+
45
+ http://www.apache.org/licenses/LICENSE-2.0
46
+
47
+ Unless required by applicable law or agreed to in writing, software
48
+ distributed under the License is distributed on an "AS IS" BASIS,
49
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
50
+ See the License for the specific language governing permissions and
51
+ limitations under the License.
52
+
53
+ 3. React & ReactDOM
54
+ License: MIT
55
+ Source: https://github.com/facebook/react
56
+
57
+ 4. Vite
58
+ License: MIT
59
+ Source: https://github.com/vitejs/vite
package/README.md ADDED
@@ -0,0 +1,60 @@
1
+ # @holmdigital/engine
2
+
3
+ > Regulatory accessibility test engine with Virtual DOM and Shadow DOM support.
4
+
5
+ This engine bridges the gap between technical accessibility scanning (using `axe-core`) and legal compliance reporting (EN 301 549). It provides prescriptive remediation advice and localized reporting.
6
+
7
+ ## Features
8
+
9
+ - **Regulatory Mapping**: Maps technical failures to EU laws.
10
+ - **HTML Structure Validation**: built-in `html-validate` checks for semantic correctness.
11
+ - **Internationalization (i18n)**: Supports English (`en`), Swedish (`sv`), German (`de`), French (`fr`), and Spanish (`es`).
12
+ - **Pseudo-Automation**: Generates Playwright test scripts for manual verification.
13
+ - **PDF Reporting**: Generates beautiful, compliant PDF reports.
14
+
15
+ ## Installation
16
+
17
+ ```bash
18
+ npm install @holmdigital/engine
19
+ ```
20
+
21
+ ## CLI Usage
22
+
23
+ ```bash
24
+ npx hd-a11y-scan <url> [options]
25
+ ```
26
+
27
+ **Options:**
28
+ - `--lang <code>` - Language code (`en`, `sv`, `de`, `fr`, `es`)
29
+ - `--ci` - Run in CI mode (exit code 1 on failure)
30
+ - `--json` - Output results as JSON
31
+ - `--pdf <path>` - Generate a PDF report
32
+ - `--viewport <size>` - Set viewport size (e.g., "mobile", "desktop")
33
+ - `--generate-tests` - Generate Pseudo-Automation tests
34
+ - `--api-key <key>` - API Key for HolmDigital Cloud (sends results to dashboard)
35
+ - `--cloud-url <url>` - Custom URL for HolmDigital Cloud API (optional)
36
+
37
+ ## Programmatic Usage
38
+
39
+ ```typescript
40
+ import { RegulatoryScanner } from '@holmdigital/engine';
41
+ import { setLanguage } from '@holmdigital/engine/dist/i18n';
42
+
43
+ // Initialize Scanner
44
+ const scanner = new RegulatoryScanner({
45
+ url: 'https://example.com',
46
+ failOnCritical: false
47
+ });
48
+
49
+ // Set Language context (optional, defaults to 'en')
50
+ setLanguage('sv');
51
+
52
+ // Run Scan
53
+ const result = await scanner.scan();
54
+
55
+ console.log(`Score: ${result.score}`);
56
+ ```
57
+
58
+ ## License
59
+
60
+ MIT © Holm Digital AB
@@ -0,0 +1,411 @@
1
+ import {
2
+ __require
3
+ } from "./chunk-Y6FXYEAI.mjs";
4
+
5
+ // src/core/virtual-dom.ts
6
+ var VirtualDOMBuilder = class {
7
+ page;
8
+ constructor(page) {
9
+ this.page = page;
10
+ }
11
+ /**
12
+ * Bygg ett virtuellt träd av hela sidan
13
+ */
14
+ async build(config = {}) {
15
+ return await this.page.evaluate((config2) => {
16
+ let nodeIdCounter = 0;
17
+ function generateId() {
18
+ return `vn-${++nodeIdCounter}`;
19
+ }
20
+ function getAttributes(element) {
21
+ const attrs = {};
22
+ for (let i = 0; i < element.attributes.length; i++) {
23
+ const attr = element.attributes[i];
24
+ attrs[attr.name] = attr.value;
25
+ }
26
+ return attrs;
27
+ }
28
+ function getRect(element) {
29
+ const r = element.getBoundingClientRect();
30
+ return {
31
+ x: r.x,
32
+ y: r.y,
33
+ width: r.width,
34
+ height: r.height
35
+ };
36
+ }
37
+ function traverse(node, parent, depth = 0) {
38
+ if (!node) return null;
39
+ if (config2.maxDepth && depth > config2.maxDepth) return null;
40
+ if (node.nodeType !== Node.ELEMENT_NODE && node.nodeType !== Node.DOCUMENT_FRAGMENT_NODE) return null;
41
+ const element = node;
42
+ const isShadowRoot = node.nodeType === Node.DOCUMENT_FRAGMENT_NODE;
43
+ const tagName = isShadowRoot ? "#shadow-root" : element.tagName.toLowerCase();
44
+ const attributes = !isShadowRoot ? getAttributes(element) : {};
45
+ let computedStyle;
46
+ if (!isShadowRoot && config2.includeComputedStyle) {
47
+ const style = window.getComputedStyle(element);
48
+ computedStyle = {};
49
+ config2.includeComputedStyle.forEach((prop) => {
50
+ computedStyle[prop] = style.getPropertyValue(prop);
51
+ });
52
+ }
53
+ const vNode = {
54
+ nodeId: generateId(),
55
+ tagName,
56
+ attributes,
57
+ children: [],
58
+ parentId: parent?.nodeId,
59
+ isShadowRoot,
60
+ rect: !isShadowRoot ? getRect(element) : { x: 0, y: 0, width: 0, height: 0 },
61
+ computedStyle,
62
+ textContent: node.textContent || void 0
63
+ };
64
+ if (isShadowRoot && node.mode) {
65
+ vNode.shadowMode = node.mode;
66
+ }
67
+ if (element.childNodes) {
68
+ Array.from(element.childNodes).forEach((child) => {
69
+ if (child.nodeType === Node.ELEMENT_NODE) {
70
+ const childVNode = traverse(child, vNode, depth + 1);
71
+ if (childVNode) vNode.children.push(childVNode);
72
+ }
73
+ });
74
+ }
75
+ if (!isShadowRoot && element.shadowRoot) {
76
+ const shadowVNode = traverse(element.shadowRoot, vNode, depth + 1);
77
+ if (shadowVNode) vNode.children.push(shadowVNode);
78
+ }
79
+ return vNode;
80
+ }
81
+ if (!document.body) {
82
+ return {
83
+ nodeId: "root-fallback",
84
+ tagName: "body",
85
+ attributes: {},
86
+ children: [],
87
+ rect: { x: 0, y: 0, width: 0, height: 0 }
88
+ };
89
+ }
90
+ return traverse(document.body);
91
+ }, config);
92
+ }
93
+ };
94
+
95
+ // src/core/regulatory-scanner.ts
96
+ import puppeteer from "puppeteer";
97
+
98
+ // src/core/html-validator.ts
99
+ import { HtmlValidate } from "html-validate";
100
+ var HtmlValidator = class {
101
+ validator;
102
+ constructor() {
103
+ const config = {
104
+ extends: ["html-validate:recommended"],
105
+ rules: {
106
+ "no-deprecated-attr": "off",
107
+ // Focus on structure, not deprecation
108
+ "prefer-native-element": "off",
109
+ "no-trailing-whitespace": "off",
110
+ "void-style": "off",
111
+ "no-inline-style": "off",
112
+ // Allowed for A11y overrides
113
+ "no-implicit-button-type": "off"
114
+ // Common in React
115
+ }
116
+ };
117
+ this.validator = new HtmlValidate(config);
118
+ }
119
+ async validate(html) {
120
+ const report = await this.validator.validateString(html);
121
+ return {
122
+ valid: report.valid,
123
+ errors: report.results.flatMap(
124
+ (result) => result.messages.map((msg) => ({
125
+ rule: msg.ruleId,
126
+ message: msg.message,
127
+ line: msg.line,
128
+ column: msg.column,
129
+ selector: msg.selector ?? void 0
130
+ }))
131
+ )
132
+ };
133
+ }
134
+ };
135
+
136
+ // src/core/regulatory-scanner.ts
137
+ var RegulatoryScanner = class {
138
+ browser = null;
139
+ options;
140
+ htmlValidator;
141
+ constructor(options) {
142
+ this.options = {
143
+ headless: true,
144
+ standard: "dos-lagen",
145
+ // Default till striktaste
146
+ silent: false,
147
+ ...options
148
+ };
149
+ this.htmlValidator = new HtmlValidator();
150
+ }
151
+ /** Log only when not in silent mode */
152
+ log(message) {
153
+ if (!this.options.silent) {
154
+ console.log(message);
155
+ }
156
+ }
157
+ /**
158
+ * Kör en fullständig regulatorisk scan
159
+ */
160
+ async scan() {
161
+ try {
162
+ await this.initBrowser();
163
+ const page = await this.getPage();
164
+ if (this.options.viewport) {
165
+ await page.setViewport(this.options.viewport);
166
+ }
167
+ let retries = 3;
168
+ while (retries > 0) {
169
+ try {
170
+ await page.goto(this.options.url, {
171
+ waitUntil: "domcontentloaded",
172
+ timeout: 6e4
173
+ });
174
+ break;
175
+ } catch (e) {
176
+ retries--;
177
+ if (retries === 0) throw e;
178
+ this.log(`Navigation failed, retrying... (${retries} attempts left)`);
179
+ await new Promise((resolve) => setTimeout(resolve, 2e3));
180
+ }
181
+ }
182
+ try {
183
+ await page.waitForNetworkIdle({
184
+ idleTime: 500,
185
+ timeout: 1e4,
186
+ concurrency: 2
187
+ });
188
+ } catch (e) {
189
+ this.log("Network busy, proceeding with scan anyway...");
190
+ }
191
+ const pageContent = await page.content();
192
+ const htmlValidation = await this.htmlValidator.validate(pageContent);
193
+ if (!htmlValidation.valid) {
194
+ this.log(`HTML Validation: Found ${htmlValidation.errors.length} structural issues.`);
195
+ }
196
+ const vDomBuilder = new VirtualDOMBuilder(page);
197
+ await vDomBuilder.build({ includeComputedStyle: ["color", "background-color"] });
198
+ await this.injectAxe(page);
199
+ this.log("Axe injected. Running analysis...");
200
+ const axeResults = await page.evaluate(async () => {
201
+ if (!document || !document.documentElement) {
202
+ return { violations: [] };
203
+ }
204
+ return await window.axe.run(document, {
205
+ iframes: false
206
+ // Inaktivera iframe-scanning för att undvika kraschar på tunga annons-sajter
207
+ // Vi tar bort runOnly tillfälligt för att se ALLA fel
208
+ /*
209
+ runOnly: {
210
+ type: 'tag',
211
+ values: ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa']
212
+ }
213
+ */
214
+ });
215
+ });
216
+ this.log(`Raw Axe Violations: ${axeResults.violations?.length || 0}`);
217
+ const regulatoryReports = await this.enrichResults(axeResults);
218
+ const result = this.generateResultPackage(regulatoryReports);
219
+ result.htmlValidation = htmlValidation;
220
+ return result;
221
+ } finally {
222
+ await this.close();
223
+ }
224
+ }
225
+ async initBrowser() {
226
+ this.browser = await puppeteer.launch({
227
+ headless: this.options.headless,
228
+ args: [
229
+ "--no-sandbox",
230
+ "--disable-setuid-sandbox",
231
+ "--disable-blink-features=AutomationControlled"
232
+ // Gömmer att det är en robot
233
+ ]
234
+ });
235
+ }
236
+ async getPage() {
237
+ if (!this.browser) throw new Error("Browser not initialized");
238
+ const page = await this.browser.newPage();
239
+ await page.setUserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36");
240
+ return page;
241
+ }
242
+ async injectAxe(page) {
243
+ const axeSource = __require("axe-core").source;
244
+ await page.evaluate(axeSource);
245
+ }
246
+ async enrichResults(axeResults) {
247
+ const reports = [];
248
+ const { searchRulesByTags, generateRegulatoryReport } = await import("@holmdigital/standards");
249
+ const { getCurrentLang } = await import("./i18n-FLJMYIKJ.mjs");
250
+ const lang = getCurrentLang();
251
+ for (const violation of axeResults.violations) {
252
+ let report = generateRegulatoryReport(violation.id, lang);
253
+ if (!report) {
254
+ const matchingRules = searchRulesByTags(violation.tags, lang);
255
+ if (matchingRules.length > 0) {
256
+ report = generateRegulatoryReport(matchingRules[0].ruleId, lang);
257
+ }
258
+ }
259
+ if (report) {
260
+ reports.push({
261
+ ...report,
262
+ holmdigitalInsight: {
263
+ ...report.holmdigitalInsight,
264
+ reasoning: violation.help
265
+ // Använd Axe's hjälptext som specifik anledning
266
+ },
267
+ // Attach extra debug info for the CLI
268
+ failingNodes: violation.nodes.map((node) => ({
269
+ html: node.html,
270
+ target: node.target.join(" "),
271
+ failureSummary: node.failureSummary
272
+ }))
273
+ });
274
+ } else {
275
+ reports.push({
276
+ ruleId: violation.id,
277
+ wcagCriteria: "Unknown",
278
+ en301549Criteria: "Unknown",
279
+ dosLagenReference: "Kr\xE4ver manuell bed\xF6mning",
280
+ diggRisk: "medium",
281
+ // Default risk
282
+ eaaImpact: "medium",
283
+ remediation: {
284
+ description: violation.help,
285
+ technicalGuidance: violation.description,
286
+ component: void 0
287
+ },
288
+ holmdigitalInsight: {
289
+ diggRisk: "medium",
290
+ eaaImpact: "medium",
291
+ swedishInterpretation: violation.help,
292
+ priorityRationale: "Detta fel uppt\xE4cktes av scannern men saknar specifik mappning i HolmDigital-databasen."
293
+ },
294
+ testability: {
295
+ automated: true,
296
+ requiresManualCheck: false,
297
+ pseudoAutomation: false,
298
+ complexity: "moderate"
299
+ }
300
+ });
301
+ }
302
+ }
303
+ return reports;
304
+ }
305
+ generateResultPackage(reports) {
306
+ const stats = {
307
+ critical: reports.filter((r) => r.holmdigitalInsight.diggRisk === "critical").length,
308
+ high: reports.filter((r) => r.holmdigitalInsight.diggRisk === "high").length,
309
+ medium: reports.filter((r) => r.holmdigitalInsight.diggRisk === "medium").length,
310
+ low: reports.filter((r) => r.holmdigitalInsight.diggRisk === "low").length,
311
+ total: reports.length
312
+ };
313
+ const weightedScore = stats.critical * 25 + // Critical violations are severe
314
+ stats.high * 15 + // High risk affects usability significantly
315
+ stats.medium * 5 + // Medium annoyance
316
+ stats.low * 1;
317
+ const score = Math.max(0, 100 - weightedScore);
318
+ const complianceStatus = stats.total === 0 ? "PASS" : "FAIL";
319
+ return {
320
+ url: this.options.url,
321
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
322
+ reports,
323
+ stats,
324
+ score,
325
+ complianceStatus
326
+ };
327
+ }
328
+ /**
329
+ * Stänger webbläsaren och frigör resurser
330
+ */
331
+ async close() {
332
+ if (this.browser) {
333
+ await this.browser.close();
334
+ this.browser = null;
335
+ }
336
+ }
337
+ };
338
+
339
+ // src/automation/pseudo-automation.ts
340
+ var PseudoAutomationEngine = class {
341
+ /**
342
+ * Generera ett Playwright-testskript baserat på en rapport
343
+ */
344
+ generateTestScript(report, url) {
345
+ if (!report.holmdigitalInsight.diggRisk) return "";
346
+ const testName = `Verify ${report.wcagCriteria} - ${report.ruleId}`;
347
+ return `
348
+ import { test, expect } from '@playwright/test';
349
+
350
+ /**
351
+ * GENERATED PSEUDO-AUTOMATION TEST
352
+ * Rule: ${report.ruleId}
353
+ * WCAG: ${report.wcagCriteria}
354
+ * EN 301 549: ${report.en301549Criteria}
355
+ * Risk: ${report.holmdigitalInsight.diggRisk}
356
+ *
357
+ * Manual Verification Required:
358
+ * ${report.remediation.description}
359
+ */
360
+
361
+ test('${testName}', async ({ page }) => {
362
+ // 1. Navigate to target
363
+ await page.goto('${url}');
364
+
365
+ // 2. Initial state verification
366
+ // TODO: Add specific selectors based on report details
367
+
368
+ // 3. Interaction steps (Example for Keyboard Navigation)
369
+ if ('${report.ruleId}' === 'keyboard-accessible') {
370
+ console.log('Verifying tab order...');
371
+ await page.keyboard.press('Tab');
372
+
373
+ // Assert focus state
374
+ const focused = await page.evaluate(() => document.activeElement?.tagName);
375
+ console.log('Focused element:', focused);
376
+
377
+ // Add assertions here based on expected focus order
378
+ }
379
+
380
+ // 4. Verification
381
+ // Manually verify that...
382
+ });
383
+ `;
384
+ }
385
+ /**
386
+ * Generera en checklista för manuell testning i Markdown
387
+ */
388
+ generateManualChecklist(report) {
389
+ return `
390
+ ### \u{1F575}\uFE0F Manual Verification: ${report.ruleId}
391
+
392
+ **Regulatory Context**
393
+ - **WCAG**: ${report.wcagCriteria}
394
+ - **DOS-lagen**: ${report.dosLagenReference}
395
+ - **Risk**: ${report.holmdigitalInsight.diggRisk.toUpperCase()}
396
+
397
+ **Instructions**
398
+ 1. [ ] ${report.remediation.description}
399
+ 2. [ ] Verify against technical guidance: ${report.remediation.technicalGuidance}
400
+
401
+ **HolmDigital Insight**
402
+ > ${report.holmdigitalInsight.swedishInterpretation}
403
+ `;
404
+ }
405
+ };
406
+
407
+ export {
408
+ VirtualDOMBuilder,
409
+ RegulatoryScanner,
410
+ PseudoAutomationEngine
411
+ };