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.
- package/AGENTS.md +44 -20
- package/README.md +2 -2
- package/dist/{audit--fSWIOgK.js → audit-DkXqQTqn.js} +84 -27
- package/dist/audit-DkXqQTqn.js.map +1 -0
- package/dist/{build-commands-Qyrlsp3n.js → build-commands-CyzuCDXg.js} +2 -2
- package/dist/{build-commands-Qyrlsp3n.js.map → build-commands-CyzuCDXg.js.map} +1 -1
- package/dist/{inline-config-DqAKsCNl.js → inline-config-BEXyRqsJ.js} +2 -2
- package/dist/{inline-config-DqAKsCNl.js.map → inline-config-BEXyRqsJ.js.map} +1 -1
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +57 -46
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +280 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +3 -3
- package/dist/{plugin-B-aiL9-V.js → plugin-CFUFgwHB.js} +126 -83
- package/dist/plugin-CFUFgwHB.js.map +1 -0
- package/package.json +7 -7
- package/src/components/DefaultLayout.svelte +2 -5
- package/src/components/Quiz.svelte +18 -26
- package/src/plugin/ast.ts +9 -2
- package/src/plugin/cli.ts +45 -46
- package/src/plugin/csp.ts +59 -0
- package/src/plugin/duplicate-cli.ts +37 -1
- package/src/plugin/export.ts +56 -27
- package/src/plugin/index.ts +117 -61
- package/src/plugin/manifest.ts +3 -23
- package/src/plugin/new-cli.ts +2 -0
- package/src/plugin/validation.ts +48 -12
- package/src/runtime/App.svelte +10 -8
- package/src/runtime/Sidebar.svelte +3 -1
- package/src/runtime/adapters/cmi5.ts +59 -402
- package/src/runtime/adapters/discovery.ts +11 -0
- package/src/runtime/adapters/index.ts +27 -60
- package/src/runtime/adapters/lms-error.ts +61 -0
- package/src/runtime/adapters/scorm2004.ts +2 -1
- package/src/runtime/adapters/web.ts +19 -4
- package/src/runtime/adapters/xapi-launch-base.ts +346 -0
- package/src/runtime/adapters/xapi.ts +26 -0
- package/src/runtime/types.ts +19 -1
- package/src/runtime/xapi/publisher.ts +5 -1
- package/src/runtime/xapi/setup.ts +24 -15
- package/src/virtual.d.ts +4 -1
- package/templates/course/course.config.js +1 -0
- package/dist/audit--fSWIOgK.js.map +0 -1
- 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
|
-
| `
|
|
557
|
-
| `
|
|
558
|
-
| `navigation.mode: "
|
|
559
|
-
| `
|
|
560
|
-
| `completion.mode: "
|
|
561
|
-
| `completion.mode: "
|
|
562
|
-
| `
|
|
563
|
-
| `a11y.
|
|
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
|
|
593
|
-
| ----------------- |
|
|
594
|
-
| `web` | Static site (HTML/CSS/JS + `assets/`)
|
|
595
|
-
| `scorm12` | SCORM 1.2 package
|
|
596
|
-
| `scorm2004` | SCORM 2004 4th Edition package
|
|
597
|
-
| `cmi5` | cmi5 package (AU + manifest)
|
|
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
|
|
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
|
-
|
|
128
|
+
result = TsParser.parse(source, {
|
|
124
129
|
ecmaVersion: "latest",
|
|
125
130
|
sourceType: "module"
|
|
126
131
|
});
|
|
127
132
|
} catch {
|
|
128
|
-
|
|
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 =
|
|
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 =
|
|
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 "
|
|
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
|
|
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
|
|
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 =
|
|
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-
|
|
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,
|
|
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
|
|
1787
|
+
//# sourceMappingURL=audit-DkXqQTqn.js.map
|