tessera-learn 0.2.2 → 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 (48) hide show
  1. package/AGENTS.md +161 -535
  2. package/README.md +2 -2
  3. package/dist/{audit-B9VHgVjk.js → audit-DkXqQTqn.js} +92 -38
  4. package/dist/audit-DkXqQTqn.js.map +1 -0
  5. package/dist/{build-commands-D127jw0J.js → build-commands-CyzuCDXg.js} +2 -2
  6. package/dist/{build-commands-D127jw0J.js.map → build-commands-CyzuCDXg.js.map} +1 -1
  7. package/dist/{inline-config-eHjv9XuA.js → inline-config-BEXyRqsJ.js} +2 -2
  8. package/dist/{inline-config-eHjv9XuA.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 +62 -54
  11. package/dist/plugin/cli.js.map +1 -1
  12. package/dist/plugin/index.d.ts +280 -3
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +3 -3
  15. package/dist/{plugin--8H9xQIl.js → plugin-CFUFgwHB.js} +126 -83
  16. package/dist/plugin-CFUFgwHB.js.map +1 -0
  17. package/package.json +12 -9
  18. package/src/components/DefaultLayout.svelte +2 -5
  19. package/src/components/Quiz.svelte +18 -26
  20. package/src/plugin/a11y/audit.ts +8 -13
  21. package/src/plugin/a11y-cli.ts +1 -4
  22. package/src/plugin/ast.ts +9 -2
  23. package/src/plugin/cli.ts +46 -48
  24. package/src/plugin/csp.ts +59 -0
  25. package/src/plugin/duplicate-cli.ts +37 -1
  26. package/src/plugin/export.ts +56 -27
  27. package/src/plugin/index.ts +117 -61
  28. package/src/plugin/manifest.ts +3 -23
  29. package/src/plugin/new-cli.ts +2 -0
  30. package/src/plugin/validate-cli.ts +10 -4
  31. package/src/plugin/validation.ts +48 -12
  32. package/src/runtime/App.svelte +10 -8
  33. package/src/runtime/Sidebar.svelte +3 -1
  34. package/src/runtime/adapters/cmi5.ts +59 -402
  35. package/src/runtime/adapters/discovery.ts +11 -0
  36. package/src/runtime/adapters/index.ts +27 -60
  37. package/src/runtime/adapters/lms-error.ts +61 -0
  38. package/src/runtime/adapters/scorm2004.ts +2 -1
  39. package/src/runtime/adapters/web.ts +19 -4
  40. package/src/runtime/adapters/xapi-launch-base.ts +346 -0
  41. package/src/runtime/adapters/xapi.ts +26 -0
  42. package/src/runtime/types.ts +19 -1
  43. package/src/runtime/xapi/publisher.ts +5 -1
  44. package/src/runtime/xapi/setup.ts +24 -15
  45. package/src/virtual.d.ts +4 -1
  46. package/templates/course/course.config.js +1 -0
  47. package/dist/audit-B9VHgVjk.js.map +0 -1
  48. package/dist/plugin--8H9xQIl.js.map +0 -1
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,27 +1631,24 @@ 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-eHjv9XuA.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"
1581
1638
  });
1582
1639
  const auditDist = resolve(projectRoot, "node_modules", ".tessera-a11y");
1583
- const distHtml = resolve(auditDist, "index.html");
1584
1640
  const prevEnv = process.env[AUDIT_ENV_FLAG];
1585
1641
  process.env[AUDIT_ENV_FLAG] = "1";
1586
1642
  let server;
1587
1643
  try {
1588
- if (options.rebuild || !existsSync(distHtml)) {
1589
- console.log("[tessera a11y] Building course…");
1590
- await vite.build(vite.mergeConfig(auditBaseConfig, {
1591
- build: {
1592
- outDir: auditDist,
1593
- emptyOutDir: true
1594
- },
1595
- logLevel: "warn"
1596
- }));
1597
- }
1644
+ console.log("[tessera a11y] Building course…");
1645
+ await vite.build(vite.mergeConfig(auditBaseConfig, {
1646
+ build: {
1647
+ outDir: auditDist,
1648
+ emptyOutDir: true
1649
+ },
1650
+ logLevel: "warn"
1651
+ }));
1598
1652
  server = await vite.preview({
1599
1653
  root: projectRoot,
1600
1654
  base: auditBaseConfig.base,
@@ -1728,6 +1782,6 @@ function printSummary(report, reportPath) {
1728
1782
  }
1729
1783
  }
1730
1784
  //#endregion
1731
- 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 };
1732
1786
 
1733
- //# sourceMappingURL=audit-B9VHgVjk.js.map
1787
+ //# sourceMappingURL=audit-DkXqQTqn.js.map