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
@@ -1,6 +1,205 @@
1
1
  import { basename, extname, relative, resolve } from "node:path";
2
- import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
2
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
3
3
  import JSON5 from "json5";
4
+ import { Parser } from "acorn";
5
+ import { tsPlugin } from "@sveltejs/acorn-typescript";
6
+ import { parse } from "svelte/compiler";
7
+ //#region src/plugin/ast.ts
8
+ const rootCache = /* @__PURE__ */ new Map();
9
+ /** Drop every cached root. Call at the start of a run to scope the cache. */
10
+ function clearParseCache() {
11
+ rootCache.clear();
12
+ }
13
+ function parseRoot(source) {
14
+ const cached = rootCache.get(source);
15
+ if (cached !== void 0) return cached;
16
+ let entry;
17
+ try {
18
+ entry = {
19
+ root: parse(source, { modern: true }),
20
+ error: null
21
+ };
22
+ } catch (error) {
23
+ entry = {
24
+ root: null,
25
+ error: (error instanceof Error ? error.message : String(error)).split("\n")[0].trim() || "parse error"
26
+ };
27
+ }
28
+ rootCache.set(source, entry);
29
+ return entry;
30
+ }
31
+ function collectComponents(root, names) {
32
+ const found = [];
33
+ const seen = /* @__PURE__ */ new Set();
34
+ const walk = (value) => {
35
+ if (!value || typeof value !== "object") return;
36
+ if (seen.has(value)) return;
37
+ seen.add(value);
38
+ if (Array.isArray(value)) {
39
+ for (const item of value) walk(item);
40
+ return;
41
+ }
42
+ const node = value;
43
+ if (node.type === "Component" && names.has(node.name)) found.push(node);
44
+ for (const key of Object.keys(node)) {
45
+ if (key === "type") continue;
46
+ walk(node[key]);
47
+ }
48
+ };
49
+ walk(root);
50
+ return found.sort((a, b) => a.start - b.start);
51
+ }
52
+ function readProps(source, node) {
53
+ const props = /* @__PURE__ */ new Map();
54
+ let hasSpread = false;
55
+ const attributes = node.attributes ?? [];
56
+ for (const attr of attributes) {
57
+ if (attr.type === "SpreadAttribute") {
58
+ hasSpread = true;
59
+ continue;
60
+ }
61
+ if (attr.type === "BindDirective") {
62
+ const expr = attr.expression;
63
+ if (expr) props.set(attr.name, {
64
+ kind: "expr",
65
+ raw: source.slice(expr.start, expr.end).trim()
66
+ });
67
+ continue;
68
+ }
69
+ if (attr.type !== "Attribute") continue;
70
+ const name = attr.name;
71
+ const value = attr.value;
72
+ if (value === true) props.set(name, { kind: "bool" });
73
+ else if (Array.isArray(value)) if (value.length === 0) props.set(name, {
74
+ kind: "string",
75
+ value: ""
76
+ });
77
+ else {
78
+ const first = value[0];
79
+ const last = value[value.length - 1];
80
+ props.set(name, {
81
+ kind: "string",
82
+ value: source.slice(first.start, last.end)
83
+ });
84
+ }
85
+ else if (value && typeof value === "object" && value.type === "ExpressionTag") {
86
+ const expr = value.expression;
87
+ props.set(name, {
88
+ kind: "expr",
89
+ raw: source.slice(expr.start, expr.end).trim()
90
+ });
91
+ if (source[attr.start] === "{") hasSpread = true;
92
+ }
93
+ }
94
+ return {
95
+ name: node.name,
96
+ props,
97
+ hasSpread
98
+ };
99
+ }
100
+ /**
101
+ * Return a one-line message if `source` is not valid Svelte, else null. Lets
102
+ * the validator surface a real syntax error itself rather than only failing
103
+ * later in the compiler (and the compile-less CLI would otherwise miss it).
104
+ */
105
+ function getParseError(source) {
106
+ return parseRoot(source).error;
107
+ }
108
+ /**
109
+ * Find every question/media component in a `.svelte` source, anywhere in the
110
+ * markup, with its props. Returns null if the source can't be parsed — callers
111
+ * then skip component validation, matching the old "skip when unsure" stance.
112
+ */
113
+ function findComponents(source, names) {
114
+ const { root } = parseRoot(source);
115
+ if (!root) return null;
116
+ return collectComponents(root, names).map((node) => readProps(source, node));
117
+ }
118
+ const TsParser = Parser.extend(tsPlugin());
119
+ function parseJsModule(source) {
120
+ try {
121
+ return TsParser.parse(source, {
122
+ ecmaVersion: "latest",
123
+ sourceType: "module"
124
+ });
125
+ } catch {
126
+ return null;
127
+ }
128
+ }
129
+ function unwrapTsCast(node) {
130
+ let current = node;
131
+ while (current && (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSTypeAssertion" || current.type === "TSNonNullExpression")) current = current.expression ?? null;
132
+ return current;
133
+ }
134
+ function findPageConfigInProgram(program, source) {
135
+ const body = program.body ?? [];
136
+ for (const node of body) {
137
+ if (node.type !== "ExportNamedDeclaration") continue;
138
+ const declaration = node.declaration;
139
+ if (!declaration || declaration.type !== "VariableDeclaration") continue;
140
+ for (const decl of declaration.declarations) {
141
+ const id = decl.id;
142
+ if (id.type !== "Identifier" || id.name !== "pageConfig") continue;
143
+ const init = unwrapTsCast(decl.init);
144
+ if (init && init.type === "ObjectExpression") return {
145
+ kind: "literal",
146
+ text: source.slice(init.start, init.end)
147
+ };
148
+ return { kind: "invalid" };
149
+ }
150
+ }
151
+ return { kind: "none" };
152
+ }
153
+ /**
154
+ * Locate the `export default { ... }` object literal in a plain JS source.
155
+ * Returns a discriminated result so callers can tell parse failure from a
156
+ * missing or non-literal default export.
157
+ */
158
+ function defaultExportObjectLiteral(jsSource) {
159
+ const program = parseJsModule(jsSource);
160
+ if (!program) return { kind: "parse-error" };
161
+ for (const node of program.body ?? []) {
162
+ if (node.type !== "ExportDefaultDeclaration") continue;
163
+ const decl = unwrapTsCast(node.declaration ?? null);
164
+ if (decl && decl.type === "ObjectExpression") return {
165
+ kind: "literal",
166
+ text: jsSource.slice(decl.start, decl.end)
167
+ };
168
+ return { kind: "invalid" };
169
+ }
170
+ return { kind: "none" };
171
+ }
172
+ const MODULE_SCRIPT_OPEN_RE = /<script\s+(?:context\s*=\s*["']module["']|module)[^>]*>/;
173
+ const SCRIPT_CLOSE = "<\/script>";
174
+ function pageConfigFromModuleScriptFallback(svelteSource) {
175
+ const open = svelteSource.match(MODULE_SCRIPT_OPEN_RE);
176
+ if (!open || open.index === void 0) return { kind: "none" };
177
+ const bodyStart = open.index + open[0].length;
178
+ let from = bodyStart;
179
+ while (true) {
180
+ const closeIdx = svelteSource.indexOf(SCRIPT_CLOSE, from);
181
+ if (closeIdx < 0) return { kind: "none" };
182
+ const body = svelteSource.slice(bodyStart, closeIdx);
183
+ const program = parseJsModule(body);
184
+ if (program) return findPageConfigInProgram(program, body);
185
+ from = closeIdx + 9;
186
+ }
187
+ }
188
+ /**
189
+ * Locate `export const pageConfig = { ... }` in a Svelte page's module script
190
+ * and return the object-literal text. Walks the page-level AST so TypeScript
191
+ * (`lang="ts"`) module scripts are handled by Svelte's own parser.
192
+ */
193
+ function pageConfigLiteral(svelteSource) {
194
+ const { root } = parseRoot(svelteSource);
195
+ if (root) {
196
+ const program = root.module?.content;
197
+ if (!program) return { kind: "none" };
198
+ return findPageConfigInProgram(program, svelteSource);
199
+ }
200
+ return pageConfigFromModuleScriptFallback(svelteSource);
201
+ }
202
+ //#endregion
4
203
  //#region src/plugin/manifest.ts
5
204
  /** Append `.svelte` if not already present. Both bare and suffixed names are accepted in author config. */
6
205
  function ensureSvelteSuffix(name) {
@@ -38,23 +237,14 @@ function deriveSlug(name, isFile = false) {
38
237
  if (isFile) return basename(name, extname(name));
39
238
  return stripPrefix(name);
40
239
  }
41
- /** Matches both Svelte 5 `<script module>` and legacy `<script context="module">`. */
42
- const MODULE_SCRIPT_RE = /<script\s+(?:context\s*=\s*["']module["']|module)[^>]*>([\s\S]*?)<\/script>/;
43
- /** Matches `export const pageConfig =` (RHS is read separately). */
44
- const PAGE_CONFIG_EXPORT_RE = /export\s+const\s+pageConfig\s*=\s*/;
45
- /** Matches `export default ` (RHS is read separately). */
46
- const DEFAULT_EXPORT_RE = /export\s+default\s*/;
47
240
  /**
48
- * Locate `export default { ... }` and return the object literal substring,
49
- * or null if no balanced object literal follows the `export default` keyword.
50
- * Used by both manifest extraction and project validation.
241
+ * Locate `export default { ... }` and return its object-literal text. Returns
242
+ * a discriminated result so callers can tell parse failure from a missing or
243
+ * non-literal default export. Used by both manifest extraction and project
244
+ * validation.
51
245
  */
52
246
  function extractDefaultExportObjectLiteral(source) {
53
- const match = source.match(DEFAULT_EXPORT_RE);
54
- if (!match || match.index === void 0) return null;
55
- const startIndex = source.indexOf("{", match.index);
56
- if (startIndex < 0) return null;
57
- return extractObjectLiteral(source, startIndex);
247
+ return defaultExportObjectLiteral(source);
58
248
  }
59
249
  /**
60
250
  * Read and JSON5-parse the `export default { ... }` literal from a project's
@@ -69,15 +259,19 @@ function readCourseConfig(projectRoot) {
69
259
  ok: false,
70
260
  reason: "missing"
71
261
  };
72
- const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
73
- if (!objectStr) return {
262
+ const result = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
263
+ if (result.kind === "parse-error") return {
264
+ ok: false,
265
+ reason: "parse-error"
266
+ };
267
+ if (result.kind !== "literal") return {
74
268
  ok: false,
75
269
  reason: "no-export"
76
270
  };
77
271
  try {
78
272
  return {
79
273
  ok: true,
80
- config: JSON5.parse(objectStr)
274
+ config: JSON5.parse(result.text)
81
275
  };
82
276
  } catch (error) {
83
277
  return {
@@ -94,30 +288,23 @@ function readCourseConfig(projectRoot) {
94
288
  */
95
289
  function readMetaFile(metaPath) {
96
290
  if (!existsSync(metaPath)) return {};
97
- const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
98
- if (!objectStr) return {};
291
+ const result = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
292
+ if (result.kind !== "literal") return {};
99
293
  try {
100
- return JSON5.parse(objectStr);
294
+ return JSON5.parse(result.text);
101
295
  } catch {
102
296
  return {};
103
297
  }
104
298
  }
105
299
  /** Source-level pageConfig extraction shared by manifest generation and build-time validation. */
106
300
  function parsePageConfigFromSource(content) {
107
- const moduleScriptMatch = content.match(MODULE_SCRIPT_RE);
108
- if (!moduleScriptMatch) return { kind: "none" };
109
- const scriptContent = moduleScriptMatch[1];
110
- const configMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
111
- if (!configMatch || configMatch.index === void 0) return { kind: "none" };
112
- if (!scriptContent.slice(configMatch.index + configMatch[0].length).trimStart().startsWith("{")) return { kind: "invalid" };
113
- const startIndex = scriptContent.indexOf("{", configMatch.index + configMatch[0].length);
114
- if (startIndex < 0) return { kind: "invalid" };
115
- const objectStr = extractObjectLiteral(scriptContent, startIndex);
116
- if (!objectStr) return { kind: "invalid" };
301
+ const literal = pageConfigLiteral(content);
302
+ if (literal.kind === "none") return { kind: "none" };
303
+ if (literal.kind === "invalid") return { kind: "invalid" };
117
304
  try {
118
305
  return {
119
306
  kind: "ok",
120
- value: JSON5.parse(objectStr)
307
+ value: JSON5.parse(literal.text)
121
308
  };
122
309
  } catch {
123
310
  return { kind: "invalid" };
@@ -131,54 +318,6 @@ function extractPageConfig(filePath) {
131
318
  return {};
132
319
  }
133
320
  /**
134
- * Extract a balanced `{...}` or `[...]` span starting at the opening bracket,
135
- * skipping strings and comments. Returns the substring (inclusive) or null if
136
- * the open char is wrong or no matching close is found. Shared by manifest
137
- * extraction, _meta/pageConfig parsing, and the validator's tag-prop parser.
138
- */
139
- function extractObjectLiteral(source, startIndex) {
140
- const open = source[startIndex];
141
- if (open !== "{" && open !== "[") return null;
142
- let depth = 0;
143
- let inString = null;
144
- let escaped = false;
145
- for (let i = startIndex; i < source.length; i++) {
146
- const char = source[i];
147
- if (escaped) {
148
- escaped = false;
149
- continue;
150
- }
151
- if (char === "\\" && inString) {
152
- escaped = true;
153
- continue;
154
- }
155
- if (inString) {
156
- if (char === inString) inString = null;
157
- continue;
158
- }
159
- if (char === "\"" || char === "'" || char === "`") {
160
- inString = char;
161
- continue;
162
- }
163
- if (char === "/" && i + 1 < source.length && source[i + 1] === "/") {
164
- const newline = source.indexOf("\n", i);
165
- i = newline === -1 ? source.length : newline;
166
- continue;
167
- }
168
- if (char === "/" && i + 1 < source.length && source[i + 1] === "*") {
169
- const end = source.indexOf("*/", i + 2);
170
- i = end === -1 ? source.length : end + 1;
171
- continue;
172
- }
173
- if (char === "{" || char === "[") depth++;
174
- if (char === "}" || char === "]") {
175
- depth--;
176
- if (depth === 0) return source.slice(startIndex, i + 1);
177
- }
178
- }
179
- return null;
180
- }
181
- /**
182
321
  * Get sorted subdirectories of a given path.
183
322
  */
184
323
  function getSortedDirs(dirPath) {
@@ -195,35 +334,70 @@ function getSvelteFiles(dirPath) {
195
334
  return readdirSync(dirPath).filter((name) => name.endsWith(".svelte")).sort();
196
335
  }
197
336
  /**
337
+ * Enumerate the course's section → lesson → file structure. Section-level
338
+ * `.svelte` files become an implicit flat lesson (`name: null`) ordered before
339
+ * the section's explicit lesson directories. Shared by manifest generation and
340
+ * build-time validation so the two never disagree on which files are pages.
341
+ */
342
+ function walkPages(pagesDir) {
343
+ const sections = [];
344
+ for (const sectionName of getSortedDirs(pagesDir)) {
345
+ const dir = resolve(pagesDir, sectionName);
346
+ const metaPath = resolve(dir, "_meta.js");
347
+ const lessons = [];
348
+ const flatFiles = getSvelteFiles(dir);
349
+ if (flatFiles.length > 0) lessons.push({
350
+ name: null,
351
+ dir,
352
+ metaPath,
353
+ files: flatFiles
354
+ });
355
+ for (const lessonName of getSortedDirs(dir)) {
356
+ const lessonDir = resolve(dir, lessonName);
357
+ lessons.push({
358
+ name: lessonName,
359
+ dir: lessonDir,
360
+ metaPath: resolve(lessonDir, "_meta.js"),
361
+ files: getSvelteFiles(lessonDir)
362
+ });
363
+ }
364
+ sections.push({
365
+ name: sectionName,
366
+ dir,
367
+ metaPath,
368
+ lessons
369
+ });
370
+ }
371
+ return sections;
372
+ }
373
+ /**
198
374
  * Generate a course manifest by scanning the pages/ directory.
199
375
  */
200
376
  function generateManifest(pagesDir) {
377
+ clearParseCache();
201
378
  const sections = [];
202
379
  const flatPages = [];
203
380
  let pageIndex = 0;
204
- const sectionDirs = getSortedDirs(pagesDir);
205
- for (const sectionName of sectionDirs) {
206
- const sectionPath = resolve(pagesDir, sectionName);
207
- const sectionMeta = readMetaFile(resolve(sectionPath, "_meta.js"));
208
- const sectionSlug = deriveSlug(sectionName);
381
+ for (const walkedSection of walkPages(pagesDir)) {
382
+ const sectionMeta = readMetaFile(walkedSection.metaPath);
383
+ const sectionSlug = deriveSlug(walkedSection.name);
209
384
  const section = {
210
385
  title: sectionMeta.title || titleCase(sectionSlug),
211
386
  slug: sectionSlug,
212
387
  lessons: []
213
388
  };
214
- const lessonDirs = getSortedDirs(sectionPath);
215
- for (const lessonName of lessonDirs) {
216
- const lessonPath = resolve(sectionPath, lessonName);
217
- const lessonMeta = readMetaFile(resolve(lessonPath, "_meta.js"));
218
- const lessonSlug = deriveSlug(lessonName);
389
+ for (const walkedLesson of walkedSection.lessons) {
390
+ const isFlat = walkedLesson.name === null;
391
+ const lessonMeta = isFlat ? sectionMeta : readMetaFile(walkedLesson.metaPath);
392
+ const lessonSlug = isFlat ? sectionSlug : deriveSlug(walkedLesson.name);
393
+ const relDir = isFlat ? `/pages/${walkedSection.name}` : `/pages/${walkedSection.name}/${walkedLesson.name}`;
219
394
  const lesson = {
220
- title: lessonMeta.title || titleCase(lessonSlug),
395
+ title: isFlat ? "" : lessonMeta.title || titleCase(lessonSlug),
221
396
  slug: lessonSlug,
222
397
  pages: []
223
398
  };
224
- const orderedFiles = orderPageFiles(getSvelteFiles(lessonPath), lessonMeta.pages);
225
- for (const fileName of orderedFiles) {
226
- const filePath = resolve(lessonPath, fileName);
399
+ for (const fileName of orderPageFiles(walkedLesson.files, lessonMeta.pages)) {
400
+ const filePath = resolve(walkedLesson.dir, fileName);
227
401
  const pageSlug = deriveSlug(fileName, true);
228
402
  let pageConfig = {};
229
403
  try {
@@ -231,12 +405,11 @@ function generateManifest(pagesDir) {
231
405
  } catch (e) {
232
406
  console.warn(`[tessera warning] ${e.message}`);
233
407
  }
234
- const relativePath = `/pages/${sectionName}/${lessonName}/${fileName}`;
235
408
  const page = {
236
409
  index: pageIndex,
237
410
  title: pageConfig.title || titleCase(pageSlug),
238
411
  slug: pageSlug,
239
- importPath: relativePath,
412
+ importPath: `${relDir}/${fileName}`,
240
413
  quiz: pageConfig.quiz || null,
241
414
  ...pageConfig.completesOn === "view" ? { completesOn: "view" } : {}
242
415
  };
@@ -336,6 +509,21 @@ function validateAuthCredential(auth) {
336
509
  return null;
337
510
  }
338
511
  //#endregion
512
+ //#region src/runtime/xapi/derive-actor.ts
513
+ /**
514
+ * Origin of an http(s) URL, else null. Shared with the config validator, which
515
+ * predicts this result to know when `actorAccountHomePage` becomes required —
516
+ * one helper keeps the two in lockstep.
517
+ */
518
+ function httpOrigin(url) {
519
+ try {
520
+ const parsed = new URL(url);
521
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null;
522
+ } catch {
523
+ return null;
524
+ }
525
+ }
526
+ //#endregion
339
527
  //#region src/runtime/interaction-format.ts
340
528
  /**
341
529
  * SCORM `short_identifier_type` / `CMIIdentifier`: alphanumerics +
@@ -554,6 +742,7 @@ const VALID_RETRY_MODES = RETRY_MODES;
554
742
  * Returns errors (block build) and warnings (informational).
555
743
  */
556
744
  function validateProject(projectRoot) {
745
+ clearParseCache();
557
746
  const errors = [];
558
747
  const warnings = [];
559
748
  if (!existsSync(resolve(projectRoot, "course.config.js"))) {
@@ -582,8 +771,8 @@ function validateProject(projectRoot) {
582
771
  function parseConfig(projectRoot, errors, warnings) {
583
772
  const read = readCourseConfig(projectRoot);
584
773
  if (!read.ok) {
585
- if (read.reason === "no-export") errors.push("course.config.js: could not parse — must use `export default { ... }` syntax");
586
- else if (read.reason === "parse-error") errors.push("course.config.js: syntax errormust export a static object literal");
774
+ if (read.reason === "no-export") errors.push("course.config.js: must use `export default { ... }` syntax");
775
+ else if (read.reason === "parse-error") errors.push("course.config.js: could not parse JavaScript syntax error");
587
776
  return null;
588
777
  }
589
778
  const config = read.config;
@@ -635,9 +824,12 @@ function isPlausibleColor(value) {
635
824
  * on primaryColor. Runtime failures are mild: an unresolved logo ships a broken
636
825
  * <img src>, an unparseable color falls back to theme defaults.
637
826
  */
827
+ function describeType(raw) {
828
+ return raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw;
829
+ }
638
830
  function validateBranding(raw, warnings) {
639
831
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
640
- warnings.push(`course.config.js: "branding" must be an object, got ${raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw} — will be ignored`);
832
+ warnings.push(`course.config.js: "branding" must be an object, got ${describeType(raw)} — will be ignored`);
641
833
  return;
642
834
  }
643
835
  const branding = raw;
@@ -659,7 +851,7 @@ function validateBranding(raw, warnings) {
659
851
  /** Shape-check the `a11y` block. Malformed values can't be silenced by `ignore`. */
660
852
  function validateA11yConfig(raw, errors) {
661
853
  if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
662
- errors.push(`course.config.js: "a11y" must be an object, got ${raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw}`);
854
+ errors.push(`course.config.js: "a11y" must be an object, got ${describeType(raw)}`);
663
855
  return;
664
856
  }
665
857
  const a11y = raw;
@@ -766,16 +958,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
766
958
  if (actor !== void 0) warnings.push(`course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`);
767
959
  if (standard === "cmi5" || standard === "web") warnings.push(`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`);
768
960
  }
769
- if (actor === void 0 && (standard === "scorm12" || standard === "scorm2004") && typeof activityId === "string") {
770
- let isHttp;
771
- try {
772
- const u = new URL(activityId);
773
- isHttp = u.protocol === "http:" || u.protocol === "https:";
774
- } catch {
775
- isHttp = false;
776
- }
777
- if (!isHttp && aahp === void 0) errors.push(`course.config.js: ${label}.activityId is not an http(s) URL, so its origin can't be used as the SCORM actor's account.homePage. Provide ${label}.actorAccountHomePage explicitly.`);
778
- }
961
+ if (actor === void 0 && (standard === "scorm12" || standard === "scorm2004") && typeof activityId === "string" && httpOrigin(activityId) === null && aahp === void 0) errors.push(`course.config.js: ${label}.activityId is not an http(s) URL, so its origin can't be used as the SCORM actor's account.homePage. Provide ${label}.actorAccountHomePage explicitly.`);
779
962
  const registration = entry.registration;
780
963
  if (registration !== void 0) {
781
964
  if (typeof registration !== "string" || !UUID_RE.test(registration)) errors.push(`course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`);
@@ -790,6 +973,22 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
790
973
  function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, warnings, assetExistsCache, exportStandard) {
791
974
  const fileRel = relative(projectRoot, filePath);
792
975
  const content = readSourceFileCached(filePath);
976
+ const parseError = getParseError(content);
977
+ if (parseError) {
978
+ errors.push(`${fileRel}: could not parse — ${parseError}`);
979
+ return {
980
+ page: {
981
+ fileRel,
982
+ navIndex,
983
+ hasGradedQuiz: false,
984
+ hasQuiz: false,
985
+ completesOnView: false
986
+ },
987
+ isQuiz: false,
988
+ isGradedQuiz: false,
989
+ parseError: true
990
+ };
991
+ }
793
992
  const pageConfig = validatePageConfig(content, fileRel, errors);
794
993
  const isQuiz = !!pageConfig?.quiz;
795
994
  let isGradedQuiz = false;
@@ -813,7 +1012,8 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
813
1012
  completesOnView
814
1013
  },
815
1014
  isQuiz,
816
- isGradedQuiz
1015
+ isGradedQuiz,
1016
+ parseError: false
817
1017
  };
818
1018
  }
819
1019
  function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
@@ -823,117 +1023,79 @@ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
823
1023
  let totalPages = 0;
824
1024
  let totalQuizzes = 0;
825
1025
  let hasGradedQuiz = false;
1026
+ let hasParseErrors = false;
826
1027
  const assetExistsCache = /* @__PURE__ */ new Map();
827
- if (!existsSync(pagesDir)) {
1028
+ const noPages = () => {
828
1029
  errors.push("No pages found. Create at least one section with a lesson and page in pages/");
829
1030
  return {
830
1031
  errors,
831
1032
  warnings,
832
- totalPages: 0,
833
- totalQuizzes: 0,
834
- hasGradedQuiz: false,
1033
+ totalPages,
1034
+ totalQuizzes,
1035
+ hasGradedQuiz,
1036
+ hasParseErrors,
835
1037
  pages
836
1038
  };
837
- }
838
- const topLevelEntries = readdirSync(pagesDir);
839
- for (const entry of topLevelEntries) {
1039
+ };
1040
+ if (!existsSync(pagesDir)) return noPages();
1041
+ for (const entry of readdirSync(pagesDir)) {
840
1042
  const fullPath = resolve(pagesDir, entry);
841
- if (entry.endsWith(".svelte") && statSync(fullPath).isFile()) {
842
- const relPath = relative(projectRoot, fullPath);
843
- warnings.push(`${relPath}: this file is outside the section/lesson structure and will be ignored`);
844
- }
1043
+ if (entry.endsWith(".svelte") && statSync(fullPath).isFile()) warnings.push(`${relative(projectRoot, fullPath)}: this file is outside the section/lesson structure and will be ignored`);
845
1044
  }
846
- const sectionDirs = topLevelEntries.filter((name) => {
847
- return statSync(resolve(pagesDir, name)).isDirectory() && !name.startsWith(".");
848
- }).sort();
849
- if (sectionDirs.length === 0) {
850
- errors.push("No pages found. Create at least one section with a lesson and page in pages/");
851
- return {
852
- errors,
853
- warnings,
854
- totalPages: 0,
855
- totalQuizzes: 0,
856
- hasGradedQuiz: false,
857
- pages
858
- };
859
- }
860
- for (const sectionName of sectionDirs) {
861
- const sectionPath = resolve(pagesDir, sectionName);
862
- const sectionRel = relative(projectRoot, sectionPath);
863
- const pagesBeforeSection = totalPages;
864
- const sectionMeta = validateMetaFile(resolve(sectionPath, "_meta.js"), sectionRel, errors);
865
- const sectionEntries = readdirSync(sectionPath);
866
- const sectionSvelteFiles = sectionEntries.filter((name) => {
867
- const full = resolve(sectionPath, name);
868
- return name.endsWith(".svelte") && statSync(full).isFile();
869
- }).sort();
870
- if (sectionMeta?.pages) for (const pageName of sectionMeta.pages) {
1045
+ const sections = walkPages(pagesDir);
1046
+ if (sections.length === 0) return noPages();
1047
+ const validateLesson = (lesson, meta) => {
1048
+ if (meta?.pages) for (const pageName of meta.pages) {
871
1049
  const fileName = ensureSvelteSuffix(pageName);
872
- if (!sectionSvelteFiles.includes(fileName)) {
873
- const metaRel = relative(projectRoot, resolve(sectionPath, "_meta.js"));
874
- errors.push(`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
875
- }
1050
+ if (!lesson.files.includes(fileName)) errors.push(`${relative(projectRoot, lesson.metaPath)}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
876
1051
  }
877
- for (const fileName of sectionSvelteFiles) {
878
- const result = validatePageFile(resolve(sectionPath, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache, exportStandard);
1052
+ if (meta?.pages && meta.pages.length > 0) {
1053
+ const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
1054
+ for (const file of lesson.files) if (!listedSet.has(file)) warnings.push(`${relative(projectRoot, resolve(lesson.dir, file))}: not listed in _meta.js pages array — will be appended at end`);
1055
+ }
1056
+ for (const fileName of orderPageFiles(lesson.files, meta?.pages)) {
1057
+ const result = validatePageFile(resolve(lesson.dir, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache, exportStandard);
879
1058
  totalPages++;
880
1059
  if (result.isQuiz) totalQuizzes++;
881
1060
  if (result.isGradedQuiz) hasGradedQuiz = true;
1061
+ if (result.parseError) hasParseErrors = true;
882
1062
  pages.push(result.page);
883
1063
  }
884
- const lessonDirs = sectionEntries.filter((name) => {
885
- return statSync(resolve(sectionPath, name)).isDirectory() && !name.startsWith(".");
886
- }).sort();
887
- for (const lessonName of lessonDirs) {
888
- const lessonPath = resolve(sectionPath, lessonName);
889
- const lessonRel = relative(projectRoot, lessonPath);
890
- const meta = validateMetaFile(resolve(lessonPath, "_meta.js"), lessonRel, errors);
891
- const svelteFiles = readdirSync(lessonPath).filter((name) => name.endsWith(".svelte")).sort();
892
- if (meta?.pages) for (const pageName of meta.pages) {
893
- const fileName = ensureSvelteSuffix(pageName);
894
- if (!svelteFiles.includes(fileName)) {
895
- const metaRel = relative(projectRoot, resolve(lessonPath, "_meta.js"));
896
- errors.push(`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
897
- }
898
- }
899
- if (meta?.pages && meta.pages.length > 0) {
900
- const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
901
- for (const file of svelteFiles) if (!listedSet.has(file)) {
902
- const relPath = relative(projectRoot, resolve(lessonPath, file));
903
- warnings.push(`${relPath}: not listed in _meta.js pages array — will be appended at end`);
904
- }
905
- }
906
- for (const fileName of svelteFiles) {
907
- const result = validatePageFile(resolve(lessonPath, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache, exportStandard);
908
- totalPages++;
909
- if (result.isQuiz) totalQuizzes++;
910
- if (result.isGradedQuiz) hasGradedQuiz = true;
911
- pages.push(result.page);
912
- }
913
- }
1064
+ };
1065
+ for (const section of sections) {
1066
+ const sectionRel = relative(projectRoot, section.dir);
1067
+ const pagesBeforeSection = totalPages;
1068
+ const sectionMeta = validateMetaFile(section.metaPath, sectionRel, errors);
1069
+ for (const lesson of section.lessons) if (lesson.name === null) validateLesson(lesson, sectionMeta);
1070
+ else validateLesson(lesson, validateMetaFile(lesson.metaPath, relative(projectRoot, lesson.dir), errors));
914
1071
  if (totalPages === pagesBeforeSection) warnings.push(`${sectionRel}: section contributed no pages and will be empty`);
915
1072
  }
916
- if (totalPages === 0) errors.push("No pages found. Create at least one section with a lesson and page in pages/");
1073
+ if (totalPages === 0) return noPages();
917
1074
  return {
918
1075
  errors,
919
1076
  warnings,
920
1077
  totalPages,
921
1078
  totalQuizzes,
922
1079
  hasGradedQuiz,
1080
+ hasParseErrors,
923
1081
  pages
924
1082
  };
925
1083
  }
926
1084
  function validateMetaFile(metaPath, parentRel, errors) {
927
1085
  if (!existsSync(metaPath)) return null;
928
1086
  const metaRel = `${parentRel}/_meta.js`;
929
- const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
930
- if (!objectStr) {
1087
+ const result = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
1088
+ if (result.kind === "parse-error") {
1089
+ errors.push(`${metaRel}: could not parse — JavaScript syntax error`);
1090
+ return null;
1091
+ }
1092
+ if (result.kind !== "literal") {
931
1093
  errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
932
1094
  return null;
933
1095
  }
934
1096
  let meta;
935
1097
  try {
936
- meta = JSON5.parse(objectStr);
1098
+ meta = JSON5.parse(result.text);
937
1099
  } catch {
938
1100
  errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
939
1101
  return null;
@@ -979,66 +1141,6 @@ const QUESTION_COMPONENT_REQUIRED = {
979
1141
  "correct"
980
1142
  ]
981
1143
  };
982
- /**
983
- * Parse the props of an opening tag starting just after the component name.
984
- * Returns null if the tag can't be parsed cleanly — callers then skip it
985
- * rather than risk a false positive.
986
- */
987
- function parseTagProps(content, start) {
988
- const props = /* @__PURE__ */ new Map();
989
- let hasSpread = false;
990
- let i = start;
991
- while (i < content.length) {
992
- while (i < content.length && /\s/.test(content[i])) i++;
993
- if (i >= content.length) return null;
994
- const c = content[i];
995
- if (c === ">") return {
996
- props,
997
- hasSpread
998
- };
999
- if (c === "/" && content[i + 1] === ">") return {
1000
- props,
1001
- hasSpread
1002
- };
1003
- if (c === "{") {
1004
- const block = extractObjectLiteral(content, i);
1005
- if (!block) return null;
1006
- hasSpread = true;
1007
- i += block.length;
1008
- continue;
1009
- }
1010
- const nameMatch = /^[A-Za-z_][\w-]*/.exec(content.slice(i));
1011
- if (!nameMatch) return null;
1012
- const propName = nameMatch[0];
1013
- i += propName.length;
1014
- while (i < content.length && /\s/.test(content[i])) i++;
1015
- if (content[i] !== "=") {
1016
- props.set(propName, { kind: "bool" });
1017
- continue;
1018
- }
1019
- i++;
1020
- while (i < content.length && /\s/.test(content[i])) i++;
1021
- const v = content[i];
1022
- if (v === "\"" || v === "'") {
1023
- const end = content.indexOf(v, i + 1);
1024
- if (end === -1) return null;
1025
- props.set(propName, {
1026
- kind: "string",
1027
- value: content.slice(i + 1, end)
1028
- });
1029
- i = end + 1;
1030
- } else if (v === "{") {
1031
- const block = extractObjectLiteral(content, i);
1032
- if (!block) return null;
1033
- props.set(propName, {
1034
- kind: "expr",
1035
- raw: block.slice(1, -1).trim()
1036
- });
1037
- i += block.length;
1038
- } else return null;
1039
- }
1040
- return null;
1041
- }
1042
1144
  function staticArray(prop) {
1043
1145
  if (prop?.kind !== "expr" || !prop.raw.startsWith("[")) return null;
1044
1146
  try {
@@ -1058,16 +1160,11 @@ function staticNumber(prop) {
1058
1160
  }
1059
1161
  }
1060
1162
  function validateQuestionComponents(content, fileRel, errors, warnings, exportStandard) {
1061
- const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join("|");
1062
- const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, "g");
1163
+ const components = findComponents(content, new Set(Object.keys(QUESTION_COMPONENT_REQUIRED)));
1164
+ if (!components) return;
1063
1165
  const seenIds = /* @__PURE__ */ new Set();
1064
1166
  const seenSanitized = /* @__PURE__ */ new Set();
1065
- let m;
1066
- while ((m = tagStartRe.exec(content)) !== null) {
1067
- const name = m[1];
1068
- const parsed = parseTagProps(content, m.index + m[0].length);
1069
- if (!parsed) continue;
1070
- const { props, hasSpread } = parsed;
1167
+ for (const { name, props, hasSpread } of components) {
1071
1168
  for (const req of QUESTION_COMPONENT_REQUIRED[name]) if (!hasSpread && !props.has(req)) errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
1072
1169
  for (const labelProp of ["options", "answers"]) if (staticArray(props.get(labelProp))?.some((e) => typeof e === "string" && e.trim() === "")) warnings.push(tag(A11Y_IDS.questionLabel, `${fileRel}: <${name}> has an empty ${labelProp === "options" ? "option" : "answer"} label`));
1073
1170
  const idProp = props.get("id");
@@ -1125,20 +1222,31 @@ function validateQuestionComponents(content, fileRel, errors, warnings, exportSt
1125
1222
  }
1126
1223
  /** Remove HTML/Svelte comments so commented-out markup isn't scanned as live. */
1127
1224
  const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
1225
+ const SCRIPT_STYLE_RE = /<(script|style)\b[\s\S]*?<\/\1>/gi;
1226
+ function stripRepeated(input, patterns) {
1227
+ let out = input;
1228
+ for (const pattern of patterns) {
1229
+ let prev;
1230
+ do {
1231
+ prev = out;
1232
+ out = out.replace(pattern, "");
1233
+ } while (out !== prev);
1234
+ }
1235
+ return out;
1236
+ }
1128
1237
  /**
1129
1238
  * Sibling to validateQuestionComponents kept out of QUESTION_COMPONENT_REQUIRED
1130
1239
  * so media isn't treated as gradable questions. Declares `warnings` directly.
1131
1240
  * Non-static (kind 'expr') values are skipped, matching the rest of the linter.
1132
1241
  */
1133
1242
  function validateMediaComponents(content, fileRel, errors, warnings) {
1134
- const scan = content.replace(HTML_COMMENT_RE, "");
1135
- const tagStartRe = /<(Image|Video|Audio)(?=[\s/>])/g;
1136
- let m;
1137
- while ((m = tagStartRe.exec(scan)) !== null) {
1138
- const name = m[1];
1139
- const parsed = parseTagProps(scan, m.index + m[0].length);
1140
- if (!parsed) continue;
1141
- const { props, hasSpread } = parsed;
1243
+ const components = findComponents(content, new Set([
1244
+ "Image",
1245
+ "Video",
1246
+ "Audio"
1247
+ ]));
1248
+ if (!components) return;
1249
+ for (const { name, props, hasSpread } of components) {
1142
1250
  if (name === "Image") {
1143
1251
  const alt = props.get("alt");
1144
1252
  const decorative = props.get("decorative");
@@ -1170,7 +1278,7 @@ function validateMediaComponents(content, fileRel, errors, warnings) {
1170
1278
  * audit.
1171
1279
  */
1172
1280
  function validateHeadingOrder(content, fileRel, warnings) {
1173
- const levels = [...content.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, "").replace(HTML_COMMENT_RE, "").matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
1281
+ const levels = [...stripRepeated(content, [SCRIPT_STYLE_RE, HTML_COMMENT_RE]).matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
1174
1282
  let prevSeen = null;
1175
1283
  for (const level of levels) {
1176
1284
  if (prevSeen !== null && level - prevSeen > 1) warnings.push(tag(A11Y_IDS.headingOrder, `${fileRel}: heading level jumps from h${prevSeen} to h${level} — don't skip levels (WCAG 1.3.1)`));
@@ -1212,11 +1320,11 @@ function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
1212
1320
  }
1213
1321
  }
1214
1322
  function crossValidate(config, pageResults, errors, warnings) {
1215
- if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
1323
+ if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz && !pageResults.hasParseErrors) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
1216
1324
  if (config.completion?.mode === "quiz" && config.scoring?.passingScore === void 0) warnings.push("completion.mode is \"quiz\" but scoring.passingScore is not set — defaulting to 70%. Set it explicitly to be sure.");
1217
1325
  const isManual = config.completion?.mode === "manual";
1218
1326
  const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
1219
- if (isManual && config.completion?.trigger === "page" && completesOnPages.length === 0) errors.push("completion.mode is \"manual\" with trigger: \"page\", but no page declares pageConfig.completesOn: \"view\". Either add a completesOn page or remove the trigger field to drop the static check.");
1327
+ if (isManual && config.completion?.trigger === "page" && completesOnPages.length === 0 && !pageResults.hasParseErrors) errors.push("completion.mode is \"manual\" with trigger: \"page\", but no page declares pageConfig.completesOn: \"view\". Either add a completesOn page or remove the trigger field to drop the static check.");
1220
1328
  if (isManual) {
1221
1329
  for (const page of pageResults.pages) if (page.hasGradedQuiz) warnings.push(`${page.fileRel}: quiz.graded is true under completion.mode: "manual". The score will be reported to the LMS for transcripts, but it will not drive completion or success status — \`markComplete()\` / completesOn does. If that's not what you want, set graded: false or change completion.mode.`);
1222
1330
  }
@@ -1239,6 +1347,221 @@ function crossValidate(config, pageResults, errors, warnings) {
1239
1347
  }
1240
1348
  }
1241
1349
  //#endregion
1242
- export { validateProject as a, reportValidationIssues as i, isPlausibleLanguageTag as n, generateManifest as o, normalizeA11y as r, readCourseConfig as s, isIgnored as t };
1350
+ //#region src/plugin/a11y/audit.ts
1351
+ const IMPACT_RANK = {
1352
+ minor: 1,
1353
+ moderate: 2,
1354
+ serious: 3,
1355
+ critical: 4
1356
+ };
1357
+ const AUDIT_ENV_FLAG = "TESSERA_A11Y_AUDIT";
1358
+ /** Map the `a11y.standard` enum to axe's cumulative `runOnly` tag list. */
1359
+ function axeTags(standard) {
1360
+ switch (standard) {
1361
+ case "wcag2a": return ["wcag2a"];
1362
+ case "wcag21aa": return [
1363
+ "wcag2a",
1364
+ "wcag2aa",
1365
+ "wcag21a",
1366
+ "wcag21aa"
1367
+ ];
1368
+ default: return ["wcag2a", "wcag2aa"];
1369
+ }
1370
+ }
1371
+ /** axe-applicable ignore entries: drop the Tier-1a/1b namespaces. */
1372
+ function axeIgnoreRules(ignore) {
1373
+ return ignore.filter((id) => !id.startsWith("tessera/") && !id.startsWith("a11y_"));
1374
+ }
1375
+ function isMissingBrowserError(message) {
1376
+ return /Executable doesn't exist|playwright install/i.test(message);
1377
+ }
1378
+ function isFailing(v, thresholdRank) {
1379
+ return !v.impact || IMPACT_RANK[v.impact] >= thresholdRank;
1380
+ }
1381
+ async function tryImport(specifier) {
1382
+ return import(specifier);
1383
+ }
1384
+ async function loadDeps() {
1385
+ let chromium;
1386
+ for (const spec of ["playwright", "@playwright/test"]) try {
1387
+ const mod = await tryImport(spec);
1388
+ if (mod.chromium) {
1389
+ chromium = mod.chromium;
1390
+ break;
1391
+ }
1392
+ } catch {}
1393
+ if (!chromium) return {
1394
+ ok: false,
1395
+ missing: "playwright"
1396
+ };
1397
+ try {
1398
+ const mod = await tryImport("@axe-core/playwright");
1399
+ if (!mod.default) return {
1400
+ ok: false,
1401
+ missing: "@axe-core/playwright"
1402
+ };
1403
+ return {
1404
+ ok: true,
1405
+ deps: {
1406
+ chromium,
1407
+ AxeBuilder: mod.default
1408
+ }
1409
+ };
1410
+ } catch {
1411
+ return {
1412
+ ok: false,
1413
+ missing: "@axe-core/playwright"
1414
+ };
1415
+ }
1416
+ }
1417
+ /**
1418
+ * Run the Tier-2 runtime accessibility audit against a built course. Builds (or
1419
+ * reuses) dist/, serves it, drives Playwright + axe-core over each page, writes
1420
+ * a11y-report.json, and returns a process exit code (0 pass, 1 fail/error).
1421
+ */
1422
+ async function runAudit(projectRoot, options = {}) {
1423
+ const threshold = options.threshold ?? "serious";
1424
+ const deps = await loadDeps();
1425
+ if (!deps.ok) {
1426
+ 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 pnpm add -D playwright @axe-core/playwright\n pnpm exec playwright install chromium");
1427
+ return 1;
1428
+ }
1429
+ const { chromium, AxeBuilder } = deps.deps;
1430
+ const read = readCourseConfig(projectRoot);
1431
+ const settings = normalizeA11y(read.ok ? read.config.a11y : void 0);
1432
+ const tags = axeTags(settings.standard);
1433
+ const disableRules = axeIgnoreRules(settings.ignore);
1434
+ const manifest = generateManifest(resolve(projectRoot, "pages"));
1435
+ const vite = await import("vite");
1436
+ const { resolveTesseraConfig } = await import("./inline-config-DYHT51G8.js");
1437
+ const auditBaseConfig = await resolveTesseraConfig(projectRoot, {
1438
+ command: "build",
1439
+ mode: "production"
1440
+ });
1441
+ const auditDist = resolve(projectRoot, "node_modules", ".tessera-a11y");
1442
+ const distHtml = resolve(auditDist, "index.html");
1443
+ const prevEnv = process.env[AUDIT_ENV_FLAG];
1444
+ process.env[AUDIT_ENV_FLAG] = "1";
1445
+ let server;
1446
+ try {
1447
+ if (options.rebuild || !existsSync(distHtml)) {
1448
+ console.log("[tessera a11y] Building course…");
1449
+ await vite.build(vite.mergeConfig(auditBaseConfig, {
1450
+ build: {
1451
+ outDir: auditDist,
1452
+ emptyOutDir: true
1453
+ },
1454
+ logLevel: "warn"
1455
+ }));
1456
+ }
1457
+ server = await vite.preview({
1458
+ root: projectRoot,
1459
+ base: auditBaseConfig.base,
1460
+ build: { outDir: auditDist },
1461
+ preview: {
1462
+ port: 0,
1463
+ host: "127.0.0.1"
1464
+ },
1465
+ logLevel: "warn"
1466
+ });
1467
+ const baseUrl = server.resolvedUrls?.local?.[0];
1468
+ if (!baseUrl) {
1469
+ console.error("[tessera a11y] Could not determine preview server URL.");
1470
+ return 1;
1471
+ }
1472
+ let browser;
1473
+ try {
1474
+ browser = await chromium.launch();
1475
+ } catch (err) {
1476
+ if (isMissingBrowserError(err instanceof Error ? err.message : String(err))) {
1477
+ console.error("\x1B[31m[tessera a11y]\x1B[0m Chromium isn't installed for Playwright.\n Install it once:\n pnpm exec playwright install chromium");
1478
+ return 1;
1479
+ }
1480
+ throw err;
1481
+ }
1482
+ const pages = [];
1483
+ try {
1484
+ const page = await (await browser.newContext()).newPage();
1485
+ const auditUrl = new URL(baseUrl);
1486
+ auditUrl.searchParams.set("__tessera_audit", "1");
1487
+ await page.goto(auditUrl.href, { waitUntil: "networkidle" });
1488
+ await page.waitForSelector("#tessera-app", { timeout: 2e4 });
1489
+ const scan = async () => {
1490
+ const builder = new AxeBuilder({ page }).withTags(tags);
1491
+ if (disableRules.length > 0) builder.disableRules(disableRules);
1492
+ return (await builder.analyze()).violations.map((v) => ({
1493
+ id: v.id,
1494
+ impact: v.impact ?? null,
1495
+ help: v.help,
1496
+ helpUrl: v.helpUrl,
1497
+ nodes: v.nodes.length
1498
+ }));
1499
+ };
1500
+ const navCount = await page.locator("button.tessera-nav-page").count();
1501
+ if (navCount === 0) pages.push({
1502
+ index: 0,
1503
+ title: manifest.pages[0]?.title ?? "(entry)",
1504
+ violations: await scan()
1505
+ });
1506
+ else for (let i = 0; i < navCount; i++) {
1507
+ const btn = page.locator("button.tessera-nav-page").nth(i);
1508
+ const title = (await btn.textContent())?.trim() || `Page ${i + 1}`;
1509
+ await btn.click();
1510
+ await page.waitForFunction((idx) => document.querySelectorAll("button.tessera-nav-page")[idx]?.getAttribute("aria-current") === "page", i, { timeout: 2e4 });
1511
+ await page.waitForLoadState("networkidle");
1512
+ pages.push({
1513
+ index: i,
1514
+ title,
1515
+ violations: await scan()
1516
+ });
1517
+ }
1518
+ } finally {
1519
+ await browser.close();
1520
+ }
1521
+ const thresholdRank = IMPACT_RANK[threshold];
1522
+ let totalViolations = 0;
1523
+ let failingViolations = 0;
1524
+ for (const p of pages) for (const v of p.violations) {
1525
+ totalViolations++;
1526
+ if (isFailing(v, thresholdRank)) failingViolations++;
1527
+ }
1528
+ const report = {
1529
+ standard: settings.standard,
1530
+ threshold,
1531
+ pages,
1532
+ totalViolations,
1533
+ failingViolations,
1534
+ passed: failingViolations === 0
1535
+ };
1536
+ const reportPath = resolve(projectRoot, "a11y-report.json");
1537
+ writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf-8");
1538
+ printSummary(report, reportPath);
1539
+ return report.passed ? 0 : 1;
1540
+ } catch (err) {
1541
+ console.error(`\x1b[31m[tessera a11y]\x1b[0m Audit could not complete: ${err instanceof Error ? err.message : String(err)}`);
1542
+ return 1;
1543
+ } finally {
1544
+ server?.httpServer?.close?.();
1545
+ if (prevEnv === void 0) delete process.env[AUDIT_ENV_FLAG];
1546
+ else process.env[AUDIT_ENV_FLAG] = prevEnv;
1547
+ }
1548
+ }
1549
+ function printSummary(report, reportPath) {
1550
+ const thresholdRank = IMPACT_RANK[report.threshold];
1551
+ for (const p of report.pages) {
1552
+ if (p.violations.length === 0) {
1553
+ console.log(`\x1b[32m ✓\x1b[0m ${p.title}`);
1554
+ continue;
1555
+ }
1556
+ const mark = p.violations.some((v) => isFailing(v, thresholdRank)) ? "\x1B[31m ✗\x1B[0m" : "\x1B[33m ⚠\x1B[0m";
1557
+ console.log(`${mark} ${p.title}`);
1558
+ for (const v of p.violations) console.log(` [${v.impact ?? "n/a"}] ${v.id} — ${v.help} (${v.nodes} node${v.nodes === 1 ? "" : "s"})`);
1559
+ }
1560
+ console.log(`\n[tessera a11y] Report written to ${reportPath}`);
1561
+ if (report.passed) console.log(`\x1b[32m[tessera a11y] Passed\x1b[0m — ${report.totalViolations} total finding(s), none at/above "${report.threshold}".`);
1562
+ else console.log(`\x1b[31m[tessera a11y] Failed\x1b[0m — ${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total).`);
1563
+ }
1564
+ //#endregion
1565
+ export { normalizeA11y as a, generateManifest as c, isPlausibleLanguageTag as i, readCourseConfig as l, runAudit as n, reportValidationIssues as o, isIgnored as r, validateProject as s, AUDIT_ENV_FLAG as t };
1243
1566
 
1244
- //# sourceMappingURL=validation-B-xTvM9B.js.map
1567
+ //# sourceMappingURL=audit-CzKAXy3Y.js.map