tessera-learn 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +17 -12
- package/README.md +1 -1
- package/dist/{audit-DkXqQTqn.js → audit-DsYqXbqm.js} +211 -183
- package/dist/audit-DsYqXbqm.js.map +1 -0
- package/dist/{build-commands-CyzuCDXg.js → build-commands-BFuiAxaR.js} +4 -4
- package/dist/build-commands-BFuiAxaR.js.map +1 -0
- package/dist/{inline-config-BEXyRqsJ.js → inline-config-DVvOCKht.js} +6 -6
- package/dist/inline-config-DVvOCKht.js.map +1 -0
- package/dist/plugin/cli.d.ts +5 -1
- package/dist/plugin/cli.d.ts.map +1 -1
- package/dist/plugin/cli.js +38 -7
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +7 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -2
- package/dist/{plugin-CFUFgwHB.js → plugin-BuMiDTmU.js} +29 -38
- package/dist/plugin-BuMiDTmU.js.map +1 -0
- package/package.json +1 -1
- package/src/components/MultipleChoice.svelte +1 -2
- package/src/plugin/build-commands.ts +7 -4
- package/src/plugin/cli.ts +54 -3
- package/src/plugin/index.ts +31 -42
- package/src/plugin/inline-config.ts +4 -2
- package/src/plugin/manifest.ts +21 -0
- package/src/plugin/validate-cli.ts +5 -2
- package/src/plugin/validation.ts +214 -233
- package/src/runtime/App.svelte +4 -1
- package/src/runtime/adapters/scorm-base.ts +15 -14
- package/src/runtime/adapters/scorm12.ts +6 -25
- package/src/runtime/adapters/scorm2004.ts +12 -55
- package/src/runtime/adapters/web.ts +5 -13
- package/src/runtime/fingerprint.ts +28 -0
- package/src/runtime/interaction-format.ts +0 -1
- package/src/runtime/persistence.ts +4 -0
- package/src/runtime/types.ts +3 -0
- package/src/runtime/xapi/publisher.ts +11 -14
- package/dist/audit-DkXqQTqn.js.map +0 -1
- package/dist/build-commands-CyzuCDXg.js.map +0 -1
- package/dist/inline-config-BEXyRqsJ.js.map +0 -1
- package/dist/plugin-CFUFgwHB.js.map +0 -1
package/src/plugin/validation.ts
CHANGED
|
@@ -40,6 +40,18 @@ export interface ValidationResult {
|
|
|
40
40
|
warnings: string[];
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Collects errors and warnings so checkers thread one argument, not a pair. */
|
|
44
|
+
export class Diagnostics implements ValidationResult {
|
|
45
|
+
errors: string[] = [];
|
|
46
|
+
warnings: string[] = [];
|
|
47
|
+
error(message: string): void {
|
|
48
|
+
this.errors.push(message);
|
|
49
|
+
}
|
|
50
|
+
warn(message: string): void {
|
|
51
|
+
this.warnings.push(message);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
43
55
|
// ---------- A11y rule IDs ----------
|
|
44
56
|
|
|
45
57
|
/** Tier-1b rule IDs. `a11y.ignore` matches these literally. */
|
|
@@ -111,24 +123,21 @@ export function normalizeA11y(raw: unknown): A11ySettings {
|
|
|
111
123
|
* promotable a11y warnings to errors) to a result in place. `ignore` suppresses
|
|
112
124
|
* at any severity, including hard contract errors; `level` only re-rates.
|
|
113
125
|
*/
|
|
114
|
-
function applyA11ySettings(
|
|
115
|
-
result: ValidationResult,
|
|
116
|
-
settings: A11ySettings,
|
|
117
|
-
): void {
|
|
126
|
+
function applyA11ySettings(d: Diagnostics, settings: A11ySettings): void {
|
|
118
127
|
if (settings.ignore.length > 0) {
|
|
119
128
|
const ignored = new Set(settings.ignore);
|
|
120
129
|
const keep = (msg: string) => !isIgnored(msg, ignored);
|
|
121
|
-
|
|
122
|
-
|
|
130
|
+
d.errors = d.errors.filter(keep);
|
|
131
|
+
d.warnings = d.warnings.filter(keep);
|
|
123
132
|
}
|
|
124
133
|
if (settings.level === 'error') {
|
|
125
134
|
const remaining: string[] = [];
|
|
126
|
-
for (const msg of
|
|
135
|
+
for (const msg of d.warnings) {
|
|
127
136
|
const id = diagnosticId(msg);
|
|
128
|
-
if (id !== null && PROMOTABLE_A11Y_IDS.has(id))
|
|
137
|
+
if (id !== null && PROMOTABLE_A11Y_IDS.has(id)) d.error(msg);
|
|
129
138
|
else remaining.push(msg);
|
|
130
139
|
}
|
|
131
|
-
|
|
140
|
+
d.warnings = remaining;
|
|
132
141
|
}
|
|
133
142
|
}
|
|
134
143
|
|
|
@@ -152,6 +161,7 @@ const KNOWN_CONFIG_FIELDS = new Set([
|
|
|
152
161
|
'description',
|
|
153
162
|
'author',
|
|
154
163
|
'version',
|
|
164
|
+
'resume',
|
|
155
165
|
'language',
|
|
156
166
|
'branding',
|
|
157
167
|
'navigation',
|
|
@@ -174,7 +184,13 @@ export function isPlausibleLanguageTag(value: unknown): value is string {
|
|
|
174
184
|
|
|
175
185
|
const VALID_NAV_MODES = ['free', 'sequential'];
|
|
176
186
|
const VALID_COMPLETION_MODES = ['quiz', 'percentage', 'manual'];
|
|
177
|
-
const VALID_EXPORT_STANDARDS = [
|
|
187
|
+
export const VALID_EXPORT_STANDARDS = [
|
|
188
|
+
'web',
|
|
189
|
+
'scorm12',
|
|
190
|
+
'scorm2004',
|
|
191
|
+
'cmi5',
|
|
192
|
+
'xapi',
|
|
193
|
+
];
|
|
178
194
|
const VALID_MANUAL_TRIGGERS = ['page'];
|
|
179
195
|
const VALID_REQUIRE_SUCCESS_STATUS = ['passed', 'failed'];
|
|
180
196
|
// Derived from the runtime types (single source of truth) — widened to
|
|
@@ -188,20 +204,22 @@ const VALID_RETRY_MODES: readonly string[] = RETRY_MODES;
|
|
|
188
204
|
* Validate a Tessera project at the given root.
|
|
189
205
|
* Returns errors (block build) and warnings (informational).
|
|
190
206
|
*/
|
|
191
|
-
export function validateProject(
|
|
207
|
+
export function validateProject(
|
|
208
|
+
projectRoot: string,
|
|
209
|
+
standardOverride?: string,
|
|
210
|
+
): ValidationResult {
|
|
192
211
|
clearParseCache();
|
|
193
|
-
const
|
|
194
|
-
const warnings: string[] = [];
|
|
212
|
+
const d = new Diagnostics();
|
|
195
213
|
|
|
196
214
|
// 1. Check course.config.js exists
|
|
197
215
|
const configPath = resolve(projectRoot, 'course.config.js');
|
|
198
216
|
if (!existsSync(configPath)) {
|
|
199
|
-
|
|
200
|
-
return
|
|
217
|
+
d.error('course.config.js not found in project root');
|
|
218
|
+
return d;
|
|
201
219
|
}
|
|
202
220
|
|
|
203
221
|
// 2. Parse and validate config
|
|
204
|
-
const config = parseConfig(projectRoot,
|
|
222
|
+
const config = parseConfig(projectRoot, d, standardOverride);
|
|
205
223
|
|
|
206
224
|
// 3. Validate pages directory
|
|
207
225
|
const pagesDir = resolve(projectRoot, 'pages');
|
|
@@ -210,31 +228,25 @@ export function validateProject(projectRoot: string): ValidationResult {
|
|
|
210
228
|
pagesDir,
|
|
211
229
|
assetsDir,
|
|
212
230
|
projectRoot,
|
|
231
|
+
d,
|
|
213
232
|
config?.export?.standard,
|
|
214
233
|
);
|
|
215
|
-
errors.push(...pageResults.errors);
|
|
216
|
-
warnings.push(...pageResults.warnings);
|
|
217
234
|
|
|
218
235
|
// 4. Contract-bypass checks on project-root shell files
|
|
219
236
|
for (const shellFile of ['layout.svelte', 'quiz.svelte']) {
|
|
220
237
|
const shellPath = resolve(projectRoot, shellFile);
|
|
221
238
|
if (existsSync(shellPath)) {
|
|
222
|
-
validateContractBypass(
|
|
223
|
-
readSourceFileCached(shellPath),
|
|
224
|
-
shellFile,
|
|
225
|
-
errors,
|
|
226
|
-
);
|
|
239
|
+
validateContractBypass(readSourceFileCached(shellPath), shellFile, d);
|
|
227
240
|
}
|
|
228
241
|
}
|
|
229
242
|
|
|
230
243
|
// 5. Cross-cutting validations
|
|
231
244
|
if (config) {
|
|
232
|
-
crossValidate(config, pageResults,
|
|
245
|
+
crossValidate(config, pageResults, d);
|
|
233
246
|
}
|
|
234
247
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
return result;
|
|
248
|
+
applyA11ySettings(d, normalizeA11y(config?.a11y));
|
|
249
|
+
return d;
|
|
238
250
|
}
|
|
239
251
|
|
|
240
252
|
// ---------- Config Validation ----------
|
|
@@ -242,6 +254,7 @@ export function validateProject(projectRoot: string): ValidationResult {
|
|
|
242
254
|
interface ParsedConfig {
|
|
243
255
|
title?: string;
|
|
244
256
|
id?: string;
|
|
257
|
+
resume?: string;
|
|
245
258
|
navigation?: { mode?: string };
|
|
246
259
|
completion?: {
|
|
247
260
|
mode?: string;
|
|
@@ -256,18 +269,16 @@ interface ParsedConfig {
|
|
|
256
269
|
|
|
257
270
|
function parseConfig(
|
|
258
271
|
projectRoot: string,
|
|
259
|
-
|
|
260
|
-
|
|
272
|
+
d: Diagnostics,
|
|
273
|
+
standardOverride?: string,
|
|
261
274
|
): ParsedConfig | null {
|
|
262
275
|
const read = readCourseConfig(projectRoot);
|
|
263
276
|
if (!read.ok) {
|
|
264
277
|
// 'missing' can't occur — validateProject checks existsSync first.
|
|
265
278
|
if (read.reason === 'no-export') {
|
|
266
|
-
|
|
279
|
+
d.error('course.config.js: must use `export default { ... }` syntax');
|
|
267
280
|
} else if (read.reason === 'parse-error') {
|
|
268
|
-
|
|
269
|
-
'course.config.js: could not parse — JavaScript syntax error',
|
|
270
|
-
);
|
|
281
|
+
d.error('course.config.js: could not parse — JavaScript syntax error');
|
|
271
282
|
}
|
|
272
283
|
return null;
|
|
273
284
|
}
|
|
@@ -276,9 +287,7 @@ function parseConfig(
|
|
|
276
287
|
// Check for unknown fields
|
|
277
288
|
for (const key of Object.keys(config)) {
|
|
278
289
|
if (!KNOWN_CONFIG_FIELDS.has(key)) {
|
|
279
|
-
|
|
280
|
-
`course.config.js: unknown field "${key}" — will be ignored`,
|
|
281
|
-
);
|
|
290
|
+
d.warn(`course.config.js: unknown field "${key}" — will be ignored`);
|
|
282
291
|
}
|
|
283
292
|
}
|
|
284
293
|
|
|
@@ -288,34 +297,34 @@ function parseConfig(
|
|
|
288
297
|
// non-string is a misconfiguration — a truthy one ships as-is, a falsy one
|
|
289
298
|
// falls back, but either way the author should fix it (error).
|
|
290
299
|
if (config.title !== undefined && typeof config.title !== 'string') {
|
|
291
|
-
|
|
300
|
+
d.error(
|
|
292
301
|
`course.config.js: "title" must be a string, got ${typeof config.title}`,
|
|
293
302
|
);
|
|
294
303
|
} else if (config.title === undefined || config.title === '') {
|
|
295
|
-
|
|
304
|
+
d.warn(
|
|
296
305
|
'course.config.js: "title" is missing or empty — the course will ship as "Untitled Course"',
|
|
297
306
|
);
|
|
298
307
|
} else if (config.title.trim() === '') {
|
|
299
|
-
|
|
308
|
+
d.warn(
|
|
300
309
|
'course.config.js: "title" is only whitespace — it ships verbatim and will not fall back to "Untitled Course"',
|
|
301
310
|
);
|
|
302
311
|
}
|
|
303
312
|
|
|
304
313
|
// Validate branding
|
|
305
314
|
if (config.branding !== undefined) {
|
|
306
|
-
validateBranding(config.branding,
|
|
315
|
+
validateBranding(config.branding, d);
|
|
307
316
|
}
|
|
308
317
|
|
|
309
318
|
// Rule 1.8: language present and well-formed (BCP-47)
|
|
310
319
|
if (config.language === undefined) {
|
|
311
|
-
|
|
320
|
+
d.warn(
|
|
312
321
|
tag(
|
|
313
322
|
A11Y_IDS.lang,
|
|
314
323
|
`course.config.js: "language" is not set — defaulting <html lang> to "en". Set it to the course's language (BCP-47, e.g. "en", "fr-CA") for WCAG 3.1.1.`,
|
|
315
324
|
),
|
|
316
325
|
);
|
|
317
326
|
} else if (!isPlausibleLanguageTag(config.language)) {
|
|
318
|
-
|
|
327
|
+
d.warn(
|
|
319
328
|
tag(
|
|
320
329
|
A11Y_IDS.lang,
|
|
321
330
|
`course.config.js: "language" (${JSON.stringify(config.language)}) is not a plausible BCP-47 tag — use e.g. "en", "es", or "fr-CA"`,
|
|
@@ -323,6 +332,22 @@ function parseConfig(
|
|
|
323
332
|
);
|
|
324
333
|
}
|
|
325
334
|
|
|
335
|
+
// Validate export.standard
|
|
336
|
+
if (config.export?.standard !== undefined) {
|
|
337
|
+
if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) {
|
|
338
|
+
d.error(
|
|
339
|
+
`course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", "cmi5", or "xapi", got "${config.export.standard}"`,
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Apply the override after validating the file value above, so every
|
|
345
|
+
// standard-dependent check below (identity, csp, xapi, crossValidate) sees
|
|
346
|
+
// what actually ships.
|
|
347
|
+
if (standardOverride) {
|
|
348
|
+
config.export = { ...config.export, standard: standardOverride };
|
|
349
|
+
}
|
|
350
|
+
|
|
326
351
|
// Identity matters for web (storage key) and cmi5/xAPI (LRS activity id);
|
|
327
352
|
// SCORM identity is owned by the LMS, so only nudge for the others.
|
|
328
353
|
const standard = config.export?.standard;
|
|
@@ -332,20 +357,20 @@ function parseConfig(
|
|
|
332
357
|
standard === 'cmi5' ||
|
|
333
358
|
standard === 'xapi';
|
|
334
359
|
if (identityStandard && !courseIdentity(config)) {
|
|
335
|
-
|
|
360
|
+
d.warn(
|
|
336
361
|
`course.config.js: no "id" set — the web storage key and cmi5/xAPI activity id then share a fixed fallback that collides across courses. Add a unique id (e.g. "urn:uuid:…"); scaffolded courses include one.`,
|
|
337
362
|
);
|
|
338
363
|
}
|
|
339
364
|
|
|
340
365
|
// Validate a11y config block
|
|
341
366
|
if (config.a11y !== undefined) {
|
|
342
|
-
validateA11yConfig(config.a11y,
|
|
367
|
+
validateA11yConfig(config.a11y, d);
|
|
343
368
|
}
|
|
344
369
|
|
|
345
370
|
// Validate navigation.mode
|
|
346
371
|
if (config.navigation?.mode !== undefined) {
|
|
347
372
|
if (!VALID_NAV_MODES.includes(config.navigation.mode)) {
|
|
348
|
-
|
|
373
|
+
d.error(
|
|
349
374
|
`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`,
|
|
350
375
|
);
|
|
351
376
|
}
|
|
@@ -354,7 +379,7 @@ function parseConfig(
|
|
|
354
379
|
// Validate completion.mode
|
|
355
380
|
if (config.completion?.mode !== undefined) {
|
|
356
381
|
if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) {
|
|
357
|
-
|
|
382
|
+
d.error(
|
|
358
383
|
`course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`,
|
|
359
384
|
);
|
|
360
385
|
}
|
|
@@ -362,11 +387,11 @@ function parseConfig(
|
|
|
362
387
|
|
|
363
388
|
if (config.completion?.trigger !== undefined) {
|
|
364
389
|
if (config.completion.mode !== 'manual') {
|
|
365
|
-
|
|
390
|
+
d.warn(
|
|
366
391
|
`course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`,
|
|
367
392
|
);
|
|
368
393
|
} else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) {
|
|
369
|
-
|
|
394
|
+
d.error(
|
|
370
395
|
`course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`,
|
|
371
396
|
);
|
|
372
397
|
}
|
|
@@ -374,7 +399,7 @@ function parseConfig(
|
|
|
374
399
|
|
|
375
400
|
if (config.completion?.requireSuccessStatus !== undefined) {
|
|
376
401
|
if (config.completion.mode !== 'manual') {
|
|
377
|
-
|
|
402
|
+
d.warn(
|
|
378
403
|
`course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`,
|
|
379
404
|
);
|
|
380
405
|
} else if (
|
|
@@ -382,30 +407,32 @@ function parseConfig(
|
|
|
382
407
|
config.completion.requireSuccessStatus,
|
|
383
408
|
)
|
|
384
409
|
) {
|
|
385
|
-
|
|
410
|
+
d.error(
|
|
386
411
|
`course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`,
|
|
387
412
|
);
|
|
388
413
|
}
|
|
389
414
|
}
|
|
390
415
|
|
|
391
|
-
// Validate
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
416
|
+
// Validate resume policy
|
|
417
|
+
if (
|
|
418
|
+
config.resume !== undefined &&
|
|
419
|
+
config.resume !== 'auto' &&
|
|
420
|
+
config.resume !== 'never'
|
|
421
|
+
) {
|
|
422
|
+
d.error(
|
|
423
|
+
`course.config.js: "resume" must be "auto" or "never", got "${config.resume}"`,
|
|
424
|
+
);
|
|
398
425
|
}
|
|
399
426
|
|
|
400
427
|
// Validate export.csp (web-only CSP extension)
|
|
401
428
|
if (config.export?.csp !== undefined) {
|
|
402
429
|
const csp = config.export.csp;
|
|
403
430
|
if (csp !== false && !isCspOverrides(csp)) {
|
|
404
|
-
|
|
431
|
+
d.warn(
|
|
405
432
|
'course.config.js: "export.csp" must be false or an object of directive → string[]; ignoring it and using the baseline CSP',
|
|
406
433
|
);
|
|
407
434
|
} else if ((config.export.standard ?? 'web') !== 'web') {
|
|
408
|
-
|
|
435
|
+
d.warn(
|
|
409
436
|
`course.config.js: "export.csp" is ignored when "export.standard" is "${config.export.standard}" (the CSP meta is web-export only)`,
|
|
410
437
|
);
|
|
411
438
|
}
|
|
@@ -415,7 +442,7 @@ function parseConfig(
|
|
|
415
442
|
if (config.scoring?.passingScore !== undefined) {
|
|
416
443
|
const score = config.scoring.passingScore;
|
|
417
444
|
if (typeof score !== 'number' || score < 0 || score > 100) {
|
|
418
|
-
|
|
445
|
+
d.error(
|
|
419
446
|
`course.config.js: "scoring.passingScore" must be 0–100, got ${score}`,
|
|
420
447
|
);
|
|
421
448
|
}
|
|
@@ -425,7 +452,7 @@ function parseConfig(
|
|
|
425
452
|
if (config.completion?.percentageThreshold !== undefined) {
|
|
426
453
|
const threshold = config.completion.percentageThreshold;
|
|
427
454
|
if (typeof threshold !== 'number' || threshold < 0 || threshold > 100) {
|
|
428
|
-
|
|
455
|
+
d.error(
|
|
429
456
|
`course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`,
|
|
430
457
|
);
|
|
431
458
|
}
|
|
@@ -433,12 +460,7 @@ function parseConfig(
|
|
|
433
460
|
|
|
434
461
|
// Validate xapi (publisher destinations)
|
|
435
462
|
if (config.xapi !== undefined) {
|
|
436
|
-
validateXAPIConfig(
|
|
437
|
-
config.xapi,
|
|
438
|
-
config.export?.standard ?? 'web',
|
|
439
|
-
errors,
|
|
440
|
-
warnings,
|
|
441
|
-
);
|
|
463
|
+
validateXAPIConfig(config.xapi, config.export?.standard ?? 'web', d);
|
|
442
464
|
}
|
|
443
465
|
|
|
444
466
|
return config;
|
|
@@ -473,9 +495,9 @@ function describeType(raw: unknown): string {
|
|
|
473
495
|
return raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw;
|
|
474
496
|
}
|
|
475
497
|
|
|
476
|
-
function validateBranding(raw: unknown,
|
|
498
|
+
function validateBranding(raw: unknown, d: Diagnostics): void {
|
|
477
499
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
478
|
-
|
|
500
|
+
d.warn(
|
|
479
501
|
`course.config.js: "branding" must be an object, got ${describeType(raw)} — will be ignored`,
|
|
480
502
|
);
|
|
481
503
|
return;
|
|
@@ -485,11 +507,11 @@ function validateBranding(raw: unknown, warnings: string[]): void {
|
|
|
485
507
|
const logo = branding.logo;
|
|
486
508
|
if (logo !== undefined) {
|
|
487
509
|
if (typeof logo !== 'string') {
|
|
488
|
-
|
|
510
|
+
d.warn(
|
|
489
511
|
`course.config.js: "branding.logo" must be a string, got ${typeof logo}`,
|
|
490
512
|
);
|
|
491
513
|
} else if (logo.startsWith('$assets/')) {
|
|
492
|
-
|
|
514
|
+
d.warn(
|
|
493
515
|
'course.config.js: "branding.logo" starts with "$assets/", but branding paths are not asset-resolved — it will ship as a literal, broken src. Use a URL or a path relative to the deployed root.',
|
|
494
516
|
);
|
|
495
517
|
}
|
|
@@ -498,11 +520,11 @@ function validateBranding(raw: unknown, warnings: string[]): void {
|
|
|
498
520
|
const primaryColor = branding.primaryColor;
|
|
499
521
|
if (primaryColor !== undefined) {
|
|
500
522
|
if (typeof primaryColor !== 'string') {
|
|
501
|
-
|
|
523
|
+
d.warn(
|
|
502
524
|
`course.config.js: "branding.primaryColor" must be a string, got ${typeof primaryColor}`,
|
|
503
525
|
);
|
|
504
526
|
} else if (!isPlausibleColor(primaryColor)) {
|
|
505
|
-
|
|
527
|
+
d.warn(
|
|
506
528
|
`course.config.js: "branding.primaryColor" "${primaryColor}" does not look like a valid CSS color — the theme will fall back to its default shades if the browser can't parse it`,
|
|
507
529
|
);
|
|
508
530
|
} else {
|
|
@@ -511,7 +533,7 @@ function validateBranding(raw: unknown, warnings: string[]): void {
|
|
|
511
533
|
// ratio covers both. Non-#hex valid colors return null and defer to Tier 2.
|
|
512
534
|
const ratio = contrastRatio(primaryColor, '#ffffff');
|
|
513
535
|
if (ratio !== null && ratio < 4.5) {
|
|
514
|
-
|
|
536
|
+
d.warn(
|
|
515
537
|
tag(
|
|
516
538
|
A11Y_IDS.primaryContrast,
|
|
517
539
|
`course.config.js: branding.primaryColor (${primaryColor}) is ${ratio.toFixed(2)}:1 against white — it's used both for links on the page background and as a button fill behind white text, and WCAG AA needs 4.5:1 for each`,
|
|
@@ -523,7 +545,7 @@ function validateBranding(raw: unknown, warnings: string[]): void {
|
|
|
523
545
|
|
|
524
546
|
const fontFamily = branding.fontFamily;
|
|
525
547
|
if (fontFamily !== undefined && typeof fontFamily !== 'string') {
|
|
526
|
-
|
|
548
|
+
d.warn(
|
|
527
549
|
`course.config.js: "branding.fontFamily" must be a string, got ${typeof fontFamily}`,
|
|
528
550
|
);
|
|
529
551
|
}
|
|
@@ -532,9 +554,9 @@ function validateBranding(raw: unknown, warnings: string[]): void {
|
|
|
532
554
|
// ---------- a11y Config Validation ----------
|
|
533
555
|
|
|
534
556
|
/** Shape-check the `a11y` block. Malformed values can't be silenced by `ignore`. */
|
|
535
|
-
function validateA11yConfig(raw: unknown,
|
|
557
|
+
function validateA11yConfig(raw: unknown, d: Diagnostics): void {
|
|
536
558
|
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
537
|
-
|
|
559
|
+
d.error(
|
|
538
560
|
`course.config.js: "a11y" must be an object, got ${describeType(raw)}`,
|
|
539
561
|
);
|
|
540
562
|
return;
|
|
@@ -545,7 +567,7 @@ function validateA11yConfig(raw: unknown, errors: string[]): void {
|
|
|
545
567
|
a11y.level !== undefined &&
|
|
546
568
|
!VALID_A11Y_LEVELS.includes(a11y.level as string)
|
|
547
569
|
) {
|
|
548
|
-
|
|
570
|
+
d.error(
|
|
549
571
|
`course.config.js: "a11y.level" must be "warn" or "error", got ${JSON.stringify(a11y.level)}`,
|
|
550
572
|
);
|
|
551
573
|
}
|
|
@@ -553,7 +575,7 @@ function validateA11yConfig(raw: unknown, errors: string[]): void {
|
|
|
553
575
|
a11y.standard !== undefined &&
|
|
554
576
|
!VALID_A11Y_STANDARDS.includes(a11y.standard as string)
|
|
555
577
|
) {
|
|
556
|
-
|
|
578
|
+
d.error(
|
|
557
579
|
`course.config.js: "a11y.standard" must be "wcag2a", "wcag2aa", or "wcag21aa", got ${JSON.stringify(a11y.standard)}`,
|
|
558
580
|
);
|
|
559
581
|
}
|
|
@@ -562,7 +584,7 @@ function validateA11yConfig(raw: unknown, errors: string[]): void {
|
|
|
562
584
|
!Array.isArray(a11y.ignore) ||
|
|
563
585
|
a11y.ignore.some((x) => typeof x !== 'string')
|
|
564
586
|
) {
|
|
565
|
-
|
|
587
|
+
d.error(
|
|
566
588
|
`course.config.js: "a11y.ignore" must be an array of rule-ID strings`,
|
|
567
589
|
);
|
|
568
590
|
}
|
|
@@ -577,8 +599,7 @@ const UUID_RE =
|
|
|
577
599
|
function validateXAPIConfig(
|
|
578
600
|
raw: unknown,
|
|
579
601
|
standard: string,
|
|
580
|
-
|
|
581
|
-
warnings: string[],
|
|
602
|
+
d: Diagnostics,
|
|
582
603
|
): void {
|
|
583
604
|
if (raw === undefined || raw === null) return;
|
|
584
605
|
|
|
@@ -588,7 +609,7 @@ function validateXAPIConfig(
|
|
|
588
609
|
|
|
589
610
|
if (Array.isArray(raw)) {
|
|
590
611
|
if (entries.length === 0) {
|
|
591
|
-
|
|
612
|
+
d.error(
|
|
592
613
|
'course.config.js: xapi must contain at least one destination, or be omitted',
|
|
593
614
|
);
|
|
594
615
|
return;
|
|
@@ -601,7 +622,7 @@ function validateXAPIConfig(
|
|
|
601
622
|
(e as { endpoint?: unknown }).endpoint === 'lms',
|
|
602
623
|
).length;
|
|
603
624
|
if (lmsCount > 1) {
|
|
604
|
-
|
|
625
|
+
d.error(
|
|
605
626
|
"course.config.js: xapi has multiple entries with endpoint: 'lms' — only one launch-inherited destination is allowed",
|
|
606
627
|
);
|
|
607
628
|
}
|
|
@@ -617,16 +638,14 @@ function validateXAPIConfig(
|
|
|
617
638
|
}
|
|
618
639
|
for (const [ep, count] of seen) {
|
|
619
640
|
if (count > 1) {
|
|
620
|
-
|
|
641
|
+
d.warn(
|
|
621
642
|
`course.config.js: xapi has ${count} entries with endpoint "${ep}" — usually a copy-paste mistake; ` +
|
|
622
643
|
'fan-out to the same LRS with different actors/activityIds is supported but uncommon.',
|
|
623
644
|
);
|
|
624
645
|
}
|
|
625
646
|
}
|
|
626
647
|
} else if (typeof raw !== 'object') {
|
|
627
|
-
|
|
628
|
-
'course.config.js: xapi must be an object or an array of objects',
|
|
629
|
-
);
|
|
648
|
+
d.error('course.config.js: xapi must be an object or an array of objects');
|
|
630
649
|
return;
|
|
631
650
|
}
|
|
632
651
|
|
|
@@ -634,15 +653,14 @@ function validateXAPIConfig(
|
|
|
634
653
|
const entry = entries[i];
|
|
635
654
|
const label = Array.isArray(raw) ? `xapi[${i}]` : 'xapi';
|
|
636
655
|
if (!entry || typeof entry !== 'object') {
|
|
637
|
-
|
|
656
|
+
d.error(`course.config.js: ${label} must be an object`);
|
|
638
657
|
continue;
|
|
639
658
|
}
|
|
640
659
|
validateSingleXAPIEntry(
|
|
641
660
|
entry as Record<string, unknown>,
|
|
642
661
|
label,
|
|
643
662
|
standard,
|
|
644
|
-
|
|
645
|
-
warnings,
|
|
663
|
+
d,
|
|
646
664
|
);
|
|
647
665
|
}
|
|
648
666
|
}
|
|
@@ -651,16 +669,15 @@ function validateSingleXAPIEntry(
|
|
|
651
669
|
entry: Record<string, unknown>,
|
|
652
670
|
label: string,
|
|
653
671
|
standard: string,
|
|
654
|
-
|
|
655
|
-
warnings: string[],
|
|
672
|
+
d: Diagnostics,
|
|
656
673
|
): void {
|
|
657
674
|
const endpoint = entry.endpoint;
|
|
658
675
|
if (endpoint === undefined) {
|
|
659
|
-
|
|
676
|
+
d.error(`course.config.js: ${label}.endpoint is required`);
|
|
660
677
|
return;
|
|
661
678
|
}
|
|
662
679
|
if (typeof endpoint !== 'string') {
|
|
663
|
-
|
|
680
|
+
d.error(`course.config.js: ${label}.endpoint must be a string`);
|
|
664
681
|
return;
|
|
665
682
|
}
|
|
666
683
|
|
|
@@ -668,7 +685,7 @@ function validateSingleXAPIEntry(
|
|
|
668
685
|
// 'lms' inherits the LRS from the launch — only the launch-based
|
|
669
686
|
// standards (cmi5, plain xAPI) carry one.
|
|
670
687
|
if (standard !== 'cmi5' && standard !== 'xapi') {
|
|
671
|
-
|
|
688
|
+
d.error(
|
|
672
689
|
`course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' or 'xapi' (you have "${standard}"). ` +
|
|
673
690
|
'Either change the export standard or specify an explicit LRS endpoint.',
|
|
674
691
|
);
|
|
@@ -683,7 +700,7 @@ function validateSingleXAPIEntry(
|
|
|
683
700
|
];
|
|
684
701
|
for (const f of forbidden) {
|
|
685
702
|
if (entry[f] !== undefined) {
|
|
686
|
-
|
|
703
|
+
d.error(
|
|
687
704
|
`course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the launch.`,
|
|
688
705
|
);
|
|
689
706
|
}
|
|
@@ -696,24 +713,24 @@ function validateSingleXAPIEntry(
|
|
|
696
713
|
try {
|
|
697
714
|
url = new URL(endpoint);
|
|
698
715
|
} catch {
|
|
699
|
-
|
|
716
|
+
d.error(
|
|
700
717
|
`course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"`,
|
|
701
718
|
);
|
|
702
719
|
return;
|
|
703
720
|
}
|
|
704
721
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
705
|
-
|
|
722
|
+
d.error(
|
|
706
723
|
`course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"`,
|
|
707
724
|
);
|
|
708
725
|
return;
|
|
709
726
|
}
|
|
710
727
|
if (url.protocol === 'http:' && process.env.NODE_ENV === 'production') {
|
|
711
|
-
|
|
728
|
+
d.warn(
|
|
712
729
|
`course.config.js: ${label}.endpoint uses http:; LRS credentials will travel in cleartext. Use https in production.`,
|
|
713
730
|
);
|
|
714
731
|
}
|
|
715
732
|
if (!endpoint.endsWith('/')) {
|
|
716
|
-
|
|
733
|
+
d.warn(
|
|
717
734
|
`course.config.js: ${label}.endpoint should end with a slash to avoid concatenation surprises ` +
|
|
718
735
|
`(e.g. 'https://lrs.example.com/xapi/' not 'https://lrs.example.com/xapi'). Runtime normalizes regardless.`,
|
|
719
736
|
);
|
|
@@ -722,21 +739,19 @@ function validateSingleXAPIEntry(
|
|
|
722
739
|
// auth — required for explicit endpoints.
|
|
723
740
|
const auth = entry.auth;
|
|
724
741
|
if (auth === undefined) {
|
|
725
|
-
|
|
742
|
+
d.error(`course.config.js: ${label}.auth is required`);
|
|
726
743
|
} else if (typeof auth === 'string') {
|
|
727
744
|
const authErr = validateAuthCredential(auth);
|
|
728
745
|
if (authErr) {
|
|
729
|
-
|
|
730
|
-
`course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`,
|
|
731
|
-
);
|
|
746
|
+
d.error(`course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`);
|
|
732
747
|
} else {
|
|
733
|
-
|
|
748
|
+
d.warn(
|
|
734
749
|
`course.config.js: ${label}.auth is a static string and will be embedded in the bundle. ` +
|
|
735
750
|
'For production, pass a function that fetches a short-lived token from a server endpoint.',
|
|
736
751
|
);
|
|
737
752
|
}
|
|
738
753
|
} else if (typeof auth !== 'function') {
|
|
739
|
-
|
|
754
|
+
d.error(
|
|
740
755
|
`course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`,
|
|
741
756
|
);
|
|
742
757
|
}
|
|
@@ -744,15 +759,15 @@ function validateSingleXAPIEntry(
|
|
|
744
759
|
// activityId — required IRI.
|
|
745
760
|
const activityId = entry.activityId;
|
|
746
761
|
if (activityId === undefined || activityId === '') {
|
|
747
|
-
|
|
762
|
+
d.error(`course.config.js: ${label}.activityId is required`);
|
|
748
763
|
} else if (typeof activityId !== 'string') {
|
|
749
|
-
|
|
764
|
+
d.error(`course.config.js: ${label}.activityId must be a string`);
|
|
750
765
|
} else {
|
|
751
766
|
try {
|
|
752
767
|
// Any absolute IRI — the URL constructor accepts uncommon schemes.
|
|
753
768
|
new URL(activityId);
|
|
754
769
|
} catch {
|
|
755
|
-
|
|
770
|
+
d.error(
|
|
756
771
|
`course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"`,
|
|
757
772
|
);
|
|
758
773
|
}
|
|
@@ -762,7 +777,7 @@ function validateSingleXAPIEntry(
|
|
|
762
777
|
const actor = entry.actor;
|
|
763
778
|
if (actor === undefined) {
|
|
764
779
|
if (standard === 'web') {
|
|
765
|
-
|
|
780
|
+
d.error(
|
|
766
781
|
`course.config.js: ${label}.actor is required for web export — there is no LMS to derive a learner identity from. ` +
|
|
767
782
|
'Provide either a static actor object or a function that resolves one (e.g. from your auth system).',
|
|
768
783
|
);
|
|
@@ -770,10 +785,10 @@ function validateSingleXAPIEntry(
|
|
|
770
785
|
} else if (typeof actor === 'object' && actor !== null) {
|
|
771
786
|
const err = validateAgent(actor);
|
|
772
787
|
if (err) {
|
|
773
|
-
|
|
788
|
+
d.error(`course.config.js: ${joinFieldError(`${label}.actor`, err)}`);
|
|
774
789
|
}
|
|
775
790
|
} else if (typeof actor !== 'function') {
|
|
776
|
-
|
|
791
|
+
d.error(
|
|
777
792
|
`course.config.js: ${label}.actor must be an object or function, got ${typeof actor}`,
|
|
778
793
|
);
|
|
779
794
|
}
|
|
@@ -783,25 +798,25 @@ function validateSingleXAPIEntry(
|
|
|
783
798
|
const aahp = entry.actorAccountHomePage;
|
|
784
799
|
if (aahp !== undefined) {
|
|
785
800
|
if (typeof aahp !== 'string') {
|
|
786
|
-
|
|
801
|
+
d.error(
|
|
787
802
|
`course.config.js: ${label}.actorAccountHomePage must be a string`,
|
|
788
803
|
);
|
|
789
804
|
} else {
|
|
790
805
|
try {
|
|
791
806
|
new URL(aahp);
|
|
792
807
|
} catch {
|
|
793
|
-
|
|
808
|
+
d.error(
|
|
794
809
|
`course.config.js: ${label}.actorAccountHomePage must be an absolute URL`,
|
|
795
810
|
);
|
|
796
811
|
}
|
|
797
812
|
}
|
|
798
813
|
if (actor !== undefined) {
|
|
799
|
-
|
|
814
|
+
d.warn(
|
|
800
815
|
`course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`,
|
|
801
816
|
);
|
|
802
817
|
}
|
|
803
818
|
if (standard === 'cmi5' || standard === 'xapi' || standard === 'web') {
|
|
804
|
-
|
|
819
|
+
d.warn(
|
|
805
820
|
`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`,
|
|
806
821
|
);
|
|
807
822
|
}
|
|
@@ -816,7 +831,7 @@ function validateSingleXAPIEntry(
|
|
|
816
831
|
httpOrigin(activityId) === null &&
|
|
817
832
|
aahp === undefined
|
|
818
833
|
) {
|
|
819
|
-
|
|
834
|
+
d.error(
|
|
820
835
|
`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. ` +
|
|
821
836
|
`Provide ${label}.actorAccountHomePage explicitly.`,
|
|
822
837
|
);
|
|
@@ -826,12 +841,12 @@ function validateSingleXAPIEntry(
|
|
|
826
841
|
const registration = entry.registration;
|
|
827
842
|
if (registration !== undefined) {
|
|
828
843
|
if (typeof registration !== 'string' || !UUID_RE.test(registration)) {
|
|
829
|
-
|
|
844
|
+
d.error(
|
|
830
845
|
`course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`,
|
|
831
846
|
);
|
|
832
847
|
}
|
|
833
848
|
if (standard !== 'cmi5' && standard !== 'xapi') {
|
|
834
|
-
|
|
849
|
+
d.warn(
|
|
835
850
|
`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.`,
|
|
836
851
|
);
|
|
837
852
|
}
|
|
@@ -848,7 +863,7 @@ interface PageInfo {
|
|
|
848
863
|
completesOnView: boolean;
|
|
849
864
|
}
|
|
850
865
|
|
|
851
|
-
interface PagesValidationResult
|
|
866
|
+
interface PagesValidationResult {
|
|
852
867
|
totalPages: number;
|
|
853
868
|
totalQuizzes: number;
|
|
854
869
|
hasGradedQuiz: boolean;
|
|
@@ -866,8 +881,7 @@ function validatePageFile(
|
|
|
866
881
|
projectRoot: string,
|
|
867
882
|
assetsDir: string,
|
|
868
883
|
navIndex: number,
|
|
869
|
-
|
|
870
|
-
warnings: string[],
|
|
884
|
+
d: Diagnostics,
|
|
871
885
|
assetExistsCache: Map<string, boolean>,
|
|
872
886
|
exportStandard?: string,
|
|
873
887
|
): {
|
|
@@ -881,7 +895,7 @@ function validatePageFile(
|
|
|
881
895
|
|
|
882
896
|
const parseError = getParseError(content);
|
|
883
897
|
if (parseError) {
|
|
884
|
-
|
|
898
|
+
d.error(`${fileRel}: could not parse — ${parseError}`);
|
|
885
899
|
return {
|
|
886
900
|
page: {
|
|
887
901
|
fileRel,
|
|
@@ -896,37 +910,31 @@ function validatePageFile(
|
|
|
896
910
|
};
|
|
897
911
|
}
|
|
898
912
|
|
|
899
|
-
const pageConfig = validatePageConfig(content, fileRel,
|
|
913
|
+
const pageConfig = validatePageConfig(content, fileRel, d);
|
|
900
914
|
|
|
901
915
|
const isQuiz = !!pageConfig?.quiz;
|
|
902
916
|
let isGradedQuiz = false;
|
|
903
917
|
if (pageConfig?.quiz) {
|
|
904
|
-
validateQuizConfig(pageConfig.quiz, fileRel,
|
|
918
|
+
validateQuizConfig(pageConfig.quiz, fileRel, d);
|
|
905
919
|
if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
|
|
906
920
|
isGradedQuiz = true;
|
|
907
921
|
}
|
|
908
922
|
}
|
|
909
923
|
|
|
910
|
-
const completesOnView = validateCompletesOn(pageConfig, fileRel,
|
|
924
|
+
const completesOnView = validateCompletesOn(pageConfig, fileRel, d);
|
|
911
925
|
|
|
912
|
-
validateAssetRefs(content, fileRel, assetsDir,
|
|
913
|
-
validateQuestionComponents(
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
warnings,
|
|
918
|
-
exportStandard,
|
|
919
|
-
);
|
|
920
|
-
validateMediaComponents(content, fileRel, errors, warnings);
|
|
921
|
-
validateHeadingOrder(content, fileRel, warnings);
|
|
922
|
-
validateContractBypass(content, fileRel, errors);
|
|
926
|
+
validateAssetRefs(content, fileRel, assetsDir, d, assetExistsCache);
|
|
927
|
+
validateQuestionComponents(content, fileRel, d, exportStandard);
|
|
928
|
+
validateMediaComponents(content, fileRel, d);
|
|
929
|
+
validateHeadingOrder(content, fileRel, d);
|
|
930
|
+
validateContractBypass(content, fileRel, d);
|
|
923
931
|
if (
|
|
924
932
|
pageConfig?.quiz &&
|
|
925
933
|
!HAS_USE_QUESTION_RE.test(content) &&
|
|
926
934
|
!HAS_QUESTION_TAG_RE.test(content) &&
|
|
927
935
|
!HAS_LOCAL_SVELTE_IMPORT_RE.test(content)
|
|
928
936
|
) {
|
|
929
|
-
|
|
937
|
+
d.warn(
|
|
930
938
|
`${fileRel}: quiz page has no question components or useQuestion() calls — ` +
|
|
931
939
|
`the quiz will have nothing to score`,
|
|
932
940
|
);
|
|
@@ -950,10 +958,9 @@ function validatePages(
|
|
|
950
958
|
pagesDir: string,
|
|
951
959
|
assetsDir: string,
|
|
952
960
|
projectRoot: string,
|
|
961
|
+
d: Diagnostics,
|
|
953
962
|
exportStandard?: string,
|
|
954
963
|
): PagesValidationResult {
|
|
955
|
-
const errors: string[] = [];
|
|
956
|
-
const warnings: string[] = [];
|
|
957
964
|
const pages: PageInfo[] = [];
|
|
958
965
|
let totalPages = 0;
|
|
959
966
|
let totalQuizzes = 0;
|
|
@@ -963,18 +970,10 @@ function validatePages(
|
|
|
963
970
|
const assetExistsCache = new Map<string, boolean>();
|
|
964
971
|
|
|
965
972
|
const noPages = (): PagesValidationResult => {
|
|
966
|
-
|
|
973
|
+
d.error(
|
|
967
974
|
'No pages found. Create at least one section with a lesson and page in pages/',
|
|
968
975
|
);
|
|
969
|
-
return {
|
|
970
|
-
errors,
|
|
971
|
-
warnings,
|
|
972
|
-
totalPages,
|
|
973
|
-
totalQuizzes,
|
|
974
|
-
hasGradedQuiz,
|
|
975
|
-
hasParseErrors,
|
|
976
|
-
pages,
|
|
977
|
-
};
|
|
976
|
+
return { totalPages, totalQuizzes, hasGradedQuiz, hasParseErrors, pages };
|
|
978
977
|
};
|
|
979
978
|
|
|
980
979
|
if (!existsSync(pagesDir)) return noPages();
|
|
@@ -983,7 +982,7 @@ function validatePages(
|
|
|
983
982
|
for (const entry of readdirSync(pagesDir)) {
|
|
984
983
|
const fullPath = resolve(pagesDir, entry);
|
|
985
984
|
if (entry.endsWith('.svelte') && statSync(fullPath).isFile()) {
|
|
986
|
-
|
|
985
|
+
d.warn(
|
|
987
986
|
`${relative(projectRoot, fullPath)}: this file is outside the section/lesson structure and will be ignored`,
|
|
988
987
|
);
|
|
989
988
|
}
|
|
@@ -1001,7 +1000,7 @@ function validatePages(
|
|
|
1001
1000
|
for (const pageName of meta.pages) {
|
|
1002
1001
|
const fileName = ensureSvelteSuffix(pageName);
|
|
1003
1002
|
if (!lesson.files.includes(fileName)) {
|
|
1004
|
-
|
|
1003
|
+
d.error(
|
|
1005
1004
|
`${relative(projectRoot, lesson.metaPath)}: pages array lists "${pageName}" but ${fileName} not found in this directory`,
|
|
1006
1005
|
);
|
|
1007
1006
|
}
|
|
@@ -1011,7 +1010,7 @@ function validatePages(
|
|
|
1011
1010
|
const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
|
|
1012
1011
|
for (const file of lesson.files) {
|
|
1013
1012
|
if (!listedSet.has(file)) {
|
|
1014
|
-
|
|
1013
|
+
d.warn(
|
|
1015
1014
|
`${relative(projectRoot, resolve(lesson.dir, file))}: not listed in _meta.js pages array — will be appended at end`,
|
|
1016
1015
|
);
|
|
1017
1016
|
}
|
|
@@ -1024,8 +1023,7 @@ function validatePages(
|
|
|
1024
1023
|
projectRoot,
|
|
1025
1024
|
assetsDir,
|
|
1026
1025
|
totalPages,
|
|
1027
|
-
|
|
1028
|
-
warnings,
|
|
1026
|
+
d,
|
|
1029
1027
|
assetExistsCache,
|
|
1030
1028
|
exportStandard,
|
|
1031
1029
|
);
|
|
@@ -1041,7 +1039,7 @@ function validatePages(
|
|
|
1041
1039
|
const sectionRel = relative(projectRoot, section.dir);
|
|
1042
1040
|
const pagesBeforeSection = totalPages;
|
|
1043
1041
|
|
|
1044
|
-
const sectionMeta = validateMetaFile(section.metaPath, sectionRel,
|
|
1042
|
+
const sectionMeta = validateMetaFile(section.metaPath, sectionRel, d);
|
|
1045
1043
|
|
|
1046
1044
|
for (const lesson of section.lessons) {
|
|
1047
1045
|
if (lesson.name === null) {
|
|
@@ -1051,7 +1049,7 @@ function validatePages(
|
|
|
1051
1049
|
const meta = validateMetaFile(
|
|
1052
1050
|
lesson.metaPath,
|
|
1053
1051
|
relative(projectRoot, lesson.dir),
|
|
1054
|
-
|
|
1052
|
+
d,
|
|
1055
1053
|
);
|
|
1056
1054
|
validateLesson(lesson, meta);
|
|
1057
1055
|
}
|
|
@@ -1059,23 +1057,13 @@ function validatePages(
|
|
|
1059
1057
|
|
|
1060
1058
|
// The page-count delta covers both the no-lessons and empty-lessons cases.
|
|
1061
1059
|
if (totalPages === pagesBeforeSection) {
|
|
1062
|
-
|
|
1063
|
-
`${sectionRel}: section contributed no pages and will be empty`,
|
|
1064
|
-
);
|
|
1060
|
+
d.warn(`${sectionRel}: section contributed no pages and will be empty`);
|
|
1065
1061
|
}
|
|
1066
1062
|
}
|
|
1067
1063
|
|
|
1068
1064
|
if (totalPages === 0) return noPages();
|
|
1069
1065
|
|
|
1070
|
-
return {
|
|
1071
|
-
errors,
|
|
1072
|
-
warnings,
|
|
1073
|
-
totalPages,
|
|
1074
|
-
totalQuizzes,
|
|
1075
|
-
hasGradedQuiz,
|
|
1076
|
-
hasParseErrors,
|
|
1077
|
-
pages,
|
|
1078
|
-
};
|
|
1066
|
+
return { totalPages, totalQuizzes, hasGradedQuiz, hasParseErrors, pages };
|
|
1079
1067
|
}
|
|
1080
1068
|
|
|
1081
1069
|
// ---------- _meta.js Validation ----------
|
|
@@ -1083,7 +1071,7 @@ function validatePages(
|
|
|
1083
1071
|
function validateMetaFile(
|
|
1084
1072
|
metaPath: string,
|
|
1085
1073
|
parentRel: string,
|
|
1086
|
-
|
|
1074
|
+
d: Diagnostics,
|
|
1087
1075
|
): { title?: string; pages?: string[] } | null {
|
|
1088
1076
|
if (!existsSync(metaPath)) return null;
|
|
1089
1077
|
|
|
@@ -1093,13 +1081,11 @@ function validateMetaFile(
|
|
|
1093
1081
|
);
|
|
1094
1082
|
|
|
1095
1083
|
if (result.kind === 'parse-error') {
|
|
1096
|
-
|
|
1084
|
+
d.error(`${metaRel}: could not parse — JavaScript syntax error`);
|
|
1097
1085
|
return null;
|
|
1098
1086
|
}
|
|
1099
1087
|
if (result.kind !== 'literal') {
|
|
1100
|
-
|
|
1101
|
-
`${metaRel}: syntax error — must export default { title: "..." }`,
|
|
1102
|
-
);
|
|
1088
|
+
d.error(`${metaRel}: syntax error — must export default { title: "..." }`);
|
|
1103
1089
|
return null;
|
|
1104
1090
|
}
|
|
1105
1091
|
|
|
@@ -1107,14 +1093,12 @@ function validateMetaFile(
|
|
|
1107
1093
|
try {
|
|
1108
1094
|
meta = JSON5.parse(result.text);
|
|
1109
1095
|
} catch {
|
|
1110
|
-
|
|
1111
|
-
`${metaRel}: syntax error — must export default { title: "..." }`,
|
|
1112
|
-
);
|
|
1096
|
+
d.error(`${metaRel}: syntax error — must export default { title: "..." }`);
|
|
1113
1097
|
return null;
|
|
1114
1098
|
}
|
|
1115
1099
|
|
|
1116
1100
|
if (!meta.title) {
|
|
1117
|
-
|
|
1101
|
+
d.error(`${metaRel}: missing required "title" field`);
|
|
1118
1102
|
}
|
|
1119
1103
|
|
|
1120
1104
|
return meta;
|
|
@@ -1125,12 +1109,12 @@ function validateMetaFile(
|
|
|
1125
1109
|
function validatePageConfig(
|
|
1126
1110
|
content: string,
|
|
1127
1111
|
fileRel: string,
|
|
1128
|
-
|
|
1112
|
+
d: Diagnostics,
|
|
1129
1113
|
): { title?: string; quiz?: unknown; completesOn?: unknown } | null {
|
|
1130
1114
|
const result = parsePageConfigFromSource(content);
|
|
1131
1115
|
if (result.kind === 'ok') return result.value;
|
|
1132
1116
|
if (result.kind === 'invalid') {
|
|
1133
|
-
|
|
1117
|
+
d.error(
|
|
1134
1118
|
`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`,
|
|
1135
1119
|
);
|
|
1136
1120
|
}
|
|
@@ -1140,11 +1124,11 @@ function validatePageConfig(
|
|
|
1140
1124
|
function validateCompletesOn(
|
|
1141
1125
|
pageConfig: { completesOn?: unknown } | null,
|
|
1142
1126
|
fileRel: string,
|
|
1143
|
-
|
|
1127
|
+
d: Diagnostics,
|
|
1144
1128
|
): boolean {
|
|
1145
1129
|
if (!pageConfig || pageConfig.completesOn === undefined) return false;
|
|
1146
1130
|
if (pageConfig.completesOn === 'view') return true;
|
|
1147
|
-
|
|
1131
|
+
d.error(
|
|
1148
1132
|
`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`,
|
|
1149
1133
|
);
|
|
1150
1134
|
return false;
|
|
@@ -1155,7 +1139,7 @@ function validateCompletesOn(
|
|
|
1155
1139
|
function validateQuizConfig(
|
|
1156
1140
|
quiz: unknown,
|
|
1157
1141
|
fileRel: string,
|
|
1158
|
-
|
|
1142
|
+
d: Diagnostics,
|
|
1159
1143
|
): void {
|
|
1160
1144
|
if (!quiz || typeof quiz !== 'object') return;
|
|
1161
1145
|
const cfg = quiz as Record<string, unknown>;
|
|
@@ -1166,7 +1150,7 @@ function validateQuizConfig(
|
|
|
1166
1150
|
val !== Infinity &&
|
|
1167
1151
|
(typeof val !== 'number' || val <= 0 || !Number.isFinite(val))
|
|
1168
1152
|
) {
|
|
1169
|
-
|
|
1153
|
+
d.error(
|
|
1170
1154
|
`${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}`,
|
|
1171
1155
|
);
|
|
1172
1156
|
}
|
|
@@ -1174,7 +1158,7 @@ function validateQuizConfig(
|
|
|
1174
1158
|
|
|
1175
1159
|
for (const field of ['graded', 'gatesProgress']) {
|
|
1176
1160
|
if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
|
|
1177
|
-
|
|
1161
|
+
d.error(
|
|
1178
1162
|
`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`,
|
|
1179
1163
|
);
|
|
1180
1164
|
}
|
|
@@ -1184,7 +1168,7 @@ function validateQuizConfig(
|
|
|
1184
1168
|
cfg.feedbackMode !== undefined &&
|
|
1185
1169
|
!VALID_FEEDBACK_MODES.includes(cfg.feedbackMode as string)
|
|
1186
1170
|
) {
|
|
1187
|
-
|
|
1171
|
+
d.error(
|
|
1188
1172
|
`${fileRel}: quiz.feedbackMode must be "review", "immediate", or "never", got "${String(cfg.feedbackMode)}"`,
|
|
1189
1173
|
);
|
|
1190
1174
|
}
|
|
@@ -1192,7 +1176,7 @@ function validateQuizConfig(
|
|
|
1192
1176
|
cfg.retryMode !== undefined &&
|
|
1193
1177
|
!VALID_RETRY_MODES.includes(cfg.retryMode as string)
|
|
1194
1178
|
) {
|
|
1195
|
-
|
|
1179
|
+
d.error(
|
|
1196
1180
|
`${fileRel}: quiz.retryMode must be "full" or "incorrect-only", got "${String(cfg.retryMode)}"`,
|
|
1197
1181
|
);
|
|
1198
1182
|
}
|
|
@@ -1230,8 +1214,7 @@ function staticNumber(prop: PropValue | undefined): number | null {
|
|
|
1230
1214
|
function validateQuestionComponents(
|
|
1231
1215
|
content: string,
|
|
1232
1216
|
fileRel: string,
|
|
1233
|
-
|
|
1234
|
-
warnings: string[],
|
|
1217
|
+
d: Diagnostics,
|
|
1235
1218
|
exportStandard?: string,
|
|
1236
1219
|
): void {
|
|
1237
1220
|
const components = findComponents(
|
|
@@ -1244,7 +1227,7 @@ function validateQuestionComponents(
|
|
|
1244
1227
|
for (const { name, props, hasSpread } of components) {
|
|
1245
1228
|
for (const req of QUESTION_COMPONENT_REQUIRED[name]) {
|
|
1246
1229
|
if (!hasSpread && !props.has(req)) {
|
|
1247
|
-
|
|
1230
|
+
d.error(`${fileRel}: <${name}> is missing required prop "${req}"`);
|
|
1248
1231
|
}
|
|
1249
1232
|
}
|
|
1250
1233
|
|
|
@@ -1252,7 +1235,7 @@ function validateQuestionComponents(
|
|
|
1252
1235
|
for (const labelProp of ['options', 'answers']) {
|
|
1253
1236
|
const entries = staticArray(props.get(labelProp));
|
|
1254
1237
|
if (entries?.some((e) => typeof e === 'string' && e.trim() === '')) {
|
|
1255
|
-
|
|
1238
|
+
d.warn(
|
|
1256
1239
|
tag(
|
|
1257
1240
|
A11Y_IDS.questionLabel,
|
|
1258
1241
|
`${fileRel}: <${name}> has an empty ${labelProp === 'options' ? 'option' : 'answer'} label`,
|
|
@@ -1264,7 +1247,7 @@ function validateQuestionComponents(
|
|
|
1264
1247
|
const idProp = props.get('id');
|
|
1265
1248
|
if (idProp?.kind === 'string') {
|
|
1266
1249
|
if (seenIds.has(idProp.value)) {
|
|
1267
|
-
|
|
1250
|
+
d.error(
|
|
1268
1251
|
`${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`,
|
|
1269
1252
|
);
|
|
1270
1253
|
} else if (exportStandard === 'scorm12') {
|
|
@@ -1273,12 +1256,12 @@ function validateQuestionComponents(
|
|
|
1273
1256
|
// flagged above) to avoid double-reporting the same id.
|
|
1274
1257
|
const sane = shortIdentifier(idProp.value);
|
|
1275
1258
|
if (sane !== idProp.value) {
|
|
1276
|
-
|
|
1259
|
+
d.warn(
|
|
1277
1260
|
`${fileRel}: question id "${idProp.value}" will be rewritten to "${sane}" for SCORM 1.2 — use only letters and digits (underscores only between them)`,
|
|
1278
1261
|
);
|
|
1279
1262
|
}
|
|
1280
1263
|
if (seenSanitized.has(sane)) {
|
|
1281
|
-
|
|
1264
|
+
d.error(
|
|
1282
1265
|
`${fileRel}: question id "${idProp.value}" collides with a prior id after SCORM 1.2 sanitization ("${sane}")`,
|
|
1283
1266
|
);
|
|
1284
1267
|
}
|
|
@@ -1289,18 +1272,18 @@ function validateQuestionComponents(
|
|
|
1289
1272
|
|
|
1290
1273
|
const weightProp = props.get('weight');
|
|
1291
1274
|
if (weightProp?.kind === 'string') {
|
|
1292
|
-
|
|
1275
|
+
d.warn(
|
|
1293
1276
|
`${fileRel}: <${name}> weight="${weightProp.value}" is a string and is ignored (treated as 1) — pass a number: weight={${weightProp.value}}`,
|
|
1294
1277
|
);
|
|
1295
1278
|
} else {
|
|
1296
1279
|
const weight = staticNumber(weightProp);
|
|
1297
1280
|
if (weight !== null) {
|
|
1298
1281
|
if (!Number.isFinite(weight)) {
|
|
1299
|
-
|
|
1282
|
+
d.error(
|
|
1300
1283
|
`${fileRel}: <${name}> weight must be finite — a non-finite weight makes the weighted score NaN, got ${weight}`,
|
|
1301
1284
|
);
|
|
1302
1285
|
} else if (!(weight > 0)) {
|
|
1303
|
-
|
|
1286
|
+
d.warn(
|
|
1304
1287
|
`${fileRel}: <${name}> weight ${weight} is not positive and is ignored (treated as 1)`,
|
|
1305
1288
|
);
|
|
1306
1289
|
}
|
|
@@ -1316,14 +1299,14 @@ function validateQuestionComponents(
|
|
|
1316
1299
|
correct < 0 ||
|
|
1317
1300
|
correct >= options.length
|
|
1318
1301
|
) {
|
|
1319
|
-
|
|
1302
|
+
d.error(
|
|
1320
1303
|
`${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`,
|
|
1321
1304
|
);
|
|
1322
1305
|
}
|
|
1323
1306
|
}
|
|
1324
1307
|
const optionFeedback = staticArray(props.get('optionFeedback'));
|
|
1325
1308
|
if (options && optionFeedback && optionFeedback.length > options.length) {
|
|
1326
|
-
|
|
1309
|
+
d.warn(
|
|
1327
1310
|
`${fileRel}: <MultipleChoice> optionFeedback has ${optionFeedback.length} entries but only ${options.length} options — the extra entries can never be shown`,
|
|
1328
1311
|
);
|
|
1329
1312
|
}
|
|
@@ -1332,7 +1315,7 @@ function validateQuestionComponents(
|
|
|
1332
1315
|
const targets = staticArray(props.get('targets'));
|
|
1333
1316
|
const correct = staticArray(props.get('correct'));
|
|
1334
1317
|
if (items && correct && correct.length !== items.length) {
|
|
1335
|
-
|
|
1318
|
+
d.error(
|
|
1336
1319
|
`${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays`,
|
|
1337
1320
|
);
|
|
1338
1321
|
}
|
|
@@ -1344,7 +1327,7 @@ function validateQuestionComponents(
|
|
|
1344
1327
|
idx < 0 ||
|
|
1345
1328
|
idx >= targets.length
|
|
1346
1329
|
) {
|
|
1347
|
-
|
|
1330
|
+
d.error(
|
|
1348
1331
|
`${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`,
|
|
1349
1332
|
);
|
|
1350
1333
|
break;
|
|
@@ -1362,7 +1345,7 @@ function validateQuestionComponents(
|
|
|
1362
1345
|
typeof (p as { right?: unknown }).right !== 'string',
|
|
1363
1346
|
);
|
|
1364
1347
|
if (bad) {
|
|
1365
|
-
|
|
1348
|
+
d.error(
|
|
1366
1349
|
`${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects`,
|
|
1367
1350
|
);
|
|
1368
1351
|
}
|
|
@@ -1371,9 +1354,9 @@ function validateQuestionComponents(
|
|
|
1371
1354
|
const answers = staticArray(props.get('answers'));
|
|
1372
1355
|
if (answers) {
|
|
1373
1356
|
if (answers.length === 0) {
|
|
1374
|
-
|
|
1357
|
+
d.error(`${fileRel}: <FillInTheBlank> answers must not be empty`);
|
|
1375
1358
|
} else if (answers.some((a) => typeof a !== 'string')) {
|
|
1376
|
-
|
|
1359
|
+
d.error(
|
|
1377
1360
|
`${fileRel}: <FillInTheBlank> answers must be an array of strings`,
|
|
1378
1361
|
);
|
|
1379
1362
|
}
|
|
@@ -1404,14 +1387,13 @@ function stripRepeated(input: string, patterns: RegExp[]): string {
|
|
|
1404
1387
|
|
|
1405
1388
|
/**
|
|
1406
1389
|
* Sibling to validateQuestionComponents kept out of QUESTION_COMPONENT_REQUIRED
|
|
1407
|
-
* so media isn't treated as gradable questions.
|
|
1390
|
+
* so media isn't treated as gradable questions.
|
|
1408
1391
|
* Non-static (kind 'expr') values are skipped, matching the rest of the linter.
|
|
1409
1392
|
*/
|
|
1410
1393
|
function validateMediaComponents(
|
|
1411
1394
|
content: string,
|
|
1412
1395
|
fileRel: string,
|
|
1413
|
-
|
|
1414
|
-
warnings: string[],
|
|
1396
|
+
d: Diagnostics,
|
|
1415
1397
|
): void {
|
|
1416
1398
|
const components = findComponents(
|
|
1417
1399
|
content,
|
|
@@ -1425,7 +1407,7 @@ function validateMediaComponents(
|
|
|
1425
1407
|
// A string value is truthy at runtime (so decorative="false" hides the
|
|
1426
1408
|
// image), but the parser sees a string, not a boolean — flag the misuse.
|
|
1427
1409
|
if (decorative?.kind === 'string') {
|
|
1428
|
-
|
|
1410
|
+
d.error(
|
|
1429
1411
|
tag(
|
|
1430
1412
|
A11Y_IDS.imageAlt,
|
|
1431
1413
|
`${fileRel}: <Image> "decorative" must be a boolean — use decorative or decorative={true}, not the string ${JSON.stringify(decorative.value)}`,
|
|
@@ -1438,7 +1420,7 @@ function validateMediaComponents(
|
|
|
1438
1420
|
(decorative?.kind === 'expr' && decorative.raw.trim() === 'true');
|
|
1439
1421
|
const altIsEmpty = alt?.kind === 'string' && alt.value.trim() === '';
|
|
1440
1422
|
if (!hasDecorative && !hasSpread && (alt === undefined || altIsEmpty)) {
|
|
1441
|
-
|
|
1423
|
+
d.error(
|
|
1442
1424
|
tag(
|
|
1443
1425
|
A11Y_IDS.imageAlt,
|
|
1444
1426
|
`${fileRel}: <Image> needs alt text, or mark it decorative={true} if purely ornamental`,
|
|
@@ -1446,7 +1428,7 @@ function validateMediaComponents(
|
|
|
1446
1428
|
);
|
|
1447
1429
|
}
|
|
1448
1430
|
if (hasDecorative && alt?.kind === 'string' && alt.value.trim() !== '') {
|
|
1449
|
-
|
|
1431
|
+
d.warn(
|
|
1450
1432
|
tag(
|
|
1451
1433
|
A11Y_IDS.imageAlt,
|
|
1452
1434
|
`${fileRel}: <Image> is decorative but also has alt text — the alt will be dropped`,
|
|
@@ -1460,7 +1442,7 @@ function validateMediaComponents(
|
|
|
1460
1442
|
const title = props.get('title');
|
|
1461
1443
|
const titleIsEmpty = title?.kind === 'string' && title.value.trim() === '';
|
|
1462
1444
|
if (!hasSpread && (title === undefined || titleIsEmpty)) {
|
|
1463
|
-
|
|
1445
|
+
d.error(
|
|
1464
1446
|
tag(
|
|
1465
1447
|
A11Y_IDS.mediaTitle,
|
|
1466
1448
|
`${fileRel}: <${name}> needs a title — it's the accessible name for the player`,
|
|
@@ -1475,7 +1457,7 @@ function validateMediaComponents(
|
|
|
1475
1457
|
isEmbed &&
|
|
1476
1458
|
props.get('transcript') === undefined
|
|
1477
1459
|
) {
|
|
1478
|
-
|
|
1460
|
+
d.warn(
|
|
1479
1461
|
tag(
|
|
1480
1462
|
A11Y_IDS.mediaTranscript,
|
|
1481
1463
|
`${fileRel}: <Video> embeds can't carry caption tracks — provide a transcript for WCAG 1.2`,
|
|
@@ -1490,7 +1472,7 @@ function validateMediaComponents(
|
|
|
1490
1472
|
props.get('tracks') === undefined &&
|
|
1491
1473
|
props.get('transcript') === undefined
|
|
1492
1474
|
) {
|
|
1493
|
-
|
|
1475
|
+
d.warn(
|
|
1494
1476
|
tag(
|
|
1495
1477
|
A11Y_IDS.mediaCaptions,
|
|
1496
1478
|
`${fileRel}: native <Video> has no caption tracks or transcript — add tracks={[…]} or a transcript for WCAG 1.2.2`,
|
|
@@ -1502,7 +1484,7 @@ function validateMediaComponents(
|
|
|
1502
1484
|
!hasSpread &&
|
|
1503
1485
|
props.get('transcript') === undefined
|
|
1504
1486
|
) {
|
|
1505
|
-
|
|
1487
|
+
d.warn(
|
|
1506
1488
|
tag(
|
|
1507
1489
|
A11Y_IDS.mediaTranscript,
|
|
1508
1490
|
`${fileRel}: <Audio> has no transcript — required for WCAG 1.2.1`,
|
|
@@ -1524,14 +1506,14 @@ function validateMediaComponents(
|
|
|
1524
1506
|
function validateHeadingOrder(
|
|
1525
1507
|
content: string,
|
|
1526
1508
|
fileRel: string,
|
|
1527
|
-
|
|
1509
|
+
d: Diagnostics,
|
|
1528
1510
|
): void {
|
|
1529
1511
|
const html = stripRepeated(content, [SCRIPT_STYLE_RE, HTML_COMMENT_RE]);
|
|
1530
1512
|
const levels = [...html.matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
|
|
1531
1513
|
let prevSeen: number | null = null;
|
|
1532
1514
|
for (const level of levels) {
|
|
1533
1515
|
if (prevSeen !== null && level - prevSeen > 1) {
|
|
1534
|
-
|
|
1516
|
+
d.warn(
|
|
1535
1517
|
tag(
|
|
1536
1518
|
A11Y_IDS.headingOrder,
|
|
1537
1519
|
`${fileRel}: heading level jumps from h${prevSeen} to h${level} — don't skip levels (WCAG 1.3.1)`,
|
|
@@ -1564,16 +1546,16 @@ const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
|
|
|
1564
1546
|
function validateContractBypass(
|
|
1565
1547
|
content: string,
|
|
1566
1548
|
fileRel: string,
|
|
1567
|
-
|
|
1549
|
+
d: Diagnostics,
|
|
1568
1550
|
): void {
|
|
1569
1551
|
if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) {
|
|
1570
|
-
|
|
1552
|
+
d.error(
|
|
1571
1553
|
`${fileRel}: dispatches "tessera-quiz-complete" directly — submit through ` +
|
|
1572
1554
|
`useQuiz().submit() so the result reaches the LMS`,
|
|
1573
1555
|
);
|
|
1574
1556
|
}
|
|
1575
1557
|
if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) {
|
|
1576
|
-
|
|
1558
|
+
d.error(
|
|
1577
1559
|
`${fileRel}: imports from tessera-learn/runtime/* — use the public hooks ` +
|
|
1578
1560
|
`(useQuiz, useQuestion, useNavigation, …) instead`,
|
|
1579
1561
|
);
|
|
@@ -1599,7 +1581,7 @@ function validateAssetRefs(
|
|
|
1599
1581
|
content: string,
|
|
1600
1582
|
fileRel: string,
|
|
1601
1583
|
assetsDir: string,
|
|
1602
|
-
|
|
1584
|
+
d: Diagnostics,
|
|
1603
1585
|
existsCache: Map<string, boolean>,
|
|
1604
1586
|
): void {
|
|
1605
1587
|
for (const assetPath of collectAssetRefs(content)) {
|
|
@@ -1610,7 +1592,7 @@ function validateAssetRefs(
|
|
|
1610
1592
|
existsCache.set(fullAssetPath, exists);
|
|
1611
1593
|
}
|
|
1612
1594
|
if (!exists) {
|
|
1613
|
-
|
|
1595
|
+
d.warn(
|
|
1614
1596
|
`${fileRel}: "$assets/${assetPath}" not found in assets/ directory`,
|
|
1615
1597
|
);
|
|
1616
1598
|
}
|
|
@@ -1622,8 +1604,7 @@ function validateAssetRefs(
|
|
|
1622
1604
|
function crossValidate(
|
|
1623
1605
|
config: ParsedConfig,
|
|
1624
1606
|
pageResults: PagesValidationResult,
|
|
1625
|
-
|
|
1626
|
-
warnings: string[],
|
|
1607
|
+
d: Diagnostics,
|
|
1627
1608
|
): void {
|
|
1628
1609
|
// completion.mode "quiz" but no graded quizzes
|
|
1629
1610
|
if (
|
|
@@ -1631,7 +1612,7 @@ function crossValidate(
|
|
|
1631
1612
|
!pageResults.hasGradedQuiz &&
|
|
1632
1613
|
!pageResults.hasParseErrors
|
|
1633
1614
|
) {
|
|
1634
|
-
|
|
1615
|
+
d.error(
|
|
1635
1616
|
'completion.mode is "quiz" but no pages have quiz config with graded: true',
|
|
1636
1617
|
);
|
|
1637
1618
|
}
|
|
@@ -1642,7 +1623,7 @@ function crossValidate(
|
|
|
1642
1623
|
config.completion?.mode === 'quiz' &&
|
|
1643
1624
|
config.scoring?.passingScore === undefined
|
|
1644
1625
|
) {
|
|
1645
|
-
|
|
1626
|
+
d.warn(
|
|
1646
1627
|
'completion.mode is "quiz" but scoring.passingScore is not set — defaulting to 70%. Set it explicitly to be sure.',
|
|
1647
1628
|
);
|
|
1648
1629
|
}
|
|
@@ -1656,7 +1637,7 @@ function crossValidate(
|
|
|
1656
1637
|
completesOnPages.length === 0 &&
|
|
1657
1638
|
!pageResults.hasParseErrors
|
|
1658
1639
|
) {
|
|
1659
|
-
|
|
1640
|
+
d.error(
|
|
1660
1641
|
'completion.mode is "manual" with trigger: "page", but no page declares pageConfig.completesOn: "view". ' +
|
|
1661
1642
|
'Either add a completesOn page or remove the trigger field to drop the static check.',
|
|
1662
1643
|
);
|
|
@@ -1665,7 +1646,7 @@ function crossValidate(
|
|
|
1665
1646
|
if (isManual) {
|
|
1666
1647
|
for (const page of pageResults.pages) {
|
|
1667
1648
|
if (page.hasGradedQuiz) {
|
|
1668
|
-
|
|
1649
|
+
d.warn(
|
|
1669
1650
|
`${page.fileRel}: quiz.graded is true under completion.mode: "manual". ` +
|
|
1670
1651
|
'The score will be reported to the LMS for transcripts, but it will not drive ' +
|
|
1671
1652
|
"completion or success status — `markComplete()` / completesOn does. If that's " +
|
|
@@ -1676,20 +1657,20 @@ function crossValidate(
|
|
|
1676
1657
|
}
|
|
1677
1658
|
|
|
1678
1659
|
if (isManual && config.completion?.percentageThreshold !== undefined) {
|
|
1679
|
-
|
|
1660
|
+
d.warn(
|
|
1680
1661
|
'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"',
|
|
1681
1662
|
);
|
|
1682
1663
|
}
|
|
1683
1664
|
if (!isManual) {
|
|
1684
1665
|
for (const page of completesOnPages) {
|
|
1685
|
-
|
|
1666
|
+
d.warn(
|
|
1686
1667
|
`${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? 'percentage'}"`,
|
|
1687
1668
|
);
|
|
1688
1669
|
}
|
|
1689
1670
|
}
|
|
1690
1671
|
for (const page of pageResults.pages) {
|
|
1691
1672
|
if (page.completesOnView && page.hasQuiz) {
|
|
1692
|
-
|
|
1673
|
+
d.warn(
|
|
1693
1674
|
`${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`,
|
|
1694
1675
|
);
|
|
1695
1676
|
}
|
|
@@ -1698,7 +1679,7 @@ function crossValidate(
|
|
|
1698
1679
|
if (isManual) {
|
|
1699
1680
|
const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
|
|
1700
1681
|
if (firstPage?.completesOnView) {
|
|
1701
|
-
|
|
1682
|
+
d.warn(
|
|
1702
1683
|
`${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`,
|
|
1703
1684
|
);
|
|
1704
1685
|
}
|
|
@@ -1735,7 +1716,7 @@ function crossValidate(
|
|
|
1735
1716
|
userStateBuffer;
|
|
1736
1717
|
|
|
1737
1718
|
if (estimatedSize > 3200) {
|
|
1738
|
-
|
|
1719
|
+
d.warn(
|
|
1739
1720
|
`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".`,
|
|
1740
1721
|
);
|
|
1741
1722
|
}
|