tessera-learn 0.0.3 → 0.0.5
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 +128 -12
- package/dist/plugin/index.js +91 -13
- package/dist/plugin/index.js.map +1 -1
- package/package.json +1 -1
- package/src/index.ts +1 -0
- package/src/plugin/export.ts +6 -2
- package/src/plugin/index.ts +13 -2
- package/src/plugin/manifest.ts +5 -3
- package/src/plugin/validation.ts +137 -7
- package/src/runtime/App.svelte +66 -2
- package/src/runtime/adapters/cmi5.ts +441 -44
- package/src/runtime/adapters/scorm-base.ts +24 -0
- package/src/runtime/adapters/scorm12.ts +2 -0
- package/src/runtime/adapters/scorm2004.ts +2 -0
- package/src/runtime/hooks.svelte.ts +39 -0
- package/src/runtime/persistence.ts +2 -0
- package/src/runtime/progress.svelte.ts +25 -0
- package/src/runtime/types.ts +23 -4
- package/src/runtime/xapi/publisher.ts +24 -5
package/AGENTS.md
CHANGED
|
@@ -88,7 +88,7 @@ Pages listed in `pages` come first in listed order; any unlisted `.svelte` files
|
|
|
88
88
|
There are five:
|
|
89
89
|
|
|
90
90
|
1. **Built-in components**: `Callout`, `Image`, `MultipleChoice`, `FillInTheBlank`, `Matching`, `Sorting`, etc., from `tessera-learn`. Use, compose, or skip.
|
|
91
|
-
2. **Hooks**: `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `usePersistence`. The stable contract between custom widgets and the runtime. Anything the built-ins do, you can do.
|
|
91
|
+
2. **Hooks**: `useQuestion`, `useQuiz`, `useNavigation`, `useProgress`, `useCompletion`, `usePersistence`. The stable contract between custom widgets and the runtime. Anything the built-ins do, you can do.
|
|
92
92
|
3. **Custom layout**: drop `layout.svelte` at the project root to replace the default chrome.
|
|
93
93
|
4. **Custom quiz shell**: drop `quiz.svelte` at the project root to replace the built-in quiz UI for every page that has `pageConfig.quiz`. Authors call `useQuiz()` for state and dispatch; question widgets continue to register through `useQuestion`.
|
|
94
94
|
5. **Custom xAPI**: `useXAPI()` returns a publisher for emitting your own xAPI verbs to one or more LRSes. See [Custom xAPI statements](#custom-xapi-statements).
|
|
@@ -404,6 +404,95 @@ Standalone questions are not graded by default. To grade one (e.g., a required r
|
|
|
404
404
|
|
|
405
405
|
---
|
|
406
406
|
|
|
407
|
+
## Manual completion
|
|
408
|
+
|
|
409
|
+
`completion.mode: "manual"` is for courses where the author — not a quiz score or a page-visit ratio — owns the moment of completion. Two examples:
|
|
410
|
+
|
|
411
|
+
- A short policy briefing where reading the final page **is** the proof of completion.
|
|
412
|
+
- A compliance "click to acknowledge" button at the end of a module.
|
|
413
|
+
|
|
414
|
+
Under manual mode, **both** triggers below are always active. First-to-fire wins; subsequent calls are idempotent.
|
|
415
|
+
|
|
416
|
+
### Trigger A: page frontmatter
|
|
417
|
+
|
|
418
|
+
Declare `completesOn: "view"` on any page. Completion fires the moment that page renders.
|
|
419
|
+
|
|
420
|
+
```svelte
|
|
421
|
+
<!-- pages/05-summary/finale.svelte -->
|
|
422
|
+
<script module>
|
|
423
|
+
export const pageConfig = {
|
|
424
|
+
title: "You're done",
|
|
425
|
+
completesOn: "view",
|
|
426
|
+
};
|
|
427
|
+
</script>
|
|
428
|
+
|
|
429
|
+
<h1>Thanks for completing the briefing.</h1>
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
`completesOn` accepts the literal string `"view"` (only value in v1). The page is marked visited and completion fires in the same effect — the LMS sees one `setCompletionStatus("complete")` immediately after the page renders.
|
|
433
|
+
|
|
434
|
+
### Trigger B: runtime hook
|
|
435
|
+
|
|
436
|
+
```svelte
|
|
437
|
+
<script>
|
|
438
|
+
import { useCompletion } from 'tessera-learn';
|
|
439
|
+
|
|
440
|
+
const { markComplete, completionStatus } = useCompletion();
|
|
441
|
+
</script>
|
|
442
|
+
|
|
443
|
+
<button
|
|
444
|
+
onclick={() => markComplete()}
|
|
445
|
+
disabled={completionStatus === 'complete'}
|
|
446
|
+
>
|
|
447
|
+
I acknowledge
|
|
448
|
+
</button>
|
|
449
|
+
|
|
450
|
+
{#if completionStatus === 'complete'}
|
|
451
|
+
<p>Recorded. You may now close this window.</p>
|
|
452
|
+
{/if}
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
Composes cleanly with custom widgets, modal close handlers, video-ended events, timer expirations, etc. Calling `markComplete()` outside `completion.mode: "manual"` is a no-op with a one-shot dev warning per session — safe to leave in shared components.
|
|
456
|
+
|
|
457
|
+
### `completion.trigger` (build-time check)
|
|
458
|
+
|
|
459
|
+
Optional. Set to `"page"` to fail the build when no page declares `completesOn: "view"`. Useful when the page-view path is load-bearing and a typo should fail the build, not the launch. Both triggers still work either way; the field only adds a static check.
|
|
460
|
+
|
|
461
|
+
```js
|
|
462
|
+
completion: { mode: "manual", trigger: "page" }
|
|
463
|
+
```
|
|
464
|
+
|
|
465
|
+
When omitted, the dev runtime warns once after 60 s if completion has not fired — a safety net that covers both "no `completesOn` page exists" and "the hook is never called" cases.
|
|
466
|
+
|
|
467
|
+
### Success status
|
|
468
|
+
|
|
469
|
+
By default `successStatus` stays `"unknown"` under manual — the LMS sees completion without a pass/fail verdict. If you want completion **and** an automatic pass (typical for "acknowledge" flows):
|
|
470
|
+
|
|
471
|
+
```js
|
|
472
|
+
completion: { mode: "manual", requireSuccessStatus: "passed" } // or "failed"
|
|
473
|
+
```
|
|
474
|
+
|
|
475
|
+
| Adapter | What the LMS sees on `markComplete()` (no `requireSuccessStatus`) |
|
|
476
|
+
| -------------- | ------------------------------------------------------------------ |
|
|
477
|
+
| SCORM 1.2 | `cmi.core.lesson_status = "completed"` |
|
|
478
|
+
| SCORM 2004 4th | `cmi.completion_status = "completed"`, `cmi.success_status = "unknown"` |
|
|
479
|
+
| cmi5 | **Completed** statement (no Passed / Failed) |
|
|
480
|
+
| web | `localStorage` only |
|
|
481
|
+
|
|
482
|
+
With `requireSuccessStatus: "passed"`, SCORM 1.2 writes `lesson_status = "passed"`, SCORM 2004 writes `success_status = "passed"`, and cmi5 emits a **Passed** statement alongside **Completed**.
|
|
483
|
+
|
|
484
|
+
### Quizzes under manual mode
|
|
485
|
+
|
|
486
|
+
A graded quiz under `mode: "manual"` reports its score to the LMS gradebook but does **not** drive completion or success — `markComplete()` / `completesOn` does. The build emits a warning to make this explicit. Set `graded: false` (or remove the quiz) if that's not what you want.
|
|
487
|
+
|
|
488
|
+
### Non-goals
|
|
489
|
+
|
|
490
|
+
- Combining manual + quiz/percentage rules ("complete when X **and** quiz passed"). Use a `useCompletion()` call inside a custom `$effect` if you need conditional logic.
|
|
491
|
+
- Per-learner conditional completion expressed in config — same answer: do it in a component with `useCompletion()`.
|
|
492
|
+
- Marking a course **incomplete** after it has been completed. Completion is monotonic in every spec we target. The runtime ignores re-marks.
|
|
493
|
+
|
|
494
|
+
---
|
|
495
|
+
|
|
407
496
|
## Assets
|
|
408
497
|
|
|
409
498
|
Drop files into `assets/`. Reference them with `$assets/` in component props:
|
|
@@ -486,12 +575,14 @@ export default {
|
|
|
486
575
|
},
|
|
487
576
|
|
|
488
577
|
completion: {
|
|
489
|
-
mode: "percentage", // "percentage"
|
|
578
|
+
mode: "percentage", // "percentage" | "quiz" | "manual"
|
|
490
579
|
percentageThreshold: 100, // 0–100 (percentage mode)
|
|
580
|
+
// trigger: "page", // (manual only) opt into build-time check
|
|
581
|
+
// requireSuccessStatus: "passed", // (manual only) "passed" | "failed"
|
|
491
582
|
},
|
|
492
583
|
|
|
493
584
|
scoring: {
|
|
494
|
-
passingScore: 70,
|
|
585
|
+
passingScore: 70, // optional under "manual" (defaults to 0)
|
|
495
586
|
},
|
|
496
587
|
|
|
497
588
|
export: {
|
|
@@ -504,6 +595,7 @@ export default {
|
|
|
504
595
|
- `navigation.mode: "sequential"` → pages unlock one at a time as each is completed.
|
|
505
596
|
- `completion.mode: "percentage"` → course completes when `visitedPages / totalPages * 100 >= percentageThreshold`.
|
|
506
597
|
- `completion.mode: "quiz"` → course completes when graded quiz average >= `scoring.passingScore`.
|
|
598
|
+
- `completion.mode: "manual"` → course completes when an author-declared trigger fires: a page declares `pageConfig.completesOn: "view"`, or any component calls `useCompletion().markComplete()`. First-to-fire wins. `scoring.passingScore` is optional (defaults to 0). See [Manual completion](#manual-completion).
|
|
507
599
|
|
|
508
600
|
### Minimum config
|
|
509
601
|
|
|
@@ -568,7 +660,7 @@ The Vite plugin runs project validation on every dev start and build (manifest s
|
|
|
568
660
|
|
|
569
661
|
## Hooks Reference
|
|
570
662
|
|
|
571
|
-
|
|
663
|
+
Six hooks plus one helper make up the stable contract between widgets and the runtime.
|
|
572
664
|
|
|
573
665
|
```js
|
|
574
666
|
import {
|
|
@@ -576,6 +668,7 @@ import {
|
|
|
576
668
|
useQuiz,
|
|
577
669
|
useNavigation,
|
|
578
670
|
useProgress,
|
|
671
|
+
useCompletion,
|
|
579
672
|
usePersistence,
|
|
580
673
|
isCorrect,
|
|
581
674
|
} from 'tessera-learn';
|
|
@@ -704,6 +797,18 @@ function useProgress(): {
|
|
|
704
797
|
};
|
|
705
798
|
```
|
|
706
799
|
|
|
800
|
+
### `useCompletion`
|
|
801
|
+
|
|
802
|
+
Trigger course completion from any component, and reactively read the current completion status. Active under `completion.mode: "manual"`; in any other mode `markComplete()` is a no-op with a one-shot dev warning. See [Manual completion](#manual-completion).
|
|
803
|
+
|
|
804
|
+
```ts
|
|
805
|
+
function useCompletion(): {
|
|
806
|
+
/** Idempotent — only the first call per session has an effect. */
|
|
807
|
+
markComplete(): void;
|
|
808
|
+
readonly completionStatus: 'incomplete' | 'complete';
|
|
809
|
+
};
|
|
810
|
+
```
|
|
811
|
+
|
|
707
812
|
### `usePersistence<T>(key)`
|
|
708
813
|
|
|
709
814
|
Per-widget persistent state. Survives reload on every adapter: `localStorage` for web, SCORM `cmi.suspend_data` for SCORM 1.2/2004, xAPI State API for cmi5. Reads sync; writes batched by the adapter. JSON-serializable values only.
|
|
@@ -878,13 +983,14 @@ The runtime translates author intent (page visits, quiz scores, completion, pers
|
|
|
878
983
|
|
|
879
984
|
| Runtime event | SCORM 1.2 | SCORM 2004 4th | cmi5 |
|
|
880
985
|
|---------------|-----------|----------------|------|
|
|
881
|
-
| Session start | `LMSInitialize("")`; read `cmi.suspend_data` and `cmi.interactions._count` | `Initialize("")`; read `cmi.suspend_data` and `cmi.interactions._count` | `POST` cmi5 `fetch` URL → token;
|
|
986
|
+
| Session start | `LMSInitialize("")`; read `cmi.suspend_data` and `cmi.interactions._count` | `Initialize("")`; read `cmi.suspend_data` and `cmi.interactions._count` | `POST` cmi5 `fetch` URL → token; `GET` `LMS.LaunchData` State (§10, → session id + Publisher Activity + launchMode + returnURL + masteryScore + moveOn); `GET` `cmi5LearnerPreferences` Agent Profile (§11); build publisher; send **Initialized**; `GET` `tessera-state` for resume |
|
|
882
987
|
| State persisted (page visited, bookmark moved, chunk revealed, `usePersistence` write, etc.) | `LMSSetValue("cmi.suspend_data", json)` (microtask-coalesced) | `SetValue("cmi.suspend_data", json)` (microtask-coalesced) | State API `PUT` `tessera-state` document, chained on the publisher queue |
|
|
883
988
|
| Graded quiz scored | `LMSSetValue("cmi.core.score.raw"\|"min"\|"max", …)` then `LMSSetValue("cmi.core.lesson_status", "passed"\|"failed")` | `SetValue("cmi.score.raw"\|"min"\|"max"\|"scaled", …)` then `SetValue("cmi.success_status", "passed"\|"failed")` | **Passed** or **Failed** statement, with `result.score.scaled` and `result.duration` (one-shot per session) |
|
|
884
|
-
| Course completion changes | Funneled into `cmi.core.lesson_status` (only one field exists) | `SetValue("cmi.completion_status", "completed"\|"incomplete")` | **Completed** statement with `result.completion = true
|
|
989
|
+
| Course completion changes | Funneled into `cmi.core.lesson_status` (only one field exists) | `SetValue("cmi.completion_status", "completed"\|"incomplete")` | **Completed** statement with `result.completion = true` and `result.duration` (one-shot per session). cmi5 §9.5.1 forbids `score` on Completed — the score rides on the subsequent **Passed**/**Failed** instead. |
|
|
990
|
+
| Author marks complete (`completion.mode: "manual"`) | `cmi.core.lesson_status = "completed"` (or `"passed"`/`"failed"` if `requireSuccessStatus` set) | `cmi.completion_status = "completed"`; `cmi.success_status = "unknown"` (or `"passed"`/`"failed"` if `requireSuccessStatus` set) | **Completed** statement; **Passed**/**Failed** if `requireSuccessStatus` set |
|
|
885
991
|
| Question answered (graded or standalone, inside or outside a quiz) | `cmi.interactions.{n}.id` / `student_response` / `result` / `time` / `type` (n continues from prior `_count`) | `cmi.interactions.{n}.id` / `learner_response` / `result` / `timestamp` / `type` (n continues from prior `_count`) | **Answered** statement; object `${activityId}#${questionId}`, definition `cmi.interaction` + `interactionType`, `result.response`, `result.success` |
|
|
886
992
|
| Resume after reload | Read `cmi.suspend_data` on init; manifest is rebuilt from code, not LMS | Read `cmi.suspend_data` on init | State API `GET` `tessera-state`; lifecycle replays from where the prior session left off |
|
|
887
|
-
| Author exit / unload | `LMSSetValue("cmi.core.exit", "suspend"\|"")`, `LMSCommit("")`, `LMSFinish("")` (queue drained synchronously) | `SetValue("cmi.exit", "suspend"\|"normal"\|...)`, `Commit("")`, `Terminate("")` (queue drained synchronously) |
|
|
993
|
+
| Author exit / unload | `LMSSetValue("cmi.core.exit", "suspend"\|"")`, `LMSCommit("")`, `LMSFinish("")` (queue drained synchronously) | `SetValue("cmi.exit", "suspend"\|"normal"\|...)`, `Commit("")`, `Terminate("")` (queue drained synchronously) | **Terminated** (always last on the wire, cmi5 §9.3.6). Explicit-exit path: `adapter.exit()` drains the queue then redirects to `returnURL` (§10.2.6). No Suspended verb — incomplete exit is signalled by Terminated without a preceding Completed; the LMS handles Abandoned and resume on next launch. |
|
|
888
994
|
| Learner identity (xAPI actor synthesis) | `cmi.core.student_id` + `cmi.core.student_name` | `cmi.learner_id` + `cmi.learner_name` | Launch-supplied actor JSON (Identified Agent) |
|
|
889
995
|
| Persistence cap | ~4096 chars per spec; many LMSes allow more, but plan for 4 KB | 64000 chars per spec | LRS-defined (typically unbounded for State API documents) |
|
|
890
996
|
| Score scale exposed to LMS | `score.raw` only (0–100) | `score.raw` (0–100) **and** `score.scaled` (0–1) | `result.score.scaled` (0–1) |
|
|
@@ -921,15 +1027,25 @@ API discovery: `API_1484_11` via the same parent/opener walk.
|
|
|
921
1027
|
|
|
922
1028
|
**Token fetch is single-use** (cmi5 §6.2). On failure, reload from the LMS to retry. The token is used as a `Basic` credential, not Bearer.
|
|
923
1029
|
|
|
924
|
-
**Lifecycle order.** **Initialized** → **Answered** (per question on submit) → **Completed** → **Passed** / **Failed** → **
|
|
1030
|
+
**Lifecycle order.** **Initialized** → **Answered** (per question on submit) → **Completed** → **Passed** / **Failed** → **Terminated** (always last, cmi5 §9.3.6). Completed / Passed / Failed are one-shot per session; once dispatched, the corresponding setter no-ops. A reloaded session may re-dispatch them, which is intended: each session sends its lifecycle exactly once. **Satisfied** and **Suspended** are not emitted by the AU — Satisfied is LMS-only (§9.3.9), and Suspended isn't a cmi5 verb (§9.3 enumerates nine; the LMS handles Abandoned / resume on relaunch).
|
|
1031
|
+
|
|
1032
|
+
**Required result fields.** Completed: `completion: true`, `duration` (no `score` — §9.5.1 forbids it). Passed: `success: true`, `duration`, `result.score.scaled` when known (§9.3.4 requires `scaled >= masteryScore` when present). Failed: `success: false`, `duration`, `result.score.scaled` when known (§9.3.5 requires `scaled < masteryScore` when present). Terminated: `duration` (§9.5.4.1). On contradiction the verb is preserved and the score is dropped with a console warning.
|
|
1033
|
+
|
|
1034
|
+
**Context per Defined Statement.** Categories: `cmi5` Category Activity on every Defined Statement (§9.6.2.1); plus `moveOn` Category on Completed / Passed / Failed (§9.6.2.2). Extensions: `sessionid` (§9.6.3.1) on every statement (Defined and Allowed) — value sourced from `LMS.LaunchData.contextTemplate` when supplied, else minted UUID. `masteryScore` extension on Passed / Failed only (§9.6.3.2). The full `contextTemplate` from `LMS.LaunchData` is merged in (§9.6.2 makes it the AU's base context; §10.2.1 says AU MUST NOT overwrite template values, so the AU's categories are concatenated and deduped against the template's, never replacing them).
|
|
1035
|
+
|
|
1036
|
+
**`LMS.LaunchData` (§10).** Fetched once at init from the State API under `stateId='LMS.LaunchData'`. The AU reads `contextTemplate`, `launchMode`, `returnURL`, `launchParameters`, `masteryScore`, and `moveOn` from it. LaunchData values override anything parsed from the launch URL (§10.2.4 makes LaunchData authoritative). When the document is absent, statements ship without the LMS-supplied Publisher Activity and may be rejected by strict LRSes — a console warning fires.
|
|
1037
|
+
|
|
1038
|
+
**Learner Preferences (§11).** `cmi5LearnerPreferences` from the Agent Profile API, fetched *before* Initialized — strict LRSes (SCORM Cloud) track that the GET happened and reject Initialized otherwise. A 404 here is normal (no preferences set); only the GET itself is required. Exposed to author code via `adapter.getLearnerPreferences()`.
|
|
1039
|
+
|
|
1040
|
+
**Launch mode (§10.2.2).** "Normal" launches emit the full lifecycle. "Browse" and "Review" launches emit only Initialized and Terminated — every other Defined Statement is silently suppressed. Exposed via `adapter.getLaunchMode()`.
|
|
925
1041
|
|
|
926
|
-
**
|
|
1042
|
+
**Return URL (§10.2.6).** `adapter.exit()` is the explicit-exit path: calls `terminate()`, awaits the publisher queue so Terminated lands before navigation, then `window.location.assign(returnURL)`. The page-unload `terminate()` path can't redirect (the browser is already navigating). Bare `getReturnURL()` is available for authors who want to inspect the URL without triggering exit.
|
|
927
1043
|
|
|
928
|
-
**
|
|
1044
|
+
**State persistence.** `tessera-state` document via the State API, keyed by `activityId` + `agent` + `registration?` + `stateId='tessera-state'` (distinct from the LMS-owned `LMS.LaunchData` and `cmi5LearnerPreferences` documents). Writes chain onto the publisher's queue so the suspend payload lands before Terminated.
|
|
929
1045
|
|
|
930
|
-
**
|
|
1046
|
+
**Manifest (`cmi5.xml`).** Generated by the plugin: course id + AU id are stable URNs derived from `config.title` (`urn:tessera:{course,au}:<hex>`). `<au>` carries `launchMethod="AnyWindow"` (CourseStructure XSD requires it), `moveOn` (`Completed` for percentage/manual, `CompletedAndPassed` for quiz mode), and `masteryScore` (rounded to 4 decimal places per §10.2.4). The `<url>` is a child element of `<au>`, not an attribute.
|
|
931
1047
|
|
|
932
|
-
**Not implemented.** No multi-AU courses (one course = one AU in v1). No **Waived** or **Abandoned** verbs. No mid-session actor refresh.
|
|
1048
|
+
**Not implemented.** No multi-AU courses (one course = one AU in v1). No **Waived** or **Abandoned** verbs (LMS-only). No mid-session actor refresh.
|
|
933
1049
|
|
|
934
1050
|
**Local testing.** Upload `dist/*-cmi5.zip` to SCORM Cloud and use the cmi5 dispatch URL it generates, the closest free equivalent to a real LMS launch.
|
|
935
1051
|
|
package/dist/plugin/index.js
CHANGED
|
@@ -207,7 +207,8 @@ function generateManifest(pagesDir) {
|
|
|
207
207
|
title: pageConfig.title || titleCase(pageSlug),
|
|
208
208
|
slug: pageSlug,
|
|
209
209
|
importPath: relativePath,
|
|
210
|
-
quiz: pageConfig.quiz || null
|
|
210
|
+
quiz: pageConfig.quiz || null,
|
|
211
|
+
...pageConfig.completesOn === "view" ? { completesOn: "view" } : {}
|
|
211
212
|
};
|
|
212
213
|
lesson.pages.push(page);
|
|
213
214
|
flatPages.push(page);
|
|
@@ -305,13 +306,19 @@ const KNOWN_CONFIG_FIELDS = new Set([
|
|
|
305
306
|
"xapi"
|
|
306
307
|
]);
|
|
307
308
|
const VALID_NAV_MODES = ["free", "sequential"];
|
|
308
|
-
const VALID_COMPLETION_MODES = [
|
|
309
|
+
const VALID_COMPLETION_MODES = [
|
|
310
|
+
"quiz",
|
|
311
|
+
"percentage",
|
|
312
|
+
"manual"
|
|
313
|
+
];
|
|
309
314
|
const VALID_EXPORT_STANDARDS = [
|
|
310
315
|
"web",
|
|
311
316
|
"scorm12",
|
|
312
317
|
"scorm2004",
|
|
313
318
|
"cmi5"
|
|
314
319
|
];
|
|
320
|
+
const VALID_MANUAL_TRIGGERS = ["page"];
|
|
321
|
+
const VALID_REQUIRE_SUCCESS_STATUS = ["passed", "failed"];
|
|
315
322
|
/**
|
|
316
323
|
* Validate a Tessera project at the given root.
|
|
317
324
|
* Returns errors (block build) and warnings (informational).
|
|
@@ -355,7 +362,15 @@ function parseConfig(configPath, errors, warnings) {
|
|
|
355
362
|
if (!VALID_NAV_MODES.includes(config.navigation.mode)) errors.push(`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`);
|
|
356
363
|
}
|
|
357
364
|
if (config.completion?.mode !== void 0) {
|
|
358
|
-
if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) errors.push(`course.config.js: "completion.mode" must be "quiz" or "
|
|
365
|
+
if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) errors.push(`course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`);
|
|
366
|
+
}
|
|
367
|
+
if (config.completion?.trigger !== void 0) {
|
|
368
|
+
if (config.completion.mode !== "manual") warnings.push(`course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`);
|
|
369
|
+
else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) errors.push(`course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`);
|
|
370
|
+
}
|
|
371
|
+
if (config.completion?.requireSuccessStatus !== void 0) {
|
|
372
|
+
if (config.completion.mode !== "manual") warnings.push(`course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`);
|
|
373
|
+
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}"`);
|
|
359
374
|
}
|
|
360
375
|
if (config.export?.standard !== void 0) {
|
|
361
376
|
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}"`);
|
|
@@ -490,6 +505,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
|
|
|
490
505
|
function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
491
506
|
const errors = [];
|
|
492
507
|
const warnings = [];
|
|
508
|
+
const pages = [];
|
|
493
509
|
let totalPages = 0;
|
|
494
510
|
let totalQuizzes = 0;
|
|
495
511
|
let hasGradedQuiz = false;
|
|
@@ -501,7 +517,8 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
501
517
|
warnings,
|
|
502
518
|
totalPages: 0,
|
|
503
519
|
totalQuizzes: 0,
|
|
504
|
-
hasGradedQuiz: false
|
|
520
|
+
hasGradedQuiz: false,
|
|
521
|
+
pages
|
|
505
522
|
};
|
|
506
523
|
}
|
|
507
524
|
const topLevelEntries = readdirSync(pagesDir);
|
|
@@ -522,7 +539,8 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
522
539
|
warnings,
|
|
523
540
|
totalPages: 0,
|
|
524
541
|
totalQuizzes: 0,
|
|
525
|
-
hasGradedQuiz: false
|
|
542
|
+
hasGradedQuiz: false,
|
|
543
|
+
pages
|
|
526
544
|
};
|
|
527
545
|
}
|
|
528
546
|
for (const sectionName of sectionDirs) {
|
|
@@ -546,12 +564,25 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
546
564
|
const fileRel = relative(projectRoot, filePath);
|
|
547
565
|
const content = readSourceFileCached(filePath);
|
|
548
566
|
const pageConfig = validatePageConfig(content, fileRel, errors);
|
|
567
|
+
const navIndex = totalPages;
|
|
549
568
|
totalPages++;
|
|
569
|
+
let pageHasGradedQuiz = false;
|
|
550
570
|
if (pageConfig?.quiz) {
|
|
551
571
|
totalQuizzes++;
|
|
552
572
|
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
553
|
-
if (pageConfig.quiz.graded === true)
|
|
573
|
+
if (pageConfig.quiz.graded === true) {
|
|
574
|
+
hasGradedQuiz = true;
|
|
575
|
+
pageHasGradedQuiz = true;
|
|
576
|
+
}
|
|
554
577
|
}
|
|
578
|
+
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
579
|
+
pages.push({
|
|
580
|
+
fileRel,
|
|
581
|
+
navIndex,
|
|
582
|
+
hasGradedQuiz: pageHasGradedQuiz,
|
|
583
|
+
hasQuiz: !!pageConfig?.quiz,
|
|
584
|
+
completesOnView
|
|
585
|
+
});
|
|
555
586
|
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
556
587
|
}
|
|
557
588
|
const lessonDirs = sectionEntries.filter((name) => {
|
|
@@ -581,12 +612,25 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
581
612
|
const fileRel = relative(projectRoot, filePath);
|
|
582
613
|
const content = readSourceFileCached(filePath);
|
|
583
614
|
const pageConfig = validatePageConfig(content, fileRel, errors);
|
|
615
|
+
const navIndex = totalPages;
|
|
584
616
|
totalPages++;
|
|
617
|
+
let pageHasGradedQuiz = false;
|
|
585
618
|
if (pageConfig?.quiz) {
|
|
586
619
|
totalQuizzes++;
|
|
587
620
|
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
588
|
-
if (pageConfig.quiz.graded === true)
|
|
621
|
+
if (pageConfig.quiz.graded === true) {
|
|
622
|
+
hasGradedQuiz = true;
|
|
623
|
+
pageHasGradedQuiz = true;
|
|
624
|
+
}
|
|
589
625
|
}
|
|
626
|
+
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
627
|
+
pages.push({
|
|
628
|
+
fileRel,
|
|
629
|
+
navIndex,
|
|
630
|
+
hasGradedQuiz: pageHasGradedQuiz,
|
|
631
|
+
hasQuiz: !!pageConfig?.quiz,
|
|
632
|
+
completesOnView
|
|
633
|
+
});
|
|
590
634
|
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
591
635
|
}
|
|
592
636
|
}
|
|
@@ -597,7 +641,8 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
|
597
641
|
warnings,
|
|
598
642
|
totalPages,
|
|
599
643
|
totalQuizzes,
|
|
600
|
-
hasGradedQuiz
|
|
644
|
+
hasGradedQuiz,
|
|
645
|
+
pages
|
|
601
646
|
};
|
|
602
647
|
}
|
|
603
648
|
function validateMetaFile(metaPath, parentRel, errors) {
|
|
@@ -624,6 +669,12 @@ function validatePageConfig(content, fileRel, errors) {
|
|
|
624
669
|
if (result.kind === "invalid") errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
|
|
625
670
|
return null;
|
|
626
671
|
}
|
|
672
|
+
function validateCompletesOn(pageConfig, fileRel, errors) {
|
|
673
|
+
if (!pageConfig || pageConfig.completesOn === void 0) return false;
|
|
674
|
+
if (pageConfig.completesOn === "view") return true;
|
|
675
|
+
errors.push(`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`);
|
|
676
|
+
return false;
|
|
677
|
+
}
|
|
627
678
|
function validateQuizConfig(quiz, fileRel, errors) {
|
|
628
679
|
if (!quiz || typeof quiz !== "object") return;
|
|
629
680
|
const cfg = quiz;
|
|
@@ -655,6 +706,19 @@ function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
|
|
|
655
706
|
}
|
|
656
707
|
function crossValidate(config, pageResults, errors, warnings) {
|
|
657
708
|
if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
|
|
709
|
+
const isManual = config.completion?.mode === "manual";
|
|
710
|
+
const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
|
|
711
|
+
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.");
|
|
712
|
+
if (isManual) {
|
|
713
|
+
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.`);
|
|
714
|
+
}
|
|
715
|
+
if (isManual && config.completion?.percentageThreshold !== void 0) warnings.push("course.config.js: \"completion.percentageThreshold\" is ignored under completion.mode: \"manual\"");
|
|
716
|
+
if (!isManual) for (const page of completesOnPages) warnings.push(`${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? "percentage"}"`);
|
|
717
|
+
for (const page of pageResults.pages) if (page.completesOnView && page.hasQuiz) warnings.push(`${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`);
|
|
718
|
+
if (isManual) {
|
|
719
|
+
const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
|
|
720
|
+
if (firstPage?.completesOnView) warnings.push(`${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`);
|
|
721
|
+
}
|
|
658
722
|
if (config.export?.standard === "scorm12") {
|
|
659
723
|
let visitedChars = 0;
|
|
660
724
|
for (let i = 0; i < pageResults.totalPages; i++) visitedChars += String(i).length + 1;
|
|
@@ -768,16 +832,17 @@ function generateCMI5Xml(config) {
|
|
|
768
832
|
const description = escapeXml(config.description || "");
|
|
769
833
|
const courseId = stableUrn("course", `tessera-course:${config.title || ""}`);
|
|
770
834
|
const auId = stableUrn("au", `tessera-au:${config.title || ""}`);
|
|
771
|
-
const masteryScore = (config.scoring?.passingScore ?? 70) / 100;
|
|
835
|
+
const masteryScore = Number(((config.scoring?.passingScore ?? 70) / 100).toFixed(4));
|
|
772
836
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
773
837
|
<courseStructure xmlns="https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd">
|
|
774
838
|
<course id="${courseId}">
|
|
775
839
|
<title><langstring lang="en-US">${title}</langstring></title>
|
|
776
840
|
<description><langstring lang="en-US">${description}</langstring></description>
|
|
777
841
|
</course>
|
|
778
|
-
<au id="${auId}"
|
|
842
|
+
<au id="${auId}" launchMethod="AnyWindow" moveOn="${config.completion?.mode === "quiz" ? "CompletedAndPassed" : "Completed"}" masteryScore="${masteryScore}">
|
|
779
843
|
<title><langstring lang="en-US">${title}</langstring></title>
|
|
780
844
|
<description><langstring lang="en-US">${description}</langstring></description>
|
|
845
|
+
<url>index.html</url>
|
|
781
846
|
</au>
|
|
782
847
|
</courseStructure>`;
|
|
783
848
|
}
|
|
@@ -1049,6 +1114,19 @@ mount(App, {
|
|
|
1049
1114
|
}
|
|
1050
1115
|
const VIRTUAL_CONFIG_ID = "virtual:tessera-config";
|
|
1051
1116
|
const RESOLVED_CONFIG_ID = "\0" + VIRTUAL_CONFIG_ID;
|
|
1117
|
+
function completionDefaults(mode) {
|
|
1118
|
+
if (mode === "manual") return {
|
|
1119
|
+
completion: { mode: "manual" },
|
|
1120
|
+
passingScore: 0
|
|
1121
|
+
};
|
|
1122
|
+
return {
|
|
1123
|
+
completion: {
|
|
1124
|
+
mode: "percentage",
|
|
1125
|
+
percentageThreshold: 100
|
|
1126
|
+
},
|
|
1127
|
+
passingScore: 70
|
|
1128
|
+
};
|
|
1129
|
+
}
|
|
1052
1130
|
function tesseraConfigPlugin() {
|
|
1053
1131
|
let projectRoot;
|
|
1054
1132
|
return {
|
|
@@ -1078,6 +1156,7 @@ function tesseraConfigPlugin() {
|
|
|
1078
1156
|
userConfig = JSON5.parse(objectStr);
|
|
1079
1157
|
} catch {}
|
|
1080
1158
|
}
|
|
1159
|
+
const { completion, passingScore } = completionDefaults(userConfig.completion?.mode);
|
|
1081
1160
|
const merged = {
|
|
1082
1161
|
title: userConfig.title || "Untitled Course",
|
|
1083
1162
|
...userConfig,
|
|
@@ -1086,12 +1165,11 @@ function tesseraConfigPlugin() {
|
|
|
1086
1165
|
...userConfig.navigation
|
|
1087
1166
|
},
|
|
1088
1167
|
completion: {
|
|
1089
|
-
|
|
1090
|
-
percentageThreshold: 100,
|
|
1168
|
+
...completion,
|
|
1091
1169
|
...userConfig.completion
|
|
1092
1170
|
},
|
|
1093
1171
|
scoring: {
|
|
1094
|
-
passingScore
|
|
1172
|
+
passingScore,
|
|
1095
1173
|
...userConfig.scoring
|
|
1096
1174
|
},
|
|
1097
1175
|
export: {
|