tessera-learn 0.0.10 → 0.0.13

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 (79) hide show
  1. package/README.md +1 -0
  2. package/dist/audit-BBJpQGqb.js +204 -0
  3. package/dist/audit-BBJpQGqb.js.map +1 -0
  4. package/dist/plugin/a11y-cli.d.ts +1 -0
  5. package/dist/plugin/a11y-cli.js +36 -0
  6. package/dist/plugin/a11y-cli.js.map +1 -0
  7. package/dist/plugin/cli.js +6 -3
  8. package/dist/plugin/cli.js.map +1 -1
  9. package/dist/plugin/index.d.ts +16 -1
  10. package/dist/plugin/index.d.ts.map +1 -1
  11. package/dist/plugin/index.js +171 -140
  12. package/dist/plugin/index.js.map +1 -1
  13. package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
  14. package/dist/validation-B-xTvM9B.js.map +1 -0
  15. package/package.json +17 -2
  16. package/src/components/Accordion.svelte +3 -1
  17. package/src/components/AccordionItem.svelte +1 -5
  18. package/src/components/Audio.svelte +22 -5
  19. package/src/components/Callout.svelte +5 -1
  20. package/src/components/Carousel.svelte +24 -8
  21. package/src/components/DefaultLayout.svelte +41 -12
  22. package/src/components/FillInTheBlank.svelte +75 -103
  23. package/src/components/Image.svelte +14 -10
  24. package/src/components/LockedBanner.svelte +5 -5
  25. package/src/components/Matching.svelte +48 -19
  26. package/src/components/MediaTracks.svelte +21 -0
  27. package/src/components/MultipleChoice.svelte +81 -102
  28. package/src/components/Quiz.svelte +63 -21
  29. package/src/components/ResultIcon.svelte +20 -4
  30. package/src/components/RevealModal.svelte +25 -22
  31. package/src/components/Sorting.svelte +61 -26
  32. package/src/components/Transcript.svelte +37 -0
  33. package/src/components/Video.svelte +25 -20
  34. package/src/components/util.ts +4 -1
  35. package/src/components/video-embed.ts +25 -0
  36. package/src/index.ts +2 -7
  37. package/src/plugin/a11y/audit.ts +299 -0
  38. package/src/plugin/a11y/contrast.ts +67 -0
  39. package/src/plugin/a11y-cli.ts +35 -0
  40. package/src/plugin/cli.ts +6 -8
  41. package/src/plugin/export.ts +60 -50
  42. package/src/plugin/index.ts +244 -101
  43. package/src/plugin/layout.ts +6 -51
  44. package/src/plugin/manifest.ts +90 -24
  45. package/src/plugin/override-plugin.ts +68 -0
  46. package/src/plugin/quiz.ts +9 -54
  47. package/src/plugin/validation.ts +768 -183
  48. package/src/runtime/App.svelte +128 -64
  49. package/src/runtime/LoadingBar.svelte +12 -3
  50. package/src/runtime/Sidebar.svelte +24 -8
  51. package/src/runtime/access.ts +15 -3
  52. package/src/runtime/adapters/cmi5.ts +68 -116
  53. package/src/runtime/adapters/format.ts +67 -0
  54. package/src/runtime/adapters/index.ts +45 -34
  55. package/src/runtime/adapters/retry.ts +25 -84
  56. package/src/runtime/adapters/scorm-base.ts +19 -15
  57. package/src/runtime/adapters/scorm12.ts +8 -9
  58. package/src/runtime/adapters/scorm2004.ts +22 -30
  59. package/src/runtime/adapters/web.ts +1 -1
  60. package/src/runtime/hooks.svelte.ts +152 -328
  61. package/src/runtime/interaction-format.ts +30 -12
  62. package/src/runtime/interaction.ts +44 -11
  63. package/src/runtime/navigation.svelte.ts +29 -40
  64. package/src/runtime/persistence.ts +2 -2
  65. package/src/runtime/progress.svelte.ts +22 -9
  66. package/src/runtime/quiz-engine.svelte.ts +361 -0
  67. package/src/runtime/quiz-policy.ts +28 -179
  68. package/src/runtime/types.ts +24 -2
  69. package/src/runtime/xapi/agent-rules.ts +11 -3
  70. package/src/runtime/xapi/client.ts +5 -5
  71. package/src/runtime/xapi/derive-actor.ts +2 -2
  72. package/src/runtime/xapi/publisher.ts +33 -40
  73. package/src/runtime/xapi/setup.ts +18 -15
  74. package/src/runtime/xapi/validation.ts +15 -6
  75. package/src/virtual.d.ts +4 -1
  76. package/styles/base.css +32 -11
  77. package/styles/layout.css +39 -18
  78. package/styles/theme.css +15 -3
  79. package/dist/validation-BxWAMMnJ.js.map +0 -1
@@ -1,7 +1,7 @@
1
1
  import { readdirSync, readFileSync, existsSync, statSync } from 'node:fs';
2
2
  import { resolve, basename, extname } from 'node:path';
3
3
  import JSON5 from 'json5';
4
- import type { QuizConfig } from '../runtime/types.js';
4
+ import type { CourseConfig, QuizConfig } from '../runtime/types.js';
5
5
 
6
6
  // ---------- Types ----------
7
7
 
@@ -48,7 +48,10 @@ export function ensureSvelteSuffix(name: string): string {
48
48
  * sharing the read avoids the second disk hit (and matters most on cold-cache
49
49
  * CI runs and large courses).
50
50
  */
51
- const fileContentCache = new Map<string, { mtimeMs: number; content: string }>();
51
+ const fileContentCache = new Map<
52
+ string,
53
+ { mtimeMs: number; content: string }
54
+ >();
52
55
 
53
56
  export function readSourceFileCached(filePath: string): string {
54
57
  const stat = statSync(filePath);
@@ -70,7 +73,7 @@ export function stripPrefix(name: string): string {
70
73
  export function titleCase(slug: string): string {
71
74
  return slug
72
75
  .split('-')
73
- .map(word => word.charAt(0).toUpperCase() + word.slice(1))
76
+ .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
74
77
  .join(' ');
75
78
  }
76
79
 
@@ -97,7 +100,9 @@ const DEFAULT_EXPORT_RE = /export\s+default\s*/;
97
100
  * or null if no balanced object literal follows the `export default` keyword.
98
101
  * Used by both manifest extraction and project validation.
99
102
  */
100
- export function extractDefaultExportObjectLiteral(source: string): string | null {
103
+ export function extractDefaultExportObjectLiteral(
104
+ source: string,
105
+ ): string | null {
101
106
  const match = source.match(DEFAULT_EXPORT_RE);
102
107
  if (!match || match.index === undefined) return null;
103
108
  const startIndex = source.indexOf('{', match.index);
@@ -105,15 +110,49 @@ export function extractDefaultExportObjectLiteral(source: string): string | null
105
110
  return extractObjectLiteral(source, startIndex);
106
111
  }
107
112
 
113
+ export type CourseConfigRead =
114
+ | { ok: true; config: Partial<CourseConfig> }
115
+ | {
116
+ ok: false;
117
+ reason: 'missing' | 'no-export' | 'parse-error';
118
+ error?: unknown;
119
+ };
120
+
121
+ /**
122
+ * Read and JSON5-parse the `export default { ... }` literal from a project's
123
+ * course.config.js. Shared by the build plugin and the validator so the read,
124
+ * cache, and parse rules live in one place. The discriminated `reason` lets
125
+ * callers that care (export, validation) emit precise errors while callers
126
+ * that just need a value can fall back on `!ok`.
127
+ */
128
+ export function readCourseConfig(projectRoot: string): CourseConfigRead {
129
+ const configPath = resolve(projectRoot, 'course.config.js');
130
+ if (!existsSync(configPath)) return { ok: false, reason: 'missing' };
131
+ const objectStr = extractDefaultExportObjectLiteral(
132
+ readSourceFileCached(configPath),
133
+ );
134
+ if (!objectStr) return { ok: false, reason: 'no-export' };
135
+ try {
136
+ return { ok: true, config: JSON5.parse(objectStr) };
137
+ } catch (error) {
138
+ return { ok: false, reason: 'parse-error', error };
139
+ }
140
+ }
141
+
108
142
  /**
109
143
  * Read a _meta.js file and extract its default export object.
110
144
  * Uses the same JSON5 approach as pageConfig extraction — find the object literal
111
145
  * after `export default` and parse it.
112
146
  */
113
- export function readMetaFile(metaPath: string): { title?: string; pages?: string[] } {
147
+ export function readMetaFile(metaPath: string): {
148
+ title?: string;
149
+ pages?: string[];
150
+ } {
114
151
  if (!existsSync(metaPath)) return {};
115
152
 
116
- const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
153
+ const objectStr = extractDefaultExportObjectLiteral(
154
+ readSourceFileCached(metaPath),
155
+ );
117
156
  if (!objectStr) return {};
118
157
 
119
158
  try {
@@ -128,12 +167,17 @@ export type PageConfigParseResult =
128
167
  /** No module script, or no `pageConfig =` export. Treat as "no config". */
129
168
  | { kind: 'none' }
130
169
  /** Found and successfully parsed. */
131
- | { kind: 'ok'; value: { title?: string; quiz?: QuizConfig; completesOn?: 'view' } }
170
+ | {
171
+ kind: 'ok';
172
+ value: { title?: string; quiz?: QuizConfig; completesOn?: 'view' };
173
+ }
132
174
  /** Found but couldn't parse as a static object literal — non-literal RHS or JSON5 failure. */
133
175
  | { kind: 'invalid' };
134
176
 
135
177
  /** Source-level pageConfig extraction shared by manifest generation and build-time validation. */
136
- export function parsePageConfigFromSource(content: string): PageConfigParseResult {
178
+ export function parsePageConfigFromSource(
179
+ content: string,
180
+ ): PageConfigParseResult {
137
181
  const moduleScriptMatch = content.match(MODULE_SCRIPT_RE);
138
182
  if (!moduleScriptMatch) return { kind: 'none' };
139
183
 
@@ -148,7 +192,10 @@ export function parsePageConfigFromSource(content: string): PageConfigParseResul
148
192
  // pageConfig assigned to something other than an object literal — flag as invalid.
149
193
  if (!afterExport.startsWith('{')) return { kind: 'invalid' };
150
194
 
151
- const startIndex = scriptContent.indexOf('{', configMatch.index + configMatch[0].length);
195
+ const startIndex = scriptContent.indexOf(
196
+ '{',
197
+ configMatch.index + configMatch[0].length,
198
+ );
152
199
  if (startIndex < 0) return { kind: 'invalid' };
153
200
  const objectStr = extractObjectLiteral(scriptContent, startIndex);
154
201
  if (!objectStr) return { kind: 'invalid' };
@@ -161,23 +208,33 @@ export function parsePageConfigFromSource(content: string): PageConfigParseResul
161
208
  }
162
209
 
163
210
  /** Extract pageConfig from a .svelte file. Throws on parse failure. */
164
- export function extractPageConfig(filePath: string): { title?: string; quiz?: QuizConfig; completesOn?: 'view' } {
211
+ export function extractPageConfig(filePath: string): {
212
+ title?: string;
213
+ quiz?: QuizConfig;
214
+ completesOn?: 'view';
215
+ } {
165
216
  const result = parsePageConfigFromSource(readSourceFileCached(filePath));
166
217
  if (result.kind === 'ok') return result.value;
167
218
  if (result.kind === 'invalid') {
168
219
  throw new Error(
169
- `${filePath}: pageConfig must be a static object literal (no variables, function calls, or computed values)`
220
+ `${filePath}: pageConfig must be a static object literal (no variables, function calls, or computed values)`,
170
221
  );
171
222
  }
172
223
  return {};
173
224
  }
174
225
 
175
226
  /**
176
- * Extract an object literal from source starting at the opening brace.
177
- * Tracks brace depth to find the matching closing brace.
227
+ * Extract a balanced `{...}` or `[...]` span starting at the opening bracket,
228
+ * skipping strings and comments. Returns the substring (inclusive) or null if
229
+ * the open char is wrong or no matching close is found. Shared by manifest
230
+ * extraction, _meta/pageConfig parsing, and the validator's tag-prop parser.
178
231
  */
179
- export function extractObjectLiteral(source: string, startIndex: number): string | null {
180
- if (source[startIndex] !== '{') return null;
232
+ export function extractObjectLiteral(
233
+ source: string,
234
+ startIndex: number,
235
+ ): string | null {
236
+ const open = source[startIndex];
237
+ if (open !== '{' && open !== '[') return null;
181
238
 
182
239
  let depth = 0;
183
240
  let inString: string | null = null;
@@ -222,8 +279,8 @@ export function extractObjectLiteral(source: string, startIndex: number): string
222
279
  continue;
223
280
  }
224
281
 
225
- if (char === '{') depth++;
226
- if (char === '}') {
282
+ if (char === '{' || char === '[') depth++;
283
+ if (char === '}' || char === ']') {
227
284
  depth--;
228
285
  if (depth === 0) {
229
286
  return source.slice(startIndex, i + 1);
@@ -240,7 +297,7 @@ export function extractObjectLiteral(source: string, startIndex: number): string
240
297
  function getSortedDirs(dirPath: string): string[] {
241
298
  if (!existsSync(dirPath)) return [];
242
299
  return readdirSync(dirPath)
243
- .filter(name => {
300
+ .filter((name) => {
244
301
  const full = resolve(dirPath, name);
245
302
  return statSync(full).isDirectory() && !name.startsWith('.');
246
303
  })
@@ -253,7 +310,7 @@ function getSortedDirs(dirPath: string): string[] {
253
310
  function getSvelteFiles(dirPath: string): string[] {
254
311
  if (!existsSync(dirPath)) return [];
255
312
  return readdirSync(dirPath)
256
- .filter(name => name.endsWith('.svelte'))
313
+ .filter((name) => name.endsWith('.svelte'))
257
314
  .sort();
258
315
  }
259
316
 
@@ -301,7 +358,11 @@ export function generateManifest(pagesDir: string): Manifest {
301
358
  const filePath = resolve(lessonPath, fileName);
302
359
  const pageSlug = deriveSlug(fileName, true);
303
360
 
304
- let pageConfig: { title?: string; quiz?: QuizConfig; completesOn?: 'view' } = {};
361
+ let pageConfig: {
362
+ title?: string;
363
+ quiz?: QuizConfig;
364
+ completesOn?: 'view';
365
+ } = {};
305
366
  try {
306
367
  pageConfig = extractPageConfig(filePath);
307
368
  } catch (e) {
@@ -318,7 +379,9 @@ export function generateManifest(pagesDir: string): Manifest {
318
379
  slug: pageSlug,
319
380
  importPath: relativePath,
320
381
  quiz: pageConfig.quiz || null,
321
- ...(pageConfig.completesOn === 'view' ? { completesOn: 'view' as const } : {}),
382
+ ...(pageConfig.completesOn === 'view'
383
+ ? { completesOn: 'view' as const }
384
+ : {}),
322
385
  };
323
386
 
324
387
  lesson.pages.push(page);
@@ -342,17 +405,20 @@ export function generateManifest(pagesDir: string): Manifest {
342
405
  /**
343
406
  * Order .svelte files: listed in `pages` array first (in order), then unlisted appended alphabetically.
344
407
  */
345
- export function orderPageFiles(allFiles: string[], pagesArray?: string[]): string[] {
408
+ export function orderPageFiles(
409
+ allFiles: string[],
410
+ pagesArray?: string[],
411
+ ): string[] {
346
412
  if (!pagesArray || pagesArray.length === 0) {
347
413
  return allFiles;
348
414
  }
349
415
 
350
416
  const listed = pagesArray.map(ensureSvelteSuffix);
351
417
  const listedSet = new Set(listed);
352
- const unlisted = allFiles.filter(f => !listedSet.has(f)).sort();
418
+ const unlisted = allFiles.filter((f) => !listedSet.has(f)).sort();
353
419
 
354
420
  // Only include listed files that actually exist
355
- const validListed = listed.filter(f => allFiles.includes(f));
421
+ const validListed = listed.filter((f) => allFiles.includes(f));
356
422
 
357
423
  return [...validListed, ...unlisted];
358
424
  }
@@ -0,0 +1,68 @@
1
+ import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
2
+ import { normalizePath } from 'vite';
3
+ import { existsSync } from 'node:fs';
4
+ import { resolve } from 'node:path';
5
+
6
+ export interface OverridePluginOptions {
7
+ name: string;
8
+ virtualId: string;
9
+ projectFile: string;
10
+ /** Built-in re-exported when the project file is absent; null export otherwise. */
11
+ builtinFile?: string;
12
+ }
13
+
14
+ /**
15
+ * A virtual module that resolves to a project-root override file when present,
16
+ * and to the built-in (or a null export) otherwise. Shared by the layout and
17
+ * quiz plugins — they differ only in the virtual id, file name, and built-in.
18
+ */
19
+ export function createOverridePlugin({
20
+ name,
21
+ virtualId,
22
+ projectFile,
23
+ builtinFile,
24
+ }: OverridePluginOptions): Plugin {
25
+ const resolvedId = '\0' + virtualId;
26
+ const fallback = builtinFile
27
+ ? `export { default } from '${normalizePath(builtinFile)}';`
28
+ : 'export default null;';
29
+ let filePath: string;
30
+
31
+ return {
32
+ name,
33
+ enforce: 'pre',
34
+
35
+ configResolved(config: ResolvedConfig) {
36
+ filePath = resolve(config.root, projectFile);
37
+ },
38
+
39
+ resolveId(id) {
40
+ if (id === virtualId) return resolvedId;
41
+ return null;
42
+ },
43
+
44
+ load(id) {
45
+ if (id !== resolvedId) return null;
46
+ if (existsSync(filePath)) {
47
+ // Only watch when it exists — addWatchFile on a missing path makes
48
+ // Vite's importAnalysis try to resolve it as a real import.
49
+ this.addWatchFile(filePath);
50
+ return `export { default } from '${normalizePath(filePath)}';`;
51
+ }
52
+ return fallback;
53
+ },
54
+
55
+ configureServer(server: ViteDevServer) {
56
+ // Only add/unlink flips load()'s output between the override and the
57
+ // fallback; a `change` leaves it identical and Svelte's own HMR handles
58
+ // the underlying file.
59
+ server.watcher.on('all', (event, changed) => {
60
+ if (changed !== filePath) return;
61
+ if (event !== 'add' && event !== 'unlink') return;
62
+ const mod = server.moduleGraph.getModuleById(resolvedId);
63
+ if (mod) server.moduleGraph.invalidateModule(mod);
64
+ server.ws.send({ type: 'full-reload' });
65
+ });
66
+ },
67
+ };
68
+ }
@@ -1,65 +1,20 @@
1
- import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
2
- import { existsSync } from 'node:fs';
1
+ import type { Plugin } from 'vite';
3
2
  import { resolve, dirname } from 'node:path';
4
3
  import { fileURLToPath } from 'node:url';
5
-
6
- const VIRTUAL_QUIZ_ID = 'virtual:tessera-quiz';
7
- const RESOLVED_QUIZ_ID = '\0' + VIRTUAL_QUIZ_ID;
4
+ import { createOverridePlugin } from './override-plugin.js';
8
5
 
9
6
  const __filename = fileURLToPath(import.meta.url);
10
7
  const __dirname = dirname(__filename);
11
8
 
12
- /**
13
- * Resolve the project's quiz shell.
14
- * `projectRoot/quiz.svelte` overrides the built-in `<Quiz>` if it exists,
15
- * otherwise the built-in is used. Mirrors `tesseraLayoutPlugin` (Phase 3A).
16
- */
17
9
  export function tesseraQuizPlugin(): Plugin {
18
- let projectRoot: string;
19
- // Resolve the built-in Quiz.svelte once. The plugin lives in
20
- // `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts` in source —
21
- // both layouts put `Quiz.svelte` two levels up under `src/components/`.
10
+ // The plugin lives in `dist/plugin/quiz.js` after build and `src/plugin/quiz.ts`
11
+ // in source both put `Quiz.svelte` two levels up under `src/components/`.
22
12
  const packageRoot = resolve(__dirname, '..', '..');
23
13
  const builtinQuiz = resolve(packageRoot, 'src', 'components', 'Quiz.svelte');
24
-
25
- return {
14
+ return createOverridePlugin({
26
15
  name: 'tessera:quiz',
27
- enforce: 'pre',
28
-
29
- configResolved(config: ResolvedConfig) {
30
- projectRoot = config.root;
31
- },
32
-
33
- resolveId(id) {
34
- if (id === VIRTUAL_QUIZ_ID) return RESOLVED_QUIZ_ID;
35
- return null;
36
- },
37
-
38
- load(id) {
39
- if (id !== RESOLVED_QUIZ_ID) return null;
40
- const userQuizPath = resolve(projectRoot, 'quiz.svelte');
41
- if (existsSync(userQuizPath)) {
42
- // Watch the user file so add/remove flips through HMR (see below).
43
- this.addWatchFile(userQuizPath);
44
- const normalized = userQuizPath.replace(/\\/g, '/');
45
- return `export { default } from '${normalized}';`;
46
- }
47
- const normalized = builtinQuiz.replace(/\\/g, '/');
48
- return `export { default } from '${normalized}';`;
49
- },
50
-
51
- configureServer(server: ViteDevServer) {
52
- const userQuizPath = resolve(projectRoot, 'quiz.svelte');
53
- // Only react to add/unlink — those flip the load() output between the
54
- // user quiz and the built-in. A `change` event leaves the resolved
55
- // module identical and is handled by Svelte's own HMR.
56
- server.watcher.on('all', (event, filePath) => {
57
- if (filePath !== userQuizPath) return;
58
- if (event !== 'add' && event !== 'unlink') return;
59
- const mod = server.moduleGraph.getModuleById(RESOLVED_QUIZ_ID);
60
- if (mod) server.moduleGraph.invalidateModule(mod);
61
- server.ws.send({ type: 'full-reload' });
62
- });
63
- },
64
- };
16
+ virtualId: 'virtual:tessera-quiz',
17
+ projectFile: 'quiz.svelte',
18
+ builtinFile: builtinQuiz,
19
+ });
65
20
  }