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