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,838 @@
1
+ import { existsSync, readdirSync, statSync } from 'node:fs';
2
+ import { resolve, relative } from 'node:path';
3
+ import JSON5 from 'json5';
4
+ import {
5
+ extractObjectLiteral,
6
+ extractDefaultExportObjectLiteral,
7
+ readSourceFileCached,
8
+ MODULE_SCRIPT_RE,
9
+ PAGE_CONFIG_EXPORT_RE,
10
+ } from './manifest.js';
11
+ import { validateAgent } from '../runtime/xapi/agent-rules.js';
12
+
13
+ // ---------- Types ----------
14
+
15
+ export interface ValidationResult {
16
+ errors: string[];
17
+ warnings: string[];
18
+ }
19
+
20
+ // Known top-level config fields
21
+ const KNOWN_CONFIG_FIELDS = new Set([
22
+ 'title',
23
+ 'description',
24
+ 'author',
25
+ 'version',
26
+ 'branding',
27
+ 'navigation',
28
+ 'completion',
29
+ 'scoring',
30
+ 'export',
31
+ 'chrome',
32
+ 'xapi',
33
+ ]);
34
+
35
+ const VALID_NAV_MODES = ['free', 'sequential'];
36
+ const VALID_COMPLETION_MODES = ['quiz', 'percentage'];
37
+ const VALID_EXPORT_STANDARDS = ['web', 'scorm12', 'scorm2004', 'cmi5'];
38
+
39
+ // ---------- Main ----------
40
+
41
+ /**
42
+ * Validate a Tessera project at the given root.
43
+ * Returns errors (block build) and warnings (informational).
44
+ */
45
+ export function validateProject(projectRoot: string): ValidationResult {
46
+ const errors: string[] = [];
47
+ const warnings: string[] = [];
48
+
49
+ // 1. Check course.config.js exists
50
+ const configPath = resolve(projectRoot, 'course.config.js');
51
+ if (!existsSync(configPath)) {
52
+ errors.push('course.config.js not found in project root');
53
+ return { errors, warnings };
54
+ }
55
+
56
+ // 2. Parse and validate config
57
+ const config = parseConfig(configPath, errors, warnings);
58
+
59
+ // 3. Validate pages directory
60
+ const pagesDir = resolve(projectRoot, 'pages');
61
+ const assetsDir = resolve(projectRoot, 'assets');
62
+ const pageResults = validatePages(pagesDir, assetsDir, projectRoot);
63
+ errors.push(...pageResults.errors);
64
+ warnings.push(...pageResults.warnings);
65
+
66
+ // 4. Cross-cutting validations
67
+ if (config) {
68
+ crossValidate(config, pageResults, errors, warnings);
69
+ }
70
+
71
+ return { errors, warnings };
72
+ }
73
+
74
+ // ---------- Config Validation ----------
75
+
76
+ interface ParsedConfig {
77
+ title?: string;
78
+ navigation?: { mode?: string };
79
+ completion?: { mode?: string; percentageThreshold?: number };
80
+ scoring?: { passingScore?: number };
81
+ export?: { standard?: string };
82
+ [key: string]: unknown;
83
+ }
84
+
85
+ function parseConfig(
86
+ configPath: string,
87
+ errors: string[],
88
+ warnings: string[]
89
+ ): ParsedConfig | null {
90
+ const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
91
+ if (!objectStr) {
92
+ errors.push(
93
+ 'course.config.js: could not parse — must use `export default { ... }` syntax'
94
+ );
95
+ return null;
96
+ }
97
+
98
+ let config: ParsedConfig;
99
+ try {
100
+ config = JSON5.parse(objectStr);
101
+ } catch {
102
+ errors.push(
103
+ 'course.config.js: syntax error — must export a static object literal'
104
+ );
105
+ return null;
106
+ }
107
+
108
+ // Check for unknown fields
109
+ for (const key of Object.keys(config)) {
110
+ if (!KNOWN_CONFIG_FIELDS.has(key)) {
111
+ warnings.push(
112
+ `course.config.js: unknown field "${key}" — will be ignored`
113
+ );
114
+ }
115
+ }
116
+
117
+ // Validate navigation.mode
118
+ if (config.navigation?.mode !== undefined) {
119
+ if (!VALID_NAV_MODES.includes(config.navigation.mode)) {
120
+ errors.push(
121
+ `course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`
122
+ );
123
+ }
124
+ }
125
+
126
+ // Validate completion.mode
127
+ if (config.completion?.mode !== undefined) {
128
+ if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) {
129
+ errors.push(
130
+ `course.config.js: "completion.mode" must be "quiz" or "percentage", got "${config.completion.mode}"`
131
+ );
132
+ }
133
+ }
134
+
135
+ // Validate export.standard
136
+ if (config.export?.standard !== undefined) {
137
+ if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) {
138
+ errors.push(
139
+ `course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"`
140
+ );
141
+ }
142
+ }
143
+
144
+ // Validate scoring.passingScore
145
+ if (config.scoring?.passingScore !== undefined) {
146
+ const score = config.scoring.passingScore;
147
+ if (typeof score !== 'number' || score < 0 || score > 100) {
148
+ errors.push(
149
+ `course.config.js: "scoring.passingScore" must be 0–100, got ${score}`
150
+ );
151
+ }
152
+ }
153
+
154
+ // Validate completion.percentageThreshold
155
+ if (config.completion?.percentageThreshold !== undefined) {
156
+ const threshold = config.completion.percentageThreshold;
157
+ if (typeof threshold !== 'number' || threshold < 0 || threshold > 100) {
158
+ errors.push(
159
+ `course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`
160
+ );
161
+ }
162
+ }
163
+
164
+ // Validate xapi (publisher destinations)
165
+ if (config.xapi !== undefined) {
166
+ validateXAPIConfig(
167
+ config.xapi,
168
+ config.export?.standard ?? 'web',
169
+ errors,
170
+ warnings
171
+ );
172
+ }
173
+
174
+ return config;
175
+ }
176
+
177
+ // ---------- xAPI Config Validation ----------
178
+
179
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
180
+ const SHA1_RE = /^[0-9a-f]{40}$/i;
181
+
182
+ function validateXAPIConfig(
183
+ raw: unknown,
184
+ standard: string,
185
+ errors: string[],
186
+ warnings: string[]
187
+ ): void {
188
+ if (raw === undefined || raw === null) return;
189
+
190
+ // Normalize to array form. The single-object case is shorthand for a
191
+ // one-element array — same machinery, no special case in the runtime.
192
+ const entries: unknown[] = Array.isArray(raw) ? raw : [raw];
193
+
194
+ if (Array.isArray(raw)) {
195
+ if (entries.length === 0) {
196
+ errors.push(
197
+ 'course.config.js: xapi must contain at least one destination, or be omitted'
198
+ );
199
+ return;
200
+ }
201
+ // At most one 'lms' entry — more than one is never legitimate.
202
+ const lmsCount = entries.filter(
203
+ (e) =>
204
+ e &&
205
+ typeof e === 'object' &&
206
+ (e as { endpoint?: unknown }).endpoint === 'lms'
207
+ ).length;
208
+ if (lmsCount > 1) {
209
+ errors.push(
210
+ "course.config.js: xapi has multiple entries with endpoint: 'lms' — only one cmi5 launch-inherited destination is allowed"
211
+ );
212
+ }
213
+ // Warn on duplicate explicit endpoints.
214
+ const seen = new Map<string, number>();
215
+ for (const e of entries) {
216
+ if (e && typeof e === 'object') {
217
+ const ep = (e as { endpoint?: unknown }).endpoint;
218
+ if (typeof ep === 'string' && ep !== 'lms') {
219
+ seen.set(ep, (seen.get(ep) ?? 0) + 1);
220
+ }
221
+ }
222
+ }
223
+ for (const [ep, count] of seen) {
224
+ if (count > 1) {
225
+ warnings.push(
226
+ `course.config.js: xapi has ${count} entries with endpoint "${ep}" — usually a copy-paste mistake; ` +
227
+ 'fan-out to the same LRS with different actors/activityIds is supported but uncommon.'
228
+ );
229
+ }
230
+ }
231
+ } else if (typeof raw !== 'object') {
232
+ errors.push(
233
+ 'course.config.js: xapi must be an object or an array of objects'
234
+ );
235
+ return;
236
+ }
237
+
238
+ for (let i = 0; i < entries.length; i++) {
239
+ const entry = entries[i];
240
+ const label = Array.isArray(raw) ? `xapi[${i}]` : 'xapi';
241
+ if (!entry || typeof entry !== 'object') {
242
+ errors.push(`course.config.js: ${label} must be an object`);
243
+ continue;
244
+ }
245
+ validateSingleXAPIEntry(
246
+ entry as Record<string, unknown>,
247
+ label,
248
+ standard,
249
+ errors,
250
+ warnings
251
+ );
252
+ }
253
+ }
254
+
255
+ function validateSingleXAPIEntry(
256
+ entry: Record<string, unknown>,
257
+ label: string,
258
+ standard: string,
259
+ errors: string[],
260
+ warnings: string[]
261
+ ): void {
262
+ const endpoint = entry.endpoint;
263
+ if (endpoint === undefined) {
264
+ errors.push(`course.config.js: ${label}.endpoint is required`);
265
+ return;
266
+ }
267
+ if (typeof endpoint !== 'string') {
268
+ errors.push(`course.config.js: ${label}.endpoint must be a string`);
269
+ return;
270
+ }
271
+
272
+ if (endpoint === 'lms') {
273
+ // Forbid under non-cmi5 export.
274
+ if (standard !== 'cmi5') {
275
+ errors.push(
276
+ `course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' (you have "${standard}"). ` +
277
+ 'Either change the export standard or specify an explicit LRS endpoint.'
278
+ );
279
+ }
280
+ // Forbid extra fields — everything is inherited from the cmi5 launch.
281
+ const forbidden = ['auth', 'actor', 'activityId', 'registration', 'actorAccountHomePage'];
282
+ for (const f of forbidden) {
283
+ if (entry[f] !== undefined) {
284
+ errors.push(
285
+ `course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the cmi5 launch.`
286
+ );
287
+ }
288
+ }
289
+ return;
290
+ }
291
+
292
+ // Explicit endpoint — must be an absolute http(s) URL.
293
+ let url: URL;
294
+ try {
295
+ url = new URL(endpoint);
296
+ } catch {
297
+ errors.push(
298
+ `course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"`
299
+ );
300
+ return;
301
+ }
302
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
303
+ errors.push(
304
+ `course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"`
305
+ );
306
+ return;
307
+ }
308
+ if (url.protocol === 'http:' && process.env.NODE_ENV === 'production') {
309
+ warnings.push(
310
+ `course.config.js: ${label}.endpoint uses http:; LRS credentials will travel in cleartext. Use https in production.`
311
+ );
312
+ }
313
+ if (!endpoint.endsWith('/')) {
314
+ warnings.push(
315
+ `course.config.js: ${label}.endpoint should end with a slash to avoid concatenation surprises ` +
316
+ `(e.g. 'https://lrs.example.com/xapi/' not 'https://lrs.example.com/xapi'). Runtime normalizes regardless.`
317
+ );
318
+ }
319
+
320
+ // auth — required for explicit endpoints.
321
+ const auth = entry.auth;
322
+ if (auth === undefined) {
323
+ errors.push(`course.config.js: ${label}.auth is required`);
324
+ } else if (typeof auth === 'string') {
325
+ if (!auth) {
326
+ errors.push(`course.config.js: ${label}.auth must be a non-empty string`);
327
+ } else if (/^basic\s/i.test(auth)) {
328
+ errors.push(
329
+ `course.config.js: ${label}.auth must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.`
330
+ );
331
+ } else if (/^bearer\s/i.test(auth)) {
332
+ errors.push(
333
+ `course.config.js: ${label}.auth: Bearer/OAuth credentials are not supported in v1. Use Basic auth, or wrap your token-exchange in an auth function that returns a Basic credential.`
334
+ );
335
+ } else {
336
+ warnings.push(
337
+ `course.config.js: ${label}.auth is a static string and will be embedded in the bundle. ` +
338
+ 'For production, pass a function that fetches a short-lived token from a server endpoint.'
339
+ );
340
+ }
341
+ } else if (typeof auth !== 'function') {
342
+ errors.push(
343
+ `course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`
344
+ );
345
+ }
346
+
347
+ // activityId — required IRI.
348
+ const activityId = entry.activityId;
349
+ if (activityId === undefined || activityId === '') {
350
+ errors.push(`course.config.js: ${label}.activityId is required`);
351
+ } else if (typeof activityId !== 'string') {
352
+ errors.push(`course.config.js: ${label}.activityId must be a string`);
353
+ } else {
354
+ try {
355
+ // Any absolute IRI — the URL constructor accepts uncommon schemes.
356
+ new URL(activityId);
357
+ } catch {
358
+ errors.push(
359
+ `course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"`
360
+ );
361
+ }
362
+ }
363
+
364
+ // actor — required under web; optional otherwise.
365
+ const actor = entry.actor;
366
+ if (actor === undefined) {
367
+ if (standard === 'web') {
368
+ errors.push(
369
+ `course.config.js: ${label}.actor is required for web export — there is no LMS to derive a learner identity from. ` +
370
+ 'Provide either a static actor object or a function that resolves one (e.g. from your auth system).'
371
+ );
372
+ }
373
+ } else if (typeof actor === 'object' && actor !== null) {
374
+ const err = validateStaticAgent(actor);
375
+ if (err) {
376
+ const joined = err.startsWith('.')
377
+ ? `${label}.actor${err}`
378
+ : `${label}.actor ${err}`;
379
+ errors.push(`course.config.js: ${joined}`);
380
+ }
381
+ } else if (typeof actor !== 'function') {
382
+ errors.push(
383
+ `course.config.js: ${label}.actor must be an object or function, got ${typeof actor}`
384
+ );
385
+ }
386
+
387
+ // actorAccountHomePage — optional, only meaningful under SCORM with no
388
+ // explicit actor.
389
+ const aahp = entry.actorAccountHomePage;
390
+ if (aahp !== undefined) {
391
+ if (typeof aahp !== 'string') {
392
+ errors.push(
393
+ `course.config.js: ${label}.actorAccountHomePage must be a string`
394
+ );
395
+ } else {
396
+ try {
397
+ new URL(aahp);
398
+ } catch {
399
+ errors.push(
400
+ `course.config.js: ${label}.actorAccountHomePage must be an absolute URL`
401
+ );
402
+ }
403
+ }
404
+ if (actor !== undefined) {
405
+ warnings.push(
406
+ `course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`
407
+ );
408
+ }
409
+ if (standard === 'cmi5' || standard === 'web') {
410
+ warnings.push(
411
+ `course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`
412
+ );
413
+ }
414
+ }
415
+
416
+ // SCORM with auto-derived actor and a non-http(s) activityId:
417
+ // actorAccountHomePage becomes required.
418
+ if (
419
+ actor === undefined &&
420
+ (standard === 'scorm12' || standard === 'scorm2004') &&
421
+ typeof activityId === 'string'
422
+ ) {
423
+ let isHttp = false;
424
+ try {
425
+ const u = new URL(activityId);
426
+ isHttp = u.protocol === 'http:' || u.protocol === 'https:';
427
+ } catch {
428
+ isHttp = false;
429
+ }
430
+ if (!isHttp && aahp === undefined) {
431
+ errors.push(
432
+ `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. ` +
433
+ `Provide ${label}.actorAccountHomePage explicitly.`
434
+ );
435
+ }
436
+ }
437
+
438
+ // registration — optional UUID v4.
439
+ const registration = entry.registration;
440
+ if (registration !== undefined) {
441
+ if (typeof registration !== 'string' || !UUID_RE.test(registration)) {
442
+ errors.push(
443
+ `course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`
444
+ );
445
+ }
446
+ if (standard !== 'cmi5') {
447
+ warnings.push(
448
+ `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.`
449
+ );
450
+ }
451
+ }
452
+ }
453
+
454
+ /**
455
+ * Build-time alias for the shared `validateAgent` rules. Suffixes are already
456
+ * prefix-friendly (no leading "actor"), so this is a straight pass-through —
457
+ * kept named so the call sites in this file stay readable.
458
+ */
459
+ const validateStaticAgent = validateAgent;
460
+
461
+ // ---------- Pages Validation ----------
462
+
463
+ interface PagesValidationResult extends ValidationResult {
464
+ totalPages: number;
465
+ totalQuizzes: number;
466
+ hasGradedQuiz: boolean;
467
+ }
468
+
469
+ function validatePages(
470
+ pagesDir: string,
471
+ assetsDir: string,
472
+ projectRoot: string
473
+ ): PagesValidationResult {
474
+ const errors: string[] = [];
475
+ const warnings: string[] = [];
476
+ let totalPages = 0;
477
+ let totalQuizzes = 0;
478
+ let hasGradedQuiz = false;
479
+
480
+ if (!existsSync(pagesDir)) {
481
+ errors.push(
482
+ 'No pages found. Create at least one section with a lesson and page in pages/'
483
+ );
484
+ return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false };
485
+ }
486
+
487
+ const topLevelEntries = readdirSync(pagesDir);
488
+
489
+ // Check for stray .svelte files at pages/ root
490
+ for (const entry of topLevelEntries) {
491
+ const fullPath = resolve(pagesDir, entry);
492
+ if (entry.endsWith('.svelte') && statSync(fullPath).isFile()) {
493
+ const relPath = relative(projectRoot, fullPath);
494
+ warnings.push(
495
+ `${relPath}: this file is outside the section/lesson structure and will be ignored`
496
+ );
497
+ }
498
+ }
499
+
500
+ // Get section directories
501
+ const sectionDirs = topLevelEntries
502
+ .filter((name) => {
503
+ const full = resolve(pagesDir, name);
504
+ return statSync(full).isDirectory() && !name.startsWith('.');
505
+ })
506
+ .sort();
507
+
508
+ if (sectionDirs.length === 0) {
509
+ errors.push(
510
+ 'No pages found. Create at least one section with a lesson and page in pages/'
511
+ );
512
+ return { errors, warnings, totalPages: 0, totalQuizzes: 0, hasGradedQuiz: false };
513
+ }
514
+
515
+ for (const sectionName of sectionDirs) {
516
+ const sectionPath = resolve(pagesDir, sectionName);
517
+ const sectionRel = relative(projectRoot, sectionPath);
518
+
519
+ // Validate section _meta.js
520
+ const sectionMeta = validateMetaFile(
521
+ resolve(sectionPath, '_meta.js'),
522
+ sectionRel,
523
+ errors
524
+ );
525
+
526
+ // Flat mode: .svelte files directly at section level are pages of an
527
+ // implicit single lesson. Validate them just like lesson-level pages.
528
+ const sectionEntries = readdirSync(sectionPath);
529
+ const sectionSvelteFiles = sectionEntries
530
+ .filter((name) => {
531
+ const full = resolve(sectionPath, name);
532
+ return name.endsWith('.svelte') && statSync(full).isFile();
533
+ })
534
+ .sort();
535
+
536
+ if (sectionMeta?.pages) {
537
+ for (const pageName of sectionMeta.pages) {
538
+ const fileName = pageName.endsWith('.svelte')
539
+ ? pageName
540
+ : `${pageName}.svelte`;
541
+ if (!sectionSvelteFiles.includes(fileName)) {
542
+ const metaRel = relative(projectRoot, resolve(sectionPath, '_meta.js'));
543
+ errors.push(
544
+ `${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`
545
+ );
546
+ }
547
+ }
548
+ }
549
+
550
+ for (const fileName of sectionSvelteFiles) {
551
+ const filePath = resolve(sectionPath, fileName);
552
+ const fileRel = relative(projectRoot, filePath);
553
+ const content = readSourceFileCached(filePath);
554
+
555
+ const pageConfig = validatePageConfig(content, fileRel, errors);
556
+ totalPages++;
557
+
558
+ if (pageConfig?.quiz) {
559
+ totalQuizzes++;
560
+ validateQuizConfig(pageConfig.quiz, fileRel, errors);
561
+ if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
562
+ hasGradedQuiz = true;
563
+ }
564
+ }
565
+
566
+ validateAssetRefs(content, fileRel, assetsDir, warnings);
567
+ }
568
+
569
+ // Get lesson directories
570
+ const lessonDirs = sectionEntries
571
+ .filter((name) => {
572
+ const full = resolve(sectionPath, name);
573
+ return statSync(full).isDirectory() && !name.startsWith('.');
574
+ })
575
+ .sort();
576
+
577
+ for (const lessonName of lessonDirs) {
578
+ const lessonPath = resolve(sectionPath, lessonName);
579
+ const lessonRel = relative(projectRoot, lessonPath);
580
+
581
+ // Validate lesson _meta.js
582
+ const meta = validateMetaFile(
583
+ resolve(lessonPath, '_meta.js'),
584
+ lessonRel,
585
+ errors
586
+ );
587
+
588
+ // Get .svelte files
589
+ const svelteFiles = readdirSync(lessonPath)
590
+ .filter((name) => name.endsWith('.svelte'))
591
+ .sort();
592
+
593
+ // Check pages array references
594
+ if (meta?.pages) {
595
+ for (const pageName of meta.pages) {
596
+ const fileName = pageName.endsWith('.svelte')
597
+ ? pageName
598
+ : `${pageName}.svelte`;
599
+ if (!svelteFiles.includes(fileName)) {
600
+ const metaRel = relative(projectRoot, resolve(lessonPath, '_meta.js'));
601
+ errors.push(
602
+ `${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`
603
+ );
604
+ }
605
+ }
606
+ }
607
+
608
+ // Check for unlisted .svelte files
609
+ if (meta?.pages && meta.pages.length > 0) {
610
+ const listedSet = new Set(
611
+ meta.pages.map((p: string) =>
612
+ p.endsWith('.svelte') ? p : `${p}.svelte`
613
+ )
614
+ );
615
+ for (const file of svelteFiles) {
616
+ if (!listedSet.has(file)) {
617
+ const relPath = relative(projectRoot, resolve(lessonPath, file));
618
+ warnings.push(
619
+ `${relPath}: not listed in _meta.js pages array — will be appended at end`
620
+ );
621
+ }
622
+ }
623
+ }
624
+
625
+ // Validate each .svelte file
626
+ for (const fileName of svelteFiles) {
627
+ const filePath = resolve(lessonPath, fileName);
628
+ const fileRel = relative(projectRoot, filePath);
629
+ const content = readSourceFileCached(filePath);
630
+
631
+ const pageConfig = validatePageConfig(content, fileRel, errors);
632
+ totalPages++;
633
+
634
+ if (pageConfig?.quiz) {
635
+ totalQuizzes++;
636
+
637
+ // Validate quiz config
638
+ validateQuizConfig(pageConfig.quiz, fileRel, errors);
639
+
640
+ if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
641
+ hasGradedQuiz = true;
642
+ }
643
+ }
644
+
645
+ // Check $assets references
646
+ validateAssetRefs(content, fileRel, assetsDir, warnings);
647
+ }
648
+ }
649
+ }
650
+
651
+ if (totalPages === 0) {
652
+ errors.push(
653
+ 'No pages found. Create at least one section with a lesson and page in pages/'
654
+ );
655
+ }
656
+
657
+ return { errors, warnings, totalPages, totalQuizzes, hasGradedQuiz };
658
+ }
659
+
660
+ // ---------- _meta.js Validation ----------
661
+
662
+ function validateMetaFile(
663
+ metaPath: string,
664
+ parentRel: string,
665
+ errors: string[]
666
+ ): { title?: string; pages?: string[] } | null {
667
+ if (!existsSync(metaPath)) return null;
668
+
669
+ const metaRel = `${parentRel}/_meta.js`;
670
+ const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
671
+
672
+ if (!objectStr) {
673
+ errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
674
+ return null;
675
+ }
676
+
677
+ let meta: { title?: string; pages?: string[] };
678
+ try {
679
+ meta = JSON5.parse(objectStr);
680
+ } catch {
681
+ errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
682
+ return null;
683
+ }
684
+
685
+ if (!meta.title) {
686
+ errors.push(`${metaRel}: missing required "title" field`);
687
+ }
688
+
689
+ return meta;
690
+ }
691
+
692
+ // ---------- pageConfig Validation ----------
693
+
694
+ function validatePageConfig(
695
+ content: string,
696
+ fileRel: string,
697
+ errors: string[]
698
+ ): { title?: string; quiz?: unknown } | null {
699
+ const moduleScriptMatch = content.match(MODULE_SCRIPT_RE);
700
+ if (!moduleScriptMatch) return null;
701
+
702
+ const scriptContent = moduleScriptMatch[1];
703
+ const exportMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
704
+ if (!exportMatch || exportMatch.index === undefined) return null;
705
+
706
+ // Now check if the RHS starts with `{` (a static object literal)
707
+ const afterEquals = scriptContent
708
+ .slice(exportMatch.index + exportMatch[0].length)
709
+ .trimStart();
710
+
711
+ if (!afterEquals.startsWith('{')) {
712
+ // pageConfig is exported but assigned to something other than an object literal
713
+ errors.push(
714
+ `${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`
715
+ );
716
+ return null;
717
+ }
718
+
719
+ // Find the opening brace in the original scriptContent
720
+ const braceIndex = scriptContent.indexOf(
721
+ '{',
722
+ exportMatch.index + exportMatch[0].length
723
+ );
724
+ const objectStr = extractObjectLiteral(scriptContent, braceIndex);
725
+ if (!objectStr) {
726
+ errors.push(
727
+ `${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`
728
+ );
729
+ return null;
730
+ }
731
+
732
+ try {
733
+ return JSON5.parse(objectStr);
734
+ } catch {
735
+ errors.push(
736
+ `${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`
737
+ );
738
+ return null;
739
+ }
740
+ }
741
+
742
+ // ---------- Quiz Config Validation ----------
743
+
744
+ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): void {
745
+ if (!quiz || typeof quiz !== 'object') return;
746
+ const cfg = quiz as { maxAttempts?: unknown; graded?: unknown };
747
+
748
+ if (cfg.maxAttempts !== undefined) {
749
+ const val = cfg.maxAttempts;
750
+ if (val !== Infinity && (typeof val !== 'number' || val <= 0 || !Number.isFinite(val))) {
751
+ errors.push(
752
+ `${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}`
753
+ );
754
+ }
755
+ }
756
+
757
+ if (cfg.graded !== undefined && typeof cfg.graded !== 'boolean') {
758
+ errors.push(
759
+ `${fileRel}: quiz.graded must be a boolean, got ${typeof cfg.graded}`
760
+ );
761
+ }
762
+ }
763
+
764
+ // ---------- Asset Reference Validation ----------
765
+
766
+ function validateAssetRefs(
767
+ content: string,
768
+ fileRel: string,
769
+ assetsDir: string,
770
+ warnings: string[]
771
+ ): void {
772
+ // Match $assets/... references in src attributes, import statements, url() etc.
773
+ const assetRefPattern = /\$assets\/([^\s"'`)]+)/g;
774
+ let match: RegExpExecArray | null;
775
+
776
+ while ((match = assetRefPattern.exec(content)) !== null) {
777
+ const assetPath = match[1];
778
+ const fullAssetPath = resolve(assetsDir, assetPath);
779
+ if (!existsSync(fullAssetPath)) {
780
+ warnings.push(
781
+ `${fileRel}: "$assets/${assetPath}" not found in assets/ directory`
782
+ );
783
+ }
784
+ }
785
+ }
786
+
787
+ // ---------- Cross-Cutting Validations ----------
788
+
789
+ function crossValidate(
790
+ config: ParsedConfig,
791
+ pageResults: PagesValidationResult,
792
+ errors: string[],
793
+ warnings: string[]
794
+ ): void {
795
+ // completion.mode "quiz" but no graded quizzes
796
+ if (config.completion?.mode === 'quiz' && !pageResults.hasGradedQuiz) {
797
+ errors.push(
798
+ 'completion.mode is "quiz" but no pages have quiz config with graded: true'
799
+ );
800
+ }
801
+
802
+ // SCORM 1.2 + high page count warning
803
+ if (config.export?.standard === 'scorm12') {
804
+ // Estimate worst-case suspend_data size when all pages are visited, all
805
+ // quizzes completed, all chunks revealed, and a modest amount of
806
+ // usePersistence / standalone-question state has accumulated.
807
+ //
808
+ // SavedState shape (see runtime/persistence.ts) — single-letter keys:
809
+ // b (bookmark), v (visited[]), q (quiz scores), d (duration),
810
+ // c (chunk progress), s (standalone scores), gs (graded standalone pages),
811
+ // u (user state from usePersistence)
812
+ //
813
+ // We can't statically detect calls to `useQuestion({ graded: true })` or
814
+ // `usePersistence`, so reserve a fixed buffer per page for those.
815
+ let visitedChars = 0;
816
+ for (let i = 0; i < pageResults.totalPages; i++) {
817
+ visitedChars += String(i).length + 1; // digit chars + comma
818
+ }
819
+ const overhead = 60; // top-level JSON overhead with all keys
820
+ const quizBytes = pageResults.totalQuizzes * 15; // q: "NNN":100,
821
+ const chunkBytes = pageResults.totalPages * 12; // c: "NNN":NN,
822
+ const standaloneBytes = pageResults.totalPages * 30;// s/gs: conservative buffer per page
823
+ const userStateBuffer = 256; // usePersistence headroom
824
+ const estimatedSize =
825
+ overhead +
826
+ visitedChars +
827
+ quizBytes +
828
+ chunkBytes +
829
+ standaloneBytes +
830
+ userStateBuffer;
831
+
832
+ if (estimatedSize > 3200) {
833
+ warnings.push(
834
+ `Course has ${pageResults.totalPages} pages with ${pageResults.totalQuizzes} quizzes — estimated SCORM 1.2 suspend_data ~${estimatedSize} bytes may exceed the 4096-byte limit when fully populated (visited + chunks + standalone scores + usePersistence). Consider using "scorm2004" or "cmi5".`
835
+ );
836
+ }
837
+ }
838
+ }