tessera-learn 0.0.13 → 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.
Files changed (68) hide show
  1. package/AGENTS.md +1794 -0
  2. package/README.md +5 -5
  3. package/dist/{validation-B-xTvM9B.js → audit-BA5o0ick.js} +605 -269
  4. package/dist/audit-BA5o0ick.js.map +1 -0
  5. package/dist/build-commands-C0OnV-Vg.js +27 -0
  6. package/dist/build-commands-C0OnV-Vg.js.map +1 -0
  7. package/dist/inline-config-CroQ-_2Y.js +31 -0
  8. package/dist/inline-config-CroQ-_2Y.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +9 -1
  10. package/dist/plugin/cli.d.ts.map +1 -0
  11. package/dist/plugin/cli.js +326 -17
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts +1 -1
  14. package/dist/plugin/index.d.ts.map +1 -1
  15. package/dist/plugin/index.js +2 -763
  16. package/dist/plugin-W_rk3Pit.js +731 -0
  17. package/dist/plugin-W_rk3Pit.js.map +1 -0
  18. package/package.json +21 -9
  19. package/src/components/FillInTheBlank.svelte +2 -2
  20. package/src/components/Matching.svelte +2 -2
  21. package/src/components/MultipleChoice.svelte +2 -2
  22. package/src/components/RevealModal.svelte +48 -103
  23. package/src/components/Sorting.svelte +2 -2
  24. package/src/components/util.ts +9 -0
  25. package/src/plugin/a11y/audit.ts +40 -8
  26. package/src/plugin/a11y-cli.ts +39 -22
  27. package/src/plugin/ast.ts +276 -0
  28. package/src/plugin/build-commands.ts +31 -0
  29. package/src/plugin/cli.ts +96 -21
  30. package/src/plugin/course-root.ts +98 -0
  31. package/src/plugin/duplicate-cli.ts +74 -0
  32. package/src/plugin/index.ts +87 -122
  33. package/src/plugin/inline-config.ts +54 -0
  34. package/src/plugin/manifest.ts +103 -136
  35. package/src/plugin/new-cli.ts +51 -0
  36. package/src/plugin/package-root.ts +24 -0
  37. package/src/plugin/project-name.ts +29 -0
  38. package/src/plugin/quiz.ts +8 -9
  39. package/src/plugin/template-copy.ts +43 -0
  40. package/src/plugin/validate-cli.ts +30 -0
  41. package/src/plugin/validation.ts +152 -244
  42. package/src/runtime/App.svelte +11 -97
  43. package/src/runtime/Sidebar.svelte +3 -1
  44. package/src/runtime/adapters/cmi5.ts +6 -10
  45. package/src/runtime/adapters/format.ts +6 -0
  46. package/src/runtime/adapters/retry.ts +1 -1
  47. package/src/runtime/adapters/scorm2004.ts +2 -4
  48. package/src/runtime/branding.ts +90 -0
  49. package/src/runtime/defaults.ts +3 -0
  50. package/src/runtime/hooks.svelte.ts +16 -53
  51. package/src/runtime/interaction-format.ts +3 -8
  52. package/src/runtime/progress.svelte.ts +47 -83
  53. package/src/runtime/xapi/derive-actor.ts +41 -48
  54. package/src/runtime/xapi/publisher.ts +14 -14
  55. package/src/runtime/xapi/setup.ts +39 -46
  56. package/templates/course/course.config.js +11 -0
  57. package/templates/course/layout.svelte +116 -0
  58. package/templates/course/pages/01-getting-started/01-welcome/_meta.js +1 -0
  59. package/templates/course/pages/01-getting-started/01-welcome/welcome.svelte +19 -0
  60. package/templates/course/pages/01-getting-started/_meta.js +1 -0
  61. package/templates/course/styles/custom.css +5 -0
  62. package/dist/audit-BBJpQGqb.js +0 -204
  63. package/dist/audit-BBJpQGqb.js.map +0 -1
  64. package/dist/plugin/a11y-cli.d.ts +0 -1
  65. package/dist/plugin/a11y-cli.js +0 -36
  66. package/dist/plugin/a11y-cli.js.map +0 -1
  67. package/dist/plugin/index.js.map +0 -1
  68. 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
  );
@@ -0,0 +1,11 @@
1
+ export default {
2
+ title: '__PROJECT_TITLE__',
3
+ language: 'en',
4
+ navigation: { mode: 'free' },
5
+ completion: { mode: 'percentage', percentageThreshold: 100 },
6
+ scoring: { passingScore: 70 },
7
+ branding: {
8
+ primaryColor: '#0066cc',
9
+ },
10
+ export: { standard: 'web' },
11
+ };
@@ -0,0 +1,116 @@
1
+ <script>
2
+ import '$shared/tokens.css';
3
+ import { useNavigation, useProgress } from 'tessera-learn';
4
+
5
+ let { page } = $props();
6
+ const nav = useNavigation();
7
+ const progress = useProgress();
8
+ </script>
9
+
10
+ <div class="course">
11
+ <header>
12
+ <h1>__PROJECT_TITLE__</h1>
13
+ <span class="progress"
14
+ >{progress.visitedPages.size} / {nav.pages.length}</span
15
+ >
16
+ </header>
17
+
18
+ <main>
19
+ {@render page()}
20
+ </main>
21
+
22
+ <footer>
23
+ <button
24
+ class="nav-btn"
25
+ disabled={!nav.canGoPrev}
26
+ onclick={() => nav.prev()}
27
+ >
28
+ ← Previous
29
+ </button>
30
+ <button
31
+ class="nav-btn nav-btn--primary"
32
+ disabled={!nav.canGoNext}
33
+ onclick={() => nav.next()}
34
+ >
35
+ Next →
36
+ </button>
37
+ </footer>
38
+ </div>
39
+
40
+ <style>
41
+ .course {
42
+ display: flex;
43
+ flex-direction: column;
44
+ min-height: 100vh;
45
+ }
46
+
47
+ header {
48
+ display: flex;
49
+ align-items: center;
50
+ justify-content: space-between;
51
+ gap: var(--tessera-spacing-md);
52
+ padding: var(--tessera-spacing-md) var(--tessera-spacing-xl);
53
+ border-bottom: 1px solid var(--tessera-border);
54
+ }
55
+
56
+ header h1 {
57
+ margin: 0;
58
+ font-size: 1.125rem;
59
+ }
60
+
61
+ .progress {
62
+ flex: none;
63
+ padding: 0.15rem 0.6rem;
64
+ border-radius: 999px;
65
+ background: var(--tessera-bg-secondary);
66
+ color: var(--tessera-text-light);
67
+ font-size: 0.875rem;
68
+ }
69
+
70
+ main {
71
+ flex: 1;
72
+ width: 100%;
73
+ max-width: var(--tessera-content-max-width);
74
+ margin: 0 auto;
75
+ padding: var(--tessera-spacing-xl);
76
+ }
77
+
78
+ footer {
79
+ display: flex;
80
+ justify-content: space-between;
81
+ gap: var(--tessera-spacing-md);
82
+ padding: var(--tessera-spacing-md) var(--tessera-spacing-xl);
83
+ border-top: 1px solid var(--tessera-border);
84
+ }
85
+
86
+ .nav-btn {
87
+ padding: 0.5rem 1rem;
88
+ border: 1px solid var(--tessera-border);
89
+ border-radius: 6px;
90
+ background: var(--tessera-bg);
91
+ color: var(--tessera-text);
92
+ cursor: pointer;
93
+ transition:
94
+ background var(--tessera-transition-fast),
95
+ border-color var(--tessera-transition-fast);
96
+ }
97
+
98
+ .nav-btn:hover:not(:disabled) {
99
+ background: var(--tessera-bg-secondary);
100
+ }
101
+
102
+ .nav-btn--primary {
103
+ border-color: transparent;
104
+ background: var(--tessera-primary);
105
+ color: #fff;
106
+ }
107
+
108
+ .nav-btn--primary:hover:not(:disabled) {
109
+ background: var(--tessera-primary-dark);
110
+ }
111
+
112
+ .nav-btn:disabled {
113
+ opacity: 0.5;
114
+ cursor: not-allowed;
115
+ }
116
+ </style>
@@ -0,0 +1 @@
1
+ export default { title: 'Welcome', pages: ['welcome'] };
@@ -0,0 +1,19 @@
1
+ <script module>
2
+ export const pageConfig = { title: 'Welcome' };
3
+ </script>
4
+
5
+ <script>
6
+ import Button from '$shared/Button.svelte';
7
+ </script>
8
+
9
+ <h1>Welcome to __PROJECT_TITLE__</h1>
10
+
11
+ <p>This is a basic demo page of your Tessera course.</p>
12
+
13
+ <p>
14
+ Point your agent to <code>AGENTS.md</code> at the workspace root for the
15
+ authoring guide. The button below is imported from the workspace design system
16
+ via <code>$shared</code>.
17
+ </p>
18
+
19
+ <Button>A shared button</Button>
@@ -0,0 +1 @@
1
+ export default { title: 'Getting Started' };
@@ -0,0 +1,5 @@
1
+ /* Course-level CSS overrides.
2
+ * Imported automatically by Tessera. Use this file for tweaks on top of the
3
+ * default theme. For deeper customisation, see the "Theming" section in
4
+ * AGENTS.md.
5
+ */
@@ -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