tessera-learn 0.0.1

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 (71) hide show
  1. package/AGENTS.md +1228 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/dist/plugin/index.d.ts +7 -0
  5. package/dist/plugin/index.d.ts.map +1 -0
  6. package/dist/plugin/index.js +1239 -0
  7. package/dist/plugin/index.js.map +1 -0
  8. package/package.json +77 -0
  9. package/src/archiver.d.ts +27 -0
  10. package/src/components/Accordion.svelte +32 -0
  11. package/src/components/AccordionItem.svelte +144 -0
  12. package/src/components/Audio.svelte +38 -0
  13. package/src/components/Callout.svelte +81 -0
  14. package/src/components/Carousel.svelte +194 -0
  15. package/src/components/CarouselSlide.svelte +32 -0
  16. package/src/components/DefaultLayout.svelte +108 -0
  17. package/src/components/FillInTheBlank.svelte +345 -0
  18. package/src/components/Image.svelte +47 -0
  19. package/src/components/Matching.svelte +513 -0
  20. package/src/components/MultipleChoice.svelte +363 -0
  21. package/src/components/Quiz.svelte +569 -0
  22. package/src/components/RevealModal.svelte +228 -0
  23. package/src/components/Sorting.svelte +663 -0
  24. package/src/components/Video.svelte +118 -0
  25. package/src/components/index.ts +15 -0
  26. package/src/components/quiz-payload.ts +71 -0
  27. package/src/components/util.ts +24 -0
  28. package/src/index.ts +56 -0
  29. package/src/plugin/export.ts +264 -0
  30. package/src/plugin/index.ts +464 -0
  31. package/src/plugin/layout.ts +55 -0
  32. package/src/plugin/manifest.ts +330 -0
  33. package/src/plugin/quiz.ts +65 -0
  34. package/src/plugin/validation.ts +838 -0
  35. package/src/runtime/App.svelte +435 -0
  36. package/src/runtime/ErrorPage.svelte +14 -0
  37. package/src/runtime/LoadingSkeleton.svelte +26 -0
  38. package/src/runtime/Sidebar.svelte +76 -0
  39. package/src/runtime/access.ts +55 -0
  40. package/src/runtime/adapters/cmi5.ts +341 -0
  41. package/src/runtime/adapters/discovery.ts +38 -0
  42. package/src/runtime/adapters/index.ts +99 -0
  43. package/src/runtime/adapters/retry.ts +284 -0
  44. package/src/runtime/adapters/scorm12.ts +172 -0
  45. package/src/runtime/adapters/scorm2004.ts +162 -0
  46. package/src/runtime/adapters/web.ts +62 -0
  47. package/src/runtime/contexts.ts +76 -0
  48. package/src/runtime/duration.ts +29 -0
  49. package/src/runtime/hooks.svelte.ts +543 -0
  50. package/src/runtime/interaction-format.ts +132 -0
  51. package/src/runtime/interaction.ts +96 -0
  52. package/src/runtime/navigation.svelte.ts +117 -0
  53. package/src/runtime/persistence.ts +56 -0
  54. package/src/runtime/progress.svelte.ts +168 -0
  55. package/src/runtime/quiz-policy.ts +227 -0
  56. package/src/runtime/slugify.ts +17 -0
  57. package/src/runtime/types.ts +92 -0
  58. package/src/runtime/xapi/agent-rules.ts +93 -0
  59. package/src/runtime/xapi/client.ts +133 -0
  60. package/src/runtime/xapi/derive-actor.ts +90 -0
  61. package/src/runtime/xapi/publisher.ts +604 -0
  62. package/src/runtime/xapi/registry.ts +38 -0
  63. package/src/runtime/xapi/setup.ts +250 -0
  64. package/src/runtime/xapi/types.ts +106 -0
  65. package/src/runtime/xapi/uuid.ts +21 -0
  66. package/src/runtime/xapi/validation.ts +71 -0
  67. package/src/runtime/xapi/version.ts +23 -0
  68. package/src/virtual.d.ts +16 -0
  69. package/styles/base.css +194 -0
  70. package/styles/layout.css +408 -0
  71. package/styles/theme.css +36 -0
@@ -0,0 +1,464 @@
1
+ import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
2
+ import { svelte } from '@sveltejs/vite-plugin-svelte';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { existsSync, readFileSync, readdirSync, statSync, writeFileSync, unlinkSync, cpSync, mkdirSync } from 'node:fs';
6
+ import { generateManifest, extractDefaultExportObjectLiteral } from './manifest.js';
7
+ import JSON5 from 'json5';
8
+ import type { Manifest } from './manifest.js';
9
+ import { validateProject } from './validation.js';
10
+ import { runExport } from './export.js';
11
+ import { tesseraLayoutPlugin } from './layout.js';
12
+ import { tesseraQuizPlugin } from './quiz.js';
13
+
14
+ const __filename = fileURLToPath(import.meta.url);
15
+ const __dirname = dirname(__filename);
16
+
17
+ // Resolve the runtime directory where App.svelte lives
18
+ function resolveRuntimeDir(): string {
19
+ const packageRoot = resolve(__dirname, '..', '..');
20
+ return resolve(packageRoot, 'src', 'runtime');
21
+ }
22
+
23
+ // Resolve the framework styles directory
24
+ function resolveStylesDir(): string {
25
+ const packageRoot = resolve(__dirname, '..', '..');
26
+ return resolve(packageRoot, 'styles');
27
+ }
28
+
29
+ export function tesseraPlugin() {
30
+ return [
31
+ svelte({
32
+ compilerOptions: { css: 'injected' },
33
+ }),
34
+ tesseraValidationPlugin(),
35
+ tesseraEntryPlugin(),
36
+ tesseraConfigPlugin(),
37
+ tesseraPagesPlugin(),
38
+ tesseraManifestPlugin(),
39
+ tesseraLayoutPlugin(),
40
+ tesseraQuizPlugin(),
41
+ tesseraExportPlugin(),
42
+ ];
43
+ }
44
+
45
+ // ---------- Entry Plugin ----------
46
+
47
+ const VIRTUAL_ENTRY_ID = 'virtual:tessera-entry';
48
+ const RESOLVED_ENTRY_ID = '\0' + VIRTUAL_ENTRY_ID;
49
+ const VIRTUAL_MAIN_ID = '/virtual:tessera-main';
50
+ const RESOLVED_MAIN_ID = '\0virtual:tessera-main';
51
+
52
+ function tesseraEntryPlugin(): Plugin {
53
+ const runtimeDir = resolveRuntimeDir();
54
+ const stylesDir = resolveStylesDir();
55
+ const appSveltePath = resolve(runtimeDir, 'App.svelte');
56
+ let projectRoot: string;
57
+ let isBuild = false;
58
+
59
+ return {
60
+ name: 'tessera:entry',
61
+ enforce: 'pre',
62
+
63
+ configResolved(config: ResolvedConfig) {
64
+ projectRoot = config.root;
65
+ isBuild = config.command === 'build';
66
+ },
67
+
68
+ // For build mode: write index.html so Rollup can find it
69
+ buildStart() {
70
+ if (isBuild) {
71
+ writeFileSync(resolve(projectRoot, 'index.html'), generateIndexHtml(), 'utf-8');
72
+ }
73
+ },
74
+
75
+ // For build mode: clean up temporary index.html and copy assets
76
+ closeBundle() {
77
+ if (isBuild) {
78
+ const htmlPath = resolve(projectRoot, 'index.html');
79
+ if (existsSync(htmlPath)) {
80
+ try { unlinkSync(htmlPath); } catch {}
81
+ }
82
+
83
+ // Copy assets/ directory to dist/assets/ so $assets/ references resolve
84
+ const assetsDir = resolve(projectRoot, 'assets');
85
+ const distAssetsDir = resolve(projectRoot, 'dist', 'assets');
86
+ if (existsSync(assetsDir)) {
87
+ mkdirSync(distAssetsDir, { recursive: true });
88
+ cpSync(assetsDir, distAssetsDir, { recursive: true });
89
+ }
90
+ }
91
+ },
92
+
93
+ // Serve index.html for the dev server
94
+ configureServer(server: ViteDevServer) {
95
+ return () => {
96
+ server.middlewares.use(async (req, res, next) => {
97
+ if (req.url === '/' || req.url === '/index.html') {
98
+ const html = generateIndexHtml();
99
+ const transformed = await server.transformIndexHtml(req.url, html);
100
+ res.setHeader('Content-Type', 'text/html');
101
+ res.statusCode = 200;
102
+ res.end(transformed);
103
+ return;
104
+ }
105
+ next();
106
+ });
107
+ };
108
+ },
109
+
110
+ resolveId(id) {
111
+ if (id === VIRTUAL_ENTRY_ID) return RESOLVED_ENTRY_ID;
112
+ if (id === VIRTUAL_MAIN_ID || id === 'virtual:tessera-main') return RESOLVED_MAIN_ID;
113
+ return null;
114
+ },
115
+
116
+ load(id) {
117
+ if (id === RESOLVED_ENTRY_ID || id === RESOLVED_MAIN_ID) {
118
+ return generateEntryScript(appSveltePath, stylesDir, projectRoot);
119
+ }
120
+ return null;
121
+ },
122
+ };
123
+ }
124
+
125
+ function generateIndexHtml(): string {
126
+ return `<!DOCTYPE html>
127
+ <html lang="en">
128
+ <head>
129
+ <meta charset="UTF-8" />
130
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
131
+ <title>Tessera Course</title>
132
+ </head>
133
+ <body>
134
+ <div id="tessera-root"></div>
135
+ <script type="module" src="/virtual:tessera-main"></script>
136
+ </body>
137
+ </html>`;
138
+ }
139
+
140
+ function generateEntryScript(appSveltePath: string, frameworkStylesDir: string, projectRoot: string): string {
141
+ const normalizedPath = appSveltePath.replace(/\\/g, '/');
142
+
143
+ // Framework CSS imports (theme → base → layout)
144
+ const frameworkCssOrder = ['theme.css', 'base.css', 'layout.css'];
145
+ const frameworkImports = frameworkCssOrder
146
+ .map(file => resolve(frameworkStylesDir, file).replace(/\\/g, '/'))
147
+ .filter(path => existsSync(path))
148
+ .map(path => `import '${path}';`)
149
+ .join('\n');
150
+
151
+ // User CSS imports from project's styles/ directory
152
+ const userStylesDir = resolve(projectRoot, 'styles');
153
+ let userImports = '';
154
+ if (existsSync(userStylesDir)) {
155
+ const userCssFiles = readdirSync(userStylesDir)
156
+ .filter(f => f.endsWith('.css'))
157
+ .sort();
158
+ userImports = userCssFiles
159
+ .map(f => resolve(userStylesDir, f).replace(/\\/g, '/'))
160
+ .map(path => `import '${path}';`)
161
+ .join('\n');
162
+ }
163
+
164
+ return `// Framework styles
165
+ ${frameworkImports}
166
+ // User styles
167
+ ${userImports}
168
+
169
+ import { mount } from 'svelte';
170
+ import App from '${normalizedPath}';
171
+
172
+ mount(App, {
173
+ target: document.getElementById('tessera-root'),
174
+ });
175
+ `;
176
+ }
177
+
178
+ // ---------- Config Plugin ----------
179
+
180
+ const VIRTUAL_CONFIG_ID = 'virtual:tessera-config';
181
+ const RESOLVED_CONFIG_ID = '\0' + VIRTUAL_CONFIG_ID;
182
+
183
+ function tesseraConfigPlugin(): Plugin {
184
+ let projectRoot: string;
185
+
186
+ return {
187
+ name: 'tessera:config',
188
+ enforce: 'pre',
189
+
190
+ config(config) {
191
+ const root = config.root || process.cwd();
192
+
193
+ return {
194
+ base: './',
195
+ resolve: {
196
+ alias: {
197
+ '$assets': resolve(root, 'assets'),
198
+ },
199
+ },
200
+ };
201
+ },
202
+
203
+ configResolved(config: ResolvedConfig) {
204
+ projectRoot = config.root;
205
+ },
206
+
207
+ resolveId(id) {
208
+ if (id === VIRTUAL_CONFIG_ID) return RESOLVED_CONFIG_ID;
209
+ return null;
210
+ },
211
+
212
+ load(id) {
213
+ if (id === RESOLVED_CONFIG_ID) {
214
+ const configPath = resolve(projectRoot, 'course.config.js');
215
+ let userConfig: Record<string, any> = {};
216
+
217
+ if (existsSync(configPath)) {
218
+ this.addWatchFile(configPath);
219
+ const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
220
+ if (objectStr) {
221
+ try { userConfig = JSON5.parse(objectStr); } catch {}
222
+ }
223
+ }
224
+
225
+ const merged = {
226
+ title: userConfig.title || 'Untitled Course',
227
+ ...userConfig,
228
+ navigation: { mode: 'free', ...userConfig.navigation },
229
+ completion: { mode: 'percentage', percentageThreshold: 100, ...userConfig.completion },
230
+ scoring: { passingScore: 70, ...userConfig.scoring },
231
+ export: { standard: 'web', ...userConfig.export },
232
+ };
233
+
234
+ return `export default ${JSON.stringify(merged)};`;
235
+ }
236
+ return null;
237
+ },
238
+ };
239
+ }
240
+
241
+ // ---------- Manifest Watch Helpers ----------
242
+
243
+ /** Register all _meta.js and .svelte files under pagesDir as watch files for build mode. */
244
+ function addWatchFiles(ctx: { addWatchFile(id: string): void }, dir: string): void {
245
+ if (!existsSync(dir)) return;
246
+ for (const entry of readdirSync(dir)) {
247
+ const full = resolve(dir, entry);
248
+ if (statSync(full).isDirectory()) {
249
+ addWatchFiles(ctx, full);
250
+ } else if (entry.endsWith('.svelte') || entry === '_meta.js') {
251
+ ctx.addWatchFile(full);
252
+ }
253
+ }
254
+ }
255
+
256
+ // ---------- Pages Plugin ----------
257
+
258
+ const VIRTUAL_PAGES_ID = 'virtual:tessera-pages';
259
+ const RESOLVED_PAGES_ID = '\0' + VIRTUAL_PAGES_ID;
260
+
261
+ /**
262
+ * Provides a virtual module that exports an import.meta.glob map for all .svelte
263
+ * pages. This runs in the user's project context so the glob resolves against their
264
+ * pages/ directory, and Vite can statically analyze it for code splitting.
265
+ */
266
+ function tesseraPagesPlugin(): Plugin {
267
+ return {
268
+ name: 'tessera:pages',
269
+ enforce: 'pre',
270
+
271
+ resolveId(id) {
272
+ if (id === VIRTUAL_PAGES_ID) return RESOLVED_PAGES_ID;
273
+ return null;
274
+ },
275
+
276
+ load(id) {
277
+ if (id === RESOLVED_PAGES_ID) {
278
+ return `export default import.meta.glob('/pages/**/*.svelte');`;
279
+ }
280
+ return null;
281
+ },
282
+ };
283
+ }
284
+
285
+ // ---------- Validation Plugin ----------
286
+
287
+ function tesseraValidationPlugin(): Plugin {
288
+ let projectRoot: string;
289
+ let isBuild = false;
290
+
291
+ return {
292
+ name: 'tessera:validation',
293
+ enforce: 'pre',
294
+
295
+ configResolved(config: ResolvedConfig) {
296
+ projectRoot = config.root;
297
+ isBuild = config.command === 'build';
298
+ // Run validation during dev (configResolved fires before server starts)
299
+ if (!isBuild) {
300
+ runValidation(projectRoot);
301
+ }
302
+ },
303
+
304
+ buildStart() {
305
+ // Run validation during build (buildStart fires once before bundling)
306
+ if (isBuild) {
307
+ runValidation(projectRoot);
308
+ }
309
+ },
310
+ };
311
+ }
312
+
313
+ function runValidation(projectRoot: string): void {
314
+ const { errors, warnings } = validateProject(projectRoot);
315
+
316
+ for (const warning of warnings) {
317
+ console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
318
+ }
319
+
320
+ if (errors.length > 0) {
321
+ for (const error of errors) {
322
+ console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
323
+ }
324
+ throw new Error(
325
+ `Tessera validation failed with ${errors.length} error(s). Fix the errors above to continue.`
326
+ );
327
+ }
328
+ }
329
+
330
+ // ---------- Export Plugin ----------
331
+
332
+ function tesseraExportPlugin(): Plugin {
333
+ let projectRoot: string;
334
+ let isBuild = false;
335
+
336
+ return {
337
+ name: 'tessera:export',
338
+ enforce: 'post',
339
+
340
+ configResolved(config: ResolvedConfig) {
341
+ projectRoot = config.root;
342
+ isBuild = config.command === 'build';
343
+ },
344
+
345
+ async closeBundle() {
346
+ if (!isBuild) return;
347
+
348
+ const configPath = resolve(projectRoot, 'course.config.js');
349
+ if (!existsSync(configPath)) {
350
+ // Validation already required course.config.js — getting here means
351
+ // the file vanished mid-build. Surface that loudly rather than
352
+ // shipping a bundle with no LMS export silently.
353
+ throw new Error(
354
+ '[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.'
355
+ );
356
+ }
357
+
358
+ const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, 'utf-8'));
359
+ if (!objectStr) {
360
+ throw new Error(
361
+ '[tessera:export] course.config.js: could not locate `export default { ... }`. Cannot determine export.standard.'
362
+ );
363
+ }
364
+
365
+ let config: any;
366
+ try {
367
+ config = JSON5.parse(objectStr);
368
+ } catch (err) {
369
+ throw new Error(
370
+ `[tessera:export] course.config.js: failed to parse export-default object literal — ${(err as Error).message}`
371
+ );
372
+ }
373
+
374
+ await runExport(projectRoot, config);
375
+ },
376
+ };
377
+ }
378
+
379
+ // ---------- Manifest Plugin ----------
380
+
381
+ const VIRTUAL_MANIFEST_ID = 'virtual:tessera-manifest';
382
+ const RESOLVED_MANIFEST_ID = '\0' + VIRTUAL_MANIFEST_ID;
383
+
384
+ function tesseraManifestPlugin(): Plugin {
385
+ let projectRoot: string;
386
+ let pagesDir: string;
387
+ let currentManifest: Manifest | null = null;
388
+ let server: ViteDevServer | null = null;
389
+
390
+ function buildManifest(): Manifest {
391
+ currentManifest = generateManifest(pagesDir);
392
+ return currentManifest;
393
+ }
394
+
395
+ return {
396
+ name: 'tessera:manifest',
397
+ enforce: 'pre',
398
+
399
+ configResolved(config: ResolvedConfig) {
400
+ projectRoot = config.root;
401
+ pagesDir = resolve(projectRoot, 'pages');
402
+ },
403
+
404
+ configureServer(devServer: ViteDevServer) {
405
+ server = devServer;
406
+
407
+ // Watch the pages directory for changes
408
+ devServer.watcher.on('all', (event, filePath) => {
409
+ if (!filePath.startsWith(pagesDir)) return;
410
+
411
+ // Rebuild manifest on relevant file changes
412
+ const isRelevant =
413
+ filePath.endsWith('.svelte') ||
414
+ filePath.endsWith('_meta.js') ||
415
+ event === 'addDir' ||
416
+ event === 'unlinkDir';
417
+
418
+ if (isRelevant) {
419
+ currentManifest = null; // invalidate cache
420
+
421
+ // Invalidate the virtual module to trigger HMR
422
+ const mod = devServer.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);
423
+ if (mod) {
424
+ devServer.moduleGraph.invalidateModule(mod);
425
+ devServer.ws.send({ type: 'full-reload' });
426
+ }
427
+
428
+ console.log(`[tessera] Manifest rebuilt (${event}: ${filePath.replace(projectRoot, '')})`);
429
+ }
430
+ });
431
+ },
432
+
433
+ buildStart() {
434
+ buildManifest();
435
+ },
436
+
437
+ resolveId(id) {
438
+ if (id === VIRTUAL_MANIFEST_ID) return RESOLVED_MANIFEST_ID;
439
+ return null;
440
+ },
441
+
442
+ load(id) {
443
+ if (id === RESOLVED_MANIFEST_ID) {
444
+ if (!currentManifest) {
445
+ buildManifest();
446
+ }
447
+
448
+ // Register watch files so Vite's built-in watcher (used in build --watch)
449
+ // knows to re-trigger when pages/ content changes.
450
+ addWatchFiles(this, pagesDir);
451
+
452
+ // Encode as base64 to prevent Vite's import analysis from
453
+ // scanning .svelte importPath strings as module imports.
454
+ // Replace Infinity with 1e9 since JSON.stringify drops it.
455
+ const json = JSON.stringify(currentManifest, (_key, value) =>
456
+ value === Infinity ? 1e9 : value
457
+ );
458
+ const b64 = Buffer.from(json).toString('base64');
459
+ return `export default JSON.parse(atob("${b64}"));`;
460
+ }
461
+ return null;
462
+ },
463
+ };
464
+ }
@@ -0,0 +1,55 @@
1
+ import type { Plugin, ResolvedConfig, ViteDevServer } from 'vite';
2
+ import { existsSync } from 'node:fs';
3
+ import { resolve } from 'node:path';
4
+
5
+ const VIRTUAL_LAYOUT_ID = 'virtual:tessera-layout';
6
+ const RESOLVED_LAYOUT_ID = '\0' + VIRTUAL_LAYOUT_ID;
7
+
8
+ export function tesseraLayoutPlugin(): Plugin {
9
+ let projectRoot: string;
10
+
11
+ return {
12
+ name: 'tessera:layout',
13
+ enforce: 'pre',
14
+
15
+ configResolved(config: ResolvedConfig) {
16
+ projectRoot = config.root;
17
+ },
18
+
19
+ resolveId(id) {
20
+ if (id === VIRTUAL_LAYOUT_ID) return RESOLVED_LAYOUT_ID;
21
+ return null;
22
+ },
23
+
24
+ load(id) {
25
+ if (id !== RESOLVED_LAYOUT_ID) return null;
26
+ const layoutPath = resolve(projectRoot, 'layout.svelte');
27
+ if (existsSync(layoutPath)) {
28
+ // Register the file with Vite so edits trigger HMR / build --watch
29
+ // re-runs. Only add when the file actually exists — calling
30
+ // addWatchFile on a non-existent path makes Vite's importAnalysis
31
+ // try to resolve it as a real import.
32
+ this.addWatchFile(layoutPath);
33
+ const normalized = layoutPath.replace(/\\/g, '/');
34
+ return `export { default } from '${normalized}';`;
35
+ }
36
+ return `export default null;`;
37
+ },
38
+
39
+ configureServer(server: ViteDevServer) {
40
+ const layoutPath = resolve(projectRoot, 'layout.svelte');
41
+ // Only react to add/unlink: those flip the virtual module's load() output
42
+ // between `export default null` and `export { default } from '...'`. A
43
+ // `change` event leaves that output identical and is handled by Svelte's
44
+ // own HMR for the underlying file — full-reloading on every edit would
45
+ // wipe in-page state for no reason.
46
+ server.watcher.on('all', (event, filePath) => {
47
+ if (filePath !== layoutPath) return;
48
+ if (event !== 'add' && event !== 'unlink') return;
49
+ const mod = server.moduleGraph.getModuleById(RESOLVED_LAYOUT_ID);
50
+ if (mod) server.moduleGraph.invalidateModule(mod);
51
+ server.ws.send({ type: 'full-reload' });
52
+ });
53
+ },
54
+ };
55
+ }