tessera-learn 0.2.3 → 0.3.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 (45) hide show
  1. package/AGENTS.md +44 -20
  2. package/README.md +2 -2
  3. package/dist/{audit--fSWIOgK.js → audit-DkXqQTqn.js} +84 -27
  4. package/dist/audit-DkXqQTqn.js.map +1 -0
  5. package/dist/{build-commands-Qyrlsp3n.js → build-commands-CyzuCDXg.js} +2 -2
  6. package/dist/{build-commands-Qyrlsp3n.js.map → build-commands-CyzuCDXg.js.map} +1 -1
  7. package/dist/{inline-config-DqAKsCNl.js → inline-config-BEXyRqsJ.js} +2 -2
  8. package/dist/{inline-config-DqAKsCNl.js.map → inline-config-BEXyRqsJ.js.map} +1 -1
  9. package/dist/plugin/cli.d.ts.map +1 -1
  10. package/dist/plugin/cli.js +57 -46
  11. package/dist/plugin/cli.js.map +1 -1
  12. package/dist/plugin/index.d.ts +280 -1
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +3 -3
  15. package/dist/{plugin-B-aiL9-V.js → plugin-CFUFgwHB.js} +126 -83
  16. package/dist/plugin-CFUFgwHB.js.map +1 -0
  17. package/package.json +7 -7
  18. package/src/components/DefaultLayout.svelte +2 -5
  19. package/src/components/Quiz.svelte +18 -26
  20. package/src/plugin/ast.ts +9 -2
  21. package/src/plugin/cli.ts +45 -46
  22. package/src/plugin/csp.ts +59 -0
  23. package/src/plugin/duplicate-cli.ts +37 -1
  24. package/src/plugin/export.ts +56 -27
  25. package/src/plugin/index.ts +117 -61
  26. package/src/plugin/manifest.ts +3 -23
  27. package/src/plugin/new-cli.ts +2 -0
  28. package/src/plugin/validation.ts +48 -12
  29. package/src/runtime/App.svelte +10 -8
  30. package/src/runtime/Sidebar.svelte +3 -1
  31. package/src/runtime/adapters/cmi5.ts +59 -402
  32. package/src/runtime/adapters/discovery.ts +11 -0
  33. package/src/runtime/adapters/index.ts +27 -60
  34. package/src/runtime/adapters/lms-error.ts +61 -0
  35. package/src/runtime/adapters/scorm2004.ts +2 -1
  36. package/src/runtime/adapters/web.ts +19 -4
  37. package/src/runtime/adapters/xapi-launch-base.ts +346 -0
  38. package/src/runtime/adapters/xapi.ts +26 -0
  39. package/src/runtime/types.ts +19 -1
  40. package/src/runtime/xapi/publisher.ts +5 -1
  41. package/src/runtime/xapi/setup.ts +24 -15
  42. package/src/virtual.d.ts +4 -1
  43. package/templates/course/course.config.js +1 -0
  44. package/dist/audit--fSWIOgK.js.map +0 -1
  45. package/dist/plugin-B-aiL9-V.js.map +0 -1
package/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AGENTS.md: Tessera Course Authoring Guide
2
2
 
3
- Tessera is an LMS-tracking runtime for interactive learning content (SCORM 1.2 / SCORM 2004 4e / cmi5 / static web). It owns tracking, progress, completion/success rollup, persistence, and navigation gating. You own the presentation layer.
3
+ Tessera is an LMS-tracking runtime for interactive learning content (SCORM 1.2 / SCORM 2004 4e / cmi5 / xAPI 1.0.3 / static web). It owns tracking, progress, completion/success rollup, persistence, and navigation gating. You own the presentation layer.
4
4
 
5
5
  This is the canonical reference for authoring a Tessera course. Read it before generating or editing course code. You are reading `node_modules/tessera-learn/AGENTS.md`; it updates when you bump `tessera-learn`.
6
6
 
@@ -430,6 +430,7 @@ By default `successStatus` stays `"unknown"`. Set `requireSuccessStatus: "passed
430
430
  | SCORM 1.2 | `lesson_status = "completed"` | `lesson_status = "passed"` |
431
431
  | SCORM 2004 4th | `completion_status = "completed"`, `success_status = "unknown"` | `success_status = "passed"` |
432
432
  | cmi5 | **Completed** (no Passed/Failed) | **Passed** alongside **Completed** |
433
+ | xapi | **Completed** (no Passed/Failed) | **Passed** alongside **Completed** |
433
434
  | web | `localStorage` only | `localStorage` only |
434
435
 
435
436
  ### Rules and non-goals
@@ -512,6 +513,7 @@ For the common case, set `branding.primaryColor` and `branding.fontFamily` in `c
512
513
  ```js
513
514
  export default {
514
515
  title: 'My Course', // required — the only field with no default
516
+ id: 'urn:uuid:…', // unique course identity; scaffolders generate one — keep it
515
517
  description: '',
516
518
  author: '',
517
519
  version: '1.0.0',
@@ -538,7 +540,7 @@ export default {
538
540
  },
539
541
 
540
542
  export: {
541
- standard: 'web', // "web" | "scorm12" | "scorm2004" | "cmi5"
543
+ standard: 'web', // "web" | "scorm12" | "scorm2004" | "cmi5" | "xapi"
542
544
  },
543
545
 
544
546
  a11y: {
@@ -551,16 +553,17 @@ export default {
551
553
 
552
554
  ### Field behaviour
553
555
 
554
- | Field | Behaviour |
555
- | ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
556
- | `language` | Sets `<html lang>` (WCAG 3.1.1). Missing/implausible value warns and falls back to `"en"` |
557
- | `navigation.mode: "free"` | All pages accessible except those blocked by gating quizzes |
558
- | `navigation.mode: "sequential"` | Pages unlock one at a time as each completes |
559
- | `completion.mode: "percentage"` | Completes when `visitedPages / totalPages * 100 >= percentageThreshold` |
560
- | `completion.mode: "quiz"` | Completes when graded quiz average >= `scoring.passingScore` |
561
- | `completion.mode: "manual"` | Completes when an author trigger fires. See [Manual completion](#manual-completion) |
562
- | `a11y.level: "error"` | Promotes captions/transcript, heading order, contrast, language, Svelte a11y warnings to errors. Hard errors (missing `alt`, missing media `title`) always block regardless |
563
- | `a11y.ignore` | Flat list matched literally against every diagnostic rule ID across all tiers (`tessera/…`, `a11y_…`, bare axe IDs) |
556
+ | Field | Behaviour |
557
+ | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
558
+ | `id` | Unique course identity; seeds the web localStorage key and the cmi5/xAPI activity id. Scaffolders mint a `urn:uuid:…`. Missing → falls back to a fixed value (collides across courses) and the build warns. `tessera duplicate` regenerates it. |
559
+ | `language` | Sets `<html lang>` (WCAG 3.1.1). Missing/implausible value warns and falls back to `"en"` |
560
+ | `navigation.mode: "free"` | All pages accessible except those blocked by gating quizzes |
561
+ | `navigation.mode: "sequential"` | Pages unlock one at a time as each completes |
562
+ | `completion.mode: "percentage"` | Completes when `visitedPages / totalPages * 100 >= percentageThreshold` |
563
+ | `completion.mode: "quiz"` | Completes when graded quiz average >= `scoring.passingScore` |
564
+ | `completion.mode: "manual"` | Completes when an author trigger fires. See [Manual completion](#manual-completion) |
565
+ | `a11y.level: "error"` | Promotes captions/transcript, heading order, contrast, language, Svelte a11y warnings to errors. Hard errors (missing `alt`, missing media `title`) always block regardless |
566
+ | `a11y.ignore` | Flat list matched literally against every diagnostic rule ID across all tiers (`tessera/…`, `a11y_…`, bare axe IDs) |
564
567
 
565
568
  ### Minimum config
566
569
 
@@ -589,15 +592,34 @@ canAccess: (ctx) => {
589
592
 
590
593
  `pnpm export <course>` writes:
591
594
 
592
- | `export.standard` | What ships | Where |
593
- | ----------------- | ------------------------------------- | ----------------------------- |
594
- | `web` | Static site (HTML/CSS/JS + `assets/`) | `dist/` (any static host) |
595
- | `scorm12` | SCORM 1.2 package | `dist/<course>-scorm12.zip` |
596
- | `scorm2004` | SCORM 2004 4th Edition package | `dist/<course>-scorm2004.zip` |
597
- | `cmi5` | cmi5 package (AU + manifest) | `dist/<course>-cmi5.zip` |
595
+ | `export.standard` | What ships | Where |
596
+ | ----------------- | ------------------------------------------- | ----------------------------- |
597
+ | `web` | Static site (HTML/CSS/JS + `assets/`) | `dist/` (any static host) |
598
+ | `scorm12` | SCORM 1.2 package | `dist/<course>-scorm12.zip` |
599
+ | `scorm2004` | SCORM 2004 4th Edition package | `dist/<course>-scorm2004.zip` |
600
+ | `cmi5` | cmi5 package (AU + manifest) | `dist/<course>-cmi5.zip` |
601
+ | `xapi` | xAPI 1.0.3 "Tin Can" package (`tincan.xml`) | `dist/<course>-xapi.zip` |
598
602
 
599
603
  Upload the LMS zips via your LMS's import flow; drop `dist/` (web) on any static host.
600
604
 
605
+ ### Web export Content-Security-Policy
606
+
607
+ Web builds emit a baseline CSP `<meta>` (LMS packages and the dev server don't). It allows any `https:` for images/media/frames/network, but **not** for scripts, styles, or fonts — so a CDN script/stylesheet/font is blocked until you allow its origin. Extend per-directive via `export.csp` (sources are **appended** to the baseline, never replaced):
608
+
609
+ ```js
610
+ export: {
611
+ standard: 'web',
612
+ csp: {
613
+ 'style-src': ['https://fonts.googleapis.com'],
614
+ 'font-src': ['https://fonts.gstatic.com'],
615
+ },
616
+ }
617
+ ```
618
+
619
+ - `export.csp: false` drops the meta entirely (use when your host sets a CSP header).
620
+ - To **tighten** or replace a directive (not just add), use a `transformIndexHtml` hook — `export.csp` only adds.
621
+ - Ignored unless `standard` is `'web'`.
622
+
601
623
  ### Validation
602
624
 
603
625
  The plugin validates on every dev start and build (page syntax, manifest shape, `pageConfig`, question components, asset references, data-contract bypass). Errors abort the build and print `[tessera error] ...`; warnings print `[tessera warning] ...` and don't block. Run `pnpm validate <course>` to check without building.
@@ -823,7 +845,7 @@ xapi: {
823
845
  activityId: 'https://example.com/courses/intro-to-x',
824
846
  }
825
847
 
826
- // cmi5 only: inherit the LMS launch LRS:
848
+ // cmi5 / xapi only: inherit the LMS launch LRS:
827
849
  xapi: { endpoint: 'lms' }
828
850
 
829
851
  // Fan out (at most one 'lms' entry):
@@ -840,6 +862,7 @@ Each destination has its own queue, auth resolver, and retry loop. One UUID per
840
862
  | Mode | `xapi` not set | `xapi.endpoint: 'lms'` | `xapi: {endpoint, ...}` (explicit) |
841
863
  | ------------- | ------------------ | ---------------------- | ------------------------------------------------------- |
842
864
  | **cmi5** | `useXAPI()` → null | Inherits launch LRS | Independent publisher; `actor` defaults to launch actor |
865
+ | **xapi** | `useXAPI()` → null | Inherits launch LRS | Independent publisher; `actor` defaults to launch actor |
843
866
  | **scorm12** | `useXAPI()` → null | **Config error** | Independent; `actor` derived from `cmi.core.student_id` |
844
867
  | **scorm2004** | `useXAPI()` → null | **Config error** | Independent; `actor` derived from `cmi.learner_id` |
845
868
  | **web** | `useXAPI()` → null | **Config error** | Independent; `actor` **required** in config |
@@ -848,7 +871,7 @@ Each destination has its own queue, auth resolver, and retry loop. One UUID per
848
871
 
849
872
  - **Actor priority:** author-supplied `xapi.actor` always wins; else cmi5 launch actor; else SCORM-derived from the LMS data model; else error. Override the SCORM-derived `homePage` via `actorAccountHomePage` (required if `activityId` is a non-URL IRI).
850
873
  - **Auth is Basic-only.** Pass the credential value, not the full header (the publisher prepends `Basic `). For OAuth, return a Basic credential from your `auth` function or run a proxy.
851
- - **Never ship a static `auth` string on web** the bundle is public. Use a function that fetches a server-brokered short-lived token. CORS must allow the served origin.
874
+ - **`course.config.js` is serialized verbatim into the client bundle** — every field is public, not just `auth`. Never put a static `auth` string, API key, or any secret in it; use a function that fetches a server-brokered short-lived token. CORS must allow the served origin.
852
875
  - **`actor` is required on web export** and resolved once per page-load (no mid-session identity change in v1 — reload to switch).
853
876
  - **Page unload rejects sends.** Once unload begins, `sendStatement` rejects (keeps cmi5 Terminated last). Do end-of-session work in a child component's `onDestroy`, not `beforeunload`.
854
877
  - **Retry:** 3 attempts, exponential backoff; 5xx/network retry, 4xx short-circuits, 409 treated as success. Opt out per call with `sendStatement(stmt, { retry: false })`.
@@ -893,6 +916,7 @@ Author-facing consequences:
893
916
  | scorm12 | Upload `dist/*-scorm12.zip` to [SCORM Cloud](https://cloud.scorm.com) (free) or Reload Player |
894
917
  | scorm2004 | SCORM Cloud (easiest); also Moodle, Cornerstone, SuccessFactors, Canvas |
895
918
  | cmi5 | Upload `dist/*-cmi5.zip` to SCORM Cloud and use its generated cmi5 dispatch URL |
919
+ | xapi | Upload `dist/*-xapi.zip` to SCORM Cloud (imports `tincan.xml`) and launch the generated URL |
896
920
  | web | Serve `dist/` from any static host |
897
921
 
898
922
  Inspect the LMS API call log to confirm `lesson_status` / `completion_status` / interactions look right.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # tessera-learn
2
2
 
3
- LMS tracking runtime for interactive learning content. One adapter layer (SCORM 1.2, SCORM 2004 4th Edition, cmi5, static Web), your choice of components.
3
+ LMS tracking runtime for interactive learning content. One adapter layer (SCORM 1.2, SCORM 2004 4th Edition, cmi5, xAPI 1.0.3, static Web), your choice of components.
4
4
 
5
5
  Tessera is a toolkit for building interactive online courses, designed for AI-assisted authoring: pages are `.svelte` files, the runtime locks the LMS data contract (tracking, completion, scoring, persistence), and an AI agent working in the workspace follows the conventions in `AGENTS.md` to write pages and components. This package is the runtime; you typically don't depend on it directly — `create-tessera` scaffolds a workspace that pins it for you. A workspace is one package that holds many courses under `courses/<name>/` and a `shared/` design system imported as `$shared`.
6
6
 
@@ -19,7 +19,7 @@ That creates a workspace with Tessera wired up, a seed course, and a root `AGENT
19
19
  - **Hooks** (`tessera-learn`): `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `useCompletion`, `usePersistence`, `useXAPI`.
20
20
  - **Vite plugin** (`tessera-learn/plugin`): `tesseraPlugin()` — wires page/layout discovery, the LMS adapter, the `$shared` alias, and the export pipeline. The `tessera` CLI (`new`/`dev`/`export`/`validate`/`a11y`/`check`) runs Vite with this plugin for you, so scaffolded workspaces need no `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
- - **LMS adapters**: SCORM 1.2, SCORM 2004 4th Edition, cmi5, static Web — selected via `course.config.js` `export.standard`.
22
+ - **LMS adapters**: SCORM 1.2, SCORM 2004 4th Edition, cmi5, xAPI 1.0.3 ("Tin Can"), static Web — selected via `course.config.js` `export.standard`.
23
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.
24
24
 
25
25
  See `AGENTS.md` for usage, signatures, and authoring conventions.
@@ -8,9 +8,11 @@ import { parse } from "svelte/compiler";
8
8
  import { spawn } from "node:child_process";
9
9
  //#region src/plugin/ast.ts
10
10
  const rootCache = /* @__PURE__ */ new Map();
11
+ const jsModuleCache = /* @__PURE__ */ new Map();
11
12
  /** Drop every cached root. Call at the start of a run to scope the cache. */
12
13
  function clearParseCache() {
13
14
  rootCache.clear();
15
+ jsModuleCache.clear();
14
16
  }
15
17
  function parseRoot(source) {
16
18
  const cached = rootCache.get(source);
@@ -119,14 +121,19 @@ function findComponents(source, names) {
119
121
  }
120
122
  const TsParser = Parser.extend(tsPlugin());
121
123
  function parseJsModule(source) {
124
+ const cached = jsModuleCache.get(source);
125
+ if (cached !== void 0) return cached;
126
+ let result;
122
127
  try {
123
- return TsParser.parse(source, {
128
+ result = TsParser.parse(source, {
124
129
  ecmaVersion: "latest",
125
130
  sourceType: "module"
126
131
  });
127
132
  } catch {
128
- return null;
133
+ result = null;
129
134
  }
135
+ jsModuleCache.set(source, result);
136
+ return result;
130
137
  }
131
138
  function unwrapTsCast(node) {
132
139
  let current = node;
@@ -240,15 +247,6 @@ function deriveSlug(name, isFile = false) {
240
247
  return stripPrefix(name);
241
248
  }
242
249
  /**
243
- * Locate `export default { ... }` and return its object-literal text. Returns
244
- * a discriminated result so callers can tell parse failure from a missing or
245
- * non-literal default export. Used by both manifest extraction and project
246
- * validation.
247
- */
248
- function extractDefaultExportObjectLiteral(source) {
249
- return defaultExportObjectLiteral(source);
250
- }
251
- /**
252
250
  * Read and JSON5-parse the `export default { ... }` literal from a project's
253
251
  * course.config.js. Shared by the build plugin and the validator so the read,
254
252
  * cache, and parse rules live in one place. The discriminated `reason` lets
@@ -261,7 +259,7 @@ function readCourseConfig(projectRoot) {
261
259
  ok: false,
262
260
  reason: "missing"
263
261
  };
264
- const result = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
262
+ const result = defaultExportObjectLiteral(readSourceFileCached(configPath));
265
263
  if (result.kind === "parse-error") return {
266
264
  ok: false,
267
265
  reason: "parse-error"
@@ -290,7 +288,7 @@ function readCourseConfig(projectRoot) {
290
288
  */
291
289
  function readMetaFile(metaPath) {
292
290
  if (!existsSync(metaPath)) return {};
293
- const result = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
291
+ const result = defaultExportObjectLiteral(readSourceFileCached(metaPath));
294
292
  if (result.kind !== "literal") return {};
295
293
  try {
296
294
  return JSON5.parse(result.text);
@@ -548,6 +546,14 @@ const FEEDBACK_MODES = [
548
546
  "never"
549
547
  ];
550
548
  const RETRY_MODES = ["full", "incorrect-only"];
549
+ /**
550
+ * Trimmed course identity, or '' when absent. Single source of truth for the
551
+ * "is there a usable id?" check shared by the web storage key, the cmi5/xAPI
552
+ * id derivation, and the config validator.
553
+ */
554
+ function courseIdentity(config) {
555
+ return typeof config.id === "string" && config.id.trim() || "";
556
+ }
551
557
  //#endregion
552
558
  //#region src/plugin/a11y/contrast.ts
553
559
  /**
@@ -606,6 +612,48 @@ function contrastRatio(a, b) {
606
612
  return (lighter + .05) / (darker + .05);
607
613
  }
608
614
  //#endregion
615
+ //#region src/plugin/csp.ts
616
+ const WEB_CSP_BASELINE = {
617
+ "default-src": ["'self'"],
618
+ "img-src": [
619
+ "'self'",
620
+ "data:",
621
+ "https:"
622
+ ],
623
+ "media-src": [
624
+ "'self'",
625
+ "blob:",
626
+ "data:",
627
+ "https:"
628
+ ],
629
+ "style-src": ["'self'", "'unsafe-inline'"],
630
+ "script-src": ["'self'", "'unsafe-inline'"],
631
+ "font-src": ["'self'", "data:"],
632
+ "connect-src": ["'self'", "https:"],
633
+ "frame-src": [
634
+ "'self'",
635
+ "blob:",
636
+ "https:"
637
+ ],
638
+ "worker-src": ["'self'", "blob:"],
639
+ "object-src": ["'none'"],
640
+ "base-uri": ["'self'"]
641
+ };
642
+ const CSP_DIRECTIVE = /^[a-zA-Z][a-zA-Z-]*$/;
643
+ const CSP_SOURCE = /^[^\s;,"<>]+$/;
644
+ function isCspOverrides(v) {
645
+ return typeof v === "object" && v !== null && !Array.isArray(v) && Object.entries(v).every(([directive, sources]) => CSP_DIRECTIVE.test(directive) && Array.isArray(sources) && sources.every((s) => typeof s === "string" && CSP_SOURCE.test(s)));
646
+ }
647
+ function buildCsp(overrides) {
648
+ const merged = new Map(Object.entries(WEB_CSP_BASELINE).map(([k, v]) => [k, [...v]]));
649
+ if (isCspOverrides(overrides)) for (const [directive, sources] of Object.entries(overrides)) {
650
+ const existing = merged.get(directive) ?? [];
651
+ for (const src of sources) if (!existing.includes(src)) existing.push(src);
652
+ merged.set(directive, existing);
653
+ }
654
+ return [...merged].map(([directive, sources]) => `${directive} ${sources.join(" ")}`).join("; ");
655
+ }
656
+ //#endregion
609
657
  //#region src/components/video-embed.ts
610
658
  /**
611
659
  * Shared YouTube/Vimeo embed detection. Used by Video.svelte to pick the iframe
@@ -640,7 +688,7 @@ const A11Y_IDS = {
640
688
  lang: "tessera/lang"
641
689
  };
642
690
  /** Promotable by `a11y.level: 'error'`; the rest are hard contract errors. */
643
- const PROMOTABLE_A11Y_IDS = new Set([
691
+ const PROMOTABLE_A11Y_IDS = /* @__PURE__ */ new Set([
644
692
  A11Y_IDS.mediaTranscript,
645
693
  A11Y_IDS.mediaCaptions,
646
694
  A11Y_IDS.questionLabel,
@@ -703,8 +751,9 @@ function reportValidationIssues({ errors, warnings }) {
703
751
  for (const warning of warnings) console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
704
752
  for (const error of errors) console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
705
753
  }
706
- const KNOWN_CONFIG_FIELDS = new Set([
754
+ const KNOWN_CONFIG_FIELDS = /* @__PURE__ */ new Set([
707
755
  "title",
756
+ "id",
708
757
  "description",
709
758
  "author",
710
759
  "version",
@@ -733,7 +782,8 @@ const VALID_EXPORT_STANDARDS = [
733
782
  "web",
734
783
  "scorm12",
735
784
  "scorm2004",
736
- "cmi5"
785
+ "cmi5",
786
+ "xapi"
737
787
  ];
738
788
  const VALID_MANUAL_TRIGGERS = ["page"];
739
789
  const VALID_REQUIRE_SUCCESS_STATUS = ["passed", "failed"];
@@ -785,6 +835,8 @@ function parseConfig(projectRoot, errors, warnings) {
785
835
  if (config.branding !== void 0) validateBranding(config.branding, warnings);
786
836
  if (config.language === void 0) warnings.push(tag(A11Y_IDS.lang, `course.config.js: "language" is not set — defaulting <html lang> to "en". Set it to the course's language (BCP-47, e.g. "en", "fr-CA") for WCAG 3.1.1.`));
787
837
  else if (!isPlausibleLanguageTag(config.language)) warnings.push(tag(A11Y_IDS.lang, `course.config.js: "language" (${JSON.stringify(config.language)}) is not a plausible BCP-47 tag — use e.g. "en", "es", or "fr-CA"`));
838
+ const standard = config.export?.standard;
839
+ if ((standard === void 0 || standard === "web" || standard === "cmi5" || standard === "xapi") && !courseIdentity(config)) warnings.push(`course.config.js: no "id" set — the web storage key and cmi5/xAPI activity id then share a fixed fallback that collides across courses. Add a unique id (e.g. "urn:uuid:…"); scaffolded courses include one.`);
788
840
  if (config.a11y !== void 0) validateA11yConfig(config.a11y, errors);
789
841
  if (config.navigation?.mode !== void 0) {
790
842
  if (!VALID_NAV_MODES.includes(config.navigation.mode)) errors.push(`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`);
@@ -801,7 +853,12 @@ function parseConfig(projectRoot, errors, warnings) {
801
853
  else if (!VALID_REQUIRE_SUCCESS_STATUS.includes(config.completion.requireSuccessStatus)) errors.push(`course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`);
802
854
  }
803
855
  if (config.export?.standard !== void 0) {
804
- if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) errors.push(`course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"`);
856
+ if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) errors.push(`course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", "cmi5", or "xapi", got "${config.export.standard}"`);
857
+ }
858
+ if (config.export?.csp !== void 0) {
859
+ const csp = config.export.csp;
860
+ if (csp !== false && !isCspOverrides(csp)) warnings.push("course.config.js: \"export.csp\" must be false or an object of directive → string[]; ignoring it and using the baseline CSP");
861
+ else if ((config.export.standard ?? "web") !== "web") warnings.push(`course.config.js: "export.csp" is ignored when "export.standard" is "${config.export.standard}" (the CSP meta is web-export only)`);
805
862
  }
806
863
  if (config.scoring?.passingScore !== void 0) {
807
864
  const score = config.scoring.passingScore;
@@ -872,7 +929,7 @@ function validateXAPIConfig(raw, standard, errors, warnings) {
872
929
  errors.push("course.config.js: xapi must contain at least one destination, or be omitted");
873
930
  return;
874
931
  }
875
- if (entries.filter((e) => e && typeof e === "object" && e.endpoint === "lms").length > 1) errors.push("course.config.js: xapi has multiple entries with endpoint: 'lms' — only one cmi5 launch-inherited destination is allowed");
932
+ if (entries.filter((e) => e && typeof e === "object" && e.endpoint === "lms").length > 1) errors.push("course.config.js: xapi has multiple entries with endpoint: 'lms' — only one launch-inherited destination is allowed");
876
933
  const seen = /* @__PURE__ */ new Map();
877
934
  for (const e of entries) if (e && typeof e === "object") {
878
935
  const ep = e.endpoint;
@@ -904,14 +961,14 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
904
961
  return;
905
962
  }
906
963
  if (endpoint === "lms") {
907
- if (standard !== "cmi5") errors.push(`course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' (you have "${standard}"). Either change the export standard or specify an explicit LRS endpoint.`);
964
+ if (standard !== "cmi5" && standard !== "xapi") errors.push(`course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' or 'xapi' (you have "${standard}"). Either change the export standard or specify an explicit LRS endpoint.`);
908
965
  for (const f of [
909
966
  "auth",
910
967
  "actor",
911
968
  "activityId",
912
969
  "registration",
913
970
  "actorAccountHomePage"
914
- ]) if (entry[f] !== void 0) errors.push(`course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the cmi5 launch.`);
971
+ ]) if (entry[f] !== void 0) errors.push(`course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the launch.`);
915
972
  return;
916
973
  }
917
974
  let url;
@@ -958,13 +1015,13 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
958
1015
  errors.push(`course.config.js: ${label}.actorAccountHomePage must be an absolute URL`);
959
1016
  }
960
1017
  if (actor !== void 0) warnings.push(`course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`);
961
- if (standard === "cmi5" || standard === "web") warnings.push(`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`);
1018
+ if (standard === "cmi5" || standard === "xapi" || standard === "web") warnings.push(`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`);
962
1019
  }
963
1020
  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.`);
964
1021
  const registration = entry.registration;
965
1022
  if (registration !== void 0) {
966
1023
  if (typeof registration !== "string" || !UUID_RE.test(registration)) errors.push(`course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`);
967
- if (standard !== "cmi5") warnings.push(`course.config.js: ${label}.registration is a cmi5 concept; the LRS will accept it under "${standard}" but most analytics tools won't know what to do with it.`);
1024
+ if (standard !== "cmi5" && standard !== "xapi") warnings.push(`course.config.js: ${label}.registration is a cmi5 concept; the LRS will accept it under "${standard}" but most analytics tools won't know what to do with it.`);
968
1025
  }
969
1026
  }
970
1027
  /**
@@ -1086,7 +1143,7 @@ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
1086
1143
  function validateMetaFile(metaPath, parentRel, errors) {
1087
1144
  if (!existsSync(metaPath)) return null;
1088
1145
  const metaRel = `${parentRel}/_meta.js`;
1089
- const result = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
1146
+ const result = defaultExportObjectLiteral(readSourceFileCached(metaPath));
1090
1147
  if (result.kind === "parse-error") {
1091
1148
  errors.push(`${metaRel}: could not parse — JavaScript syntax error`);
1092
1149
  return null;
@@ -1242,7 +1299,7 @@ function stripRepeated(input, patterns) {
1242
1299
  * Non-static (kind 'expr') values are skipped, matching the rest of the linter.
1243
1300
  */
1244
1301
  function validateMediaComponents(content, fileRel, errors, warnings) {
1245
- const components = findComponents(content, new Set([
1302
+ const components = findComponents(content, /* @__PURE__ */ new Set([
1246
1303
  "Image",
1247
1304
  "Video",
1248
1305
  "Audio"
@@ -1574,7 +1631,7 @@ async function runAudit(projectRoot, workspaceRoot, options = {}) {
1574
1631
  const disableRules = axeIgnoreRules(settings.ignore);
1575
1632
  const manifest = generateManifest(resolve(projectRoot, "pages"));
1576
1633
  const vite = await import("vite");
1577
- const { resolveTesseraConfig } = await import("./inline-config-DqAKsCNl.js");
1634
+ const { resolveTesseraConfig } = await import("./inline-config-BEXyRqsJ.js");
1578
1635
  const auditBaseConfig = await resolveTesseraConfig(projectRoot, workspaceRoot, {
1579
1636
  command: "build",
1580
1637
  mode: "production"
@@ -1725,6 +1782,6 @@ function printSummary(report, reportPath) {
1725
1782
  }
1726
1783
  }
1727
1784
  //#endregion
1728
- export { isPlausibleLanguageTag as a, validateProject as c, isIgnored as i, generateManifest as l, runAudit as n, normalizeA11y as o, resolvePackageRoot as r, reportValidationIssues as s, AUDIT_ENV_FLAG as t, readCourseConfig as u };
1785
+ export { isPlausibleLanguageTag as a, validateProject as c, generateManifest as d, readCourseConfig as f, isIgnored as i, buildCsp as l, runAudit as n, normalizeA11y as o, resolvePackageRoot as r, reportValidationIssues as s, AUDIT_ENV_FLAG as t, courseIdentity as u };
1729
1786
 
1730
- //# sourceMappingURL=audit--fSWIOgK.js.map
1787
+ //# sourceMappingURL=audit-DkXqQTqn.js.map