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.
- package/AGENTS.md +1228 -0
- package/LICENSE +21 -0
- package/README.md +21 -0
- package/dist/plugin/index.d.ts +7 -0
- package/dist/plugin/index.d.ts.map +1 -0
- package/dist/plugin/index.js +1239 -0
- package/dist/plugin/index.js.map +1 -0
- package/package.json +77 -0
- package/src/archiver.d.ts +27 -0
- package/src/components/Accordion.svelte +32 -0
- package/src/components/AccordionItem.svelte +144 -0
- package/src/components/Audio.svelte +38 -0
- package/src/components/Callout.svelte +81 -0
- package/src/components/Carousel.svelte +194 -0
- package/src/components/CarouselSlide.svelte +32 -0
- package/src/components/DefaultLayout.svelte +108 -0
- package/src/components/FillInTheBlank.svelte +345 -0
- package/src/components/Image.svelte +47 -0
- package/src/components/Matching.svelte +513 -0
- package/src/components/MultipleChoice.svelte +363 -0
- package/src/components/Quiz.svelte +569 -0
- package/src/components/RevealModal.svelte +228 -0
- package/src/components/Sorting.svelte +663 -0
- package/src/components/Video.svelte +118 -0
- package/src/components/index.ts +15 -0
- package/src/components/quiz-payload.ts +71 -0
- package/src/components/util.ts +24 -0
- package/src/index.ts +56 -0
- package/src/plugin/export.ts +264 -0
- package/src/plugin/index.ts +464 -0
- package/src/plugin/layout.ts +55 -0
- package/src/plugin/manifest.ts +330 -0
- package/src/plugin/quiz.ts +65 -0
- package/src/plugin/validation.ts +838 -0
- package/src/runtime/App.svelte +435 -0
- package/src/runtime/ErrorPage.svelte +14 -0
- package/src/runtime/LoadingSkeleton.svelte +26 -0
- package/src/runtime/Sidebar.svelte +76 -0
- package/src/runtime/access.ts +55 -0
- package/src/runtime/adapters/cmi5.ts +341 -0
- package/src/runtime/adapters/discovery.ts +38 -0
- package/src/runtime/adapters/index.ts +99 -0
- package/src/runtime/adapters/retry.ts +284 -0
- package/src/runtime/adapters/scorm12.ts +172 -0
- package/src/runtime/adapters/scorm2004.ts +162 -0
- package/src/runtime/adapters/web.ts +62 -0
- package/src/runtime/contexts.ts +76 -0
- package/src/runtime/duration.ts +29 -0
- package/src/runtime/hooks.svelte.ts +543 -0
- package/src/runtime/interaction-format.ts +132 -0
- package/src/runtime/interaction.ts +96 -0
- package/src/runtime/navigation.svelte.ts +117 -0
- package/src/runtime/persistence.ts +56 -0
- package/src/runtime/progress.svelte.ts +168 -0
- package/src/runtime/quiz-policy.ts +227 -0
- package/src/runtime/slugify.ts +17 -0
- package/src/runtime/types.ts +92 -0
- package/src/runtime/xapi/agent-rules.ts +93 -0
- package/src/runtime/xapi/client.ts +133 -0
- package/src/runtime/xapi/derive-actor.ts +90 -0
- package/src/runtime/xapi/publisher.ts +604 -0
- package/src/runtime/xapi/registry.ts +38 -0
- package/src/runtime/xapi/setup.ts +250 -0
- package/src/runtime/xapi/types.ts +106 -0
- package/src/runtime/xapi/uuid.ts +21 -0
- package/src/runtime/xapi/validation.ts +71 -0
- package/src/runtime/xapi/version.ts +23 -0
- package/src/virtual.d.ts +16 -0
- package/styles/base.css +194 -0
- package/styles/layout.css +408 -0
- 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
|
+
}
|