tessera-learn 0.0.11 → 0.0.13
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/README.md +1 -0
- package/dist/audit-BBJpQGqb.js +204 -0
- package/dist/audit-BBJpQGqb.js.map +1 -0
- package/dist/plugin/a11y-cli.d.ts +1 -0
- package/dist/plugin/a11y-cli.js +36 -0
- package/dist/plugin/a11y-cli.js.map +1 -0
- package/dist/plugin/cli.js +2 -1
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +16 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +85 -10
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-D9DXlqNP.js → validation-B-xTvM9B.js} +342 -18
- package/dist/validation-B-xTvM9B.js.map +1 -0
- package/package.json +17 -2
- package/src/components/Accordion.svelte +3 -1
- package/src/components/AccordionItem.svelte +1 -5
- package/src/components/Audio.svelte +17 -3
- package/src/components/Callout.svelte +5 -1
- package/src/components/Carousel.svelte +24 -8
- package/src/components/DefaultLayout.svelte +41 -12
- package/src/components/FillInTheBlank.svelte +16 -6
- package/src/components/Image.svelte +12 -3
- package/src/components/LockedBanner.svelte +2 -1
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +33 -13
- package/src/components/Quiz.svelte +61 -20
- package/src/components/ResultIcon.svelte +20 -4
- package/src/components/RevealModal.svelte +25 -22
- package/src/components/Sorting.svelte +61 -26
- package/src/components/Transcript.svelte +37 -0
- package/src/components/Video.svelte +21 -18
- package/src/components/util.ts +3 -1
- package/src/components/video-embed.ts +25 -0
- package/src/index.ts +2 -7
- package/src/plugin/a11y/audit.ts +299 -0
- package/src/plugin/a11y/contrast.ts +67 -0
- package/src/plugin/a11y-cli.ts +35 -0
- package/src/plugin/cli.ts +4 -1
- package/src/plugin/export.ts +42 -14
- package/src/plugin/index.ts +216 -44
- package/src/plugin/manifest.ts +62 -22
- package/src/plugin/validation.ts +736 -122
- package/src/runtime/App.svelte +119 -48
- package/src/runtime/LoadingBar.svelte +12 -3
- package/src/runtime/Sidebar.svelte +24 -8
- package/src/runtime/access.ts +15 -3
- package/src/runtime/adapters/cmi5.ts +55 -33
- package/src/runtime/adapters/index.ts +22 -10
- package/src/runtime/adapters/retry.ts +25 -20
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +7 -8
- package/src/runtime/adapters/scorm2004.ts +11 -14
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -326
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +27 -11
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +13 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +9 -3
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +4 -1
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +32 -29
- package/src/runtime/xapi/setup.ts +18 -15
- package/src/runtime/xapi/validation.ts +15 -6
- package/src/virtual.d.ts +4 -1
- package/styles/base.css +32 -11
- package/styles/layout.css +39 -18
- package/styles/theme.css +15 -3
- package/dist/validation-D9DXlqNP.js.map +0 -1
package/src/plugin/validation.ts
CHANGED
|
@@ -14,6 +14,10 @@ import {
|
|
|
14
14
|
validateAuthCredential,
|
|
15
15
|
joinFieldError,
|
|
16
16
|
} from '../runtime/xapi/agent-rules.js';
|
|
17
|
+
import { shortIdentifier } from '../runtime/interaction-format.js';
|
|
18
|
+
import { FEEDBACK_MODES, RETRY_MODES } from '../runtime/types.js';
|
|
19
|
+
import { contrastRatio } from './a11y/contrast.js';
|
|
20
|
+
import { isVideoEmbed } from '../components/video-embed.js';
|
|
17
21
|
|
|
18
22
|
// ---------- Types ----------
|
|
19
23
|
|
|
@@ -22,8 +26,103 @@ export interface ValidationResult {
|
|
|
22
26
|
warnings: string[];
|
|
23
27
|
}
|
|
24
28
|
|
|
29
|
+
// ---------- A11y rule IDs ----------
|
|
30
|
+
|
|
31
|
+
/** Tier-1b rule IDs. `a11y.ignore` matches these literally. */
|
|
32
|
+
const A11Y_IDS = {
|
|
33
|
+
imageAlt: 'tessera/image-alt',
|
|
34
|
+
mediaTitle: 'tessera/media-title',
|
|
35
|
+
mediaTranscript: 'tessera/media-transcript',
|
|
36
|
+
mediaCaptions: 'tessera/media-captions',
|
|
37
|
+
questionLabel: 'tessera/question-label',
|
|
38
|
+
headingOrder: 'tessera/heading-order',
|
|
39
|
+
primaryContrast: 'tessera/primary-contrast',
|
|
40
|
+
lang: 'tessera/lang',
|
|
41
|
+
} as const;
|
|
42
|
+
|
|
43
|
+
/** Promotable by `a11y.level: 'error'`; the rest are hard contract errors. */
|
|
44
|
+
const PROMOTABLE_A11Y_IDS = new Set<string>([
|
|
45
|
+
A11Y_IDS.mediaTranscript,
|
|
46
|
+
A11Y_IDS.mediaCaptions,
|
|
47
|
+
A11Y_IDS.questionLabel,
|
|
48
|
+
A11Y_IDS.headingOrder,
|
|
49
|
+
A11Y_IDS.primaryContrast,
|
|
50
|
+
A11Y_IDS.lang,
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
/** Prefix a diagnostic with its rule ID so `a11y.ignore` / `level` can match it. */
|
|
54
|
+
function tag(id: string, message: string): string {
|
|
55
|
+
return `[${id}] ${message}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function diagnosticId(message: string): string | null {
|
|
59
|
+
const m = /^\[([^\]]+)\] /.exec(message);
|
|
60
|
+
return m ? m[1] : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** True when a tagged diagnostic's rule ID is in the ignore set. */
|
|
64
|
+
export function isIgnored(
|
|
65
|
+
message: string,
|
|
66
|
+
ignore: ReadonlySet<string>,
|
|
67
|
+
): boolean {
|
|
68
|
+
const id = diagnosticId(message);
|
|
69
|
+
return id !== null && ignore.has(id);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export interface A11ySettings {
|
|
73
|
+
level: 'warn' | 'error';
|
|
74
|
+
standard: 'wcag2a' | 'wcag2aa' | 'wcag21aa';
|
|
75
|
+
ignore: string[];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const VALID_A11Y_LEVELS = ['warn', 'error'];
|
|
79
|
+
const VALID_A11Y_STANDARDS = ['wcag2a', 'wcag2aa', 'wcag21aa'];
|
|
80
|
+
|
|
81
|
+
/** Normalize the raw `a11y` config to defaults, ignoring malformed pieces. */
|
|
82
|
+
export function normalizeA11y(raw: unknown): A11ySettings {
|
|
83
|
+
const a11y =
|
|
84
|
+
raw && typeof raw === 'object' ? (raw as Record<string, unknown>) : {};
|
|
85
|
+
const level = a11y.level === 'error' ? 'error' : 'warn';
|
|
86
|
+
const standard = VALID_A11Y_STANDARDS.includes(a11y.standard as string)
|
|
87
|
+
? (a11y.standard as A11ySettings['standard'])
|
|
88
|
+
: 'wcag2aa';
|
|
89
|
+
const ignore = Array.isArray(a11y.ignore)
|
|
90
|
+
? a11y.ignore.filter((x): x is string => typeof x === 'string')
|
|
91
|
+
: [];
|
|
92
|
+
return { level, standard, ignore };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Apply `a11y.ignore` (drop tagged diagnostics) and `a11y.level` (promote the
|
|
97
|
+
* promotable a11y warnings to errors) to a result in place. `ignore` suppresses
|
|
98
|
+
* at any severity, including hard contract errors; `level` only re-rates.
|
|
99
|
+
*/
|
|
100
|
+
function applyA11ySettings(
|
|
101
|
+
result: ValidationResult,
|
|
102
|
+
settings: A11ySettings,
|
|
103
|
+
): void {
|
|
104
|
+
if (settings.ignore.length > 0) {
|
|
105
|
+
const ignored = new Set(settings.ignore);
|
|
106
|
+
const keep = (msg: string) => !isIgnored(msg, ignored);
|
|
107
|
+
result.errors = result.errors.filter(keep);
|
|
108
|
+
result.warnings = result.warnings.filter(keep);
|
|
109
|
+
}
|
|
110
|
+
if (settings.level === 'error') {
|
|
111
|
+
const remaining: string[] = [];
|
|
112
|
+
for (const msg of result.warnings) {
|
|
113
|
+
const id = diagnosticId(msg);
|
|
114
|
+
if (id !== null && PROMOTABLE_A11Y_IDS.has(id)) result.errors.push(msg);
|
|
115
|
+
else remaining.push(msg);
|
|
116
|
+
}
|
|
117
|
+
result.warnings = remaining;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
25
121
|
/** Print validation warnings (yellow) then errors (red). Shared by the dev/build plugin and the CLI. */
|
|
26
|
-
export function reportValidationIssues({
|
|
122
|
+
export function reportValidationIssues({
|
|
123
|
+
errors,
|
|
124
|
+
warnings,
|
|
125
|
+
}: ValidationResult): void {
|
|
27
126
|
for (const warning of warnings) {
|
|
28
127
|
console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
|
|
29
128
|
}
|
|
@@ -38,6 +137,7 @@ const KNOWN_CONFIG_FIELDS = new Set([
|
|
|
38
137
|
'description',
|
|
39
138
|
'author',
|
|
40
139
|
'version',
|
|
140
|
+
'language',
|
|
41
141
|
'branding',
|
|
42
142
|
'navigation',
|
|
43
143
|
'completion',
|
|
@@ -45,13 +145,27 @@ const KNOWN_CONFIG_FIELDS = new Set([
|
|
|
45
145
|
'export',
|
|
46
146
|
'chrome',
|
|
47
147
|
'xapi',
|
|
148
|
+
'a11y',
|
|
48
149
|
]);
|
|
49
150
|
|
|
151
|
+
// Heuristic, not a full BCP-47 grammar: a 2–3 letter primary subtag (any case)
|
|
152
|
+
// plus any number of 1–8 alphanumeric subtags (script/region/variant/singleton).
|
|
153
|
+
const BCP47_RE = /^[A-Za-z]{2,3}(-[A-Za-z0-9]{1,8})*$/;
|
|
154
|
+
|
|
155
|
+
/** Plausible BCP-47 tag? Shared by the linter and the <html lang> emitter. */
|
|
156
|
+
export function isPlausibleLanguageTag(value: unknown): value is string {
|
|
157
|
+
return typeof value === 'string' && BCP47_RE.test(value);
|
|
158
|
+
}
|
|
159
|
+
|
|
50
160
|
const VALID_NAV_MODES = ['free', 'sequential'];
|
|
51
161
|
const VALID_COMPLETION_MODES = ['quiz', 'percentage', 'manual'];
|
|
52
162
|
const VALID_EXPORT_STANDARDS = ['web', 'scorm12', 'scorm2004', 'cmi5'];
|
|
53
163
|
const VALID_MANUAL_TRIGGERS = ['page'];
|
|
54
164
|
const VALID_REQUIRE_SUCCESS_STATUS = ['passed', 'failed'];
|
|
165
|
+
// Derived from the runtime types (single source of truth) — widened to
|
|
166
|
+
// string[] so .includes() accepts an arbitrary author-supplied value.
|
|
167
|
+
const VALID_FEEDBACK_MODES: readonly string[] = FEEDBACK_MODES;
|
|
168
|
+
const VALID_RETRY_MODES: readonly string[] = RETRY_MODES;
|
|
55
169
|
|
|
56
170
|
// ---------- Main ----------
|
|
57
171
|
|
|
@@ -76,7 +190,12 @@ export function validateProject(projectRoot: string): ValidationResult {
|
|
|
76
190
|
// 3. Validate pages directory
|
|
77
191
|
const pagesDir = resolve(projectRoot, 'pages');
|
|
78
192
|
const assetsDir = resolve(projectRoot, 'assets');
|
|
79
|
-
const pageResults = validatePages(
|
|
193
|
+
const pageResults = validatePages(
|
|
194
|
+
pagesDir,
|
|
195
|
+
assetsDir,
|
|
196
|
+
projectRoot,
|
|
197
|
+
config?.export?.standard,
|
|
198
|
+
);
|
|
80
199
|
errors.push(...pageResults.errors);
|
|
81
200
|
warnings.push(...pageResults.warnings);
|
|
82
201
|
|
|
@@ -84,7 +203,11 @@ export function validateProject(projectRoot: string): ValidationResult {
|
|
|
84
203
|
for (const shellFile of ['layout.svelte', 'quiz.svelte']) {
|
|
85
204
|
const shellPath = resolve(projectRoot, shellFile);
|
|
86
205
|
if (existsSync(shellPath)) {
|
|
87
|
-
validateContractBypass(
|
|
206
|
+
validateContractBypass(
|
|
207
|
+
readSourceFileCached(shellPath),
|
|
208
|
+
shellFile,
|
|
209
|
+
errors,
|
|
210
|
+
);
|
|
88
211
|
}
|
|
89
212
|
}
|
|
90
213
|
|
|
@@ -93,7 +216,9 @@ export function validateProject(projectRoot: string): ValidationResult {
|
|
|
93
216
|
crossValidate(config, pageResults, errors, warnings);
|
|
94
217
|
}
|
|
95
218
|
|
|
96
|
-
|
|
219
|
+
const result: ValidationResult = { errors, warnings };
|
|
220
|
+
applyA11ySettings(result, normalizeA11y(config?.a11y));
|
|
221
|
+
return result;
|
|
97
222
|
}
|
|
98
223
|
|
|
99
224
|
// ---------- Config Validation ----------
|
|
@@ -115,18 +240,18 @@ interface ParsedConfig {
|
|
|
115
240
|
function parseConfig(
|
|
116
241
|
projectRoot: string,
|
|
117
242
|
errors: string[],
|
|
118
|
-
warnings: string[]
|
|
243
|
+
warnings: string[],
|
|
119
244
|
): ParsedConfig | null {
|
|
120
245
|
const read = readCourseConfig(projectRoot);
|
|
121
246
|
if (!read.ok) {
|
|
122
247
|
// 'missing' can't occur — validateProject checks existsSync first.
|
|
123
248
|
if (read.reason === 'no-export') {
|
|
124
249
|
errors.push(
|
|
125
|
-
'course.config.js: could not parse — must use `export default { ... }` syntax'
|
|
250
|
+
'course.config.js: could not parse — must use `export default { ... }` syntax',
|
|
126
251
|
);
|
|
127
252
|
} else if (read.reason === 'parse-error') {
|
|
128
253
|
errors.push(
|
|
129
|
-
'course.config.js: syntax error — must export a static object literal'
|
|
254
|
+
'course.config.js: syntax error — must export a static object literal',
|
|
130
255
|
);
|
|
131
256
|
}
|
|
132
257
|
return null;
|
|
@@ -137,16 +262,62 @@ function parseConfig(
|
|
|
137
262
|
for (const key of Object.keys(config)) {
|
|
138
263
|
if (!KNOWN_CONFIG_FIELDS.has(key)) {
|
|
139
264
|
warnings.push(
|
|
140
|
-
`course.config.js: unknown field "${key}" — will be ignored
|
|
265
|
+
`course.config.js: unknown field "${key}" — will be ignored`,
|
|
141
266
|
);
|
|
142
267
|
}
|
|
143
268
|
}
|
|
144
269
|
|
|
270
|
+
// Validate title against the runtime merge `userConfig.title || "Untitled
|
|
271
|
+
// Course"`: a missing or empty string falls back to the default (warn), a
|
|
272
|
+
// whitespace-only string is truthy and ships verbatim (warn), and a
|
|
273
|
+
// non-string is a misconfiguration — a truthy one ships as-is, a falsy one
|
|
274
|
+
// falls back, but either way the author should fix it (error).
|
|
275
|
+
if (config.title !== undefined && typeof config.title !== 'string') {
|
|
276
|
+
errors.push(
|
|
277
|
+
`course.config.js: "title" must be a string, got ${typeof config.title}`,
|
|
278
|
+
);
|
|
279
|
+
} else if (config.title === undefined || config.title === '') {
|
|
280
|
+
warnings.push(
|
|
281
|
+
'course.config.js: "title" is missing or empty — the course will ship as "Untitled Course"',
|
|
282
|
+
);
|
|
283
|
+
} else if (config.title.trim() === '') {
|
|
284
|
+
warnings.push(
|
|
285
|
+
'course.config.js: "title" is only whitespace — it ships verbatim and will not fall back to "Untitled Course"',
|
|
286
|
+
);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// Validate branding
|
|
290
|
+
if (config.branding !== undefined) {
|
|
291
|
+
validateBranding(config.branding, warnings);
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Rule 1.8: language present and well-formed (BCP-47)
|
|
295
|
+
if (config.language === undefined) {
|
|
296
|
+
warnings.push(
|
|
297
|
+
tag(
|
|
298
|
+
A11Y_IDS.lang,
|
|
299
|
+
`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.`,
|
|
300
|
+
),
|
|
301
|
+
);
|
|
302
|
+
} else if (!isPlausibleLanguageTag(config.language)) {
|
|
303
|
+
warnings.push(
|
|
304
|
+
tag(
|
|
305
|
+
A11Y_IDS.lang,
|
|
306
|
+
`course.config.js: "language" (${JSON.stringify(config.language)}) is not a plausible BCP-47 tag — use e.g. "en", "es", or "fr-CA"`,
|
|
307
|
+
),
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// Validate a11y config block
|
|
312
|
+
if (config.a11y !== undefined) {
|
|
313
|
+
validateA11yConfig(config.a11y, errors);
|
|
314
|
+
}
|
|
315
|
+
|
|
145
316
|
// Validate navigation.mode
|
|
146
317
|
if (config.navigation?.mode !== undefined) {
|
|
147
318
|
if (!VALID_NAV_MODES.includes(config.navigation.mode)) {
|
|
148
319
|
errors.push(
|
|
149
|
-
`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"
|
|
320
|
+
`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`,
|
|
150
321
|
);
|
|
151
322
|
}
|
|
152
323
|
}
|
|
@@ -155,7 +326,7 @@ function parseConfig(
|
|
|
155
326
|
if (config.completion?.mode !== undefined) {
|
|
156
327
|
if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) {
|
|
157
328
|
errors.push(
|
|
158
|
-
`course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"
|
|
329
|
+
`course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`,
|
|
159
330
|
);
|
|
160
331
|
}
|
|
161
332
|
}
|
|
@@ -163,11 +334,11 @@ function parseConfig(
|
|
|
163
334
|
if (config.completion?.trigger !== undefined) {
|
|
164
335
|
if (config.completion.mode !== 'manual') {
|
|
165
336
|
warnings.push(
|
|
166
|
-
`course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"
|
|
337
|
+
`course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`,
|
|
167
338
|
);
|
|
168
339
|
} else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) {
|
|
169
340
|
errors.push(
|
|
170
|
-
`course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"
|
|
341
|
+
`course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`,
|
|
171
342
|
);
|
|
172
343
|
}
|
|
173
344
|
}
|
|
@@ -175,11 +346,15 @@ function parseConfig(
|
|
|
175
346
|
if (config.completion?.requireSuccessStatus !== undefined) {
|
|
176
347
|
if (config.completion.mode !== 'manual') {
|
|
177
348
|
warnings.push(
|
|
178
|
-
`course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"
|
|
349
|
+
`course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`,
|
|
179
350
|
);
|
|
180
|
-
} else if (
|
|
351
|
+
} else if (
|
|
352
|
+
!VALID_REQUIRE_SUCCESS_STATUS.includes(
|
|
353
|
+
config.completion.requireSuccessStatus,
|
|
354
|
+
)
|
|
355
|
+
) {
|
|
181
356
|
errors.push(
|
|
182
|
-
`course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"
|
|
357
|
+
`course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`,
|
|
183
358
|
);
|
|
184
359
|
}
|
|
185
360
|
}
|
|
@@ -188,7 +363,7 @@ function parseConfig(
|
|
|
188
363
|
if (config.export?.standard !== undefined) {
|
|
189
364
|
if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) {
|
|
190
365
|
errors.push(
|
|
191
|
-
`course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"
|
|
366
|
+
`course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"`,
|
|
192
367
|
);
|
|
193
368
|
}
|
|
194
369
|
}
|
|
@@ -198,7 +373,7 @@ function parseConfig(
|
|
|
198
373
|
const score = config.scoring.passingScore;
|
|
199
374
|
if (typeof score !== 'number' || score < 0 || score > 100) {
|
|
200
375
|
errors.push(
|
|
201
|
-
`course.config.js: "scoring.passingScore" must be 0–100, got ${score}
|
|
376
|
+
`course.config.js: "scoring.passingScore" must be 0–100, got ${score}`,
|
|
202
377
|
);
|
|
203
378
|
}
|
|
204
379
|
}
|
|
@@ -208,7 +383,7 @@ function parseConfig(
|
|
|
208
383
|
const threshold = config.completion.percentageThreshold;
|
|
209
384
|
if (typeof threshold !== 'number' || threshold < 0 || threshold > 100) {
|
|
210
385
|
errors.push(
|
|
211
|
-
`course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}
|
|
386
|
+
`course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`,
|
|
212
387
|
);
|
|
213
388
|
}
|
|
214
389
|
}
|
|
@@ -219,23 +394,144 @@ function parseConfig(
|
|
|
219
394
|
config.xapi,
|
|
220
395
|
config.export?.standard ?? 'web',
|
|
221
396
|
errors,
|
|
222
|
-
warnings
|
|
397
|
+
warnings,
|
|
223
398
|
);
|
|
224
399
|
}
|
|
225
400
|
|
|
226
401
|
return config;
|
|
227
402
|
}
|
|
228
403
|
|
|
404
|
+
// ---------- Branding Validation ----------
|
|
405
|
+
|
|
406
|
+
// Permissive approximation of the browser's accepted color set: hex 3/4/6/8,
|
|
407
|
+
// any CSS functional notation (rgb/hsl/hwb/lab/lch/oklab/oklch/color), or a
|
|
408
|
+
// bare keyword (named colors, transparent, currentColor). parseColor's real
|
|
409
|
+
// check (App.svelte) is browser-only and the runtime degrades gracefully, so
|
|
410
|
+
// an unrecognized value is advisory, never an error — lean permissive to avoid
|
|
411
|
+
// rejecting values the browser would accept.
|
|
412
|
+
const HEX_COLOR_RE = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/;
|
|
413
|
+
const FUNC_COLOR_RE =
|
|
414
|
+
/^(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklab|oklch|color)\(.*\)$/i;
|
|
415
|
+
const NAMED_COLOR_RE = /^[a-zA-Z]+$/;
|
|
416
|
+
|
|
417
|
+
function isPlausibleColor(value: string): boolean {
|
|
418
|
+
const v = value.trim();
|
|
419
|
+
return (
|
|
420
|
+
HEX_COLOR_RE.test(v) || FUNC_COLOR_RE.test(v) || NAMED_COLOR_RE.test(v)
|
|
421
|
+
);
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Format checks on the branding block (advisory) plus rule 1.7's contrast check
|
|
426
|
+
* on primaryColor. Runtime failures are mild: an unresolved logo ships a broken
|
|
427
|
+
* <img src>, an unparseable color falls back to theme defaults.
|
|
428
|
+
*/
|
|
429
|
+
function validateBranding(raw: unknown, warnings: string[]): void {
|
|
430
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
431
|
+
warnings.push(
|
|
432
|
+
`course.config.js: "branding" must be an object, got ${raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw} — will be ignored`,
|
|
433
|
+
);
|
|
434
|
+
return;
|
|
435
|
+
}
|
|
436
|
+
const branding = raw as Record<string, unknown>;
|
|
437
|
+
|
|
438
|
+
const logo = branding.logo;
|
|
439
|
+
if (logo !== undefined) {
|
|
440
|
+
if (typeof logo !== 'string') {
|
|
441
|
+
warnings.push(
|
|
442
|
+
`course.config.js: "branding.logo" must be a string, got ${typeof logo}`,
|
|
443
|
+
);
|
|
444
|
+
} else if (logo.startsWith('$assets/')) {
|
|
445
|
+
warnings.push(
|
|
446
|
+
'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.',
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
const primaryColor = branding.primaryColor;
|
|
452
|
+
if (primaryColor !== undefined) {
|
|
453
|
+
if (typeof primaryColor !== 'string') {
|
|
454
|
+
warnings.push(
|
|
455
|
+
`course.config.js: "branding.primaryColor" must be a string, got ${typeof primaryColor}`,
|
|
456
|
+
);
|
|
457
|
+
} else if (!isPlausibleColor(primaryColor)) {
|
|
458
|
+
warnings.push(
|
|
459
|
+
`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`,
|
|
460
|
+
);
|
|
461
|
+
} else {
|
|
462
|
+
// Rule 1.7: primaryColor is used both as links on the default white page
|
|
463
|
+
// background and as a button fill behind white text — symmetric, so one
|
|
464
|
+
// ratio covers both. Non-#hex valid colors return null and defer to Tier 2.
|
|
465
|
+
const ratio = contrastRatio(primaryColor, '#ffffff');
|
|
466
|
+
if (ratio !== null && ratio < 4.5) {
|
|
467
|
+
warnings.push(
|
|
468
|
+
tag(
|
|
469
|
+
A11Y_IDS.primaryContrast,
|
|
470
|
+
`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`,
|
|
471
|
+
),
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const fontFamily = branding.fontFamily;
|
|
478
|
+
if (fontFamily !== undefined && typeof fontFamily !== 'string') {
|
|
479
|
+
warnings.push(
|
|
480
|
+
`course.config.js: "branding.fontFamily" must be a string, got ${typeof fontFamily}`,
|
|
481
|
+
);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// ---------- a11y Config Validation ----------
|
|
486
|
+
|
|
487
|
+
/** Shape-check the `a11y` block. Malformed values can't be silenced by `ignore`. */
|
|
488
|
+
function validateA11yConfig(raw: unknown, errors: string[]): void {
|
|
489
|
+
if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
490
|
+
errors.push(
|
|
491
|
+
`course.config.js: "a11y" must be an object, got ${raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw}`,
|
|
492
|
+
);
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const a11y = raw as Record<string, unknown>;
|
|
496
|
+
|
|
497
|
+
if (
|
|
498
|
+
a11y.level !== undefined &&
|
|
499
|
+
!VALID_A11Y_LEVELS.includes(a11y.level as string)
|
|
500
|
+
) {
|
|
501
|
+
errors.push(
|
|
502
|
+
`course.config.js: "a11y.level" must be "warn" or "error", got ${JSON.stringify(a11y.level)}`,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
if (
|
|
506
|
+
a11y.standard !== undefined &&
|
|
507
|
+
!VALID_A11Y_STANDARDS.includes(a11y.standard as string)
|
|
508
|
+
) {
|
|
509
|
+
errors.push(
|
|
510
|
+
`course.config.js: "a11y.standard" must be "wcag2a", "wcag2aa", or "wcag21aa", got ${JSON.stringify(a11y.standard)}`,
|
|
511
|
+
);
|
|
512
|
+
}
|
|
513
|
+
if (a11y.ignore !== undefined) {
|
|
514
|
+
if (
|
|
515
|
+
!Array.isArray(a11y.ignore) ||
|
|
516
|
+
a11y.ignore.some((x) => typeof x !== 'string')
|
|
517
|
+
) {
|
|
518
|
+
errors.push(
|
|
519
|
+
`course.config.js: "a11y.ignore" must be an array of rule-ID strings`,
|
|
520
|
+
);
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
|
|
229
525
|
// ---------- xAPI Config Validation ----------
|
|
230
526
|
|
|
231
|
-
const UUID_RE =
|
|
232
|
-
|
|
527
|
+
const UUID_RE =
|
|
528
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
233
529
|
|
|
234
530
|
function validateXAPIConfig(
|
|
235
531
|
raw: unknown,
|
|
236
532
|
standard: string,
|
|
237
533
|
errors: string[],
|
|
238
|
-
warnings: string[]
|
|
534
|
+
warnings: string[],
|
|
239
535
|
): void {
|
|
240
536
|
if (raw === undefined || raw === null) return;
|
|
241
537
|
|
|
@@ -246,7 +542,7 @@ function validateXAPIConfig(
|
|
|
246
542
|
if (Array.isArray(raw)) {
|
|
247
543
|
if (entries.length === 0) {
|
|
248
544
|
errors.push(
|
|
249
|
-
'course.config.js: xapi must contain at least one destination, or be omitted'
|
|
545
|
+
'course.config.js: xapi must contain at least one destination, or be omitted',
|
|
250
546
|
);
|
|
251
547
|
return;
|
|
252
548
|
}
|
|
@@ -255,11 +551,11 @@ function validateXAPIConfig(
|
|
|
255
551
|
(e) =>
|
|
256
552
|
e &&
|
|
257
553
|
typeof e === 'object' &&
|
|
258
|
-
(e as { endpoint?: unknown }).endpoint === 'lms'
|
|
554
|
+
(e as { endpoint?: unknown }).endpoint === 'lms',
|
|
259
555
|
).length;
|
|
260
556
|
if (lmsCount > 1) {
|
|
261
557
|
errors.push(
|
|
262
|
-
"course.config.js: xapi has multiple entries with endpoint: 'lms' — only one cmi5 launch-inherited destination is allowed"
|
|
558
|
+
"course.config.js: xapi has multiple entries with endpoint: 'lms' — only one cmi5 launch-inherited destination is allowed",
|
|
263
559
|
);
|
|
264
560
|
}
|
|
265
561
|
// Warn on duplicate explicit endpoints.
|
|
@@ -276,13 +572,13 @@ function validateXAPIConfig(
|
|
|
276
572
|
if (count > 1) {
|
|
277
573
|
warnings.push(
|
|
278
574
|
`course.config.js: xapi has ${count} entries with endpoint "${ep}" — usually a copy-paste mistake; ` +
|
|
279
|
-
'fan-out to the same LRS with different actors/activityIds is supported but uncommon.'
|
|
575
|
+
'fan-out to the same LRS with different actors/activityIds is supported but uncommon.',
|
|
280
576
|
);
|
|
281
577
|
}
|
|
282
578
|
}
|
|
283
579
|
} else if (typeof raw !== 'object') {
|
|
284
580
|
errors.push(
|
|
285
|
-
'course.config.js: xapi must be an object or an array of objects'
|
|
581
|
+
'course.config.js: xapi must be an object or an array of objects',
|
|
286
582
|
);
|
|
287
583
|
return;
|
|
288
584
|
}
|
|
@@ -299,7 +595,7 @@ function validateXAPIConfig(
|
|
|
299
595
|
label,
|
|
300
596
|
standard,
|
|
301
597
|
errors,
|
|
302
|
-
warnings
|
|
598
|
+
warnings,
|
|
303
599
|
);
|
|
304
600
|
}
|
|
305
601
|
}
|
|
@@ -309,7 +605,7 @@ function validateSingleXAPIEntry(
|
|
|
309
605
|
label: string,
|
|
310
606
|
standard: string,
|
|
311
607
|
errors: string[],
|
|
312
|
-
warnings: string[]
|
|
608
|
+
warnings: string[],
|
|
313
609
|
): void {
|
|
314
610
|
const endpoint = entry.endpoint;
|
|
315
611
|
if (endpoint === undefined) {
|
|
@@ -326,15 +622,21 @@ function validateSingleXAPIEntry(
|
|
|
326
622
|
if (standard !== 'cmi5') {
|
|
327
623
|
errors.push(
|
|
328
624
|
`course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' (you have "${standard}"). ` +
|
|
329
|
-
'Either change the export standard or specify an explicit LRS endpoint.'
|
|
625
|
+
'Either change the export standard or specify an explicit LRS endpoint.',
|
|
330
626
|
);
|
|
331
627
|
}
|
|
332
628
|
// Forbid extra fields — everything is inherited from the cmi5 launch.
|
|
333
|
-
const forbidden = [
|
|
629
|
+
const forbidden = [
|
|
630
|
+
'auth',
|
|
631
|
+
'actor',
|
|
632
|
+
'activityId',
|
|
633
|
+
'registration',
|
|
634
|
+
'actorAccountHomePage',
|
|
635
|
+
];
|
|
334
636
|
for (const f of forbidden) {
|
|
335
637
|
if (entry[f] !== undefined) {
|
|
336
638
|
errors.push(
|
|
337
|
-
`course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the cmi5 launch
|
|
639
|
+
`course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the cmi5 launch.`,
|
|
338
640
|
);
|
|
339
641
|
}
|
|
340
642
|
}
|
|
@@ -347,25 +649,25 @@ function validateSingleXAPIEntry(
|
|
|
347
649
|
url = new URL(endpoint);
|
|
348
650
|
} catch {
|
|
349
651
|
errors.push(
|
|
350
|
-
`course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"
|
|
652
|
+
`course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"`,
|
|
351
653
|
);
|
|
352
654
|
return;
|
|
353
655
|
}
|
|
354
656
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
355
657
|
errors.push(
|
|
356
|
-
`course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"
|
|
658
|
+
`course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"`,
|
|
357
659
|
);
|
|
358
660
|
return;
|
|
359
661
|
}
|
|
360
662
|
if (url.protocol === 'http:' && process.env.NODE_ENV === 'production') {
|
|
361
663
|
warnings.push(
|
|
362
|
-
`course.config.js: ${label}.endpoint uses http:; LRS credentials will travel in cleartext. Use https in production
|
|
664
|
+
`course.config.js: ${label}.endpoint uses http:; LRS credentials will travel in cleartext. Use https in production.`,
|
|
363
665
|
);
|
|
364
666
|
}
|
|
365
667
|
if (!endpoint.endsWith('/')) {
|
|
366
668
|
warnings.push(
|
|
367
669
|
`course.config.js: ${label}.endpoint should end with a slash to avoid concatenation surprises ` +
|
|
368
|
-
`(e.g. 'https://lrs.example.com/xapi/' not 'https://lrs.example.com/xapi'). Runtime normalizes regardless
|
|
670
|
+
`(e.g. 'https://lrs.example.com/xapi/' not 'https://lrs.example.com/xapi'). Runtime normalizes regardless.`,
|
|
369
671
|
);
|
|
370
672
|
}
|
|
371
673
|
|
|
@@ -376,16 +678,18 @@ function validateSingleXAPIEntry(
|
|
|
376
678
|
} else if (typeof auth === 'string') {
|
|
377
679
|
const authErr = validateAuthCredential(auth);
|
|
378
680
|
if (authErr) {
|
|
379
|
-
errors.push(
|
|
681
|
+
errors.push(
|
|
682
|
+
`course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`,
|
|
683
|
+
);
|
|
380
684
|
} else {
|
|
381
685
|
warnings.push(
|
|
382
686
|
`course.config.js: ${label}.auth is a static string and will be embedded in the bundle. ` +
|
|
383
|
-
'For production, pass a function that fetches a short-lived token from a server endpoint.'
|
|
687
|
+
'For production, pass a function that fetches a short-lived token from a server endpoint.',
|
|
384
688
|
);
|
|
385
689
|
}
|
|
386
690
|
} else if (typeof auth !== 'function') {
|
|
387
691
|
errors.push(
|
|
388
|
-
`course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}
|
|
692
|
+
`course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`,
|
|
389
693
|
);
|
|
390
694
|
}
|
|
391
695
|
|
|
@@ -401,7 +705,7 @@ function validateSingleXAPIEntry(
|
|
|
401
705
|
new URL(activityId);
|
|
402
706
|
} catch {
|
|
403
707
|
errors.push(
|
|
404
|
-
`course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"
|
|
708
|
+
`course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"`,
|
|
405
709
|
);
|
|
406
710
|
}
|
|
407
711
|
}
|
|
@@ -412,7 +716,7 @@ function validateSingleXAPIEntry(
|
|
|
412
716
|
if (standard === 'web') {
|
|
413
717
|
errors.push(
|
|
414
718
|
`course.config.js: ${label}.actor is required for web export — there is no LMS to derive a learner identity from. ` +
|
|
415
|
-
'Provide either a static actor object or a function that resolves one (e.g. from your auth system).'
|
|
719
|
+
'Provide either a static actor object or a function that resolves one (e.g. from your auth system).',
|
|
416
720
|
);
|
|
417
721
|
}
|
|
418
722
|
} else if (typeof actor === 'object' && actor !== null) {
|
|
@@ -422,7 +726,7 @@ function validateSingleXAPIEntry(
|
|
|
422
726
|
}
|
|
423
727
|
} else if (typeof actor !== 'function') {
|
|
424
728
|
errors.push(
|
|
425
|
-
`course.config.js: ${label}.actor must be an object or function, got ${typeof actor}
|
|
729
|
+
`course.config.js: ${label}.actor must be an object or function, got ${typeof actor}`,
|
|
426
730
|
);
|
|
427
731
|
}
|
|
428
732
|
|
|
@@ -432,25 +736,25 @@ function validateSingleXAPIEntry(
|
|
|
432
736
|
if (aahp !== undefined) {
|
|
433
737
|
if (typeof aahp !== 'string') {
|
|
434
738
|
errors.push(
|
|
435
|
-
`course.config.js: ${label}.actorAccountHomePage must be a string
|
|
739
|
+
`course.config.js: ${label}.actorAccountHomePage must be a string`,
|
|
436
740
|
);
|
|
437
741
|
} else {
|
|
438
742
|
try {
|
|
439
743
|
new URL(aahp);
|
|
440
744
|
} catch {
|
|
441
745
|
errors.push(
|
|
442
|
-
`course.config.js: ${label}.actorAccountHomePage must be an absolute URL
|
|
746
|
+
`course.config.js: ${label}.actorAccountHomePage must be an absolute URL`,
|
|
443
747
|
);
|
|
444
748
|
}
|
|
445
749
|
}
|
|
446
750
|
if (actor !== undefined) {
|
|
447
751
|
warnings.push(
|
|
448
|
-
`course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly
|
|
752
|
+
`course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`,
|
|
449
753
|
);
|
|
450
754
|
}
|
|
451
755
|
if (standard === 'cmi5' || standard === 'web') {
|
|
452
756
|
warnings.push(
|
|
453
|
-
`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}"
|
|
757
|
+
`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`,
|
|
454
758
|
);
|
|
455
759
|
}
|
|
456
760
|
}
|
|
@@ -462,7 +766,7 @@ function validateSingleXAPIEntry(
|
|
|
462
766
|
(standard === 'scorm12' || standard === 'scorm2004') &&
|
|
463
767
|
typeof activityId === 'string'
|
|
464
768
|
) {
|
|
465
|
-
let isHttp
|
|
769
|
+
let isHttp: boolean;
|
|
466
770
|
try {
|
|
467
771
|
const u = new URL(activityId);
|
|
468
772
|
isHttp = u.protocol === 'http:' || u.protocol === 'https:';
|
|
@@ -472,7 +776,7 @@ function validateSingleXAPIEntry(
|
|
|
472
776
|
if (!isHttp && aahp === undefined) {
|
|
473
777
|
errors.push(
|
|
474
778
|
`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. ` +
|
|
475
|
-
`Provide ${label}.actorAccountHomePage explicitly
|
|
779
|
+
`Provide ${label}.actorAccountHomePage explicitly.`,
|
|
476
780
|
);
|
|
477
781
|
}
|
|
478
782
|
}
|
|
@@ -482,12 +786,12 @@ function validateSingleXAPIEntry(
|
|
|
482
786
|
if (registration !== undefined) {
|
|
483
787
|
if (typeof registration !== 'string' || !UUID_RE.test(registration)) {
|
|
484
788
|
errors.push(
|
|
485
|
-
`course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"
|
|
789
|
+
`course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`,
|
|
486
790
|
);
|
|
487
791
|
}
|
|
488
792
|
if (standard !== 'cmi5') {
|
|
489
793
|
warnings.push(
|
|
490
|
-
`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
|
|
794
|
+
`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.`,
|
|
491
795
|
);
|
|
492
796
|
}
|
|
493
797
|
}
|
|
@@ -522,7 +826,8 @@ function validatePageFile(
|
|
|
522
826
|
navIndex: number,
|
|
523
827
|
errors: string[],
|
|
524
828
|
warnings: string[],
|
|
525
|
-
assetExistsCache: Map<string, boolean
|
|
829
|
+
assetExistsCache: Map<string, boolean>,
|
|
830
|
+
exportStandard?: string,
|
|
526
831
|
): { page: PageInfo; isQuiz: boolean; isGradedQuiz: boolean } {
|
|
527
832
|
const fileRel = relative(projectRoot, filePath);
|
|
528
833
|
const content = readSourceFileCached(filePath);
|
|
@@ -541,7 +846,15 @@ function validatePageFile(
|
|
|
541
846
|
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
542
847
|
|
|
543
848
|
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
544
|
-
validateQuestionComponents(
|
|
849
|
+
validateQuestionComponents(
|
|
850
|
+
content,
|
|
851
|
+
fileRel,
|
|
852
|
+
errors,
|
|
853
|
+
warnings,
|
|
854
|
+
exportStandard,
|
|
855
|
+
);
|
|
856
|
+
validateMediaComponents(content, fileRel, errors, warnings);
|
|
857
|
+
validateHeadingOrder(content, fileRel, warnings);
|
|
545
858
|
validateContractBypass(content, fileRel, errors);
|
|
546
859
|
if (
|
|
547
860
|
pageConfig?.quiz &&
|
|
@@ -551,12 +864,18 @@ function validatePageFile(
|
|
|
551
864
|
) {
|
|
552
865
|
warnings.push(
|
|
553
866
|
`${fileRel}: quiz page has no question components or useQuestion() calls — ` +
|
|
554
|
-
`the quiz will have nothing to score
|
|
867
|
+
`the quiz will have nothing to score`,
|
|
555
868
|
);
|
|
556
869
|
}
|
|
557
870
|
|
|
558
871
|
return {
|
|
559
|
-
page: {
|
|
872
|
+
page: {
|
|
873
|
+
fileRel,
|
|
874
|
+
navIndex,
|
|
875
|
+
hasGradedQuiz: isGradedQuiz,
|
|
876
|
+
hasQuiz: isQuiz,
|
|
877
|
+
completesOnView,
|
|
878
|
+
},
|
|
560
879
|
isQuiz,
|
|
561
880
|
isGradedQuiz,
|
|
562
881
|
};
|
|
@@ -565,7 +884,8 @@ function validatePageFile(
|
|
|
565
884
|
function validatePages(
|
|
566
885
|
pagesDir: string,
|
|
567
886
|
assetsDir: string,
|
|
568
|
-
projectRoot: string
|
|
887
|
+
projectRoot: string,
|
|
888
|
+
exportStandard?: string,
|
|
569
889
|
): PagesValidationResult {
|
|
570
890
|
const errors: string[] = [];
|
|
571
891
|
const warnings: string[] = [];
|
|
@@ -578,9 +898,16 @@ function validatePages(
|
|
|
578
898
|
|
|
579
899
|
if (!existsSync(pagesDir)) {
|
|
580
900
|
errors.push(
|
|
581
|
-
'No pages found. Create at least one section with a lesson and page in pages/'
|
|
901
|
+
'No pages found. Create at least one section with a lesson and page in pages/',
|
|
582
902
|
);
|
|
583
|
-
return {
|
|
903
|
+
return {
|
|
904
|
+
errors,
|
|
905
|
+
warnings,
|
|
906
|
+
totalPages: 0,
|
|
907
|
+
totalQuizzes: 0,
|
|
908
|
+
hasGradedQuiz: false,
|
|
909
|
+
pages,
|
|
910
|
+
};
|
|
584
911
|
}
|
|
585
912
|
|
|
586
913
|
const topLevelEntries = readdirSync(pagesDir);
|
|
@@ -591,7 +918,7 @@ function validatePages(
|
|
|
591
918
|
if (entry.endsWith('.svelte') && statSync(fullPath).isFile()) {
|
|
592
919
|
const relPath = relative(projectRoot, fullPath);
|
|
593
920
|
warnings.push(
|
|
594
|
-
`${relPath}: this file is outside the section/lesson structure and will be ignored
|
|
921
|
+
`${relPath}: this file is outside the section/lesson structure and will be ignored`,
|
|
595
922
|
);
|
|
596
923
|
}
|
|
597
924
|
}
|
|
@@ -606,20 +933,28 @@ function validatePages(
|
|
|
606
933
|
|
|
607
934
|
if (sectionDirs.length === 0) {
|
|
608
935
|
errors.push(
|
|
609
|
-
'No pages found. Create at least one section with a lesson and page in pages/'
|
|
936
|
+
'No pages found. Create at least one section with a lesson and page in pages/',
|
|
610
937
|
);
|
|
611
|
-
return {
|
|
938
|
+
return {
|
|
939
|
+
errors,
|
|
940
|
+
warnings,
|
|
941
|
+
totalPages: 0,
|
|
942
|
+
totalQuizzes: 0,
|
|
943
|
+
hasGradedQuiz: false,
|
|
944
|
+
pages,
|
|
945
|
+
};
|
|
612
946
|
}
|
|
613
947
|
|
|
614
948
|
for (const sectionName of sectionDirs) {
|
|
615
949
|
const sectionPath = resolve(pagesDir, sectionName);
|
|
616
950
|
const sectionRel = relative(projectRoot, sectionPath);
|
|
951
|
+
const pagesBeforeSection = totalPages;
|
|
617
952
|
|
|
618
953
|
// Validate section _meta.js
|
|
619
954
|
const sectionMeta = validateMetaFile(
|
|
620
955
|
resolve(sectionPath, '_meta.js'),
|
|
621
956
|
sectionRel,
|
|
622
|
-
errors
|
|
957
|
+
errors,
|
|
623
958
|
);
|
|
624
959
|
|
|
625
960
|
// Flat mode: .svelte files directly at section level are pages of an
|
|
@@ -636,9 +971,12 @@ function validatePages(
|
|
|
636
971
|
for (const pageName of sectionMeta.pages) {
|
|
637
972
|
const fileName = ensureSvelteSuffix(pageName);
|
|
638
973
|
if (!sectionSvelteFiles.includes(fileName)) {
|
|
639
|
-
const metaRel = relative(
|
|
974
|
+
const metaRel = relative(
|
|
975
|
+
projectRoot,
|
|
976
|
+
resolve(sectionPath, '_meta.js'),
|
|
977
|
+
);
|
|
640
978
|
errors.push(
|
|
641
|
-
`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory
|
|
979
|
+
`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`,
|
|
642
980
|
);
|
|
643
981
|
}
|
|
644
982
|
}
|
|
@@ -652,7 +990,8 @@ function validatePages(
|
|
|
652
990
|
totalPages,
|
|
653
991
|
errors,
|
|
654
992
|
warnings,
|
|
655
|
-
assetExistsCache
|
|
993
|
+
assetExistsCache,
|
|
994
|
+
exportStandard,
|
|
656
995
|
);
|
|
657
996
|
totalPages++;
|
|
658
997
|
if (result.isQuiz) totalQuizzes++;
|
|
@@ -676,7 +1015,7 @@ function validatePages(
|
|
|
676
1015
|
const meta = validateMetaFile(
|
|
677
1016
|
resolve(lessonPath, '_meta.js'),
|
|
678
1017
|
lessonRel,
|
|
679
|
-
errors
|
|
1018
|
+
errors,
|
|
680
1019
|
);
|
|
681
1020
|
|
|
682
1021
|
// Get .svelte files
|
|
@@ -689,9 +1028,12 @@ function validatePages(
|
|
|
689
1028
|
for (const pageName of meta.pages) {
|
|
690
1029
|
const fileName = ensureSvelteSuffix(pageName);
|
|
691
1030
|
if (!svelteFiles.includes(fileName)) {
|
|
692
|
-
const metaRel = relative(
|
|
1031
|
+
const metaRel = relative(
|
|
1032
|
+
projectRoot,
|
|
1033
|
+
resolve(lessonPath, '_meta.js'),
|
|
1034
|
+
);
|
|
693
1035
|
errors.push(
|
|
694
|
-
`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory
|
|
1036
|
+
`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`,
|
|
695
1037
|
);
|
|
696
1038
|
}
|
|
697
1039
|
}
|
|
@@ -704,7 +1046,7 @@ function validatePages(
|
|
|
704
1046
|
if (!listedSet.has(file)) {
|
|
705
1047
|
const relPath = relative(projectRoot, resolve(lessonPath, file));
|
|
706
1048
|
warnings.push(
|
|
707
|
-
`${relPath}: not listed in _meta.js pages array — will be appended at end
|
|
1049
|
+
`${relPath}: not listed in _meta.js pages array — will be appended at end`,
|
|
708
1050
|
);
|
|
709
1051
|
}
|
|
710
1052
|
}
|
|
@@ -719,7 +1061,8 @@ function validatePages(
|
|
|
719
1061
|
totalPages,
|
|
720
1062
|
errors,
|
|
721
1063
|
warnings,
|
|
722
|
-
assetExistsCache
|
|
1064
|
+
assetExistsCache,
|
|
1065
|
+
exportStandard,
|
|
723
1066
|
);
|
|
724
1067
|
totalPages++;
|
|
725
1068
|
if (result.isQuiz) totalQuizzes++;
|
|
@@ -727,11 +1070,18 @@ function validatePages(
|
|
|
727
1070
|
pages.push(result.page);
|
|
728
1071
|
}
|
|
729
1072
|
}
|
|
1073
|
+
|
|
1074
|
+
// The page-count delta covers both the no-lessons and empty-lessons cases.
|
|
1075
|
+
if (totalPages === pagesBeforeSection) {
|
|
1076
|
+
warnings.push(
|
|
1077
|
+
`${sectionRel}: section contributed no pages and will be empty`,
|
|
1078
|
+
);
|
|
1079
|
+
}
|
|
730
1080
|
}
|
|
731
1081
|
|
|
732
1082
|
if (totalPages === 0) {
|
|
733
1083
|
errors.push(
|
|
734
|
-
'No pages found. Create at least one section with a lesson and page in pages/'
|
|
1084
|
+
'No pages found. Create at least one section with a lesson and page in pages/',
|
|
735
1085
|
);
|
|
736
1086
|
}
|
|
737
1087
|
|
|
@@ -743,15 +1093,19 @@ function validatePages(
|
|
|
743
1093
|
function validateMetaFile(
|
|
744
1094
|
metaPath: string,
|
|
745
1095
|
parentRel: string,
|
|
746
|
-
errors: string[]
|
|
1096
|
+
errors: string[],
|
|
747
1097
|
): { title?: string; pages?: string[] } | null {
|
|
748
1098
|
if (!existsSync(metaPath)) return null;
|
|
749
1099
|
|
|
750
1100
|
const metaRel = `${parentRel}/_meta.js`;
|
|
751
|
-
const objectStr = extractDefaultExportObjectLiteral(
|
|
1101
|
+
const objectStr = extractDefaultExportObjectLiteral(
|
|
1102
|
+
readSourceFileCached(metaPath),
|
|
1103
|
+
);
|
|
752
1104
|
|
|
753
1105
|
if (!objectStr) {
|
|
754
|
-
errors.push(
|
|
1106
|
+
errors.push(
|
|
1107
|
+
`${metaRel}: syntax error — must export default { title: "..." }`,
|
|
1108
|
+
);
|
|
755
1109
|
return null;
|
|
756
1110
|
}
|
|
757
1111
|
|
|
@@ -759,7 +1113,9 @@ function validateMetaFile(
|
|
|
759
1113
|
try {
|
|
760
1114
|
meta = JSON5.parse(objectStr);
|
|
761
1115
|
} catch {
|
|
762
|
-
errors.push(
|
|
1116
|
+
errors.push(
|
|
1117
|
+
`${metaRel}: syntax error — must export default { title: "..." }`,
|
|
1118
|
+
);
|
|
763
1119
|
return null;
|
|
764
1120
|
}
|
|
765
1121
|
|
|
@@ -775,13 +1131,13 @@ function validateMetaFile(
|
|
|
775
1131
|
function validatePageConfig(
|
|
776
1132
|
content: string,
|
|
777
1133
|
fileRel: string,
|
|
778
|
-
errors: string[]
|
|
1134
|
+
errors: string[],
|
|
779
1135
|
): { title?: string; quiz?: unknown; completesOn?: unknown } | null {
|
|
780
1136
|
const result = parsePageConfigFromSource(content);
|
|
781
1137
|
if (result.kind === 'ok') return result.value;
|
|
782
1138
|
if (result.kind === 'invalid') {
|
|
783
1139
|
errors.push(
|
|
784
|
-
`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)
|
|
1140
|
+
`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`,
|
|
785
1141
|
);
|
|
786
1142
|
}
|
|
787
1143
|
return null;
|
|
@@ -790,27 +1146,34 @@ function validatePageConfig(
|
|
|
790
1146
|
function validateCompletesOn(
|
|
791
1147
|
pageConfig: { completesOn?: unknown } | null,
|
|
792
1148
|
fileRel: string,
|
|
793
|
-
errors: string[]
|
|
1149
|
+
errors: string[],
|
|
794
1150
|
): boolean {
|
|
795
1151
|
if (!pageConfig || pageConfig.completesOn === undefined) return false;
|
|
796
1152
|
if (pageConfig.completesOn === 'view') return true;
|
|
797
1153
|
errors.push(
|
|
798
|
-
`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}
|
|
1154
|
+
`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`,
|
|
799
1155
|
);
|
|
800
1156
|
return false;
|
|
801
1157
|
}
|
|
802
1158
|
|
|
803
1159
|
// ---------- Quiz Config Validation ----------
|
|
804
1160
|
|
|
805
|
-
function validateQuizConfig(
|
|
1161
|
+
function validateQuizConfig(
|
|
1162
|
+
quiz: unknown,
|
|
1163
|
+
fileRel: string,
|
|
1164
|
+
errors: string[],
|
|
1165
|
+
): void {
|
|
806
1166
|
if (!quiz || typeof quiz !== 'object') return;
|
|
807
1167
|
const cfg = quiz as Record<string, unknown>;
|
|
808
1168
|
|
|
809
1169
|
if (cfg.maxAttempts !== undefined) {
|
|
810
1170
|
const val = cfg.maxAttempts;
|
|
811
|
-
if (
|
|
1171
|
+
if (
|
|
1172
|
+
val !== Infinity &&
|
|
1173
|
+
(typeof val !== 'number' || val <= 0 || !Number.isFinite(val))
|
|
1174
|
+
) {
|
|
812
1175
|
errors.push(
|
|
813
|
-
`${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}
|
|
1176
|
+
`${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}`,
|
|
814
1177
|
);
|
|
815
1178
|
}
|
|
816
1179
|
}
|
|
@@ -818,10 +1181,27 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
|
|
|
818
1181
|
for (const field of ['graded', 'gatesProgress']) {
|
|
819
1182
|
if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
|
|
820
1183
|
errors.push(
|
|
821
|
-
`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}
|
|
1184
|
+
`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`,
|
|
822
1185
|
);
|
|
823
1186
|
}
|
|
824
1187
|
}
|
|
1188
|
+
|
|
1189
|
+
if (
|
|
1190
|
+
cfg.feedbackMode !== undefined &&
|
|
1191
|
+
!VALID_FEEDBACK_MODES.includes(cfg.feedbackMode as string)
|
|
1192
|
+
) {
|
|
1193
|
+
errors.push(
|
|
1194
|
+
`${fileRel}: quiz.feedbackMode must be "review", "immediate", or "never", got "${String(cfg.feedbackMode)}"`,
|
|
1195
|
+
);
|
|
1196
|
+
}
|
|
1197
|
+
if (
|
|
1198
|
+
cfg.retryMode !== undefined &&
|
|
1199
|
+
!VALID_RETRY_MODES.includes(cfg.retryMode as string)
|
|
1200
|
+
) {
|
|
1201
|
+
errors.push(
|
|
1202
|
+
`${fileRel}: quiz.retryMode must be "full" or "incorrect-only", got "${String(cfg.retryMode)}"`,
|
|
1203
|
+
);
|
|
1204
|
+
}
|
|
825
1205
|
}
|
|
826
1206
|
|
|
827
1207
|
// ---------- Question Component Validation ----------
|
|
@@ -838,25 +1218,31 @@ type PropValue =
|
|
|
838
1218
|
| { kind: 'expr'; raw: string }
|
|
839
1219
|
| { kind: 'bool' };
|
|
840
1220
|
|
|
841
|
-
|
|
842
1221
|
/**
|
|
843
1222
|
* Parse the props of an opening tag starting just after the component name.
|
|
844
1223
|
* Returns null if the tag can't be parsed cleanly — callers then skip it
|
|
845
1224
|
* rather than risk a false positive.
|
|
846
1225
|
*/
|
|
847
|
-
function parseTagProps(
|
|
1226
|
+
function parseTagProps(
|
|
1227
|
+
content: string,
|
|
1228
|
+
start: number,
|
|
1229
|
+
): { props: Map<string, PropValue>; hasSpread: boolean } | null {
|
|
848
1230
|
const props = new Map<string, PropValue>();
|
|
1231
|
+
let hasSpread = false;
|
|
849
1232
|
let i = start;
|
|
850
1233
|
while (i < content.length) {
|
|
851
1234
|
while (i < content.length && /\s/.test(content[i])) i++;
|
|
852
1235
|
if (i >= content.length) return null;
|
|
853
1236
|
const c = content[i];
|
|
854
|
-
if (c === '>') return props;
|
|
855
|
-
if (c === '/' && content[i + 1] === '>') return props;
|
|
856
|
-
// Spread / shorthand expression — skip the whole {...} block
|
|
1237
|
+
if (c === '>') return { props, hasSpread };
|
|
1238
|
+
if (c === '/' && content[i + 1] === '>') return { props, hasSpread };
|
|
1239
|
+
// Spread / shorthand expression — skip the whole {...} block, but record
|
|
1240
|
+
// that unseen props may be supplied here so callers can suppress
|
|
1241
|
+
// false-positive "missing required prop / alt / title" diagnostics.
|
|
857
1242
|
if (c === '{') {
|
|
858
1243
|
const block = extractObjectLiteral(content, i);
|
|
859
1244
|
if (!block) return null;
|
|
1245
|
+
hasSpread = true;
|
|
860
1246
|
i += block.length;
|
|
861
1247
|
continue;
|
|
862
1248
|
}
|
|
@@ -912,50 +1298,113 @@ function staticNumber(prop: PropValue | undefined): number | null {
|
|
|
912
1298
|
function validateQuestionComponents(
|
|
913
1299
|
content: string,
|
|
914
1300
|
fileRel: string,
|
|
915
|
-
errors: string[]
|
|
1301
|
+
errors: string[],
|
|
1302
|
+
warnings: string[],
|
|
1303
|
+
exportStandard?: string,
|
|
916
1304
|
): void {
|
|
917
1305
|
const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join('|');
|
|
918
1306
|
const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, 'g');
|
|
919
1307
|
const seenIds = new Set<string>();
|
|
1308
|
+
const seenSanitized = new Set<string>();
|
|
920
1309
|
let m: RegExpExecArray | null;
|
|
921
1310
|
while ((m = tagStartRe.exec(content)) !== null) {
|
|
922
1311
|
const name = m[1];
|
|
923
|
-
const
|
|
924
|
-
if (!
|
|
1312
|
+
const parsed = parseTagProps(content, m.index + m[0].length);
|
|
1313
|
+
if (!parsed) continue;
|
|
1314
|
+
const { props, hasSpread } = parsed;
|
|
925
1315
|
|
|
926
1316
|
for (const req of QUESTION_COMPONENT_REQUIRED[name]) {
|
|
927
|
-
if (!props.has(req)) {
|
|
1317
|
+
if (!hasSpread && !props.has(req)) {
|
|
928
1318
|
errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
|
|
929
1319
|
}
|
|
930
1320
|
}
|
|
931
1321
|
|
|
1322
|
+
// Rule 1.5: empty option/answer labels are both an a11y and a scoring bug.
|
|
1323
|
+
for (const labelProp of ['options', 'answers']) {
|
|
1324
|
+
const entries = staticArray(props.get(labelProp));
|
|
1325
|
+
if (entries?.some((e) => typeof e === 'string' && e.trim() === '')) {
|
|
1326
|
+
warnings.push(
|
|
1327
|
+
tag(
|
|
1328
|
+
A11Y_IDS.questionLabel,
|
|
1329
|
+
`${fileRel}: <${name}> has an empty ${labelProp === 'options' ? 'option' : 'answer'} label`,
|
|
1330
|
+
),
|
|
1331
|
+
);
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
932
1335
|
const idProp = props.get('id');
|
|
933
1336
|
if (idProp?.kind === 'string') {
|
|
934
1337
|
if (seenIds.has(idProp.value)) {
|
|
935
1338
|
errors.push(
|
|
936
|
-
`${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id
|
|
1339
|
+
`${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`,
|
|
937
1340
|
);
|
|
1341
|
+
} else if (exportStandard === 'scorm12') {
|
|
1342
|
+
// scorm12-only: shortIdentifier strips non-alphanumerics, so distinct
|
|
1343
|
+
// raw ids can collide after sanitization. Skip raw duplicates (already
|
|
1344
|
+
// flagged above) to avoid double-reporting the same id.
|
|
1345
|
+
const sane = shortIdentifier(idProp.value);
|
|
1346
|
+
if (sane !== idProp.value) {
|
|
1347
|
+
warnings.push(
|
|
1348
|
+
`${fileRel}: question id "${idProp.value}" will be rewritten to "${sane}" for SCORM 1.2 — use only letters and digits (underscores only between them)`,
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
if (seenSanitized.has(sane)) {
|
|
1352
|
+
errors.push(
|
|
1353
|
+
`${fileRel}: question id "${idProp.value}" collides with a prior id after SCORM 1.2 sanitization ("${sane}")`,
|
|
1354
|
+
);
|
|
1355
|
+
}
|
|
1356
|
+
seenSanitized.add(sane);
|
|
938
1357
|
}
|
|
939
1358
|
seenIds.add(idProp.value);
|
|
940
1359
|
}
|
|
941
1360
|
|
|
1361
|
+
const weightProp = props.get('weight');
|
|
1362
|
+
if (weightProp?.kind === 'string') {
|
|
1363
|
+
warnings.push(
|
|
1364
|
+
`${fileRel}: <${name}> weight="${weightProp.value}" is a string and is ignored (treated as 1) — pass a number: weight={${weightProp.value}}`,
|
|
1365
|
+
);
|
|
1366
|
+
} else {
|
|
1367
|
+
const weight = staticNumber(weightProp);
|
|
1368
|
+
if (weight !== null) {
|
|
1369
|
+
if (!Number.isFinite(weight)) {
|
|
1370
|
+
errors.push(
|
|
1371
|
+
`${fileRel}: <${name}> weight must be finite — a non-finite weight makes the weighted score NaN, got ${weight}`,
|
|
1372
|
+
);
|
|
1373
|
+
} else if (!(weight > 0)) {
|
|
1374
|
+
warnings.push(
|
|
1375
|
+
`${fileRel}: <${name}> weight ${weight} is not positive and is ignored (treated as 1)`,
|
|
1376
|
+
);
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
}
|
|
1380
|
+
|
|
942
1381
|
if (name === 'MultipleChoice') {
|
|
943
1382
|
const options = staticArray(props.get('options'));
|
|
944
1383
|
const correct = staticNumber(props.get('correct'));
|
|
945
1384
|
if (options && correct !== null) {
|
|
946
|
-
if (
|
|
1385
|
+
if (
|
|
1386
|
+
!Number.isInteger(correct) ||
|
|
1387
|
+
correct < 0 ||
|
|
1388
|
+
correct >= options.length
|
|
1389
|
+
) {
|
|
947
1390
|
errors.push(
|
|
948
|
-
`${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})
|
|
1391
|
+
`${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`,
|
|
949
1392
|
);
|
|
950
1393
|
}
|
|
951
1394
|
}
|
|
1395
|
+
const optionFeedback = staticArray(props.get('optionFeedback'));
|
|
1396
|
+
if (options && optionFeedback && optionFeedback.length > options.length) {
|
|
1397
|
+
warnings.push(
|
|
1398
|
+
`${fileRel}: <MultipleChoice> optionFeedback has ${optionFeedback.length} entries but only ${options.length} options — the extra entries can never be shown`,
|
|
1399
|
+
);
|
|
1400
|
+
}
|
|
952
1401
|
} else if (name === 'Sorting') {
|
|
953
1402
|
const items = staticArray(props.get('items'));
|
|
954
1403
|
const targets = staticArray(props.get('targets'));
|
|
955
1404
|
const correct = staticArray(props.get('correct'));
|
|
956
1405
|
if (items && correct && correct.length !== items.length) {
|
|
957
1406
|
errors.push(
|
|
958
|
-
`${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays
|
|
1407
|
+
`${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays`,
|
|
959
1408
|
);
|
|
960
1409
|
}
|
|
961
1410
|
if (targets && correct) {
|
|
@@ -967,7 +1416,7 @@ function validateQuestionComponents(
|
|
|
967
1416
|
idx >= targets.length
|
|
968
1417
|
) {
|
|
969
1418
|
errors.push(
|
|
970
|
-
`${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})
|
|
1419
|
+
`${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`,
|
|
971
1420
|
);
|
|
972
1421
|
break;
|
|
973
1422
|
}
|
|
@@ -981,11 +1430,11 @@ function validateQuestionComponents(
|
|
|
981
1430
|
typeof p !== 'object' ||
|
|
982
1431
|
p === null ||
|
|
983
1432
|
typeof (p as { left?: unknown }).left !== 'string' ||
|
|
984
|
-
typeof (p as { right?: unknown }).right !== 'string'
|
|
1433
|
+
typeof (p as { right?: unknown }).right !== 'string',
|
|
985
1434
|
);
|
|
986
1435
|
if (bad) {
|
|
987
1436
|
errors.push(
|
|
988
|
-
`${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects
|
|
1437
|
+
`${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects`,
|
|
989
1438
|
);
|
|
990
1439
|
}
|
|
991
1440
|
}
|
|
@@ -996,7 +1445,7 @@ function validateQuestionComponents(
|
|
|
996
1445
|
errors.push(`${fileRel}: <FillInTheBlank> answers must not be empty`);
|
|
997
1446
|
} else if (answers.some((a) => typeof a !== 'string')) {
|
|
998
1447
|
errors.push(
|
|
999
|
-
`${fileRel}: <FillInTheBlank> answers must be an array of strings
|
|
1448
|
+
`${fileRel}: <FillInTheBlank> answers must be an array of strings`,
|
|
1000
1449
|
);
|
|
1001
1450
|
}
|
|
1002
1451
|
}
|
|
@@ -1004,6 +1453,156 @@ function validateQuestionComponents(
|
|
|
1004
1453
|
}
|
|
1005
1454
|
}
|
|
1006
1455
|
|
|
1456
|
+
// ---------- Media Component Validation (rules 1.3 / 1.4) ----------
|
|
1457
|
+
|
|
1458
|
+
/** Remove HTML/Svelte comments so commented-out markup isn't scanned as live. */
|
|
1459
|
+
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
1460
|
+
|
|
1461
|
+
/**
|
|
1462
|
+
* Sibling to validateQuestionComponents kept out of QUESTION_COMPONENT_REQUIRED
|
|
1463
|
+
* so media isn't treated as gradable questions. Declares `warnings` directly.
|
|
1464
|
+
* Non-static (kind 'expr') values are skipped, matching the rest of the linter.
|
|
1465
|
+
*/
|
|
1466
|
+
function validateMediaComponents(
|
|
1467
|
+
content: string,
|
|
1468
|
+
fileRel: string,
|
|
1469
|
+
errors: string[],
|
|
1470
|
+
warnings: string[],
|
|
1471
|
+
): void {
|
|
1472
|
+
const scan = content.replace(HTML_COMMENT_RE, '');
|
|
1473
|
+
const tagStartRe = /<(Image|Video|Audio)(?=[\s/>])/g;
|
|
1474
|
+
let m: RegExpExecArray | null;
|
|
1475
|
+
while ((m = tagStartRe.exec(scan)) !== null) {
|
|
1476
|
+
const name = m[1];
|
|
1477
|
+
const parsed = parseTagProps(scan, m.index + m[0].length);
|
|
1478
|
+
if (!parsed) continue;
|
|
1479
|
+
const { props, hasSpread } = parsed;
|
|
1480
|
+
|
|
1481
|
+
if (name === 'Image') {
|
|
1482
|
+
const alt = props.get('alt');
|
|
1483
|
+
const decorative = props.get('decorative');
|
|
1484
|
+
// A string value is truthy at runtime (so decorative="false" hides the
|
|
1485
|
+
// image), but the parser sees a string, not a boolean — flag the misuse.
|
|
1486
|
+
if (decorative?.kind === 'string') {
|
|
1487
|
+
errors.push(
|
|
1488
|
+
tag(
|
|
1489
|
+
A11Y_IDS.imageAlt,
|
|
1490
|
+
`${fileRel}: <Image> "decorative" must be a boolean — use decorative or decorative={true}, not the string ${JSON.stringify(decorative.value)}`,
|
|
1491
|
+
),
|
|
1492
|
+
);
|
|
1493
|
+
continue;
|
|
1494
|
+
}
|
|
1495
|
+
const hasDecorative =
|
|
1496
|
+
decorative?.kind === 'bool' ||
|
|
1497
|
+
(decorative?.kind === 'expr' && decorative.raw.trim() === 'true');
|
|
1498
|
+
const altIsEmpty = alt?.kind === 'string' && alt.value.trim() === '';
|
|
1499
|
+
if (!hasDecorative && !hasSpread && (alt === undefined || altIsEmpty)) {
|
|
1500
|
+
errors.push(
|
|
1501
|
+
tag(
|
|
1502
|
+
A11Y_IDS.imageAlt,
|
|
1503
|
+
`${fileRel}: <Image> needs alt text, or mark it decorative={true} if purely ornamental`,
|
|
1504
|
+
),
|
|
1505
|
+
);
|
|
1506
|
+
}
|
|
1507
|
+
if (hasDecorative && alt?.kind === 'string' && alt.value.trim() !== '') {
|
|
1508
|
+
warnings.push(
|
|
1509
|
+
tag(
|
|
1510
|
+
A11Y_IDS.imageAlt,
|
|
1511
|
+
`${fileRel}: <Image> is decorative but also has alt text — the alt will be dropped`,
|
|
1512
|
+
),
|
|
1513
|
+
);
|
|
1514
|
+
}
|
|
1515
|
+
continue;
|
|
1516
|
+
}
|
|
1517
|
+
|
|
1518
|
+
// Video / Audio
|
|
1519
|
+
const title = props.get('title');
|
|
1520
|
+
const titleIsEmpty = title?.kind === 'string' && title.value.trim() === '';
|
|
1521
|
+
if (!hasSpread && (title === undefined || titleIsEmpty)) {
|
|
1522
|
+
errors.push(
|
|
1523
|
+
tag(
|
|
1524
|
+
A11Y_IDS.mediaTitle,
|
|
1525
|
+
`${fileRel}: <${name}> needs a title — it's the accessible name for the player`,
|
|
1526
|
+
),
|
|
1527
|
+
);
|
|
1528
|
+
}
|
|
1529
|
+
const src = props.get('src');
|
|
1530
|
+
const isEmbed = src?.kind === 'string' && isVideoEmbed(src.value);
|
|
1531
|
+
if (
|
|
1532
|
+
name === 'Video' &&
|
|
1533
|
+
!hasSpread &&
|
|
1534
|
+
isEmbed &&
|
|
1535
|
+
props.get('transcript') === undefined
|
|
1536
|
+
) {
|
|
1537
|
+
warnings.push(
|
|
1538
|
+
tag(
|
|
1539
|
+
A11Y_IDS.mediaTranscript,
|
|
1540
|
+
`${fileRel}: <Video> embeds can't carry caption tracks — provide a transcript for WCAG 1.2`,
|
|
1541
|
+
),
|
|
1542
|
+
);
|
|
1543
|
+
}
|
|
1544
|
+
if (
|
|
1545
|
+
name === 'Video' &&
|
|
1546
|
+
!hasSpread &&
|
|
1547
|
+
src?.kind === 'string' &&
|
|
1548
|
+
!isEmbed &&
|
|
1549
|
+
props.get('tracks') === undefined &&
|
|
1550
|
+
props.get('transcript') === undefined
|
|
1551
|
+
) {
|
|
1552
|
+
warnings.push(
|
|
1553
|
+
tag(
|
|
1554
|
+
A11Y_IDS.mediaCaptions,
|
|
1555
|
+
`${fileRel}: native <Video> has no caption tracks or transcript — add tracks={[…]} or a transcript for WCAG 1.2.2`,
|
|
1556
|
+
),
|
|
1557
|
+
);
|
|
1558
|
+
}
|
|
1559
|
+
if (
|
|
1560
|
+
name === 'Audio' &&
|
|
1561
|
+
!hasSpread &&
|
|
1562
|
+
props.get('transcript') === undefined
|
|
1563
|
+
) {
|
|
1564
|
+
warnings.push(
|
|
1565
|
+
tag(
|
|
1566
|
+
A11Y_IDS.mediaTranscript,
|
|
1567
|
+
`${fileRel}: <Audio> has no transcript — required for WCAG 1.2.1`,
|
|
1568
|
+
),
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// ---------- Heading Order Validation (rule 1.6) ----------
|
|
1575
|
+
|
|
1576
|
+
/**
|
|
1577
|
+
* Warn on a skipped heading level (e.g. h2 → h4). Scripts, styles, and comments
|
|
1578
|
+
* are stripped first so string literals, CSS, and commented-out markup can't be
|
|
1579
|
+
* miscounted. No "one h1 per page" check — the layout owns the page h1 and child
|
|
1580
|
+
* components emit headings a static scan can't see; that belongs to the Tier-2
|
|
1581
|
+
* audit.
|
|
1582
|
+
*/
|
|
1583
|
+
function validateHeadingOrder(
|
|
1584
|
+
content: string,
|
|
1585
|
+
fileRel: string,
|
|
1586
|
+
warnings: string[],
|
|
1587
|
+
): void {
|
|
1588
|
+
const html = content
|
|
1589
|
+
.replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, '')
|
|
1590
|
+
.replace(HTML_COMMENT_RE, '');
|
|
1591
|
+
const levels = [...html.matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
|
|
1592
|
+
let prevSeen: number | null = null;
|
|
1593
|
+
for (const level of levels) {
|
|
1594
|
+
if (prevSeen !== null && level - prevSeen > 1) {
|
|
1595
|
+
warnings.push(
|
|
1596
|
+
tag(
|
|
1597
|
+
A11Y_IDS.headingOrder,
|
|
1598
|
+
`${fileRel}: heading level jumps from h${prevSeen} to h${level} — don't skip levels (WCAG 1.3.1)`,
|
|
1599
|
+
),
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
prevSeen = level;
|
|
1603
|
+
}
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1007
1606
|
// ---------- Contract Bypass Detection ----------
|
|
1008
1607
|
|
|
1009
1608
|
const QUIZ_COMPLETE_DISPATCH_RE =
|
|
@@ -1011,7 +1610,7 @@ const QUIZ_COMPLETE_DISPATCH_RE =
|
|
|
1011
1610
|
const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
|
|
1012
1611
|
const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
|
|
1013
1612
|
const HAS_QUESTION_TAG_RE = new RegExp(
|
|
1014
|
-
`<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])
|
|
1613
|
+
`<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`,
|
|
1015
1614
|
);
|
|
1016
1615
|
// Custom widget imported from a local `.svelte` file may wrap useQuestion.
|
|
1017
1616
|
// Treat its presence as enough to suppress the "no questions" warning —
|
|
@@ -1026,18 +1625,18 @@ const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
|
|
|
1026
1625
|
function validateContractBypass(
|
|
1027
1626
|
content: string,
|
|
1028
1627
|
fileRel: string,
|
|
1029
|
-
errors: string[]
|
|
1628
|
+
errors: string[],
|
|
1030
1629
|
): void {
|
|
1031
1630
|
if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) {
|
|
1032
1631
|
errors.push(
|
|
1033
1632
|
`${fileRel}: dispatches "tessera-quiz-complete" directly — submit through ` +
|
|
1034
|
-
`useQuiz().submit() so the result reaches the LMS
|
|
1633
|
+
`useQuiz().submit() so the result reaches the LMS`,
|
|
1035
1634
|
);
|
|
1036
1635
|
}
|
|
1037
1636
|
if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) {
|
|
1038
1637
|
errors.push(
|
|
1039
1638
|
`${fileRel}: imports from tessera-learn/runtime/* — use the public hooks ` +
|
|
1040
|
-
`(useQuiz, useQuestion, useNavigation, …) instead
|
|
1639
|
+
`(useQuiz, useQuestion, useNavigation, …) instead`,
|
|
1041
1640
|
);
|
|
1042
1641
|
}
|
|
1043
1642
|
}
|
|
@@ -1052,7 +1651,7 @@ function collectAssetRefs(content: string): string[] {
|
|
|
1052
1651
|
let match: RegExpExecArray | null;
|
|
1053
1652
|
ASSET_REF_RE.lastIndex = 0;
|
|
1054
1653
|
while ((match = ASSET_REF_RE.exec(content)) !== null) {
|
|
1055
|
-
seen.add(match[1]);
|
|
1654
|
+
seen.add(match[1].replace(/[?#].*$/, ''));
|
|
1056
1655
|
}
|
|
1057
1656
|
return [...seen];
|
|
1058
1657
|
}
|
|
@@ -1062,7 +1661,7 @@ function validateAssetRefs(
|
|
|
1062
1661
|
fileRel: string,
|
|
1063
1662
|
assetsDir: string,
|
|
1064
1663
|
warnings: string[],
|
|
1065
|
-
existsCache: Map<string, boolean
|
|
1664
|
+
existsCache: Map<string, boolean>,
|
|
1066
1665
|
): void {
|
|
1067
1666
|
for (const assetPath of collectAssetRefs(content)) {
|
|
1068
1667
|
const fullAssetPath = resolve(assetsDir, assetPath);
|
|
@@ -1073,7 +1672,7 @@ function validateAssetRefs(
|
|
|
1073
1672
|
}
|
|
1074
1673
|
if (!exists) {
|
|
1075
1674
|
warnings.push(
|
|
1076
|
-
`${fileRel}: "$assets/${assetPath}" not found in assets/ directory
|
|
1675
|
+
`${fileRel}: "$assets/${assetPath}" not found in assets/ directory`,
|
|
1077
1676
|
);
|
|
1078
1677
|
}
|
|
1079
1678
|
}
|
|
@@ -1085,22 +1684,37 @@ function crossValidate(
|
|
|
1085
1684
|
config: ParsedConfig,
|
|
1086
1685
|
pageResults: PagesValidationResult,
|
|
1087
1686
|
errors: string[],
|
|
1088
|
-
warnings: string[]
|
|
1687
|
+
warnings: string[],
|
|
1089
1688
|
): void {
|
|
1090
1689
|
// completion.mode "quiz" but no graded quizzes
|
|
1091
1690
|
if (config.completion?.mode === 'quiz' && !pageResults.hasGradedQuiz) {
|
|
1092
1691
|
errors.push(
|
|
1093
|
-
'completion.mode is "quiz" but no pages have quiz config with graded: true'
|
|
1692
|
+
'completion.mode is "quiz" but no pages have quiz config with graded: true',
|
|
1693
|
+
);
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// completion.mode "quiz" with an implicit pass threshold — the merge defaults
|
|
1697
|
+
// to 70, so this is a nudge, not an error.
|
|
1698
|
+
if (
|
|
1699
|
+
config.completion?.mode === 'quiz' &&
|
|
1700
|
+
config.scoring?.passingScore === undefined
|
|
1701
|
+
) {
|
|
1702
|
+
warnings.push(
|
|
1703
|
+
'completion.mode is "quiz" but scoring.passingScore is not set — defaulting to 70%. Set it explicitly to be sure.',
|
|
1094
1704
|
);
|
|
1095
1705
|
}
|
|
1096
1706
|
|
|
1097
1707
|
const isManual = config.completion?.mode === 'manual';
|
|
1098
1708
|
const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
|
|
1099
1709
|
|
|
1100
|
-
if (
|
|
1710
|
+
if (
|
|
1711
|
+
isManual &&
|
|
1712
|
+
config.completion?.trigger === 'page' &&
|
|
1713
|
+
completesOnPages.length === 0
|
|
1714
|
+
) {
|
|
1101
1715
|
errors.push(
|
|
1102
1716
|
'completion.mode is "manual" with trigger: "page", but no page declares pageConfig.completesOn: "view". ' +
|
|
1103
|
-
'Either add a completesOn page or remove the trigger field to drop the static check.'
|
|
1717
|
+
'Either add a completesOn page or remove the trigger field to drop the static check.',
|
|
1104
1718
|
);
|
|
1105
1719
|
}
|
|
1106
1720
|
|
|
@@ -1110,8 +1724,8 @@ function crossValidate(
|
|
|
1110
1724
|
warnings.push(
|
|
1111
1725
|
`${page.fileRel}: quiz.graded is true under completion.mode: "manual". ` +
|
|
1112
1726
|
'The score will be reported to the LMS for transcripts, but it will not drive ' +
|
|
1113
|
-
|
|
1114
|
-
'not what you want, set graded: false or change completion.mode.'
|
|
1727
|
+
"completion or success status — `markComplete()` / completesOn does. If that's " +
|
|
1728
|
+
'not what you want, set graded: false or change completion.mode.',
|
|
1115
1729
|
);
|
|
1116
1730
|
}
|
|
1117
1731
|
}
|
|
@@ -1119,20 +1733,20 @@ function crossValidate(
|
|
|
1119
1733
|
|
|
1120
1734
|
if (isManual && config.completion?.percentageThreshold !== undefined) {
|
|
1121
1735
|
warnings.push(
|
|
1122
|
-
'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"'
|
|
1736
|
+
'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"',
|
|
1123
1737
|
);
|
|
1124
1738
|
}
|
|
1125
1739
|
if (!isManual) {
|
|
1126
1740
|
for (const page of completesOnPages) {
|
|
1127
1741
|
warnings.push(
|
|
1128
|
-
`${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? 'percentage'}"
|
|
1742
|
+
`${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? 'percentage'}"`,
|
|
1129
1743
|
);
|
|
1130
1744
|
}
|
|
1131
1745
|
}
|
|
1132
1746
|
for (const page of pageResults.pages) {
|
|
1133
1747
|
if (page.completesOnView && page.hasQuiz) {
|
|
1134
1748
|
warnings.push(
|
|
1135
|
-
`${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake
|
|
1749
|
+
`${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`,
|
|
1136
1750
|
);
|
|
1137
1751
|
}
|
|
1138
1752
|
}
|
|
@@ -1141,7 +1755,7 @@ function crossValidate(
|
|
|
1141
1755
|
const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
|
|
1142
1756
|
if (firstPage?.completesOnView) {
|
|
1143
1757
|
warnings.push(
|
|
1144
|
-
`${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content
|
|
1758
|
+
`${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`,
|
|
1145
1759
|
);
|
|
1146
1760
|
}
|
|
1147
1761
|
}
|
|
@@ -1163,11 +1777,11 @@ function crossValidate(
|
|
|
1163
1777
|
for (let i = 0; i < pageResults.totalPages; i++) {
|
|
1164
1778
|
visitedChars += String(i).length + 1; // digit chars + comma
|
|
1165
1779
|
}
|
|
1166
|
-
const overhead = 60;
|
|
1167
|
-
const quizBytes = pageResults.totalQuizzes * 15;
|
|
1168
|
-
const chunkBytes = pageResults.totalPages * 12;
|
|
1169
|
-
const standaloneBytes = pageResults.totalPages * 30
|
|
1170
|
-
const userStateBuffer = 256;
|
|
1780
|
+
const overhead = 60; // top-level JSON overhead with all keys
|
|
1781
|
+
const quizBytes = pageResults.totalQuizzes * 15; // q: "NNN":100,
|
|
1782
|
+
const chunkBytes = pageResults.totalPages * 12; // c: "NNN":NN,
|
|
1783
|
+
const standaloneBytes = pageResults.totalPages * 30; // s/gs: conservative buffer per page
|
|
1784
|
+
const userStateBuffer = 256; // usePersistence headroom
|
|
1171
1785
|
const estimatedSize =
|
|
1172
1786
|
overhead +
|
|
1173
1787
|
visitedChars +
|
|
@@ -1178,7 +1792,7 @@ function crossValidate(
|
|
|
1178
1792
|
|
|
1179
1793
|
if (estimatedSize > 3200) {
|
|
1180
1794
|
warnings.push(
|
|
1181
|
-
`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"
|
|
1795
|
+
`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".`,
|
|
1182
1796
|
);
|
|
1183
1797
|
}
|
|
1184
1798
|
}
|