scorm-kit 0.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 +21 -0
- package/README.md +109 -0
- package/bin/scorm-kit.js +62 -0
- package/package.json +32 -0
- package/src/a11y/a11y.js +401 -0
- package/src/cmi5/cmi5.js +561 -0
- package/src/confine.js +20 -0
- package/src/diff/diff.js +277 -0
- package/src/i18n/i18n-cli.js +222 -0
- package/src/i18n/runtime/i18n.js +168 -0
- package/src/lint/lint.js +323 -0
- package/src/mock/mock.js +197 -0
- package/src/mock/web/mock-lms.css +57 -0
- package/src/mock/web/mock-lms.html +53 -0
- package/src/mock/web/mock-lms.js +328 -0
- package/src/privacy/privacy.js +535 -0
- package/src/report/report.js +148 -0
- package/src/rum/rum-cli.js +144 -0
- package/src/rum/runtime/rum.js +156 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Parth Dhanani
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# scorm-kit
|
|
2
|
+
|
|
3
|
+
[](https://github.com/parthdhanani/scorm-kit/actions/workflows/test.yml)
|
|
4
|
+
[](LICENSE)
|
|
5
|
+
[](https://nodejs.org)
|
|
6
|
+
[](test/run.js)
|
|
7
|
+
[](package.json)
|
|
8
|
+
|
|
9
|
+
**An opinionated SCORM / cmi5 / xAPI build pipeline for teams that ship.** One CLI, nine subcommands, written because the existing ecosystem stops at "publish from Storyline and pray."
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npm install -g github:parthdhanani/scorm-kit # installs from source (not yet on npm)
|
|
13
|
+
|
|
14
|
+
scorm-kit lint course.zip # static analysis: manifest, API, asset refs
|
|
15
|
+
scorm-kit a11y course.zip # WCAG 2.2 AA static audit
|
|
16
|
+
scorm-kit diff before.zip after.zip # structured diff for PR review
|
|
17
|
+
scorm-kit i18n course.zip --strings strings.json # bundle a translation pack
|
|
18
|
+
scorm-kit mock course.zip # local LMS for testing without Moodle
|
|
19
|
+
scorm-kit rum course.zip --endpoint https://rum.example.com/ingest # inject RUM
|
|
20
|
+
scorm-kit privacy course.zip # PII / data-leak static audit
|
|
21
|
+
scorm-kit cmi5 validate|lint|convert ... # cmi5 validator + SCORM→cmi5 wrapper
|
|
22
|
+
scorm-kit report course.zip # one health-gate score: lint + a11y + privacy
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Exit codes are conventional: `0` clean, `1` warnings only, `2` errors. Every command supports `--json` for CI pipelines.
|
|
26
|
+
|
|
27
|
+
## Why this exists
|
|
28
|
+
|
|
29
|
+
A production SCORM course in 2026 still ships as an opaque zip you upload to an LMS and hope for the best. The packaging tools don't lint. The LMSs don't surface runtime errors. There is no `diff` for PR review. There is no local runtime for testing. Multi-language ships as N separate packages bloating the gradebook. Captions are an afterthought.
|
|
30
|
+
|
|
31
|
+
`scorm-kit` is the toolbelt I wanted while running the SCORM team at Kidvento (eight people, K-12, ~100 modules a year). Each subcommand was originally written to fix a real production incident or a real workflow gap; the unified CLI is the cleanup pass.
|
|
32
|
+
|
|
33
|
+
## Commands
|
|
34
|
+
|
|
35
|
+
### `scorm-kit lint <package>`
|
|
36
|
+
|
|
37
|
+
Static analysis. Catches the classes of bug that Storyline's own publish step silently misses and that surface as "the LMS is being weird" three weeks later. Rules cover manifest correctness, SCORM API wrapper discovery, `cmi.interactions` collisions, broken asset references, and asset-size warnings.
|
|
38
|
+
|
|
39
|
+
### `scorm-kit a11y <package>`
|
|
40
|
+
|
|
41
|
+
WCAG 2.2 AA static audit of every HTML file in the package. Flags missing `lang`, missing `alt`, filename-as-alt, video without `<track>`, audio without transcript, heading-level skips, positive `tabindex`, missing form labels, ARIA misuse. Static-analysis level, not a replacement for a manual audit — but catches 80% of the cheap regressions.
|
|
42
|
+
|
|
43
|
+
### `scorm-kit diff <before> <after>`
|
|
44
|
+
|
|
45
|
+
Treats SCORM packages as reviewable artifacts, not binary blobs. Parses `imsmanifest.xml` semantically, hashes the asset list, and emits a unified line diff for text files (HTML/CSS/JS/JSON/XML/VTT). Drop it in CI to gate large unintended changes; use it in PR review to read what actually changed.
|
|
46
|
+
|
|
47
|
+
### `scorm-kit i18n <package> --strings strings.json`
|
|
48
|
+
|
|
49
|
+
Bundles a translation pack and a small runtime into a SCORM 1.2 package, turning it into **one package, N languages**, with the learner picking at launch. Authors annotate their HTML with `data-i18n` attributes; the runtime swaps text, media `<source>`s, and `<track>` captions on language change. Choice persists via `cmi.student_preference.language`. No re-publishing per language. No gradebook split.
|
|
50
|
+
|
|
51
|
+
### `scorm-kit mock <package>`
|
|
52
|
+
|
|
53
|
+
A local SCORM 1.2 runtime — tiny HTTP server, iframe shell, full `window.API` implementation, every method call recorded with timestamp and last-error code. Use it to develop and debug a SCORM package without uploading to Moodle every cycle. Inject failures (`--fail set`, `--fail init`) to test the course's error handling. Export the session log as JSON for regression tests.
|
|
54
|
+
|
|
55
|
+
### `scorm-kit rum <package> --endpoint <url>`
|
|
56
|
+
|
|
57
|
+
Injects a Real User Monitoring runtime. Captures navigation timing, resource-load failures, JS errors, long tasks, and slide transitions; batches and POSTs as JSON beacons. The signal an LMS has never offered. Pair with `cmi.core.student_id` as the actor (or pseudonymise upstream).
|
|
58
|
+
|
|
59
|
+
> **Note:** The `--token` value is embedded in plaintext inside the output SCORM package (`rum-config.js`). Anyone who unzips the package can read it. Use a scoped, rotatable token — not a master API key.
|
|
60
|
+
|
|
61
|
+
### `scorm-kit cmi5 <validate|lint|convert> <package>`
|
|
62
|
+
|
|
63
|
+
cmi5 is the 2016 ADL spec that replaces SCORM 1.2 for new builds — SCORM-style "launch and handshake" with xAPI-based tracking. Most enterprise LMS RFPs in 2026 require cmi5 support.
|
|
64
|
+
|
|
65
|
+
- `cmi5 validate <pkg>` — structural validation: `cmi5.xml` shape, root `courseStructure` + namespace, `<course>` and `<au>` required attributes, `launchMethod`/`moveOn` enums, `masteryScore` range, IRI shape on ids and `activityType`, AU launch URLs resolve inside the package.
|
|
66
|
+
- `cmi5 lint <pkg>` — validate plus interop checks: unique ids, no duplicate launch URLs, `en` langstring present (LMSs default to en and show blank otherwise), ISO-8601 `duration`, namespaced extension keys, `waivedMoveOnConditions` consistent with `moveOn`.
|
|
67
|
+
- `cmi5 convert <scorm.zip> --out <cmi5.zip>` — wraps a SCORM 1.2 package as cmi5 by generating `cmi5.xml` that references the SCORM SCO's launch HTML as the cmi5 AU. The SCORM API stays in place, so the package degrades gracefully if launched from a SCORM-only LMS. This is the **dual-stream** pattern most teams now use: SCORM for HR completion records, cmi5/xAPI for behavioural data.
|
|
68
|
+
|
|
69
|
+
### `scorm-kit privacy <package>`
|
|
70
|
+
|
|
71
|
+
Static PII / data-leak audit. Catches the leaks a procurement-grade compliance review would flag and your courseware vendors won't tell you about: hard-coded emails, phone numbers, SSN/DOB patterns, third-party trackers (GA/GTM/Hotjar/FullStory/Mixpanel/Segment/Amplitude/etc.), font CDNs that set cookies, off-package iframes and form actions, bearer tokens and API keys checked into the bundle, S3 signed URLs, plaintext xAPI `mbox`, learner-id keys used as `localStorage` keys, and the classic `cmi.core.student_name → innerHTML` XSS vector. Allowlist your own LMS/CDN with `--allow lms.example.com,cdn.example.com`. Pair with `lint` and `a11y` in CI for an opinionated three-pass build gate.
|
|
72
|
+
|
|
73
|
+
### `scorm-kit report <package>`
|
|
74
|
+
|
|
75
|
+
One pre-upload health gate. Runs `lint`, `a11y`, and `privacy` in one pass, aggregates their findings into a single **Build Health score (0–100)**, and reports how many issues were caught before the package ever reached an LMS — because every one of them is a learner ticket you'd otherwise get two weeks later. Pure composition: it shells out to the three subcommands and adds no analysis of its own. Score model is deliberately simple and explainable — each error costs 10, each warning costs 3, floored at 0. `--json` for CI. Exit `0`/`1`/`2` as usual.
|
|
76
|
+
|
|
77
|
+
```
|
|
78
|
+
$ scorm-kit report course.zip
|
|
79
|
+
|
|
80
|
+
lint ✗ 1 error 5 warnings
|
|
81
|
+
a11y ✗ 2 errors 0 warnings
|
|
82
|
+
privacy ✓ 0 errors 0 warnings
|
|
83
|
+
|
|
84
|
+
Build health: 55/100 (needs work)
|
|
85
|
+
8 issues caught before upload — each one a learner ticket you won't get in two weeks.
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
## What scorm-kit is not
|
|
89
|
+
|
|
90
|
+
- A SCORM authoring tool. For that, see **[Storycraft](../storycraft)** — the Markdown→SCORM compiler that this toolkit was built around.
|
|
91
|
+
- A full WCAG audit. `a11y` is static analysis; manual testing is still required for compliance signoff.
|
|
92
|
+
- A replacement for the LMS. `mock` is for development; production still ships to your real LMS.
|
|
93
|
+
|
|
94
|
+
## Building from source
|
|
95
|
+
|
|
96
|
+
```bash
|
|
97
|
+
git clone https://github.com/parthdhanani/scorm-kit
|
|
98
|
+
cd scorm-kit
|
|
99
|
+
npm install # zero runtime deps; install is just to populate node_modules cache
|
|
100
|
+
npm test # runs the test suite using fixture zips
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Tests
|
|
104
|
+
|
|
105
|
+
Each subcommand has 3–5 tests in `test/` that exercise it against the sample SCORM zips in `fixtures/` (built by Storycraft). Run them with `npm test`.
|
|
106
|
+
|
|
107
|
+
## License
|
|
108
|
+
|
|
109
|
+
MIT. See `LICENSE`.
|
package/bin/scorm-kit.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
|
|
4
|
+
var path = require("path");
|
|
5
|
+
var { spawnSync } = require("child_process");
|
|
6
|
+
|
|
7
|
+
var SUBCOMMANDS = {
|
|
8
|
+
lint: "../src/lint/lint.js",
|
|
9
|
+
a11y: "../src/a11y/a11y.js",
|
|
10
|
+
diff: "../src/diff/diff.js",
|
|
11
|
+
i18n: "../src/i18n/i18n-cli.js",
|
|
12
|
+
mock: "../src/mock/mock.js",
|
|
13
|
+
rum: "../src/rum/rum-cli.js",
|
|
14
|
+
privacy: "../src/privacy/privacy.js",
|
|
15
|
+
cmi5: "../src/cmi5/cmi5.js",
|
|
16
|
+
report: "../src/report/report.js",
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
var HELP = [
|
|
20
|
+
"scorm-kit — opinionated SCORM 1.2 build pipeline",
|
|
21
|
+
"",
|
|
22
|
+
"Usage: scorm-kit <command> [args]",
|
|
23
|
+
"",
|
|
24
|
+
"Commands:",
|
|
25
|
+
" lint <package> static analysis (manifest, API, assets)",
|
|
26
|
+
" a11y <package> WCAG 2.2 AA static audit",
|
|
27
|
+
" diff <before> <after> structured diff of two packages",
|
|
28
|
+
" i18n <package> --strings ... bundle a translation pack + runtime",
|
|
29
|
+
" mock <package> [--port N] local LMS runtime for testing",
|
|
30
|
+
" rum <package> --endpoint ... inject real-user-monitoring runtime",
|
|
31
|
+
" privacy <package> PII / data-leak static audit",
|
|
32
|
+
" cmi5 validate|lint|convert ... cmi5 package validator + SCORM→cmi5 wrapper",
|
|
33
|
+
" report <package> one health-gate score (lint + a11y + privacy)",
|
|
34
|
+
"",
|
|
35
|
+
"Each command exits 0 on success, 1 on warnings, 2 on errors.",
|
|
36
|
+
"Run `scorm-kit <command> --help` for command-specific options.",
|
|
37
|
+
].join("\n");
|
|
38
|
+
|
|
39
|
+
function main(argv) {
|
|
40
|
+
var args = argv.slice(2);
|
|
41
|
+
if (args.length === 0 || args[0] === "-h" || args[0] === "--help") {
|
|
42
|
+
console.log(HELP);
|
|
43
|
+
process.exit(args.length === 0 ? 2 : 0);
|
|
44
|
+
}
|
|
45
|
+
if (args[0] === "--version" || args[0] === "-v") {
|
|
46
|
+
var pkg = require("../package.json");
|
|
47
|
+
console.log("scorm-kit " + pkg.version);
|
|
48
|
+
process.exit(0);
|
|
49
|
+
}
|
|
50
|
+
var sub = args.shift();
|
|
51
|
+
var script = SUBCOMMANDS[sub];
|
|
52
|
+
if (!script) {
|
|
53
|
+
console.error("scorm-kit: unknown command '" + sub + "'");
|
|
54
|
+
console.error("Run `scorm-kit --help` for the list of commands.");
|
|
55
|
+
process.exit(2);
|
|
56
|
+
}
|
|
57
|
+
var full = path.resolve(__dirname, script);
|
|
58
|
+
var res = spawnSync(process.execPath, [full].concat(args), { stdio: "inherit" });
|
|
59
|
+
process.exit(res.status == null ? 1 : res.status);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
main(process.argv);
|
package/package.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "scorm-kit",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Opinionated SCORM / cmi5 / xAPI build pipeline: lint, a11y, diff, i18n, mock, rum, privacy audit, cmi5 validator + SCORM→cmi5 wrapper.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"scorm-kit": "bin/scorm-kit.js"
|
|
7
|
+
},
|
|
8
|
+
"scripts": {
|
|
9
|
+
"test": "node test/run.js",
|
|
10
|
+
"lint": "eslint ."
|
|
11
|
+
},
|
|
12
|
+
"devDependencies": {
|
|
13
|
+
"@eslint/js": "^9.0.0",
|
|
14
|
+
"eslint": "^9.0.0",
|
|
15
|
+
"globals": "^15.0.0"
|
|
16
|
+
},
|
|
17
|
+
"keywords": ["scorm", "scorm-1.2", "cmi5", "xapi", "elearning", "lms", "cli", "lint", "wcag", "a11y", "privacy", "learning-engineering"],
|
|
18
|
+
"license": "MIT",
|
|
19
|
+
"engines": {
|
|
20
|
+
"node": ">=18"
|
|
21
|
+
},
|
|
22
|
+
"files": [
|
|
23
|
+
"bin",
|
|
24
|
+
"src",
|
|
25
|
+
"README.md",
|
|
26
|
+
"LICENSE"
|
|
27
|
+
],
|
|
28
|
+
"repository": {
|
|
29
|
+
"type": "git",
|
|
30
|
+
"url": "https://github.com/parthdhanani/scorm-kit"
|
|
31
|
+
}
|
|
32
|
+
}
|
package/src/a11y/a11y.js
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/*
|
|
3
|
+
* scorm-a11y — WCAG 2.2 AA static auditor for SCORM 1.2 packages.
|
|
4
|
+
*
|
|
5
|
+
* scorm-a11y path/to/package.zip
|
|
6
|
+
* scorm-a11y path/to/unzipped-dir
|
|
7
|
+
*
|
|
8
|
+
* Checks every HTML file in the package against the accessibility failure
|
|
9
|
+
* modes that recur across the catalogues I've audited. Output is grouped
|
|
10
|
+
* by severity (error / warn / info) and can be emitted as JSON for CI.
|
|
11
|
+
*
|
|
12
|
+
* Rules:
|
|
13
|
+
*
|
|
14
|
+
* doc-no-lang <html> has no lang attribute
|
|
15
|
+
* doc-no-title <title> empty or missing
|
|
16
|
+
* doc-empty-title <title> contains only whitespace
|
|
17
|
+
*
|
|
18
|
+
* img-no-alt <img> has no alt attribute (decorative needs alt="")
|
|
19
|
+
* img-alt-filename alt looks like a filename ("logo.png", "img_42")
|
|
20
|
+
* img-redundant-alt alt repeats the surrounding link text
|
|
21
|
+
*
|
|
22
|
+
* video-no-track <video> has no <track kind="captions"> child
|
|
23
|
+
* audio-no-transcript <audio> with no nearby transcript link
|
|
24
|
+
*
|
|
25
|
+
* heading-skip heading level skipped (h1 → h3)
|
|
26
|
+
* heading-no-h1 HTML has headings but no <h1>
|
|
27
|
+
*
|
|
28
|
+
* link-no-text <a> has no text content and no aria-label / title
|
|
29
|
+
* link-generic-text link text is "click here", "read more", etc.
|
|
30
|
+
*
|
|
31
|
+
* button-no-name <button> with no text content and no aria-label
|
|
32
|
+
*
|
|
33
|
+
* form-input-no-label <input>/<select>/<textarea> has no associated label
|
|
34
|
+
*
|
|
35
|
+
* div-click-no-role <div onclick=...> without role= and tabindex=
|
|
36
|
+
* tabindex-positive tabindex > 0 (breaks natural tab order)
|
|
37
|
+
*
|
|
38
|
+
* iframe-no-title <iframe> with no title attribute
|
|
39
|
+
* table-no-headers <table> with rows but no <th> or scope=
|
|
40
|
+
*
|
|
41
|
+
* aria-bad-attr common aria-* typos (aria-labelby, aria-labeled, etc.)
|
|
42
|
+
* aria-hidden-focusable focusable element inside aria-hidden="true"
|
|
43
|
+
*
|
|
44
|
+
* lang-mixed-no-attr page-level lang attr present but inline foreign
|
|
45
|
+
* text spans have no lang= override (heuristic)
|
|
46
|
+
*
|
|
47
|
+
* Exit codes: 0 = clean, 1 = warnings only, 2 = errors.
|
|
48
|
+
*/
|
|
49
|
+
"use strict";
|
|
50
|
+
|
|
51
|
+
var fs = require("fs");
|
|
52
|
+
var path = require("path");
|
|
53
|
+
var os = require("os");
|
|
54
|
+
var { spawnSync } = require("child_process");
|
|
55
|
+
var verifyConfinement = require("../confine");
|
|
56
|
+
|
|
57
|
+
// ---------- rules table ----------------------------------------------------
|
|
58
|
+
|
|
59
|
+
var RULES = {
|
|
60
|
+
"doc-no-lang": { sev: "error", msg: "<html> missing lang attribute" },
|
|
61
|
+
"doc-no-title": { sev: "error", msg: "no <title> element" },
|
|
62
|
+
"doc-empty-title": { sev: "error", msg: "<title> is empty" },
|
|
63
|
+
"img-no-alt": { sev: "error", msg: "<img> missing alt attribute (use alt=\"\" for decorative)" },
|
|
64
|
+
"img-alt-filename": { sev: "warn", msg: "<img> alt looks like a filename" },
|
|
65
|
+
"img-redundant-alt": { sev: "warn", msg: "<img> alt repeats surrounding link text" },
|
|
66
|
+
"video-no-track": { sev: "error", msg: "<video> has no <track kind=\"captions\">" },
|
|
67
|
+
"audio-no-transcript": { sev: "warn", msg: "<audio> with no nearby transcript link" },
|
|
68
|
+
"heading-skip": { sev: "warn", msg: "heading level skipped" },
|
|
69
|
+
"heading-no-h1": { sev: "warn", msg: "HTML has headings but no <h1>" },
|
|
70
|
+
"link-no-text": { sev: "error", msg: "<a> has no accessible name" },
|
|
71
|
+
"link-generic-text": { sev: "warn", msg: "link text is generic (\"click here\" / \"read more\")" },
|
|
72
|
+
"button-no-name": { sev: "error", msg: "<button> has no accessible name" },
|
|
73
|
+
"form-input-no-label": { sev: "error", msg: "form control has no associated label" },
|
|
74
|
+
"div-click-no-role": { sev: "error", msg: "<div onclick=...> without role/tabindex (not keyboard accessible)" },
|
|
75
|
+
"tabindex-positive": { sev: "warn", msg: "tabindex > 0 breaks natural tab order" },
|
|
76
|
+
"iframe-no-title": { sev: "error", msg: "<iframe> missing title attribute" },
|
|
77
|
+
"table-no-headers": { sev: "warn", msg: "<table> has rows but no <th> or scope=" },
|
|
78
|
+
"aria-bad-attr": { sev: "error", msg: "invalid aria-* attribute name (likely typo)" },
|
|
79
|
+
"aria-hidden-focusable":{ sev: "error", msg: "focusable element inside aria-hidden=\"true\"" },
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Common aria-* misspellings I've actually seen in audits.
|
|
83
|
+
var VALID_ARIA = new Set([
|
|
84
|
+
"aria-label", "aria-labelledby", "aria-describedby", "aria-hidden",
|
|
85
|
+
"aria-live", "aria-atomic", "aria-relevant", "aria-busy",
|
|
86
|
+
"aria-controls", "aria-owns", "aria-flowto", "aria-activedescendant",
|
|
87
|
+
"aria-expanded", "aria-pressed", "aria-checked", "aria-selected",
|
|
88
|
+
"aria-disabled", "aria-readonly", "aria-required", "aria-invalid",
|
|
89
|
+
"aria-haspopup", "aria-current", "aria-modal", "aria-multiline",
|
|
90
|
+
"aria-multiselectable", "aria-orientation", "aria-sort", "aria-level",
|
|
91
|
+
"aria-posinset", "aria-setsize", "aria-valuemin", "aria-valuemax",
|
|
92
|
+
"aria-valuenow", "aria-valuetext", "aria-autocomplete", "aria-placeholder",
|
|
93
|
+
"aria-roledescription", "aria-keyshortcuts", "aria-details", "aria-errormessage",
|
|
94
|
+
"aria-colcount", "aria-colindex", "aria-colspan",
|
|
95
|
+
"aria-rowcount", "aria-rowindex", "aria-rowspan",
|
|
96
|
+
"aria-dropeffect", "aria-grabbed",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
var GENERIC_LINK_TEXT = new Set([
|
|
100
|
+
"click here", "click", "here", "read more", "more", "learn more",
|
|
101
|
+
"details", "link", "this", "this link", "more info", "info",
|
|
102
|
+
]);
|
|
103
|
+
|
|
104
|
+
// ---------- args -----------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
function parseArgs(argv) {
|
|
107
|
+
var a = { input: "", json: false, noColor: false, infoOff: false };
|
|
108
|
+
for (var i = 0; i < argv.length; i++) {
|
|
109
|
+
var k = argv[i];
|
|
110
|
+
if (k === "--json") a.json = true;
|
|
111
|
+
else if (k === "--no-color") a.noColor = true;
|
|
112
|
+
else if (k === "--no-info") a.infoOff = true;
|
|
113
|
+
else if (k === "-h" || k === "--help") { usage(); process.exit(0); }
|
|
114
|
+
else if (k[0] === "-") { console.error("Unknown flag: " + k); process.exit(2); }
|
|
115
|
+
else if (!a.input) a.input = k;
|
|
116
|
+
else { console.error("Unexpected arg: " + k); process.exit(2); }
|
|
117
|
+
}
|
|
118
|
+
if (!a.input) { usage(); process.exit(2); }
|
|
119
|
+
return a;
|
|
120
|
+
}
|
|
121
|
+
function usage() {
|
|
122
|
+
console.error("Usage: scorm-a11y <package.zip | dir> [--json] [--no-info] [--no-color]");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ---------- zip ------------------------------------------------------------
|
|
126
|
+
|
|
127
|
+
function unzipToTemp(zipPath) {
|
|
128
|
+
var tmp = fs.mkdtempSync(path.join(os.tmpdir(), "scorm-a11y-"));
|
|
129
|
+
var r = spawnSync("unzip", ["-q", "-o", zipPath, "-d", tmp]);
|
|
130
|
+
if (r.status !== 0) throw new Error("unzip: " + r.stderr.toString());
|
|
131
|
+
verifyConfinement(tmp);
|
|
132
|
+
return tmp;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------- walk -----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
function walk(dir, acc) {
|
|
138
|
+
acc = acc || [];
|
|
139
|
+
for (var name of fs.readdirSync(dir)) {
|
|
140
|
+
var p = path.join(dir, name);
|
|
141
|
+
var st = fs.statSync(p);
|
|
142
|
+
if (st.isDirectory()) walk(p, acc);
|
|
143
|
+
else acc.push(p);
|
|
144
|
+
}
|
|
145
|
+
return acc;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// ---------- finding type ---------------------------------------------------
|
|
149
|
+
|
|
150
|
+
function find(rule, file, line, detail) {
|
|
151
|
+
return { rule: rule, sev: RULES[rule].sev, msg: RULES[rule].msg, file: file, line: line, detail: detail || "" };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function lineOf(html, idx) {
|
|
155
|
+
var n = 1;
|
|
156
|
+
for (var i = 0; i < idx && i < html.length; i++) if (html[i] === "\n") n++;
|
|
157
|
+
return n;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------- attribute parsing ---------------------------------------------
|
|
161
|
+
|
|
162
|
+
function getAttr(tagStr, name) {
|
|
163
|
+
var re = new RegExp("\\b" + name + '\\s*=\\s*(["\'])([^"\']*)\\1', "i");
|
|
164
|
+
var m = re.exec(tagStr);
|
|
165
|
+
return m ? m[2] : null;
|
|
166
|
+
}
|
|
167
|
+
function hasAttr(tagStr, name) {
|
|
168
|
+
return new RegExp("\\b" + name + "(\\s|=|>|/)", "i").test(tagStr);
|
|
169
|
+
}
|
|
170
|
+
// ---------- audit one HTML --------------------------------------------------
|
|
171
|
+
|
|
172
|
+
function auditHtml(file, html) {
|
|
173
|
+
var findings = [];
|
|
174
|
+
var rel = file;
|
|
175
|
+
|
|
176
|
+
// doc-no-lang
|
|
177
|
+
var htmlTag = /<html\b[^>]*>/i.exec(html);
|
|
178
|
+
if (htmlTag && !getAttr(htmlTag[0], "lang")) {
|
|
179
|
+
findings.push(find("doc-no-lang", rel, lineOf(html, htmlTag.index)));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// title — search inside <head> so we don't pick up <svg><title> by accident
|
|
183
|
+
var headM = /<head\b[^>]*>([\s\S]*?)<\/head>/i.exec(html);
|
|
184
|
+
var headSlice = headM ? headM[1] : html;
|
|
185
|
+
var headStart = headM ? headM.index : 0;
|
|
186
|
+
var titleM = /<title\b[^>]*>([\s\S]*?)<\/title>/i.exec(headSlice);
|
|
187
|
+
if (!titleM) {
|
|
188
|
+
findings.push(find("doc-no-title", rel, 1));
|
|
189
|
+
} else if (!titleM[1].trim()) {
|
|
190
|
+
findings.push(find("doc-empty-title", rel, lineOf(html, headStart + titleM.index)));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// images
|
|
194
|
+
var imgRe = /<img\b([^>]*)>/gi, m;
|
|
195
|
+
while ((m = imgRe.exec(html)) !== null) {
|
|
196
|
+
var attrs = m[1];
|
|
197
|
+
if (!hasAttr(attrs, "alt")) {
|
|
198
|
+
findings.push(find("img-no-alt", rel, lineOf(html, m.index), m[0].slice(0, 80)));
|
|
199
|
+
} else {
|
|
200
|
+
var alt = getAttr(attrs, "alt") || "";
|
|
201
|
+
if (alt && /\.(png|jpg|jpeg|gif|svg|webp|bmp)$/i.test(alt.trim())) {
|
|
202
|
+
findings.push(find("img-alt-filename", rel, lineOf(html, m.index), "alt=\"" + alt + "\""));
|
|
203
|
+
}
|
|
204
|
+
if (alt && /^(img|image|picture|photo)[_\s-]?\d+$/i.test(alt.trim())) {
|
|
205
|
+
findings.push(find("img-alt-filename", rel, lineOf(html, m.index), "alt=\"" + alt + "\""));
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// video without <track kind="captions">
|
|
211
|
+
var videoRe = /<video\b([^>]*)>([\s\S]*?)<\/video>/gi;
|
|
212
|
+
while ((m = videoRe.exec(html)) !== null) {
|
|
213
|
+
var inner = m[2];
|
|
214
|
+
if (!/<track\b[^>]*\bkind\s*=\s*["']captions["']/i.test(inner)) {
|
|
215
|
+
findings.push(find("video-no-track", rel, lineOf(html, m.index)));
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// audio without transcript link nearby (heuristic: look for "transcript" in next 500 chars)
|
|
220
|
+
var audioRe = /<audio\b[^>]*>[\s\S]*?<\/audio>/gi;
|
|
221
|
+
while ((m = audioRe.exec(html)) !== null) {
|
|
222
|
+
var after = html.slice(m.index, m.index + 500 + m[0].length);
|
|
223
|
+
if (!/transcript/i.test(after)) {
|
|
224
|
+
findings.push(find("audio-no-transcript", rel, lineOf(html, m.index)));
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// headings
|
|
229
|
+
var headRe = /<h([1-6])\b[^>]*>/gi;
|
|
230
|
+
var levels = [];
|
|
231
|
+
while ((m = headRe.exec(html)) !== null) levels.push({ lvl: +m[1], pos: m.index });
|
|
232
|
+
if (levels.length > 0) {
|
|
233
|
+
if (!levels.some(function (l) { return l.lvl === 1; })) {
|
|
234
|
+
findings.push(find("heading-no-h1", rel, lineOf(html, levels[0].pos)));
|
|
235
|
+
}
|
|
236
|
+
for (var i = 1; i < levels.length; i++) {
|
|
237
|
+
var d = levels[i].lvl - levels[i - 1].lvl;
|
|
238
|
+
if (d > 1) {
|
|
239
|
+
findings.push(find("heading-skip", rel, lineOf(html, levels[i].pos),
|
|
240
|
+
"h" + levels[i - 1].lvl + " → h" + levels[i].lvl));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// links
|
|
246
|
+
var linkRe = /<a\b([^>]*)>([\s\S]*?)<\/a>/gi;
|
|
247
|
+
while ((m = linkRe.exec(html)) !== null) {
|
|
248
|
+
var aAttrs = m[1], aText = m[2].replace(/<[^>]+>/g, "").trim();
|
|
249
|
+
var aria = getAttr(aAttrs, "aria-label");
|
|
250
|
+
var title = getAttr(aAttrs, "title");
|
|
251
|
+
var hasName = aText || aria || title;
|
|
252
|
+
if (!hasName) {
|
|
253
|
+
var imgInside = /<img\b[^>]*\balt\s*=\s*["']([^"']+)["']/i.exec(m[2]);
|
|
254
|
+
if (!imgInside || !imgInside[1].trim()) {
|
|
255
|
+
findings.push(find("link-no-text", rel, lineOf(html, m.index)));
|
|
256
|
+
}
|
|
257
|
+
} else {
|
|
258
|
+
if (aText && GENERIC_LINK_TEXT.has(aText.toLowerCase())) {
|
|
259
|
+
findings.push(find("link-generic-text", rel, lineOf(html, m.index), "\"" + aText + "\""));
|
|
260
|
+
}
|
|
261
|
+
// Redundant: img alt inside the link matches the link's surrounding text.
|
|
262
|
+
var imgInside2 = /<img\b[^>]*\balt\s*=\s*["']([^"']+)["']/i.exec(m[2]);
|
|
263
|
+
if (imgInside2 && aText && imgInside2[1].trim().toLowerCase() === aText.toLowerCase()) {
|
|
264
|
+
findings.push(find("img-redundant-alt", rel, lineOf(html, m.index),
|
|
265
|
+
"alt=\"" + imgInside2[1] + "\" duplicates link text"));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// buttons
|
|
271
|
+
var btnRe = /<button\b([^>]*)>([\s\S]*?)<\/button>/gi;
|
|
272
|
+
while ((m = btnRe.exec(html)) !== null) {
|
|
273
|
+
var bAttrs = m[1], bText = m[2].replace(/<[^>]+>/g, "").trim();
|
|
274
|
+
if (!bText && !getAttr(bAttrs, "aria-label") && !getAttr(bAttrs, "title")) {
|
|
275
|
+
findings.push(find("button-no-name", rel, lineOf(html, m.index)));
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// form inputs missing labels
|
|
280
|
+
var inputRe = /<(input|select|textarea)\b([^>]*)>/gi;
|
|
281
|
+
while ((m = inputRe.exec(html)) !== null) {
|
|
282
|
+
var iType = (getAttr(m[2], "type") || "").toLowerCase();
|
|
283
|
+
if (m[1].toLowerCase() === "input" && ["hidden", "submit", "button", "reset", "image"].indexOf(iType) >= 0) continue;
|
|
284
|
+
var id = getAttr(m[2], "id");
|
|
285
|
+
var aria2 = getAttr(m[2], "aria-label") || getAttr(m[2], "aria-labelledby");
|
|
286
|
+
var hasLabel = aria2;
|
|
287
|
+
if (!hasLabel && id) {
|
|
288
|
+
var labelRe = new RegExp('<label\\b[^>]*\\bfor\\s*=\\s*["\']' + id.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&") + '["\']', "i");
|
|
289
|
+
if (labelRe.test(html)) hasLabel = true;
|
|
290
|
+
}
|
|
291
|
+
if (!hasLabel) {
|
|
292
|
+
findings.push(find("form-input-no-label", rel, lineOf(html, m.index), "<" + m[1] + ">"));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// div with onclick but no role/tabindex
|
|
297
|
+
var divClickRe = /<div\b([^>]*\bonclick\b[^>]*)>/gi;
|
|
298
|
+
while ((m = divClickRe.exec(html)) !== null) {
|
|
299
|
+
if (!getAttr(m[1], "role") || !hasAttr(m[1], "tabindex")) {
|
|
300
|
+
findings.push(find("div-click-no-role", rel, lineOf(html, m.index)));
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// tabindex > 0
|
|
305
|
+
var tabRe = /\btabindex\s*=\s*["'](\d+)["']/gi;
|
|
306
|
+
while ((m = tabRe.exec(html)) !== null) {
|
|
307
|
+
if (+m[1] > 0) findings.push(find("tabindex-positive", rel, lineOf(html, m.index), "tabindex=\"" + m[1] + "\""));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
// iframes
|
|
311
|
+
var iframeRe = /<iframe\b([^>]*)>/gi;
|
|
312
|
+
while ((m = iframeRe.exec(html)) !== null) {
|
|
313
|
+
if (!getAttr(m[1], "title")) findings.push(find("iframe-no-title", rel, lineOf(html, m.index)));
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// tables with rows but no <th>/scope
|
|
317
|
+
var tableRe = /<table\b[^>]*>([\s\S]*?)<\/table>/gi;
|
|
318
|
+
while ((m = tableRe.exec(html)) !== null) {
|
|
319
|
+
var tBody = m[1];
|
|
320
|
+
if (/<tr\b/i.test(tBody) && !/<th\b/i.test(tBody) && !/scope\s*=/i.test(tBody)) {
|
|
321
|
+
findings.push(find("table-no-headers", rel, lineOf(html, m.index)));
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// aria-* typos
|
|
326
|
+
var ariaRe = /\b(aria-[a-zA-Z]+)\s*=/g;
|
|
327
|
+
while ((m = ariaRe.exec(html)) !== null) {
|
|
328
|
+
var attr = m[1].toLowerCase();
|
|
329
|
+
if (!VALID_ARIA.has(attr)) {
|
|
330
|
+
findings.push(find("aria-bad-attr", rel, lineOf(html, m.index), attr));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// aria-hidden=true wrapping focusable elements
|
|
335
|
+
var hiddenRe = /<([a-z]+)\b[^>]*\baria-hidden\s*=\s*["']true["'][^>]*>([\s\S]*?)<\/\1>/gi;
|
|
336
|
+
while ((m = hiddenRe.exec(html)) !== null) {
|
|
337
|
+
if (/<(a|button|input|select|textarea|iframe)\b/i.test(m[2]) || /\btabindex\s*=\s*["']0["']/i.test(m[2])) {
|
|
338
|
+
findings.push(find("aria-hidden-focusable", rel, lineOf(html, m.index)));
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return findings;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// ---------- main -----------------------------------------------------------
|
|
346
|
+
|
|
347
|
+
function color(s, code, on) { return on ? "\x1b[" + code + "m" + s + "\x1b[0m" : s; }
|
|
348
|
+
|
|
349
|
+
function main() {
|
|
350
|
+
var args = parseArgs(process.argv.slice(2));
|
|
351
|
+
if (!fs.existsSync(args.input)) { console.error("Not found: " + args.input); process.exit(2); }
|
|
352
|
+
|
|
353
|
+
var inputIsZip = fs.statSync(args.input).isFile();
|
|
354
|
+
var root, cleanup = function () {};
|
|
355
|
+
if (inputIsZip) {
|
|
356
|
+
root = unzipToTemp(args.input);
|
|
357
|
+
cleanup = function () { try { fs.rmSync(root, { recursive: true, force: true }); } catch (e) {} };
|
|
358
|
+
} else {
|
|
359
|
+
root = path.resolve(args.input);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
try {
|
|
363
|
+
var htmls = walk(root).filter(function (p) { return /\.html?$/i.test(p); });
|
|
364
|
+
var all = [];
|
|
365
|
+
for (var f of htmls) {
|
|
366
|
+
var html = fs.readFileSync(f, "utf8");
|
|
367
|
+
var rel = path.relative(root, f);
|
|
368
|
+
var fs2 = auditHtml(rel, html);
|
|
369
|
+
all = all.concat(fs2);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
if (args.infoOff) all = all.filter(function (x) { return x.sev !== "info"; });
|
|
373
|
+
|
|
374
|
+
if (args.json) {
|
|
375
|
+
console.log(JSON.stringify({ findings: all }, null, 2));
|
|
376
|
+
} else {
|
|
377
|
+
var byFile = {};
|
|
378
|
+
for (var f2 of all) { (byFile[f2.file] = byFile[f2.file] || []).push(f2); }
|
|
379
|
+
var useColor = !args.noColor && process.stdout.isTTY;
|
|
380
|
+
var sevColor = { error: 31, warn: 33, info: 36 };
|
|
381
|
+
for (var file of Object.keys(byFile).sort()) {
|
|
382
|
+
console.log("\n" + color(file, 1, useColor));
|
|
383
|
+
for (var item of byFile[file]) {
|
|
384
|
+
var sev = color(item.sev.toUpperCase().padEnd(5), sevColor[item.sev] || 0, useColor);
|
|
385
|
+
console.log(" " + sev + " L" + String(item.line).padEnd(4) + " " + item.rule.padEnd(22) + " " + item.msg + (item.detail ? " — " + item.detail : ""));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
var errs = all.filter(function (x) { return x.sev === "error"; }).length;
|
|
389
|
+
var warns = all.filter(function (x) { return x.sev === "warn"; }).length;
|
|
390
|
+
console.log("\n" + errs + " error(s), " + warns + " warning(s) across " + htmls.length + " HTML file(s).");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
var anyErr = all.some(function (x) { return x.sev === "error"; });
|
|
394
|
+
var anyWarn = all.some(function (x) { return x.sev === "warn"; });
|
|
395
|
+
process.exit(anyErr ? 2 : anyWarn ? 1 : 0);
|
|
396
|
+
} finally {
|
|
397
|
+
cleanup();
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
main();
|