tessera-learn 0.0.13 → 0.1.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.
Files changed (56) hide show
  1. package/AGENTS.md +1744 -0
  2. package/README.md +2 -2
  3. package/dist/{validation-B-xTvM9B.js → audit-CzKAXy3Y.js} +591 -268
  4. package/dist/audit-CzKAXy3Y.js.map +1 -0
  5. package/dist/build-commands-D101M_qb.js +27 -0
  6. package/dist/build-commands-D101M_qb.js.map +1 -0
  7. package/dist/inline-config-DYHT51G8.js +29 -0
  8. package/dist/inline-config-DYHT51G8.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +5 -1
  10. package/dist/plugin/cli.d.ts.map +1 -0
  11. package/dist/plugin/cli.js +108 -15
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +2 -763
  15. package/dist/plugin-y35ym9A3.js +744 -0
  16. package/dist/plugin-y35ym9A3.js.map +1 -0
  17. package/package.json +12 -9
  18. package/src/components/FillInTheBlank.svelte +2 -2
  19. package/src/components/Matching.svelte +2 -2
  20. package/src/components/MultipleChoice.svelte +2 -2
  21. package/src/components/RevealModal.svelte +48 -103
  22. package/src/components/Sorting.svelte +2 -2
  23. package/src/components/util.ts +9 -0
  24. package/src/plugin/a11y/audit.ts +35 -8
  25. package/src/plugin/a11y-cli.ts +35 -22
  26. package/src/plugin/ast.ts +276 -0
  27. package/src/plugin/build-commands.ts +25 -0
  28. package/src/plugin/cli.ts +53 -21
  29. package/src/plugin/index.ts +87 -122
  30. package/src/plugin/inline-config.ts +43 -0
  31. package/src/plugin/manifest.ts +103 -136
  32. package/src/plugin/package-root.ts +24 -0
  33. package/src/plugin/quiz.ts +8 -9
  34. package/src/plugin/validate-cli.ts +30 -0
  35. package/src/plugin/validation.ts +152 -244
  36. package/src/runtime/App.svelte +11 -97
  37. package/src/runtime/Sidebar.svelte +3 -1
  38. package/src/runtime/adapters/cmi5.ts +6 -10
  39. package/src/runtime/adapters/format.ts +6 -0
  40. package/src/runtime/adapters/retry.ts +1 -1
  41. package/src/runtime/adapters/scorm2004.ts +2 -4
  42. package/src/runtime/branding.ts +90 -0
  43. package/src/runtime/defaults.ts +3 -0
  44. package/src/runtime/hooks.svelte.ts +16 -53
  45. package/src/runtime/interaction-format.ts +3 -8
  46. package/src/runtime/progress.svelte.ts +47 -83
  47. package/src/runtime/xapi/derive-actor.ts +41 -48
  48. package/src/runtime/xapi/publisher.ts +14 -14
  49. package/src/runtime/xapi/setup.ts +39 -46
  50. package/dist/audit-BBJpQGqb.js +0 -204
  51. package/dist/audit-BBJpQGqb.js.map +0 -1
  52. package/dist/plugin/a11y-cli.d.ts +0 -1
  53. package/dist/plugin/a11y-cli.js +0 -36
  54. package/dist/plugin/a11y-cli.js.map +0 -1
  55. package/dist/plugin/index.js.map +0 -1
  56. package/dist/validation-B-xTvM9B.js.map +0 -1
@@ -3,54 +3,48 @@ import type { SCORM12API } from '../adapters/scorm12.js';
3
3
  import type { SCORM2004API } from '../adapters/scorm2004.js';
4
4
 
5
5
  /**
6
- * Compute the default SCORM-derived `account.homePage` from the activity
7
- * IRI. Returns the URL origin when `activityId` is an http(s) URL,
8
- * otherwise null. Callers that get null and have no `actorAccountHomePage`
9
- * override should treat it as a config error (the build-time validator
10
- * already enforces this; this is a runtime fallback for completeness).
6
+ * Origin of an http(s) URL, else null. Shared with the config validator, which
7
+ * predicts this result to know when `actorAccountHomePage` becomes required
8
+ * one helper keeps the two in lockstep.
11
9
  */
12
- export function defaultAccountHomePage(activityId: string): string | null {
10
+ export function httpOrigin(url: string): string | null {
13
11
  try {
14
- const url = new URL(activityId);
15
- if (url.protocol === 'http:' || url.protocol === 'https:') {
16
- return url.origin;
17
- }
18
- return null;
12
+ const parsed = new URL(url);
13
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:'
14
+ ? parsed.origin
15
+ : null;
19
16
  } catch {
20
17
  return null;
21
18
  }
22
19
  }
23
20
 
24
21
  /**
25
- * Synthesize an Identified Agent for SCORM 1.2 from the LMS data model.
22
+ * Synthesize an Identified Agent from the SCORM learner fields.
26
23
  *
27
- * { account: { homePage, name: cmi.core.student_id },
28
- * name: cmi.core.student_name,
29
- * objectType: 'Agent' }
24
+ * { account: { homePage, name: <id> }, name: <name>, objectType: 'Agent' }
30
25
  *
31
26
  * The `account` IFI satisfies xAPI's Identified Agent rule. `homePage`
32
- * defaults to the activityId origin so analytics keyed on actor identity
33
- * stay stable across LMS hosts; the author's `actorAccountHomePage`
34
- * overrides when the authority namespace is elsewhere.
35
- *
36
- * Returns null if `student_id` is missing — caller should not construct
37
- * a publisher in that case (the LRS would 400 on every send anyway).
27
+ * defaults to the activityId origin so analytics keyed on actor identity stay
28
+ * stable across LMS hosts; the author's `actorAccountHomePage` overrides when
29
+ * the authority namespace is elsewhere. Returns null if the id is missing —
30
+ * the caller should not construct a publisher (the LRS would 400 every send).
38
31
  */
39
- export function synthesizeSCORM12Actor(
40
- api: SCORM12API,
32
+ function synthesizeActor(
33
+ readId: () => string,
34
+ readName: () => string,
41
35
  activityId: string,
42
36
  actorAccountHomePage?: string,
43
37
  ): XAPIAgent | null {
44
38
  let id = '';
45
39
  let name = '';
46
40
  try {
47
- id = api.LMSGetValue('cmi.core.student_id') || '';
41
+ id = readId() || '';
48
42
  } catch {}
49
43
  try {
50
- name = api.LMSGetValue('cmi.core.student_name') || '';
44
+ name = readName() || '';
51
45
  } catch {}
52
46
  if (!id) return null;
53
- const homePage = actorAccountHomePage ?? defaultAccountHomePage(activityId);
47
+ const homePage = actorAccountHomePage ?? httpOrigin(activityId);
54
48
  if (!homePage) return null;
55
49
  const agent: XAPIAgent = {
56
50
  account: { homePage, name: id },
@@ -60,31 +54,30 @@ export function synthesizeSCORM12Actor(
60
54
  return agent;
61
55
  }
62
56
 
63
- /**
64
- * Synthesize an Identified Agent for SCORM 2004 from the LMS data model.
65
- * Same structure as SCORM 1.2 but reads from `cmi.learner_id` /
66
- * `cmi.learner_name` (the renamed 2004 fields).
67
- */
57
+ /** SCORM 1.2 actor from `cmi.core.student_id` / `cmi.core.student_name`. */
58
+ export function synthesizeSCORM12Actor(
59
+ api: SCORM12API,
60
+ activityId: string,
61
+ actorAccountHomePage?: string,
62
+ ): XAPIAgent | null {
63
+ return synthesizeActor(
64
+ () => api.LMSGetValue('cmi.core.student_id'),
65
+ () => api.LMSGetValue('cmi.core.student_name'),
66
+ activityId,
67
+ actorAccountHomePage,
68
+ );
69
+ }
70
+
71
+ /** SCORM 2004 actor from `cmi.learner_id` / `cmi.learner_name` (renamed 2004 fields). */
68
72
  export function synthesizeSCORM2004Actor(
69
73
  api: SCORM2004API,
70
74
  activityId: string,
71
75
  actorAccountHomePage?: string,
72
76
  ): XAPIAgent | null {
73
- let id = '';
74
- let name = '';
75
- try {
76
- id = api.GetValue('cmi.learner_id') || '';
77
- } catch {}
78
- try {
79
- name = api.GetValue('cmi.learner_name') || '';
80
- } catch {}
81
- if (!id) return null;
82
- const homePage = actorAccountHomePage ?? defaultAccountHomePage(activityId);
83
- if (!homePage) return null;
84
- const agent: XAPIAgent = {
85
- account: { homePage, name: id },
86
- objectType: 'Agent',
87
- };
88
- if (name) agent.name = name;
89
- return agent;
77
+ return synthesizeActor(
78
+ () => api.GetValue('cmi.learner_id'),
79
+ () => api.GetValue('cmi.learner_name'),
80
+ activityId,
81
+ actorAccountHomePage,
82
+ );
90
83
  }
@@ -482,18 +482,22 @@ export class XAPIPublisher {
482
482
  }));
483
483
  }
484
484
 
485
+ #buildHeaders(token: string): Headers {
486
+ const headers = new Headers();
487
+ if (token) headers.set('Authorization', `Basic ${token}`);
488
+ headers.set('X-Experience-API-Version', X_API_VERSION);
489
+ headers.set('Content-Type', 'application/json');
490
+ return headers;
491
+ }
492
+
485
493
  #fetchWithToken(
486
494
  token: string,
487
495
  body: string,
488
496
  keepalive: boolean,
489
497
  ): Promise<SendOutcome> {
490
- const headers = new Headers();
491
- if (token) headers.set('Authorization', `Basic ${token}`);
492
- headers.set('X-Experience-API-Version', X_API_VERSION);
493
- headers.set('Content-Type', 'application/json');
494
498
  return fetch(this.#statementsUrl, {
495
499
  method: 'POST',
496
- headers,
500
+ headers: this.#buildHeaders(token),
497
501
  body,
498
502
  keepalive,
499
503
  })
@@ -521,18 +525,14 @@ export class XAPIPublisher {
521
525
  ) {
522
526
  this.#cachedAuth = null;
523
527
  return this.#resolveAuth(true)
524
- .then((newToken) => {
525
- const retryHeaders = new Headers();
526
- if (newToken) retryHeaders.set('Authorization', `Basic ${newToken}`);
527
- retryHeaders.set('X-Experience-API-Version', X_API_VERSION);
528
- retryHeaders.set('Content-Type', 'application/json');
529
- return fetch(this.#statementsUrl, {
528
+ .then((newToken) =>
529
+ fetch(this.#statementsUrl, {
530
530
  method: 'POST',
531
- headers: retryHeaders,
531
+ headers: this.#buildHeaders(newToken),
532
532
  body,
533
533
  keepalive,
534
- });
535
- })
534
+ }),
535
+ )
536
536
  .then((retryResp): SendOutcome => {
537
537
  if (retryResp.ok || retryResp.status === 409) {
538
538
  return { ok: true, status: retryResp.status };
@@ -90,6 +90,10 @@ function makeSCORMDevFallbackPublisher(
90
90
  * adapter present in non-cmi5 export modes — the validator should have
91
91
  * caught this at build time).
92
92
  */
93
+ type ActorResolution =
94
+ | { kind: 'actor'; value: XAPIAgent | (() => XAPIAgent | Promise<XAPIAgent>) }
95
+ | { kind: 'scorm-fallback'; standard: 'scorm12' | 'scorm2004' };
96
+
93
97
  function resolveDestination(
94
98
  entry: XAPIConfig,
95
99
  config: CourseConfig,
@@ -114,24 +118,18 @@ function resolveDestination(
114
118
 
115
119
  // Explicit endpoint.
116
120
  const explicit = entry as XAPIExplicitConfig;
117
- const actorOrResolver = resolveExplicitActor(explicit, config, adapter);
118
- if (actorOrResolver === null) return null;
119
- if (
120
- typeof actorOrResolver === 'object' &&
121
- (actorOrResolver as { __scormDevFallback?: 'scorm12' | 'scorm2004' })
122
- .__scormDevFallback
123
- ) {
124
- const std = (
125
- actorOrResolver as { __scormDevFallback: 'scorm12' | 'scorm2004' }
126
- ).__scormDevFallback;
127
- return { kind: 'explicit', publisher: makeSCORMDevFallbackPublisher(std) };
121
+ const resolution = resolveExplicitActor(explicit, config, adapter);
122
+ if (resolution === null) return null;
123
+ if (resolution.kind === 'scorm-fallback') {
124
+ return {
125
+ kind: 'explicit',
126
+ publisher: makeSCORMDevFallbackPublisher(resolution.standard),
127
+ };
128
128
  }
129
129
  const publisher = new XAPIPublisher({
130
130
  endpoint: explicit.endpoint,
131
131
  auth: explicit.auth,
132
- actor: actorOrResolver as
133
- | XAPIAgent
134
- | (() => XAPIAgent | Promise<XAPIAgent>),
132
+ actor: resolution.value,
135
133
  activityId: explicit.activityId,
136
134
  registration: explicit.registration,
137
135
  });
@@ -149,50 +147,45 @@ function resolveExplicitActor(
149
147
  explicit: XAPIExplicitConfig,
150
148
  config: CourseConfig,
151
149
  adapter: PersistenceAdapter | null,
152
- ):
153
- | XAPIAgent
154
- | (() => XAPIAgent | Promise<XAPIAgent>)
155
- | { __scormDevFallback: 'scorm12' | 'scorm2004' }
156
- | null {
157
- if (explicit.actor !== undefined) return explicit.actor;
158
- // No author-supplied actor — try mode-specific derivation.
150
+ ): ActorResolution | null {
151
+ if (explicit.actor !== undefined) {
152
+ return { kind: 'actor', value: explicit.actor };
153
+ }
159
154
  if (config.export?.standard === 'cmi5' && adapter instanceof CMI5Adapter) {
160
155
  const inner = adapter.getPublisher();
161
- if (inner) {
162
- // The cmi5 adapter's publisher has the launch actor cached.
163
- try {
164
- return inner.getActor();
165
- } catch {
166
- return null;
167
- }
156
+ if (!inner) return null;
157
+ try {
158
+ return { kind: 'actor', value: inner.getActor() };
159
+ } catch {
160
+ return null;
168
161
  }
169
- return null;
170
162
  }
171
163
  if (config.export?.standard === 'scorm12') {
172
164
  if (adapter instanceof SCORM12Adapter) {
173
- return synthesizeSCORM12Actor(
174
- adapter.getAPI(),
175
- explicit.activityId,
176
- explicit.actorAccountHomePage,
177
- );
165
+ return {
166
+ kind: 'actor',
167
+ value: synthesizeSCORM12Actor(
168
+ adapter.getAPI(),
169
+ explicit.activityId,
170
+ explicit.actorAccountHomePage,
171
+ ) as XAPIAgent,
172
+ };
178
173
  }
179
- // Adapter is the WebAdapter dev fallback. Mirror the cmi5 'lms'
180
- // dev-fallback path: install a stub publisher that surfaces an
181
- // explicit error rather than silently no-oping. Authors get the
182
- // same dev/prod parity in SCORM that they get in cmi5.
183
- return { __scormDevFallback: 'scorm12' };
174
+ return { kind: 'scorm-fallback', standard: 'scorm12' };
184
175
  }
185
176
  if (config.export?.standard === 'scorm2004') {
186
177
  if (adapter instanceof SCORM2004Adapter) {
187
- return synthesizeSCORM2004Actor(
188
- adapter.getAPI(),
189
- explicit.activityId,
190
- explicit.actorAccountHomePage,
191
- );
178
+ return {
179
+ kind: 'actor',
180
+ value: synthesizeSCORM2004Actor(
181
+ adapter.getAPI(),
182
+ explicit.activityId,
183
+ explicit.actorAccountHomePage,
184
+ ) as XAPIAgent,
185
+ };
192
186
  }
193
- return { __scormDevFallback: 'scorm2004' };
187
+ return { kind: 'scorm-fallback', standard: 'scorm2004' };
194
188
  }
195
- // Web export with no actor — build-time validator should have errored.
196
189
  console.warn(
197
190
  'Tessera xAPI: explicit destination has no actor and no derivation source — skipping.',
198
191
  );
@@ -1,204 +0,0 @@
1
- import { o as generateManifest, r as normalizeA11y, s as readCourseConfig } from "./validation-B-xTvM9B.js";
2
- import { resolve } from "node:path";
3
- import { existsSync, writeFileSync } from "node:fs";
4
- //#region src/plugin/a11y/audit.ts
5
- const IMPACT_RANK = {
6
- minor: 1,
7
- moderate: 2,
8
- serious: 3,
9
- critical: 4
10
- };
11
- const AUDIT_ENV_FLAG = "TESSERA_A11Y_AUDIT";
12
- /** Map the `a11y.standard` enum to axe's cumulative `runOnly` tag list. */
13
- function axeTags(standard) {
14
- switch (standard) {
15
- case "wcag2a": return ["wcag2a"];
16
- case "wcag21aa": return [
17
- "wcag2a",
18
- "wcag2aa",
19
- "wcag21a",
20
- "wcag21aa"
21
- ];
22
- default: return ["wcag2a", "wcag2aa"];
23
- }
24
- }
25
- /** axe-applicable ignore entries: drop the Tier-1a/1b namespaces. */
26
- function axeIgnoreRules(ignore) {
27
- return ignore.filter((id) => !id.startsWith("tessera/") && !id.startsWith("a11y_"));
28
- }
29
- function isFailing(v, thresholdRank) {
30
- return !v.impact || IMPACT_RANK[v.impact] >= thresholdRank;
31
- }
32
- async function tryImport(specifier) {
33
- return import(specifier);
34
- }
35
- async function loadDeps() {
36
- let chromium;
37
- for (const spec of ["playwright", "@playwright/test"]) try {
38
- const mod = await tryImport(spec);
39
- if (mod.chromium) {
40
- chromium = mod.chromium;
41
- break;
42
- }
43
- } catch {}
44
- if (!chromium) return {
45
- ok: false,
46
- missing: "playwright"
47
- };
48
- try {
49
- const mod = await tryImport("@axe-core/playwright");
50
- if (!mod.default) return {
51
- ok: false,
52
- missing: "@axe-core/playwright"
53
- };
54
- return {
55
- ok: true,
56
- deps: {
57
- chromium,
58
- AxeBuilder: mod.default
59
- }
60
- };
61
- } catch {
62
- return {
63
- ok: false,
64
- missing: "@axe-core/playwright"
65
- };
66
- }
67
- }
68
- /**
69
- * Run the Tier-2 runtime accessibility audit against a built course. Builds (or
70
- * reuses) dist/, serves it, drives Playwright + axe-core over each page, writes
71
- * a11y-report.json, and returns a process exit code (0 pass, 1 fail/error).
72
- */
73
- async function runAudit(projectRoot, options = {}) {
74
- const threshold = options.threshold ?? "serious";
75
- const deps = await loadDeps();
76
- if (!deps.ok) {
77
- console.error("\x1B[31m[tessera a11y]\x1B[0m Tier 2 needs Playwright + axe-core, which aren't installed.\n Install them to run the runtime audit:\n npm i -D playwright @axe-core/playwright\n npx playwright install chromium");
78
- return 1;
79
- }
80
- const { chromium, AxeBuilder } = deps.deps;
81
- const read = readCourseConfig(projectRoot);
82
- const settings = normalizeA11y(read.ok ? read.config.a11y : void 0);
83
- const tags = axeTags(settings.standard);
84
- const disableRules = axeIgnoreRules(settings.ignore);
85
- const manifest = generateManifest(resolve(projectRoot, "pages"));
86
- const vite = await import("vite");
87
- const auditDist = resolve(projectRoot, "node_modules", ".tessera-a11y");
88
- const distHtml = resolve(auditDist, "index.html");
89
- const prevEnv = process.env[AUDIT_ENV_FLAG];
90
- process.env[AUDIT_ENV_FLAG] = "1";
91
- let server;
92
- try {
93
- if (options.rebuild || !existsSync(distHtml)) {
94
- console.log("[tessera a11y] Building course…");
95
- await vite.build({
96
- root: projectRoot,
97
- build: {
98
- outDir: auditDist,
99
- emptyOutDir: true
100
- },
101
- logLevel: "warn"
102
- });
103
- }
104
- server = await vite.preview({
105
- root: projectRoot,
106
- build: { outDir: auditDist },
107
- preview: {
108
- port: 0,
109
- host: "127.0.0.1"
110
- },
111
- logLevel: "warn"
112
- });
113
- const baseUrl = server.resolvedUrls?.local?.[0];
114
- if (!baseUrl) {
115
- console.error("[tessera a11y] Could not determine preview server URL.");
116
- return 1;
117
- }
118
- const browser = await chromium.launch();
119
- const pages = [];
120
- try {
121
- const page = await (await browser.newContext()).newPage();
122
- const auditUrl = new URL(baseUrl);
123
- auditUrl.searchParams.set("__tessera_audit", "1");
124
- await page.goto(auditUrl.href, { waitUntil: "networkidle" });
125
- await page.waitForSelector("#tessera-app", { timeout: 2e4 });
126
- const scan = async () => {
127
- const builder = new AxeBuilder({ page }).withTags(tags);
128
- if (disableRules.length > 0) builder.disableRules(disableRules);
129
- return (await builder.analyze()).violations.map((v) => ({
130
- id: v.id,
131
- impact: v.impact ?? null,
132
- help: v.help,
133
- helpUrl: v.helpUrl,
134
- nodes: v.nodes.length
135
- }));
136
- };
137
- const navCount = await page.locator("button.tessera-nav-page").count();
138
- if (navCount === 0) pages.push({
139
- index: 0,
140
- title: manifest.pages[0]?.title ?? "(entry)",
141
- violations: await scan()
142
- });
143
- else for (let i = 0; i < navCount; i++) {
144
- const btn = page.locator("button.tessera-nav-page").nth(i);
145
- const title = (await btn.textContent())?.trim() || `Page ${i + 1}`;
146
- await btn.click();
147
- await page.waitForFunction((idx) => document.querySelectorAll("button.tessera-nav-page")[idx]?.getAttribute("aria-current") === "page", i, { timeout: 2e4 });
148
- await page.waitForLoadState("networkidle");
149
- pages.push({
150
- index: i,
151
- title,
152
- violations: await scan()
153
- });
154
- }
155
- } finally {
156
- await browser.close();
157
- }
158
- const thresholdRank = IMPACT_RANK[threshold];
159
- let totalViolations = 0;
160
- let failingViolations = 0;
161
- for (const p of pages) for (const v of p.violations) {
162
- totalViolations++;
163
- if (isFailing(v, thresholdRank)) failingViolations++;
164
- }
165
- const report = {
166
- standard: settings.standard,
167
- threshold,
168
- pages,
169
- totalViolations,
170
- failingViolations,
171
- passed: failingViolations === 0
172
- };
173
- const reportPath = resolve(projectRoot, "a11y-report.json");
174
- writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf-8");
175
- printSummary(report, reportPath);
176
- return report.passed ? 0 : 1;
177
- } catch (err) {
178
- console.error(`\x1b[31m[tessera a11y]\x1b[0m Audit could not complete: ${err instanceof Error ? err.message : String(err)}`);
179
- return 1;
180
- } finally {
181
- server?.httpServer?.close?.();
182
- if (prevEnv === void 0) delete process.env[AUDIT_ENV_FLAG];
183
- else process.env[AUDIT_ENV_FLAG] = prevEnv;
184
- }
185
- }
186
- function printSummary(report, reportPath) {
187
- const thresholdRank = IMPACT_RANK[report.threshold];
188
- for (const p of report.pages) {
189
- if (p.violations.length === 0) {
190
- console.log(`\x1b[32m ✓\x1b[0m ${p.title}`);
191
- continue;
192
- }
193
- const mark = p.violations.some((v) => isFailing(v, thresholdRank)) ? "\x1B[31m ✗\x1B[0m" : "\x1B[33m ⚠\x1B[0m";
194
- console.log(`${mark} ${p.title}`);
195
- for (const v of p.violations) console.log(` [${v.impact ?? "n/a"}] ${v.id} — ${v.help} (${v.nodes} node${v.nodes === 1 ? "" : "s"})`);
196
- }
197
- console.log(`\n[tessera a11y] Report written to ${reportPath}`);
198
- if (report.passed) console.log(`\x1b[32m[tessera a11y] Passed\x1b[0m — ${report.totalViolations} total finding(s), none at/above "${report.threshold}".`);
199
- else console.log(`\x1b[31m[tessera a11y] Failed\x1b[0m — ${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total).`);
200
- }
201
- //#endregion
202
- export { runAudit as n, AUDIT_ENV_FLAG as t };
203
-
204
- //# sourceMappingURL=audit-BBJpQGqb.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"audit-BBJpQGqb.js","names":[],"sources":["../src/plugin/a11y/audit.ts"],"sourcesContent":["import { existsSync, writeFileSync } from 'node:fs';\nimport { resolve } from 'node:path';\nimport { generateManifest, readCourseConfig } from '../manifest.js';\nimport { normalizeA11y, type A11ySettings } from '../validation.js';\n\nexport interface AuditOptions {\n /** Minimum violation impact that fails the run (CI gate). Default 'serious'. */\n threshold?: ImpactLevel;\n /** Force a fresh `vite build` even if dist/ exists. */\n rebuild?: boolean;\n}\n\nexport type ImpactLevel = 'minor' | 'moderate' | 'serious' | 'critical';\n\nconst IMPACT_RANK: Record<ImpactLevel, number> = {\n minor: 1,\n moderate: 2,\n serious: 3,\n critical: 4,\n};\n\n// Set by runAudit during its build/preview; the plugin forces the WebAdapter,\n// skips export packaging, and stubs xAPI while it's set. See plugin/index.ts.\nexport const AUDIT_ENV_FLAG = 'TESSERA_A11Y_AUDIT';\n\ninterface AxeViolation {\n id: string;\n impact: ImpactLevel | null;\n help: string;\n helpUrl: string;\n nodes: number;\n}\n\ninterface PageAuditResult {\n index: number;\n title: string;\n violations: AxeViolation[];\n}\n\ninterface AuditReport {\n standard: A11ySettings['standard'];\n threshold: ImpactLevel;\n pages: PageAuditResult[];\n totalViolations: number;\n failingViolations: number;\n passed: boolean;\n}\n\n/** Map the `a11y.standard` enum to axe's cumulative `runOnly` tag list. */\nexport function axeTags(standard: A11ySettings['standard']): string[] {\n switch (standard) {\n case 'wcag2a':\n return ['wcag2a'];\n case 'wcag21aa':\n return ['wcag2a', 'wcag2aa', 'wcag21a', 'wcag21aa'];\n case 'wcag2aa':\n default:\n return ['wcag2a', 'wcag2aa'];\n }\n}\n\n/** axe-applicable ignore entries: drop the Tier-1a/1b namespaces. */\nexport function axeIgnoreRules(ignore: string[]): string[] {\n return ignore.filter(\n (id) => !id.startsWith('tessera/') && !id.startsWith('a11y_'),\n );\n}\n\n// A violation with no impact is treated as failing rather than slipping the\n// gate at every threshold.\nfunction isFailing(v: AxeViolation, thresholdRank: number): boolean {\n return !v.impact || IMPACT_RANK[v.impact] >= thresholdRank;\n}\n\n// Optional deps loaded by variable specifier so tsc doesn't require them to be\n// installed — Tier 2 is opt-in and the absence is handled with a clear message.\nasync function tryImport(specifier: string): Promise<unknown> {\n return import(specifier);\n}\n\ninterface LoadedDeps {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n chromium: any;\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n AxeBuilder: any;\n}\n\nasync function loadDeps(): Promise<\n { ok: true; deps: LoadedDeps } | { ok: false; missing: string }\n> {\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let chromium: any;\n for (const spec of ['playwright', '@playwright/test']) {\n try {\n const mod = (await tryImport(spec)) as { chromium?: unknown };\n if (mod.chromium) {\n chromium = mod.chromium;\n break;\n }\n } catch {\n // try the next specifier\n }\n }\n if (!chromium) return { ok: false, missing: 'playwright' };\n\n try {\n const mod = (await tryImport('@axe-core/playwright')) as {\n default?: unknown;\n };\n if (!mod.default) return { ok: false, missing: '@axe-core/playwright' };\n return { ok: true, deps: { chromium, AxeBuilder: mod.default } };\n } catch {\n return { ok: false, missing: '@axe-core/playwright' };\n }\n}\n\n/**\n * Run the Tier-2 runtime accessibility audit against a built course. Builds (or\n * reuses) dist/, serves it, drives Playwright + axe-core over each page, writes\n * a11y-report.json, and returns a process exit code (0 pass, 1 fail/error).\n */\nexport async function runAudit(\n projectRoot: string,\n options: AuditOptions = {},\n): Promise<number> {\n const threshold: ImpactLevel = options.threshold ?? 'serious';\n\n const deps = await loadDeps();\n if (!deps.ok) {\n console.error(\n `\\x1b[31m[tessera a11y]\\x1b[0m Tier 2 needs Playwright + axe-core, which aren't installed.\\n` +\n ` Install them to run the runtime audit:\\n` +\n ` npm i -D playwright @axe-core/playwright\\n` +\n ` npx playwright install chromium`,\n );\n return 1;\n }\n const { chromium, AxeBuilder } = deps.deps;\n\n const read = readCourseConfig(projectRoot);\n const settings = normalizeA11y(read.ok ? read.config.a11y : undefined);\n const tags = axeTags(settings.standard);\n const disableRules = axeIgnoreRules(settings.ignore);\n\n const manifest = generateManifest(resolve(projectRoot, 'pages'));\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n const vite = (await import('vite')) as any;\n\n // A throwaway web build, kept out of dist/ so a real LMS export is untouched.\n const auditDist = resolve(projectRoot, 'node_modules', '.tessera-a11y');\n const distHtml = resolve(auditDist, 'index.html');\n\n const prevEnv = process.env[AUDIT_ENV_FLAG];\n process.env[AUDIT_ENV_FLAG] = '1';\n\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n let server: any;\n try {\n if (options.rebuild || !existsSync(distHtml)) {\n console.log('[tessera a11y] Building course…');\n await vite.build({\n root: projectRoot,\n build: { outDir: auditDist, emptyOutDir: true },\n logLevel: 'warn',\n });\n }\n\n server = await vite.preview({\n root: projectRoot,\n build: { outDir: auditDist },\n preview: { port: 0, host: '127.0.0.1' },\n logLevel: 'warn',\n });\n const baseUrl: string | undefined = server.resolvedUrls?.local?.[0];\n if (!baseUrl) {\n console.error('[tessera a11y] Could not determine preview server URL.');\n return 1;\n }\n\n const browser = await chromium.launch();\n const pages: PageAuditResult[] = [];\n try {\n // axe-core/playwright requires a page from an explicit context.\n const context = await browser.newContext();\n const page = await context.newPage();\n // ?__tessera_audit unlocks navigation so quiz-gated pages can be scanned.\n const auditUrl = new URL(baseUrl);\n auditUrl.searchParams.set('__tessera_audit', '1');\n await page.goto(auditUrl.href, { waitUntil: 'networkidle' });\n await page.waitForSelector('#tessera-app', { timeout: 20_000 });\n\n const scan = async (): Promise<AxeViolation[]> => {\n const builder = new AxeBuilder({ page }).withTags(tags);\n if (disableRules.length > 0) builder.disableRules(disableRules);\n const out = await builder.analyze();\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n return out.violations.map((v: any) => ({\n id: v.id,\n impact: v.impact ?? null,\n help: v.help,\n helpUrl: v.helpUrl,\n nodes: v.nodes.length,\n }));\n };\n\n const navCount = await page.locator('button.tessera-nav-page').count();\n if (navCount === 0) {\n // No sidebar (custom chrome) — audit whatever is rendered at the entry.\n pages.push({\n index: 0,\n title: manifest.pages[0]?.title ?? '(entry)',\n violations: await scan(),\n });\n } else {\n for (let i = 0; i < navCount; i++) {\n const btn = page.locator('button.tessera-nav-page').nth(i);\n const title = (await btn.textContent())?.trim() || `Page ${i + 1}`;\n await btn.click();\n await page.waitForFunction(\n (idx: number) =>\n document\n .querySelectorAll('button.tessera-nav-page')\n [idx]?.getAttribute('aria-current') === 'page',\n i,\n { timeout: 20_000 },\n );\n await page.waitForLoadState('networkidle');\n pages.push({ index: i, title, violations: await scan() });\n }\n }\n } finally {\n await browser.close();\n }\n\n const thresholdRank = IMPACT_RANK[threshold];\n let totalViolations = 0;\n let failingViolations = 0;\n for (const p of pages) {\n for (const v of p.violations) {\n totalViolations++;\n if (isFailing(v, thresholdRank)) failingViolations++;\n }\n }\n\n const report: AuditReport = {\n standard: settings.standard,\n threshold,\n pages,\n totalViolations,\n failingViolations,\n passed: failingViolations === 0,\n };\n const reportPath = resolve(projectRoot, 'a11y-report.json');\n writeFileSync(reportPath, JSON.stringify(report, null, 2), 'utf-8');\n\n printSummary(report, reportPath);\n return report.passed ? 0 : 1;\n } catch (err) {\n console.error(\n `\\x1b[31m[tessera a11y]\\x1b[0m Audit could not complete: ${\n err instanceof Error ? err.message : String(err)\n }`,\n );\n return 1;\n } finally {\n server?.httpServer?.close?.();\n if (prevEnv === undefined) delete process.env[AUDIT_ENV_FLAG];\n else process.env[AUDIT_ENV_FLAG] = prevEnv;\n }\n}\n\nfunction printSummary(report: AuditReport, reportPath: string): void {\n const thresholdRank = IMPACT_RANK[report.threshold];\n for (const p of report.pages) {\n if (p.violations.length === 0) {\n console.log(`\\x1b[32m ✓\\x1b[0m ${p.title}`);\n continue;\n }\n const failing = p.violations.some((v) => isFailing(v, thresholdRank));\n const mark = failing ? '\\x1b[31m ✗\\x1b[0m' : '\\x1b[33m ⚠\\x1b[0m';\n console.log(`${mark} ${p.title}`);\n for (const v of p.violations) {\n console.log(\n ` [${v.impact ?? 'n/a'}] ${v.id} — ${v.help} (${v.nodes} node${v.nodes === 1 ? '' : 's'})`,\n );\n }\n }\n console.log(`\\n[tessera a11y] Report written to ${reportPath}`);\n if (report.passed) {\n console.log(\n `\\x1b[32m[tessera a11y] Passed\\x1b[0m — ${report.totalViolations} total finding(s), none at/above \"${report.threshold}\".`,\n );\n } else {\n console.log(\n `\\x1b[31m[tessera a11y] Failed\\x1b[0m — ${report.failingViolations} finding(s) at/above \"${report.threshold}\" (of ${report.totalViolations} total).`,\n );\n }\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- export { };
@@ -1,36 +0,0 @@
1
- #!/usr/bin/env node
2
- import { n as runAudit } from "../audit-BBJpQGqb.js";
3
- //#region src/plugin/a11y-cli.ts
4
- const VALID_THRESHOLDS = [
5
- "minor",
6
- "moderate",
7
- "serious",
8
- "critical"
9
- ];
10
- const args = process.argv.slice(2);
11
- let threshold;
12
- let rebuild = false;
13
- for (let i = 0; i < args.length; i++) {
14
- const arg = args[i];
15
- if (arg === "--threshold") {
16
- const value = args[++i];
17
- if (!VALID_THRESHOLDS.includes(value)) {
18
- console.error(`[tessera a11y] --threshold must be one of: ${VALID_THRESHOLDS.join(", ")}`);
19
- process.exit(1);
20
- }
21
- threshold = value;
22
- } else if (arg === "--build") rebuild = true;
23
- else {
24
- console.error(`[tessera a11y] Unknown argument: ${arg}`);
25
- process.exit(1);
26
- }
27
- }
28
- const code = await runAudit(process.cwd(), {
29
- threshold,
30
- rebuild
31
- });
32
- process.exit(code);
33
- //#endregion
34
- export {};
35
-
36
- //# sourceMappingURL=a11y-cli.js.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"a11y-cli.js","names":[],"sources":["../../src/plugin/a11y-cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { runAudit, type ImpactLevel } from './a11y/audit.js';\n\nconst VALID_THRESHOLDS: ImpactLevel[] = [\n 'minor',\n 'moderate',\n 'serious',\n 'critical',\n];\n\nconst args = process.argv.slice(2);\nlet threshold: ImpactLevel | undefined;\nlet rebuild = false;\n\nfor (let i = 0; i < args.length; i++) {\n const arg = args[i];\n if (arg === '--threshold') {\n const value = args[++i] as ImpactLevel;\n if (!VALID_THRESHOLDS.includes(value)) {\n console.error(\n `[tessera a11y] --threshold must be one of: ${VALID_THRESHOLDS.join(', ')}`,\n );\n process.exit(1);\n }\n threshold = value;\n } else if (arg === '--build') {\n rebuild = true;\n } else {\n console.error(`[tessera a11y] Unknown argument: ${arg}`);\n process.exit(1);\n }\n}\n\nconst code = await runAudit(process.cwd(), { threshold, rebuild });\nprocess.exit(code);\n"],"mappings":";;;AAGA,MAAM,mBAAkC;CACtC;CACA;CACA;CACA;CACD;AAED,MAAM,OAAO,QAAQ,KAAK,MAAM,EAAE;AAClC,IAAI;AACJ,IAAI,UAAU;AAEd,KAAK,IAAI,IAAI,GAAG,IAAI,KAAK,QAAQ,KAAK;CACpC,MAAM,MAAM,KAAK;CACjB,IAAI,QAAQ,eAAe;EACzB,MAAM,QAAQ,KAAK,EAAE;EACrB,IAAI,CAAC,iBAAiB,SAAS,MAAM,EAAE;GACrC,QAAQ,MACN,8CAA8C,iBAAiB,KAAK,KAAK,GAC1E;GACD,QAAQ,KAAK,EAAE;;EAEjB,YAAY;QACP,IAAI,QAAQ,WACjB,UAAU;MACL;EACL,QAAQ,MAAM,oCAAoC,MAAM;EACxD,QAAQ,KAAK,EAAE;;;AAInB,MAAM,OAAO,MAAM,SAAS,QAAQ,KAAK,EAAE;CAAE;CAAW;CAAS,CAAC;AAClE,QAAQ,KAAK,KAAK"}