tessera-learn 0.0.11 → 0.0.13

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 (75) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +2 -1
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +85 -10
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +17 -3
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +16 -6
  23. package/src/components/Image.svelte +12 -3
  24. package/src/components/LockedBanner.svelte +2 -1
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +33 -13
  28. package/src/components/Quiz.svelte +61 -20
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +21 -18
  34. package/src/components/util.ts +3 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +4 -1
  41. package/src/plugin/export.ts +42 -14
  42. package/src/plugin/index.ts +216 -44
  43. package/src/plugin/manifest.ts +62 -22
  44. package/src/plugin/validation.ts +736 -122
  45. package/src/runtime/App.svelte +119 -48
  46. package/src/runtime/LoadingBar.svelte +12 -3
  47. package/src/runtime/Sidebar.svelte +24 -8
  48. package/src/runtime/access.ts +15 -3
  49. package/src/runtime/adapters/cmi5.ts +55 -33
  50. package/src/runtime/adapters/index.ts +22 -10
  51. package/src/runtime/adapters/retry.ts +25 -20
  52. package/src/runtime/adapters/scorm-base.ts +19 -15
  53. package/src/runtime/adapters/scorm12.ts +7 -8
  54. package/src/runtime/adapters/scorm2004.ts +11 -14
  55. package/src/runtime/adapters/web.ts +1 -1
  56. package/src/runtime/hooks.svelte.ts +152 -326
  57. package/src/runtime/interaction-format.ts +30 -12
  58. package/src/runtime/interaction.ts +44 -11
  59. package/src/runtime/navigation.svelte.ts +27 -11
  60. package/src/runtime/persistence.ts +2 -2
  61. package/src/runtime/progress.svelte.ts +13 -9
  62. package/src/runtime/quiz-engine.svelte.ts +361 -0
  63. package/src/runtime/quiz-policy.ts +9 -3
  64. package/src/runtime/types.ts +24 -2
  65. package/src/runtime/xapi/agent-rules.ts +4 -1
  66. package/src/runtime/xapi/client.ts +5 -5
  67. package/src/runtime/xapi/derive-actor.ts +2 -2
  68. package/src/runtime/xapi/publisher.ts +32 -29
  69. package/src/runtime/xapi/setup.ts +18 -15
  70. package/src/runtime/xapi/validation.ts +15 -6
  71. package/src/virtual.d.ts +4 -1
  72. package/styles/base.css +32 -11
  73. package/styles/layout.css +39 -18
  74. package/styles/theme.css +15 -3
  75. package/dist/validation-D9DXlqNP.js.map +0 -1
package/README.md CHANGED
@@ -20,6 +20,7 @@ That creates a project with Tessera wired up, a starter page structure, and the
20
20
  - **Vite plugin** (`tessera-learn/plugin`): `tesseraPlugin()` — wires page/layout discovery, the LMS adapter, and the export pipeline. Used in your project's `vite.config.js`.
21
21
  - **Built-in components** (`tessera-learn`): `Callout`, `Image`, `Audio`, `Video`, `Accordion` / `AccordionItem`, `Carousel` / `CarouselSlide`, `RevealModal`, `Quiz`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, `DefaultLayout`.
22
22
  - **LMS adapters**: SCORM 1.2, SCORM 2004 4th Edition, cmi5, static Web — selected via `course.config.js` `export.standard`.
23
+ - **Accessibility checks**: static rules (alt text, media titles/captions, heading order, contrast, `<html lang>`) run inside validation and the build with zero extra dependencies; an opt-in runtime audit (`tessera-a11y`, with `playwright` + `@axe-core/playwright` as optional peers) renders every page and gates on axe-core violations.
23
24
 
24
25
  See `AGENTS.md` for usage, signatures, and authoring conventions.
25
26
 
@@ -0,0 +1,204 @@
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
@@ -0,0 +1 @@
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":";;;;AAcA,MAAM,cAA2C;CAC/C,OAAO;CACP,UAAU;CACV,SAAS;CACT,UAAU;CACX;AAID,MAAa,iBAAiB;;AA0B9B,SAAgB,QAAQ,UAA8C;CACpE,QAAQ,UAAR;EACE,KAAK,UACH,OAAO,CAAC,SAAS;EACnB,KAAK,YACH,OAAO;GAAC;GAAU;GAAW;GAAW;GAAW;EAErD,SACE,OAAO,CAAC,UAAU,UAAU;;;;AAKlC,SAAgB,eAAe,QAA4B;CACzD,OAAO,OAAO,QACX,OAAO,CAAC,GAAG,WAAW,WAAW,IAAI,CAAC,GAAG,WAAW,QAAQ,CAC9D;;AAKH,SAAS,UAAU,GAAiB,eAAgC;CAClE,OAAO,CAAC,EAAE,UAAU,YAAY,EAAE,WAAW;;AAK/C,eAAe,UAAU,WAAqC;CAC5D,OAAO,OAAO;;AAUhB,eAAe,WAEb;CAEA,IAAI;CACJ,KAAK,MAAM,QAAQ,CAAC,cAAc,mBAAmB,EACnD,IAAI;EACF,MAAM,MAAO,MAAM,UAAU,KAAK;EAClC,IAAI,IAAI,UAAU;GAChB,WAAW,IAAI;GACf;;SAEI;CAIV,IAAI,CAAC,UAAU,OAAO;EAAE,IAAI;EAAO,SAAS;EAAc;CAE1D,IAAI;EACF,MAAM,MAAO,MAAM,UAAU,uBAAuB;EAGpD,IAAI,CAAC,IAAI,SAAS,OAAO;GAAE,IAAI;GAAO,SAAS;GAAwB;EACvE,OAAO;GAAE,IAAI;GAAM,MAAM;IAAE;IAAU,YAAY,IAAI;IAAS;GAAE;SAC1D;EACN,OAAO;GAAE,IAAI;GAAO,SAAS;GAAwB;;;;;;;;AASzD,eAAsB,SACpB,aACA,UAAwB,EAAE,EACT;CACjB,MAAM,YAAyB,QAAQ,aAAa;CAEpD,MAAM,OAAO,MAAM,UAAU;CAC7B,IAAI,CAAC,KAAK,IAAI;EACZ,QAAQ,MACN,yNAID;EACD,OAAO;;CAET,MAAM,EAAE,UAAU,eAAe,KAAK;CAEtC,MAAM,OAAO,iBAAiB,YAAY;CAC1C,MAAM,WAAW,cAAc,KAAK,KAAK,KAAK,OAAO,OAAO,KAAA,EAAU;CACtE,MAAM,OAAO,QAAQ,SAAS,SAAS;CACvC,MAAM,eAAe,eAAe,SAAS,OAAO;CAEpD,MAAM,WAAW,iBAAiB,QAAQ,aAAa,QAAQ,CAAC;CAGhE,MAAM,OAAQ,MAAM,OAAO;CAG3B,MAAM,YAAY,QAAQ,aAAa,gBAAgB,gBAAgB;CACvE,MAAM,WAAW,QAAQ,WAAW,aAAa;CAEjD,MAAM,UAAU,QAAQ,IAAI;CAC5B,QAAQ,IAAI,kBAAkB;CAG9B,IAAI;CACJ,IAAI;EACF,IAAI,QAAQ,WAAW,CAAC,WAAW,SAAS,EAAE;GAC5C,QAAQ,IAAI,kCAAkC;GAC9C,MAAM,KAAK,MAAM;IACf,MAAM;IACN,OAAO;KAAE,QAAQ;KAAW,aAAa;KAAM;IAC/C,UAAU;IACX,CAAC;;EAGJ,SAAS,MAAM,KAAK,QAAQ;GAC1B,MAAM;GACN,OAAO,EAAE,QAAQ,WAAW;GAC5B,SAAS;IAAE,MAAM;IAAG,MAAM;IAAa;GACvC,UAAU;GACX,CAAC;EACF,MAAM,UAA8B,OAAO,cAAc,QAAQ;EACjE,IAAI,CAAC,SAAS;GACZ,QAAQ,MAAM,yDAAyD;GACvE,OAAO;;EAGT,MAAM,UAAU,MAAM,SAAS,QAAQ;EACvC,MAAM,QAA2B,EAAE;EACnC,IAAI;GAGF,MAAM,OAAO,OAAM,MADG,QAAQ,YAAY,EACf,SAAS;GAEpC,MAAM,WAAW,IAAI,IAAI,QAAQ;GACjC,SAAS,aAAa,IAAI,mBAAmB,IAAI;GACjD,MAAM,KAAK,KAAK,SAAS,MAAM,EAAE,WAAW,eAAe,CAAC;GAC5D,MAAM,KAAK,gBAAgB,gBAAgB,EAAE,SAAS,KAAQ,CAAC;GAE/D,MAAM,OAAO,YAAqC;IAChD,MAAM,UAAU,IAAI,WAAW,EAAE,MAAM,CAAC,CAAC,SAAS,KAAK;IACvD,IAAI,aAAa,SAAS,GAAG,QAAQ,aAAa,aAAa;IAG/D,QAAO,MAFW,QAAQ,SAAS,EAExB,WAAW,KAAK,OAAY;KACrC,IAAI,EAAE;KACN,QAAQ,EAAE,UAAU;KACpB,MAAM,EAAE;KACR,SAAS,EAAE;KACX,OAAO,EAAE,MAAM;KAChB,EAAE;;GAGL,MAAM,WAAW,MAAM,KAAK,QAAQ,0BAA0B,CAAC,OAAO;GACtE,IAAI,aAAa,GAEf,MAAM,KAAK;IACT,OAAO;IACP,OAAO,SAAS,MAAM,IAAI,SAAS;IACnC,YAAY,MAAM,MAAM;IACzB,CAAC;QAEF,KAAK,IAAI,IAAI,GAAG,IAAI,UAAU,KAAK;IACjC,MAAM,MAAM,KAAK,QAAQ,0BAA0B,CAAC,IAAI,EAAE;IAC1D,MAAM,SAAS,MAAM,IAAI,aAAa,GAAG,MAAM,IAAI,QAAQ,IAAI;IAC/D,MAAM,IAAI,OAAO;IACjB,MAAM,KAAK,iBACR,QACC,SACG,iBAAiB,0BAA0B,CAC3C,MAAM,aAAa,eAAe,KAAK,QAC5C,GACA,EAAE,SAAS,KAAQ,CACpB;IACD,MAAM,KAAK,iBAAiB,cAAc;IAC1C,MAAM,KAAK;KAAE,OAAO;KAAG;KAAO,YAAY,MAAM,MAAM;KAAE,CAAC;;YAGrD;GACR,MAAM,QAAQ,OAAO;;EAGvB,MAAM,gBAAgB,YAAY;EAClC,IAAI,kBAAkB;EACtB,IAAI,oBAAoB;EACxB,KAAK,MAAM,KAAK,OACd,KAAK,MAAM,KAAK,EAAE,YAAY;GAC5B;GACA,IAAI,UAAU,GAAG,cAAc,EAAE;;EAIrC,MAAM,SAAsB;GAC1B,UAAU,SAAS;GACnB;GACA;GACA;GACA;GACA,QAAQ,sBAAsB;GAC/B;EACD,MAAM,aAAa,QAAQ,aAAa,mBAAmB;EAC3D,cAAc,YAAY,KAAK,UAAU,QAAQ,MAAM,EAAE,EAAE,QAAQ;EAEnE,aAAa,QAAQ,WAAW;EAChC,OAAO,OAAO,SAAS,IAAI;UACpB,KAAK;EACZ,QAAQ,MACN,2DACE,eAAe,QAAQ,IAAI,UAAU,OAAO,IAAI,GAEnD;EACD,OAAO;WACC;EACR,QAAQ,YAAY,SAAS;EAC7B,IAAI,YAAY,KAAA,GAAW,OAAO,QAAQ,IAAI;OACzC,QAAQ,IAAI,kBAAkB;;;AAIvC,SAAS,aAAa,QAAqB,YAA0B;CACnE,MAAM,gBAAgB,YAAY,OAAO;CACzC,KAAK,MAAM,KAAK,OAAO,OAAO;EAC5B,IAAI,EAAE,WAAW,WAAW,GAAG;GAC7B,QAAQ,IAAI,sBAAsB,EAAE,QAAQ;GAC5C;;EAGF,MAAM,OADU,EAAE,WAAW,MAAM,MAAM,UAAU,GAAG,cAAc,CAChD,GAAG,uBAAuB;EAC9C,QAAQ,IAAI,GAAG,KAAK,GAAG,EAAE,QAAQ;EACjC,KAAK,MAAM,KAAK,EAAE,YAChB,QAAQ,IACN,UAAU,EAAE,UAAU,MAAM,IAAI,EAAE,GAAG,KAAK,EAAE,KAAK,IAAI,EAAE,MAAM,OAAO,EAAE,UAAU,IAAI,KAAK,IAAI,GAC9F;;CAGL,QAAQ,IAAI,sCAAsC,aAAa;CAC/D,IAAI,OAAO,QACT,QAAQ,IACN,0CAA0C,OAAO,gBAAgB,oCAAoC,OAAO,UAAU,IACvH;MAED,QAAQ,IACN,0CAA0C,OAAO,kBAAkB,wBAAwB,OAAO,UAAU,QAAQ,OAAO,gBAAgB,UAC5I"}
@@ -0,0 +1 @@
1
+ export { };
@@ -0,0 +1,36 @@
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
@@ -0,0 +1 @@
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"}
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env node
2
- import { n as validateProject, t as reportValidationIssues } from "../validation-D9DXlqNP.js";
2
+ import { a as validateProject, i as reportValidationIssues } from "../validation-B-xTvM9B.js";
3
3
  //#region src/plugin/cli.ts
4
4
  const { errors, warnings } = validateProject(process.cwd());
5
5
  reportValidationIssues({
@@ -13,6 +13,7 @@ if (errors.length > 0) {
13
13
  }
14
14
  if (warnings.length > 0) console.log(`\n\x1b[33mValidation passed with ${warnings.length} warning(s).\x1b[0m`);
15
15
  else console.log("\x1B[32m[tessera]\x1B[0m Validation passed — no issues found.");
16
+ console.log("\x1B[2m[tessera] Static checks only. For a full runtime accessibility audit, run: npm run accessibility-check\x1B[0m");
16
17
  process.exit(0);
17
18
  //#endregion
18
19
  export {};
@@ -1 +1 @@
1
- {"version":3,"file":"cli.js","names":[],"sources":["../../src/plugin/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { validateProject, reportValidationIssues } from './validation.js';\n\nconst projectRoot = process.cwd();\nconst { errors, warnings } = validateProject(projectRoot);\n\nreportValidationIssues({ errors, warnings });\n\nif (errors.length > 0) {\n const summary =\n `Validation failed with ${errors.length} error(s)` +\n (warnings.length > 0 ? ` and ${warnings.length} warning(s)` : '') +\n '.';\n console.error(`\\n\\x1b[31m${summary}\\x1b[0m`);\n process.exit(1);\n}\n\nif (warnings.length > 0) {\n console.log(\n `\\n\\x1b[33mValidation passed with ${warnings.length} warning(s).\\x1b[0m`\n );\n} else {\n console.log('\\x1b[32m[tessera]\\x1b[0m Validation passed — no issues found.');\n}\nprocess.exit(0);\n"],"mappings":";;;AAIA,MAAM,EAAE,QAAQ,aAAa,gBADT,QAAQ,KAC4B,CAAC;AAEzD,uBAAuB;CAAE;CAAQ;CAAU,CAAC;AAE5C,IAAI,OAAO,SAAS,GAAG;CACrB,MAAM,UACJ,0BAA0B,OAAO,OAAO,cACvC,SAAS,SAAS,IAAI,QAAQ,SAAS,OAAO,eAAe,MAC9D;CACF,QAAQ,MAAM,aAAa,QAAQ,SAAS;CAC5C,QAAQ,KAAK,EAAE;;AAGjB,IAAI,SAAS,SAAS,GACpB,QAAQ,IACN,oCAAoC,SAAS,OAAO,qBACrD;KAED,QAAQ,IAAI,gEAAgE;AAE9E,QAAQ,KAAK,EAAE"}
1
+ {"version":3,"file":"cli.js","names":[],"sources":["../../src/plugin/cli.ts"],"sourcesContent":["#!/usr/bin/env node\nimport { validateProject, reportValidationIssues } from './validation.js';\n\nconst projectRoot = process.cwd();\nconst { errors, warnings } = validateProject(projectRoot);\n\nreportValidationIssues({ errors, warnings });\n\nif (errors.length > 0) {\n const summary =\n `Validation failed with ${errors.length} error(s)` +\n (warnings.length > 0 ? ` and ${warnings.length} warning(s)` : '') +\n '.';\n console.error(`\\n\\x1b[31m${summary}\\x1b[0m`);\n process.exit(1);\n}\n\nif (warnings.length > 0) {\n console.log(\n `\\n\\x1b[33mValidation passed with ${warnings.length} warning(s).\\x1b[0m`,\n );\n} else {\n console.log('\\x1b[32m[tessera]\\x1b[0m Validation passed — no issues found.');\n}\nconsole.log(\n '\\x1b[2m[tessera] Static checks only. For a full runtime accessibility audit, run: npm run accessibility-check\\x1b[0m',\n);\nprocess.exit(0);\n"],"mappings":";;;AAIA,MAAM,EAAE,QAAQ,aAAa,gBADT,QAAQ,KAC4B,CAAC;AAEzD,uBAAuB;CAAE;CAAQ;CAAU,CAAC;AAE5C,IAAI,OAAO,SAAS,GAAG;CACrB,MAAM,UACJ,0BAA0B,OAAO,OAAO,cACvC,SAAS,SAAS,IAAI,QAAQ,SAAS,OAAO,eAAe,MAC9D;CACF,QAAQ,MAAM,aAAa,QAAQ,SAAS;CAC5C,QAAQ,KAAK,EAAE;;AAGjB,IAAI,SAAS,SAAS,GACpB,QAAQ,IACN,oCAAoC,SAAS,OAAO,qBACrD;KAED,QAAQ,IAAI,gEAAgE;AAE9E,QAAQ,IACN,uHACD;AACD,QAAQ,KAAK,EAAE"}
@@ -1,7 +1,22 @@
1
1
  import { Plugin } from "vite";
2
2
 
3
+ //#region src/plugin/a11y/audit.d.ts
4
+ interface AuditOptions {
5
+ /** Minimum violation impact that fails the run (CI gate). Default 'serious'. */
6
+ threshold?: ImpactLevel;
7
+ /** Force a fresh `vite build` even if dist/ exists. */
8
+ rebuild?: boolean;
9
+ }
10
+ type ImpactLevel = 'minor' | 'moderate' | 'serious' | 'critical';
11
+ /**
12
+ * Run the Tier-2 runtime accessibility audit against a built course. Builds (or
13
+ * reuses) dist/, serves it, drives Playwright + axe-core over each page, writes
14
+ * a11y-report.json, and returns a process exit code (0 pass, 1 fail/error).
15
+ */
16
+ declare function runAudit(projectRoot: string, options?: AuditOptions): Promise<number>;
17
+ //#endregion
3
18
  //#region src/plugin/index.d.ts
4
19
  declare function tesseraPlugin(): (Plugin<any> | Plugin<any>[])[];
5
20
  //#endregion
6
- export { tesseraPlugin };
21
+ export { type AuditOptions, type ImpactLevel, runAudit, tesseraPlugin };
7
22
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/plugin/index.ts"],"mappings":";;;iBA2BgB,aAAA,CAAA,IAAa,MAAA,QAAA,MAAA"}
1
+ {"version":3,"file":"index.d.ts","names":[],"sources":["../../src/plugin/a11y/audit.ts","../../src/plugin/index.ts"],"mappings":";;;UAKiB,YAAA;;EAEf,SAAA,GAAY,WAAA;EAFe;EAI3B,OAAA;AAAA;AAAA,KAGU,WAAA;;;;;AA6GZ;iBAAsB,QAAA,CACpB,WAAA,UACA,OAAA,GAAS,YAAA,GACR,OAAA;;;iBCtCa,aAAA,CAAA,IAAa,MAAA,QAAA,MAAA"}
@@ -1,7 +1,8 @@
1
- import { i as readCourseConfig, n as validateProject, r as generateManifest, t as reportValidationIssues } from "../validation-D9DXlqNP.js";
1
+ import { a as validateProject, i as reportValidationIssues, n as isPlausibleLanguageTag, o as generateManifest, r as normalizeA11y, s as readCourseConfig, t as isIgnored } from "../validation-B-xTvM9B.js";
2
+ import { n as runAudit, t as AUDIT_ENV_FLAG } from "../audit-BBJpQGqb.js";
2
3
  import { svelte } from "@sveltejs/vite-plugin-svelte";
3
4
  import { fileURLToPath } from "node:url";
4
- import { dirname, resolve } from "node:path";
5
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
5
6
  import { cpSync, createWriteStream, existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
6
7
  import { createHash } from "node:crypto";
7
8
  import { ZipArchive } from "archiver";
@@ -251,6 +252,9 @@ function tesseraQuizPlugin() {
251
252
  }
252
253
  //#endregion
253
254
  //#region src/plugin/index.ts
255
+ function isAuditBuild() {
256
+ return process.env[AUDIT_ENV_FLAG] === "1";
257
+ }
254
258
  const __dirname = dirname(fileURLToPath(import.meta.url));
255
259
  function resolveRuntimeDir() {
256
260
  return resolve(resolve(__dirname, "..", ".."), "src", "runtime");
@@ -258,13 +262,44 @@ function resolveRuntimeDir() {
258
262
  function resolveStylesDir() {
259
263
  return resolve(resolve(__dirname, "..", ".."), "styles");
260
264
  }
265
+ function projectFileRel(filename, projectRoot) {
266
+ if (!filename || !projectRoot) return null;
267
+ if (filename.startsWith("\0") || filename.includes("virtual:") || filename.includes("node_modules")) return null;
268
+ const rel = relative(projectRoot, isAbsolute(filename) ? filename : resolve(projectRoot, filename));
269
+ if (rel.startsWith("..") || isAbsolute(rel) || rel.includes("node_modules")) return null;
270
+ return rel;
271
+ }
261
272
  function tesseraPlugin() {
262
273
  const manifestRef = {
263
274
  current: null,
264
275
  root: ""
265
276
  };
277
+ const a11y = {
278
+ warnings: [],
279
+ projectRoot: "",
280
+ isBuild: false,
281
+ settings: normalizeA11y(void 0)
282
+ };
266
283
  return [
267
- svelte({ compilerOptions: { css: "external" } }),
284
+ svelte({
285
+ compilerOptions: { css: "external" },
286
+ onwarn(warning, defaultHandler) {
287
+ if (warning.code?.startsWith("a11y")) {
288
+ const rel = projectFileRel(warning.filename, a11y.projectRoot);
289
+ if (rel !== null) {
290
+ const msg = `[${warning.code}] ${rel}: ${warning.message}`;
291
+ if (a11y.isBuild) a11y.warnings.push(msg);
292
+ else if (!a11y.settings.ignore.includes(warning.code)) reportValidationIssues({
293
+ errors: [],
294
+ warnings: [msg]
295
+ });
296
+ }
297
+ return;
298
+ }
299
+ defaultHandler?.(warning);
300
+ }
301
+ }),
302
+ tesseraA11yCompilerPlugin(a11y),
268
303
  tesseraValidationPlugin(),
269
304
  tesseraEntryPlugin(),
270
305
  tesseraConfigPlugin(),
@@ -287,16 +322,18 @@ function tesseraEntryPlugin() {
287
322
  const stylesDir = resolveStylesDir();
288
323
  const appSveltePath = resolve(runtimeDir, "App.svelte");
289
324
  let projectRoot;
325
+ let outDir;
290
326
  let isBuild = false;
291
327
  return {
292
328
  name: "tessera:entry",
293
329
  enforce: "pre",
294
330
  configResolved(config) {
295
331
  projectRoot = config.root;
332
+ outDir = resolve(config.root, config.build.outDir);
296
333
  isBuild = config.command === "build";
297
334
  },
298
335
  buildStart() {
299
- if (isBuild) writeFileSync(resolve(projectRoot, "index.html"), generateIndexHtml(), "utf-8");
336
+ if (isBuild) writeFileSync(resolve(projectRoot, "index.html"), generateIndexHtml(readLanguage(projectRoot)), "utf-8");
300
337
  },
301
338
  closeBundle() {
302
339
  if (isBuild) {
@@ -305,7 +342,7 @@ function tesseraEntryPlugin() {
305
342
  unlinkSync(htmlPath);
306
343
  } catch {}
307
344
  const assetsDir = resolve(projectRoot, "assets");
308
- const distAssetsDir = resolve(projectRoot, "dist", "assets");
345
+ const distAssetsDir = resolve(outDir, "assets");
309
346
  if (existsSync(assetsDir)) {
310
347
  mkdirSync(distAssetsDir, { recursive: true });
311
348
  cpSync(assetsDir, distAssetsDir, { recursive: true });
@@ -316,7 +353,7 @@ function tesseraEntryPlugin() {
316
353
  return () => {
317
354
  server.middlewares.use(async (req, res, next) => {
318
355
  if (req.url === "/" || req.url === "/index.html") {
319
- const html = generateIndexHtml();
356
+ const html = generateIndexHtml(readLanguage(projectRoot));
320
357
  const transformed = await server.transformIndexHtml(req.url, html);
321
358
  res.setHeader("Content-Type", "text/html");
322
359
  res.statusCode = 200;
@@ -338,9 +375,14 @@ function tesseraEntryPlugin() {
338
375
  }
339
376
  };
340
377
  }
341
- function generateIndexHtml() {
378
+ function readLanguage(projectRoot) {
379
+ const read = readCourseConfig(projectRoot);
380
+ const lang = read.ok ? read.config.language : void 0;
381
+ return isPlausibleLanguageTag(lang) ? lang : "en";
382
+ }
383
+ function generateIndexHtml(lang) {
342
384
  return `<!DOCTYPE html>
343
- <html lang="en">
385
+ <html lang="${lang}">
344
386
  <head>
345
387
  <meta charset="UTF-8" />
346
388
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
@@ -399,7 +441,7 @@ function tesseraConfigPlugin() {
399
441
  return {
400
442
  base: "./",
401
443
  build: { assetsDir: "tessera" },
402
- resolve: { alias: { "$assets": resolve(config.root || process.cwd(), "assets") } },
444
+ resolve: { alias: { $assets: resolve(config.root || process.cwd(), "assets") } },
403
445
  optimizeDeps: { exclude: ["tessera-learn"] }
404
446
  };
405
447
  },
@@ -489,6 +531,36 @@ function tesseraValidationPlugin() {
489
531
  }
490
532
  };
491
533
  }
534
+ function tesseraA11yCompilerPlugin(a11y) {
535
+ return {
536
+ name: "tessera:a11y-compiler",
537
+ enforce: "pre",
538
+ configResolved(config) {
539
+ a11y.projectRoot = config.root;
540
+ a11y.isBuild = config.command === "build";
541
+ const read = readCourseConfig(config.root);
542
+ a11y.settings = normalizeA11y(read.ok ? read.config.a11y : void 0);
543
+ },
544
+ buildEnd() {
545
+ if (!a11y.isBuild || a11y.warnings.length === 0) return;
546
+ const ignored = new Set(a11y.settings.ignore);
547
+ const warnings = a11y.warnings.filter((msg) => !isIgnored(msg, ignored));
548
+ a11y.warnings = [];
549
+ if (warnings.length === 0) return;
550
+ if (a11y.settings.level === "error") {
551
+ reportValidationIssues({
552
+ errors: warnings,
553
+ warnings: []
554
+ });
555
+ throw new Error(`Tessera: ${warnings.length} a11y issue(s) with a11y.level: 'error'. Fix the errors above to continue.`);
556
+ }
557
+ reportValidationIssues({
558
+ errors: [],
559
+ warnings
560
+ });
561
+ }
562
+ };
563
+ }
492
564
  function runValidation(projectRoot) {
493
565
  const result = validateProject(projectRoot);
494
566
  reportValidationIssues(result);
@@ -506,6 +578,7 @@ function tesseraExportPlugin() {
506
578
  },
507
579
  async closeBundle() {
508
580
  if (!isBuild) return;
581
+ if (isAuditBuild()) return;
509
582
  const read = readCourseConfig(projectRoot);
510
583
  if (!read.ok) {
511
584
  if (read.reason === "missing") throw new Error("[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.");
@@ -588,6 +661,7 @@ function tesseraAdapterPlugin() {
588
661
  let standard = "web";
589
662
  const read = readCourseConfig(projectRoot);
590
663
  if (read.ok && typeof read.config.export?.standard === "string") standard = read.config.export.standard;
664
+ if (isAuditBuild()) standard = "web";
591
665
  switch (standard) {
592
666
  case "scorm12": return `
593
667
  import { SCORM12Adapter } from 'tessera-learn/runtime/adapters/scorm12.js';
@@ -647,6 +721,7 @@ function tesseraXAPISetupPlugin() {
647
721
  load(id) {
648
722
  if (id !== RESOLVED_XAPI_SETUP_ID) return null;
649
723
  if (!isBuild) return `export { buildXAPIClient } from 'tessera-learn/runtime/xapi/setup.js';`;
724
+ if (isAuditBuild()) return `export async function buildXAPIClient() { return null; }`;
650
725
  let standard = "web";
651
726
  let hasXapi = false;
652
727
  const read = readCourseConfig(projectRoot);
@@ -684,6 +759,6 @@ function tesseraFirstPagePreloadPlugin(manifestRef) {
684
759
  };
685
760
  }
686
761
  //#endregion
687
- export { tesseraPlugin };
762
+ export { runAudit, tesseraPlugin };
688
763
 
689
764
  //# sourceMappingURL=index.js.map