tessera-learn 0.0.10 → 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 +6 -3
- 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 +171 -140
- package/dist/plugin/index.js.map +1 -1
- package/dist/{validation-BxWAMMnJ.js → validation-B-xTvM9B.js} +417 -81
- 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 +22 -5
- 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 +75 -103
- package/src/components/Image.svelte +14 -10
- package/src/components/LockedBanner.svelte +5 -5
- package/src/components/Matching.svelte +48 -19
- package/src/components/MediaTracks.svelte +21 -0
- package/src/components/MultipleChoice.svelte +81 -102
- package/src/components/Quiz.svelte +63 -21
- 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 +25 -20
- package/src/components/util.ts +4 -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 +6 -8
- package/src/plugin/export.ts +60 -50
- package/src/plugin/index.ts +244 -101
- package/src/plugin/layout.ts +6 -51
- package/src/plugin/manifest.ts +90 -24
- package/src/plugin/override-plugin.ts +68 -0
- package/src/plugin/quiz.ts +9 -54
- package/src/plugin/validation.ts +768 -183
- package/src/runtime/App.svelte +128 -64
- 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 +68 -116
- package/src/runtime/adapters/format.ts +67 -0
- package/src/runtime/adapters/index.ts +45 -34
- package/src/runtime/adapters/retry.ts +25 -84
- package/src/runtime/adapters/scorm-base.ts +19 -15
- package/src/runtime/adapters/scorm12.ts +8 -9
- package/src/runtime/adapters/scorm2004.ts +22 -30
- package/src/runtime/adapters/web.ts +1 -1
- package/src/runtime/hooks.svelte.ts +152 -328
- package/src/runtime/interaction-format.ts +30 -12
- package/src/runtime/interaction.ts +44 -11
- package/src/runtime/navigation.svelte.ts +29 -40
- package/src/runtime/persistence.ts +2 -2
- package/src/runtime/progress.svelte.ts +22 -9
- package/src/runtime/quiz-engine.svelte.ts +361 -0
- package/src/runtime/quiz-policy.ts +28 -179
- package/src/runtime/types.ts +24 -2
- package/src/runtime/xapi/agent-rules.ts +11 -3
- package/src/runtime/xapi/client.ts +5 -5
- package/src/runtime/xapi/derive-actor.ts +2 -2
- package/src/runtime/xapi/publisher.ts +33 -40
- 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-BxWAMMnJ.js.map +0 -1
package/src/plugin/validation.ts
CHANGED
|
@@ -3,11 +3,21 @@ import { resolve, relative } from 'node:path';
|
|
|
3
3
|
import JSON5 from 'json5';
|
|
4
4
|
import {
|
|
5
5
|
extractDefaultExportObjectLiteral,
|
|
6
|
+
extractObjectLiteral,
|
|
6
7
|
parsePageConfigFromSource,
|
|
7
8
|
readSourceFileCached,
|
|
8
9
|
ensureSvelteSuffix,
|
|
10
|
+
readCourseConfig,
|
|
9
11
|
} from './manifest.js';
|
|
10
|
-
import {
|
|
12
|
+
import {
|
|
13
|
+
validateAgent,
|
|
14
|
+
validateAuthCredential,
|
|
15
|
+
joinFieldError,
|
|
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';
|
|
11
21
|
|
|
12
22
|
// ---------- Types ----------
|
|
13
23
|
|
|
@@ -16,12 +26,118 @@ export interface ValidationResult {
|
|
|
16
26
|
warnings: string[];
|
|
17
27
|
}
|
|
18
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
|
+
|
|
121
|
+
/** Print validation warnings (yellow) then errors (red). Shared by the dev/build plugin and the CLI. */
|
|
122
|
+
export function reportValidationIssues({
|
|
123
|
+
errors,
|
|
124
|
+
warnings,
|
|
125
|
+
}: ValidationResult): void {
|
|
126
|
+
for (const warning of warnings) {
|
|
127
|
+
console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
|
|
128
|
+
}
|
|
129
|
+
for (const error of errors) {
|
|
130
|
+
console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
19
134
|
// Known top-level config fields
|
|
20
135
|
const KNOWN_CONFIG_FIELDS = new Set([
|
|
21
136
|
'title',
|
|
22
137
|
'description',
|
|
23
138
|
'author',
|
|
24
139
|
'version',
|
|
140
|
+
'language',
|
|
25
141
|
'branding',
|
|
26
142
|
'navigation',
|
|
27
143
|
'completion',
|
|
@@ -29,13 +145,27 @@ const KNOWN_CONFIG_FIELDS = new Set([
|
|
|
29
145
|
'export',
|
|
30
146
|
'chrome',
|
|
31
147
|
'xapi',
|
|
148
|
+
'a11y',
|
|
32
149
|
]);
|
|
33
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
|
+
|
|
34
160
|
const VALID_NAV_MODES = ['free', 'sequential'];
|
|
35
161
|
const VALID_COMPLETION_MODES = ['quiz', 'percentage', 'manual'];
|
|
36
162
|
const VALID_EXPORT_STANDARDS = ['web', 'scorm12', 'scorm2004', 'cmi5'];
|
|
37
163
|
const VALID_MANUAL_TRIGGERS = ['page'];
|
|
38
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;
|
|
39
169
|
|
|
40
170
|
// ---------- Main ----------
|
|
41
171
|
|
|
@@ -55,12 +185,17 @@ export function validateProject(projectRoot: string): ValidationResult {
|
|
|
55
185
|
}
|
|
56
186
|
|
|
57
187
|
// 2. Parse and validate config
|
|
58
|
-
const config = parseConfig(
|
|
188
|
+
const config = parseConfig(projectRoot, errors, warnings);
|
|
59
189
|
|
|
60
190
|
// 3. Validate pages directory
|
|
61
191
|
const pagesDir = resolve(projectRoot, 'pages');
|
|
62
192
|
const assetsDir = resolve(projectRoot, 'assets');
|
|
63
|
-
const pageResults = validatePages(
|
|
193
|
+
const pageResults = validatePages(
|
|
194
|
+
pagesDir,
|
|
195
|
+
assetsDir,
|
|
196
|
+
projectRoot,
|
|
197
|
+
config?.export?.standard,
|
|
198
|
+
);
|
|
64
199
|
errors.push(...pageResults.errors);
|
|
65
200
|
warnings.push(...pageResults.warnings);
|
|
66
201
|
|
|
@@ -68,7 +203,11 @@ export function validateProject(projectRoot: string): ValidationResult {
|
|
|
68
203
|
for (const shellFile of ['layout.svelte', 'quiz.svelte']) {
|
|
69
204
|
const shellPath = resolve(projectRoot, shellFile);
|
|
70
205
|
if (existsSync(shellPath)) {
|
|
71
|
-
validateContractBypass(
|
|
206
|
+
validateContractBypass(
|
|
207
|
+
readSourceFileCached(shellPath),
|
|
208
|
+
shellFile,
|
|
209
|
+
errors,
|
|
210
|
+
);
|
|
72
211
|
}
|
|
73
212
|
}
|
|
74
213
|
|
|
@@ -77,7 +216,9 @@ export function validateProject(projectRoot: string): ValidationResult {
|
|
|
77
216
|
crossValidate(config, pageResults, errors, warnings);
|
|
78
217
|
}
|
|
79
218
|
|
|
80
|
-
|
|
219
|
+
const result: ValidationResult = { errors, warnings };
|
|
220
|
+
applyA11ySettings(result, normalizeA11y(config?.a11y));
|
|
221
|
+
return result;
|
|
81
222
|
}
|
|
82
223
|
|
|
83
224
|
// ---------- Config Validation ----------
|
|
@@ -97,42 +238,86 @@ interface ParsedConfig {
|
|
|
97
238
|
}
|
|
98
239
|
|
|
99
240
|
function parseConfig(
|
|
100
|
-
|
|
241
|
+
projectRoot: string,
|
|
101
242
|
errors: string[],
|
|
102
|
-
warnings: string[]
|
|
243
|
+
warnings: string[],
|
|
103
244
|
): ParsedConfig | null {
|
|
104
|
-
const
|
|
105
|
-
if (!
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
errors.push(
|
|
117
|
-
'course.config.js: syntax error — must export a static object literal'
|
|
118
|
-
);
|
|
245
|
+
const read = readCourseConfig(projectRoot);
|
|
246
|
+
if (!read.ok) {
|
|
247
|
+
// 'missing' can't occur — validateProject checks existsSync first.
|
|
248
|
+
if (read.reason === 'no-export') {
|
|
249
|
+
errors.push(
|
|
250
|
+
'course.config.js: could not parse — must use `export default { ... }` syntax',
|
|
251
|
+
);
|
|
252
|
+
} else if (read.reason === 'parse-error') {
|
|
253
|
+
errors.push(
|
|
254
|
+
'course.config.js: syntax error — must export a static object literal',
|
|
255
|
+
);
|
|
256
|
+
}
|
|
119
257
|
return null;
|
|
120
258
|
}
|
|
259
|
+
const config = read.config as ParsedConfig;
|
|
121
260
|
|
|
122
261
|
// Check for unknown fields
|
|
123
262
|
for (const key of Object.keys(config)) {
|
|
124
263
|
if (!KNOWN_CONFIG_FIELDS.has(key)) {
|
|
125
264
|
warnings.push(
|
|
126
|
-
`course.config.js: unknown field "${key}" — will be ignored
|
|
265
|
+
`course.config.js: unknown field "${key}" — will be ignored`,
|
|
127
266
|
);
|
|
128
267
|
}
|
|
129
268
|
}
|
|
130
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
|
+
|
|
131
316
|
// Validate navigation.mode
|
|
132
317
|
if (config.navigation?.mode !== undefined) {
|
|
133
318
|
if (!VALID_NAV_MODES.includes(config.navigation.mode)) {
|
|
134
319
|
errors.push(
|
|
135
|
-
`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}"`,
|
|
136
321
|
);
|
|
137
322
|
}
|
|
138
323
|
}
|
|
@@ -141,7 +326,7 @@ function parseConfig(
|
|
|
141
326
|
if (config.completion?.mode !== undefined) {
|
|
142
327
|
if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) {
|
|
143
328
|
errors.push(
|
|
144
|
-
`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}"`,
|
|
145
330
|
);
|
|
146
331
|
}
|
|
147
332
|
}
|
|
@@ -149,11 +334,11 @@ function parseConfig(
|
|
|
149
334
|
if (config.completion?.trigger !== undefined) {
|
|
150
335
|
if (config.completion.mode !== 'manual') {
|
|
151
336
|
warnings.push(
|
|
152
|
-
`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"`,
|
|
153
338
|
);
|
|
154
339
|
} else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) {
|
|
155
340
|
errors.push(
|
|
156
|
-
`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}"`,
|
|
157
342
|
);
|
|
158
343
|
}
|
|
159
344
|
}
|
|
@@ -161,11 +346,15 @@ function parseConfig(
|
|
|
161
346
|
if (config.completion?.requireSuccessStatus !== undefined) {
|
|
162
347
|
if (config.completion.mode !== 'manual') {
|
|
163
348
|
warnings.push(
|
|
164
|
-
`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"`,
|
|
165
350
|
);
|
|
166
|
-
} else if (
|
|
351
|
+
} else if (
|
|
352
|
+
!VALID_REQUIRE_SUCCESS_STATUS.includes(
|
|
353
|
+
config.completion.requireSuccessStatus,
|
|
354
|
+
)
|
|
355
|
+
) {
|
|
167
356
|
errors.push(
|
|
168
|
-
`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}"`,
|
|
169
358
|
);
|
|
170
359
|
}
|
|
171
360
|
}
|
|
@@ -174,7 +363,7 @@ function parseConfig(
|
|
|
174
363
|
if (config.export?.standard !== undefined) {
|
|
175
364
|
if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) {
|
|
176
365
|
errors.push(
|
|
177
|
-
`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}"`,
|
|
178
367
|
);
|
|
179
368
|
}
|
|
180
369
|
}
|
|
@@ -184,7 +373,7 @@ function parseConfig(
|
|
|
184
373
|
const score = config.scoring.passingScore;
|
|
185
374
|
if (typeof score !== 'number' || score < 0 || score > 100) {
|
|
186
375
|
errors.push(
|
|
187
|
-
`course.config.js: "scoring.passingScore" must be 0–100, got ${score}
|
|
376
|
+
`course.config.js: "scoring.passingScore" must be 0–100, got ${score}`,
|
|
188
377
|
);
|
|
189
378
|
}
|
|
190
379
|
}
|
|
@@ -194,7 +383,7 @@ function parseConfig(
|
|
|
194
383
|
const threshold = config.completion.percentageThreshold;
|
|
195
384
|
if (typeof threshold !== 'number' || threshold < 0 || threshold > 100) {
|
|
196
385
|
errors.push(
|
|
197
|
-
`course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}
|
|
386
|
+
`course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`,
|
|
198
387
|
);
|
|
199
388
|
}
|
|
200
389
|
}
|
|
@@ -205,23 +394,144 @@ function parseConfig(
|
|
|
205
394
|
config.xapi,
|
|
206
395
|
config.export?.standard ?? 'web',
|
|
207
396
|
errors,
|
|
208
|
-
warnings
|
|
397
|
+
warnings,
|
|
209
398
|
);
|
|
210
399
|
}
|
|
211
400
|
|
|
212
401
|
return config;
|
|
213
402
|
}
|
|
214
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
|
+
|
|
215
525
|
// ---------- xAPI Config Validation ----------
|
|
216
526
|
|
|
217
|
-
const UUID_RE =
|
|
218
|
-
|
|
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;
|
|
219
529
|
|
|
220
530
|
function validateXAPIConfig(
|
|
221
531
|
raw: unknown,
|
|
222
532
|
standard: string,
|
|
223
533
|
errors: string[],
|
|
224
|
-
warnings: string[]
|
|
534
|
+
warnings: string[],
|
|
225
535
|
): void {
|
|
226
536
|
if (raw === undefined || raw === null) return;
|
|
227
537
|
|
|
@@ -232,7 +542,7 @@ function validateXAPIConfig(
|
|
|
232
542
|
if (Array.isArray(raw)) {
|
|
233
543
|
if (entries.length === 0) {
|
|
234
544
|
errors.push(
|
|
235
|
-
'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',
|
|
236
546
|
);
|
|
237
547
|
return;
|
|
238
548
|
}
|
|
@@ -241,11 +551,11 @@ function validateXAPIConfig(
|
|
|
241
551
|
(e) =>
|
|
242
552
|
e &&
|
|
243
553
|
typeof e === 'object' &&
|
|
244
|
-
(e as { endpoint?: unknown }).endpoint === 'lms'
|
|
554
|
+
(e as { endpoint?: unknown }).endpoint === 'lms',
|
|
245
555
|
).length;
|
|
246
556
|
if (lmsCount > 1) {
|
|
247
557
|
errors.push(
|
|
248
|
-
"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",
|
|
249
559
|
);
|
|
250
560
|
}
|
|
251
561
|
// Warn on duplicate explicit endpoints.
|
|
@@ -262,13 +572,13 @@ function validateXAPIConfig(
|
|
|
262
572
|
if (count > 1) {
|
|
263
573
|
warnings.push(
|
|
264
574
|
`course.config.js: xapi has ${count} entries with endpoint "${ep}" — usually a copy-paste mistake; ` +
|
|
265
|
-
'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.',
|
|
266
576
|
);
|
|
267
577
|
}
|
|
268
578
|
}
|
|
269
579
|
} else if (typeof raw !== 'object') {
|
|
270
580
|
errors.push(
|
|
271
|
-
'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',
|
|
272
582
|
);
|
|
273
583
|
return;
|
|
274
584
|
}
|
|
@@ -285,7 +595,7 @@ function validateXAPIConfig(
|
|
|
285
595
|
label,
|
|
286
596
|
standard,
|
|
287
597
|
errors,
|
|
288
|
-
warnings
|
|
598
|
+
warnings,
|
|
289
599
|
);
|
|
290
600
|
}
|
|
291
601
|
}
|
|
@@ -295,7 +605,7 @@ function validateSingleXAPIEntry(
|
|
|
295
605
|
label: string,
|
|
296
606
|
standard: string,
|
|
297
607
|
errors: string[],
|
|
298
|
-
warnings: string[]
|
|
608
|
+
warnings: string[],
|
|
299
609
|
): void {
|
|
300
610
|
const endpoint = entry.endpoint;
|
|
301
611
|
if (endpoint === undefined) {
|
|
@@ -312,15 +622,21 @@ function validateSingleXAPIEntry(
|
|
|
312
622
|
if (standard !== 'cmi5') {
|
|
313
623
|
errors.push(
|
|
314
624
|
`course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' (you have "${standard}"). ` +
|
|
315
|
-
'Either change the export standard or specify an explicit LRS endpoint.'
|
|
625
|
+
'Either change the export standard or specify an explicit LRS endpoint.',
|
|
316
626
|
);
|
|
317
627
|
}
|
|
318
628
|
// Forbid extra fields — everything is inherited from the cmi5 launch.
|
|
319
|
-
const forbidden = [
|
|
629
|
+
const forbidden = [
|
|
630
|
+
'auth',
|
|
631
|
+
'actor',
|
|
632
|
+
'activityId',
|
|
633
|
+
'registration',
|
|
634
|
+
'actorAccountHomePage',
|
|
635
|
+
];
|
|
320
636
|
for (const f of forbidden) {
|
|
321
637
|
if (entry[f] !== undefined) {
|
|
322
638
|
errors.push(
|
|
323
|
-
`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.`,
|
|
324
640
|
);
|
|
325
641
|
}
|
|
326
642
|
}
|
|
@@ -333,25 +649,25 @@ function validateSingleXAPIEntry(
|
|
|
333
649
|
url = new URL(endpoint);
|
|
334
650
|
} catch {
|
|
335
651
|
errors.push(
|
|
336
|
-
`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}"`,
|
|
337
653
|
);
|
|
338
654
|
return;
|
|
339
655
|
}
|
|
340
656
|
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
341
657
|
errors.push(
|
|
342
|
-
`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}"`,
|
|
343
659
|
);
|
|
344
660
|
return;
|
|
345
661
|
}
|
|
346
662
|
if (url.protocol === 'http:' && process.env.NODE_ENV === 'production') {
|
|
347
663
|
warnings.push(
|
|
348
|
-
`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.`,
|
|
349
665
|
);
|
|
350
666
|
}
|
|
351
667
|
if (!endpoint.endsWith('/')) {
|
|
352
668
|
warnings.push(
|
|
353
669
|
`course.config.js: ${label}.endpoint should end with a slash to avoid concatenation surprises ` +
|
|
354
|
-
`(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.`,
|
|
355
671
|
);
|
|
356
672
|
}
|
|
357
673
|
|
|
@@ -360,25 +676,20 @@ function validateSingleXAPIEntry(
|
|
|
360
676
|
if (auth === undefined) {
|
|
361
677
|
errors.push(`course.config.js: ${label}.auth is required`);
|
|
362
678
|
} else if (typeof auth === 'string') {
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
} else if (/^basic\s/i.test(auth)) {
|
|
366
|
-
errors.push(
|
|
367
|
-
`course.config.js: ${label}.auth must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.`
|
|
368
|
-
);
|
|
369
|
-
} else if (/^bearer\s/i.test(auth)) {
|
|
679
|
+
const authErr = validateAuthCredential(auth);
|
|
680
|
+
if (authErr) {
|
|
370
681
|
errors.push(
|
|
371
|
-
`course.config.js: ${label}.auth
|
|
682
|
+
`course.config.js: ${joinFieldError(`${label}.auth`, authErr)}`,
|
|
372
683
|
);
|
|
373
684
|
} else {
|
|
374
685
|
warnings.push(
|
|
375
686
|
`course.config.js: ${label}.auth is a static string and will be embedded in the bundle. ` +
|
|
376
|
-
'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.',
|
|
377
688
|
);
|
|
378
689
|
}
|
|
379
690
|
} else if (typeof auth !== 'function') {
|
|
380
691
|
errors.push(
|
|
381
|
-
`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}`,
|
|
382
693
|
);
|
|
383
694
|
}
|
|
384
695
|
|
|
@@ -394,7 +705,7 @@ function validateSingleXAPIEntry(
|
|
|
394
705
|
new URL(activityId);
|
|
395
706
|
} catch {
|
|
396
707
|
errors.push(
|
|
397
|
-
`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}"`,
|
|
398
709
|
);
|
|
399
710
|
}
|
|
400
711
|
}
|
|
@@ -405,20 +716,17 @@ function validateSingleXAPIEntry(
|
|
|
405
716
|
if (standard === 'web') {
|
|
406
717
|
errors.push(
|
|
407
718
|
`course.config.js: ${label}.actor is required for web export — there is no LMS to derive a learner identity from. ` +
|
|
408
|
-
'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).',
|
|
409
720
|
);
|
|
410
721
|
}
|
|
411
722
|
} else if (typeof actor === 'object' && actor !== null) {
|
|
412
723
|
const err = validateAgent(actor);
|
|
413
724
|
if (err) {
|
|
414
|
-
|
|
415
|
-
? `${label}.actor${err}`
|
|
416
|
-
: `${label}.actor ${err}`;
|
|
417
|
-
errors.push(`course.config.js: ${joined}`);
|
|
725
|
+
errors.push(`course.config.js: ${joinFieldError(`${label}.actor`, err)}`);
|
|
418
726
|
}
|
|
419
727
|
} else if (typeof actor !== 'function') {
|
|
420
728
|
errors.push(
|
|
421
|
-
`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}`,
|
|
422
730
|
);
|
|
423
731
|
}
|
|
424
732
|
|
|
@@ -428,25 +736,25 @@ function validateSingleXAPIEntry(
|
|
|
428
736
|
if (aahp !== undefined) {
|
|
429
737
|
if (typeof aahp !== 'string') {
|
|
430
738
|
errors.push(
|
|
431
|
-
`course.config.js: ${label}.actorAccountHomePage must be a string
|
|
739
|
+
`course.config.js: ${label}.actorAccountHomePage must be a string`,
|
|
432
740
|
);
|
|
433
741
|
} else {
|
|
434
742
|
try {
|
|
435
743
|
new URL(aahp);
|
|
436
744
|
} catch {
|
|
437
745
|
errors.push(
|
|
438
|
-
`course.config.js: ${label}.actorAccountHomePage must be an absolute URL
|
|
746
|
+
`course.config.js: ${label}.actorAccountHomePage must be an absolute URL`,
|
|
439
747
|
);
|
|
440
748
|
}
|
|
441
749
|
}
|
|
442
750
|
if (actor !== undefined) {
|
|
443
751
|
warnings.push(
|
|
444
|
-
`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.`,
|
|
445
753
|
);
|
|
446
754
|
}
|
|
447
755
|
if (standard === 'cmi5' || standard === 'web') {
|
|
448
756
|
warnings.push(
|
|
449
|
-
`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}".`,
|
|
450
758
|
);
|
|
451
759
|
}
|
|
452
760
|
}
|
|
@@ -458,7 +766,7 @@ function validateSingleXAPIEntry(
|
|
|
458
766
|
(standard === 'scorm12' || standard === 'scorm2004') &&
|
|
459
767
|
typeof activityId === 'string'
|
|
460
768
|
) {
|
|
461
|
-
let isHttp
|
|
769
|
+
let isHttp: boolean;
|
|
462
770
|
try {
|
|
463
771
|
const u = new URL(activityId);
|
|
464
772
|
isHttp = u.protocol === 'http:' || u.protocol === 'https:';
|
|
@@ -468,7 +776,7 @@ function validateSingleXAPIEntry(
|
|
|
468
776
|
if (!isHttp && aahp === undefined) {
|
|
469
777
|
errors.push(
|
|
470
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. ` +
|
|
471
|
-
`Provide ${label}.actorAccountHomePage explicitly
|
|
779
|
+
`Provide ${label}.actorAccountHomePage explicitly.`,
|
|
472
780
|
);
|
|
473
781
|
}
|
|
474
782
|
}
|
|
@@ -478,12 +786,12 @@ function validateSingleXAPIEntry(
|
|
|
478
786
|
if (registration !== undefined) {
|
|
479
787
|
if (typeof registration !== 'string' || !UUID_RE.test(registration)) {
|
|
480
788
|
errors.push(
|
|
481
|
-
`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)}"`,
|
|
482
790
|
);
|
|
483
791
|
}
|
|
484
792
|
if (standard !== 'cmi5') {
|
|
485
793
|
warnings.push(
|
|
486
|
-
`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.`,
|
|
487
795
|
);
|
|
488
796
|
}
|
|
489
797
|
}
|
|
@@ -518,7 +826,8 @@ function validatePageFile(
|
|
|
518
826
|
navIndex: number,
|
|
519
827
|
errors: string[],
|
|
520
828
|
warnings: string[],
|
|
521
|
-
assetExistsCache: Map<string, boolean
|
|
829
|
+
assetExistsCache: Map<string, boolean>,
|
|
830
|
+
exportStandard?: string,
|
|
522
831
|
): { page: PageInfo; isQuiz: boolean; isGradedQuiz: boolean } {
|
|
523
832
|
const fileRel = relative(projectRoot, filePath);
|
|
524
833
|
const content = readSourceFileCached(filePath);
|
|
@@ -537,7 +846,15 @@ function validatePageFile(
|
|
|
537
846
|
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
538
847
|
|
|
539
848
|
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
540
|
-
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);
|
|
541
858
|
validateContractBypass(content, fileRel, errors);
|
|
542
859
|
if (
|
|
543
860
|
pageConfig?.quiz &&
|
|
@@ -547,12 +864,18 @@ function validatePageFile(
|
|
|
547
864
|
) {
|
|
548
865
|
warnings.push(
|
|
549
866
|
`${fileRel}: quiz page has no question components or useQuestion() calls — ` +
|
|
550
|
-
`the quiz will have nothing to score
|
|
867
|
+
`the quiz will have nothing to score`,
|
|
551
868
|
);
|
|
552
869
|
}
|
|
553
870
|
|
|
554
871
|
return {
|
|
555
|
-
page: {
|
|
872
|
+
page: {
|
|
873
|
+
fileRel,
|
|
874
|
+
navIndex,
|
|
875
|
+
hasGradedQuiz: isGradedQuiz,
|
|
876
|
+
hasQuiz: isQuiz,
|
|
877
|
+
completesOnView,
|
|
878
|
+
},
|
|
556
879
|
isQuiz,
|
|
557
880
|
isGradedQuiz,
|
|
558
881
|
};
|
|
@@ -561,7 +884,8 @@ function validatePageFile(
|
|
|
561
884
|
function validatePages(
|
|
562
885
|
pagesDir: string,
|
|
563
886
|
assetsDir: string,
|
|
564
|
-
projectRoot: string
|
|
887
|
+
projectRoot: string,
|
|
888
|
+
exportStandard?: string,
|
|
565
889
|
): PagesValidationResult {
|
|
566
890
|
const errors: string[] = [];
|
|
567
891
|
const warnings: string[] = [];
|
|
@@ -574,9 +898,16 @@ function validatePages(
|
|
|
574
898
|
|
|
575
899
|
if (!existsSync(pagesDir)) {
|
|
576
900
|
errors.push(
|
|
577
|
-
'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/',
|
|
578
902
|
);
|
|
579
|
-
return {
|
|
903
|
+
return {
|
|
904
|
+
errors,
|
|
905
|
+
warnings,
|
|
906
|
+
totalPages: 0,
|
|
907
|
+
totalQuizzes: 0,
|
|
908
|
+
hasGradedQuiz: false,
|
|
909
|
+
pages,
|
|
910
|
+
};
|
|
580
911
|
}
|
|
581
912
|
|
|
582
913
|
const topLevelEntries = readdirSync(pagesDir);
|
|
@@ -587,7 +918,7 @@ function validatePages(
|
|
|
587
918
|
if (entry.endsWith('.svelte') && statSync(fullPath).isFile()) {
|
|
588
919
|
const relPath = relative(projectRoot, fullPath);
|
|
589
920
|
warnings.push(
|
|
590
|
-
`${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`,
|
|
591
922
|
);
|
|
592
923
|
}
|
|
593
924
|
}
|
|
@@ -602,20 +933,28 @@ function validatePages(
|
|
|
602
933
|
|
|
603
934
|
if (sectionDirs.length === 0) {
|
|
604
935
|
errors.push(
|
|
605
|
-
'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/',
|
|
606
937
|
);
|
|
607
|
-
return {
|
|
938
|
+
return {
|
|
939
|
+
errors,
|
|
940
|
+
warnings,
|
|
941
|
+
totalPages: 0,
|
|
942
|
+
totalQuizzes: 0,
|
|
943
|
+
hasGradedQuiz: false,
|
|
944
|
+
pages,
|
|
945
|
+
};
|
|
608
946
|
}
|
|
609
947
|
|
|
610
948
|
for (const sectionName of sectionDirs) {
|
|
611
949
|
const sectionPath = resolve(pagesDir, sectionName);
|
|
612
950
|
const sectionRel = relative(projectRoot, sectionPath);
|
|
951
|
+
const pagesBeforeSection = totalPages;
|
|
613
952
|
|
|
614
953
|
// Validate section _meta.js
|
|
615
954
|
const sectionMeta = validateMetaFile(
|
|
616
955
|
resolve(sectionPath, '_meta.js'),
|
|
617
956
|
sectionRel,
|
|
618
|
-
errors
|
|
957
|
+
errors,
|
|
619
958
|
);
|
|
620
959
|
|
|
621
960
|
// Flat mode: .svelte files directly at section level are pages of an
|
|
@@ -632,9 +971,12 @@ function validatePages(
|
|
|
632
971
|
for (const pageName of sectionMeta.pages) {
|
|
633
972
|
const fileName = ensureSvelteSuffix(pageName);
|
|
634
973
|
if (!sectionSvelteFiles.includes(fileName)) {
|
|
635
|
-
const metaRel = relative(
|
|
974
|
+
const metaRel = relative(
|
|
975
|
+
projectRoot,
|
|
976
|
+
resolve(sectionPath, '_meta.js'),
|
|
977
|
+
);
|
|
636
978
|
errors.push(
|
|
637
|
-
`${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`,
|
|
638
980
|
);
|
|
639
981
|
}
|
|
640
982
|
}
|
|
@@ -648,7 +990,8 @@ function validatePages(
|
|
|
648
990
|
totalPages,
|
|
649
991
|
errors,
|
|
650
992
|
warnings,
|
|
651
|
-
assetExistsCache
|
|
993
|
+
assetExistsCache,
|
|
994
|
+
exportStandard,
|
|
652
995
|
);
|
|
653
996
|
totalPages++;
|
|
654
997
|
if (result.isQuiz) totalQuizzes++;
|
|
@@ -672,7 +1015,7 @@ function validatePages(
|
|
|
672
1015
|
const meta = validateMetaFile(
|
|
673
1016
|
resolve(lessonPath, '_meta.js'),
|
|
674
1017
|
lessonRel,
|
|
675
|
-
errors
|
|
1018
|
+
errors,
|
|
676
1019
|
);
|
|
677
1020
|
|
|
678
1021
|
// Get .svelte files
|
|
@@ -685,9 +1028,12 @@ function validatePages(
|
|
|
685
1028
|
for (const pageName of meta.pages) {
|
|
686
1029
|
const fileName = ensureSvelteSuffix(pageName);
|
|
687
1030
|
if (!svelteFiles.includes(fileName)) {
|
|
688
|
-
const metaRel = relative(
|
|
1031
|
+
const metaRel = relative(
|
|
1032
|
+
projectRoot,
|
|
1033
|
+
resolve(lessonPath, '_meta.js'),
|
|
1034
|
+
);
|
|
689
1035
|
errors.push(
|
|
690
|
-
`${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`,
|
|
691
1037
|
);
|
|
692
1038
|
}
|
|
693
1039
|
}
|
|
@@ -700,7 +1046,7 @@ function validatePages(
|
|
|
700
1046
|
if (!listedSet.has(file)) {
|
|
701
1047
|
const relPath = relative(projectRoot, resolve(lessonPath, file));
|
|
702
1048
|
warnings.push(
|
|
703
|
-
`${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`,
|
|
704
1050
|
);
|
|
705
1051
|
}
|
|
706
1052
|
}
|
|
@@ -715,7 +1061,8 @@ function validatePages(
|
|
|
715
1061
|
totalPages,
|
|
716
1062
|
errors,
|
|
717
1063
|
warnings,
|
|
718
|
-
assetExistsCache
|
|
1064
|
+
assetExistsCache,
|
|
1065
|
+
exportStandard,
|
|
719
1066
|
);
|
|
720
1067
|
totalPages++;
|
|
721
1068
|
if (result.isQuiz) totalQuizzes++;
|
|
@@ -723,11 +1070,18 @@ function validatePages(
|
|
|
723
1070
|
pages.push(result.page);
|
|
724
1071
|
}
|
|
725
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
|
+
}
|
|
726
1080
|
}
|
|
727
1081
|
|
|
728
1082
|
if (totalPages === 0) {
|
|
729
1083
|
errors.push(
|
|
730
|
-
'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/',
|
|
731
1085
|
);
|
|
732
1086
|
}
|
|
733
1087
|
|
|
@@ -739,15 +1093,19 @@ function validatePages(
|
|
|
739
1093
|
function validateMetaFile(
|
|
740
1094
|
metaPath: string,
|
|
741
1095
|
parentRel: string,
|
|
742
|
-
errors: string[]
|
|
1096
|
+
errors: string[],
|
|
743
1097
|
): { title?: string; pages?: string[] } | null {
|
|
744
1098
|
if (!existsSync(metaPath)) return null;
|
|
745
1099
|
|
|
746
1100
|
const metaRel = `${parentRel}/_meta.js`;
|
|
747
|
-
const objectStr = extractDefaultExportObjectLiteral(
|
|
1101
|
+
const objectStr = extractDefaultExportObjectLiteral(
|
|
1102
|
+
readSourceFileCached(metaPath),
|
|
1103
|
+
);
|
|
748
1104
|
|
|
749
1105
|
if (!objectStr) {
|
|
750
|
-
errors.push(
|
|
1106
|
+
errors.push(
|
|
1107
|
+
`${metaRel}: syntax error — must export default { title: "..." }`,
|
|
1108
|
+
);
|
|
751
1109
|
return null;
|
|
752
1110
|
}
|
|
753
1111
|
|
|
@@ -755,7 +1113,9 @@ function validateMetaFile(
|
|
|
755
1113
|
try {
|
|
756
1114
|
meta = JSON5.parse(objectStr);
|
|
757
1115
|
} catch {
|
|
758
|
-
errors.push(
|
|
1116
|
+
errors.push(
|
|
1117
|
+
`${metaRel}: syntax error — must export default { title: "..." }`,
|
|
1118
|
+
);
|
|
759
1119
|
return null;
|
|
760
1120
|
}
|
|
761
1121
|
|
|
@@ -771,13 +1131,13 @@ function validateMetaFile(
|
|
|
771
1131
|
function validatePageConfig(
|
|
772
1132
|
content: string,
|
|
773
1133
|
fileRel: string,
|
|
774
|
-
errors: string[]
|
|
1134
|
+
errors: string[],
|
|
775
1135
|
): { title?: string; quiz?: unknown; completesOn?: unknown } | null {
|
|
776
1136
|
const result = parsePageConfigFromSource(content);
|
|
777
1137
|
if (result.kind === 'ok') return result.value;
|
|
778
1138
|
if (result.kind === 'invalid') {
|
|
779
1139
|
errors.push(
|
|
780
|
-
`${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)`,
|
|
781
1141
|
);
|
|
782
1142
|
}
|
|
783
1143
|
return null;
|
|
@@ -786,27 +1146,34 @@ function validatePageConfig(
|
|
|
786
1146
|
function validateCompletesOn(
|
|
787
1147
|
pageConfig: { completesOn?: unknown } | null,
|
|
788
1148
|
fileRel: string,
|
|
789
|
-
errors: string[]
|
|
1149
|
+
errors: string[],
|
|
790
1150
|
): boolean {
|
|
791
1151
|
if (!pageConfig || pageConfig.completesOn === undefined) return false;
|
|
792
1152
|
if (pageConfig.completesOn === 'view') return true;
|
|
793
1153
|
errors.push(
|
|
794
|
-
`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}
|
|
1154
|
+
`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`,
|
|
795
1155
|
);
|
|
796
1156
|
return false;
|
|
797
1157
|
}
|
|
798
1158
|
|
|
799
1159
|
// ---------- Quiz Config Validation ----------
|
|
800
1160
|
|
|
801
|
-
function validateQuizConfig(
|
|
1161
|
+
function validateQuizConfig(
|
|
1162
|
+
quiz: unknown,
|
|
1163
|
+
fileRel: string,
|
|
1164
|
+
errors: string[],
|
|
1165
|
+
): void {
|
|
802
1166
|
if (!quiz || typeof quiz !== 'object') return;
|
|
803
1167
|
const cfg = quiz as Record<string, unknown>;
|
|
804
1168
|
|
|
805
1169
|
if (cfg.maxAttempts !== undefined) {
|
|
806
1170
|
const val = cfg.maxAttempts;
|
|
807
|
-
if (
|
|
1171
|
+
if (
|
|
1172
|
+
val !== Infinity &&
|
|
1173
|
+
(typeof val !== 'number' || val <= 0 || !Number.isFinite(val))
|
|
1174
|
+
) {
|
|
808
1175
|
errors.push(
|
|
809
|
-
`${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)}`,
|
|
810
1177
|
);
|
|
811
1178
|
}
|
|
812
1179
|
}
|
|
@@ -814,10 +1181,27 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
|
|
|
814
1181
|
for (const field of ['graded', 'gatesProgress']) {
|
|
815
1182
|
if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
|
|
816
1183
|
errors.push(
|
|
817
|
-
`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}
|
|
1184
|
+
`${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`,
|
|
818
1185
|
);
|
|
819
1186
|
}
|
|
820
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
|
+
}
|
|
821
1205
|
}
|
|
822
1206
|
|
|
823
1207
|
// ---------- Question Component Validation ----------
|
|
@@ -834,58 +1218,31 @@ type PropValue =
|
|
|
834
1218
|
| { kind: 'expr'; raw: string }
|
|
835
1219
|
| { kind: 'bool' };
|
|
836
1220
|
|
|
837
|
-
/** Extract a balanced {...} or [...] span starting at startIndex, or null. */
|
|
838
|
-
function extractBalanced(source: string, startIndex: number): string | null {
|
|
839
|
-
const open = source[startIndex];
|
|
840
|
-
if (open !== '{' && open !== '[') return null;
|
|
841
|
-
let depth = 0;
|
|
842
|
-
let inString: string | null = null;
|
|
843
|
-
let escaped = false;
|
|
844
|
-
for (let i = startIndex; i < source.length; i++) {
|
|
845
|
-
const char = source[i];
|
|
846
|
-
if (escaped) {
|
|
847
|
-
escaped = false;
|
|
848
|
-
continue;
|
|
849
|
-
}
|
|
850
|
-
if (char === '\\' && inString) {
|
|
851
|
-
escaped = true;
|
|
852
|
-
continue;
|
|
853
|
-
}
|
|
854
|
-
if (inString) {
|
|
855
|
-
if (char === inString) inString = null;
|
|
856
|
-
continue;
|
|
857
|
-
}
|
|
858
|
-
if (char === '"' || char === "'" || char === '`') {
|
|
859
|
-
inString = char;
|
|
860
|
-
continue;
|
|
861
|
-
}
|
|
862
|
-
if (char === '{' || char === '[') depth++;
|
|
863
|
-
if (char === '}' || char === ']') {
|
|
864
|
-
depth--;
|
|
865
|
-
if (depth === 0) return source.slice(startIndex, i + 1);
|
|
866
|
-
}
|
|
867
|
-
}
|
|
868
|
-
return null;
|
|
869
|
-
}
|
|
870
|
-
|
|
871
1221
|
/**
|
|
872
1222
|
* Parse the props of an opening tag starting just after the component name.
|
|
873
1223
|
* Returns null if the tag can't be parsed cleanly — callers then skip it
|
|
874
1224
|
* rather than risk a false positive.
|
|
875
1225
|
*/
|
|
876
|
-
function parseTagProps(
|
|
1226
|
+
function parseTagProps(
|
|
1227
|
+
content: string,
|
|
1228
|
+
start: number,
|
|
1229
|
+
): { props: Map<string, PropValue>; hasSpread: boolean } | null {
|
|
877
1230
|
const props = new Map<string, PropValue>();
|
|
1231
|
+
let hasSpread = false;
|
|
878
1232
|
let i = start;
|
|
879
1233
|
while (i < content.length) {
|
|
880
1234
|
while (i < content.length && /\s/.test(content[i])) i++;
|
|
881
1235
|
if (i >= content.length) return null;
|
|
882
1236
|
const c = content[i];
|
|
883
|
-
if (c === '>') return props;
|
|
884
|
-
if (c === '/' && content[i + 1] === '>') return props;
|
|
885
|
-
// 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.
|
|
886
1242
|
if (c === '{') {
|
|
887
|
-
const block =
|
|
1243
|
+
const block = extractObjectLiteral(content, i);
|
|
888
1244
|
if (!block) return null;
|
|
1245
|
+
hasSpread = true;
|
|
889
1246
|
i += block.length;
|
|
890
1247
|
continue;
|
|
891
1248
|
}
|
|
@@ -907,7 +1264,7 @@ function parseTagProps(content: string, start: number): Map<string, PropValue> |
|
|
|
907
1264
|
props.set(propName, { kind: 'string', value: content.slice(i + 1, end) });
|
|
908
1265
|
i = end + 1;
|
|
909
1266
|
} else if (v === '{') {
|
|
910
|
-
const block =
|
|
1267
|
+
const block = extractObjectLiteral(content, i);
|
|
911
1268
|
if (!block) return null;
|
|
912
1269
|
props.set(propName, { kind: 'expr', raw: block.slice(1, -1).trim() });
|
|
913
1270
|
i += block.length;
|
|
@@ -941,50 +1298,113 @@ function staticNumber(prop: PropValue | undefined): number | null {
|
|
|
941
1298
|
function validateQuestionComponents(
|
|
942
1299
|
content: string,
|
|
943
1300
|
fileRel: string,
|
|
944
|
-
errors: string[]
|
|
1301
|
+
errors: string[],
|
|
1302
|
+
warnings: string[],
|
|
1303
|
+
exportStandard?: string,
|
|
945
1304
|
): void {
|
|
946
1305
|
const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join('|');
|
|
947
1306
|
const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, 'g');
|
|
948
1307
|
const seenIds = new Set<string>();
|
|
1308
|
+
const seenSanitized = new Set<string>();
|
|
949
1309
|
let m: RegExpExecArray | null;
|
|
950
1310
|
while ((m = tagStartRe.exec(content)) !== null) {
|
|
951
1311
|
const name = m[1];
|
|
952
|
-
const
|
|
953
|
-
if (!
|
|
1312
|
+
const parsed = parseTagProps(content, m.index + m[0].length);
|
|
1313
|
+
if (!parsed) continue;
|
|
1314
|
+
const { props, hasSpread } = parsed;
|
|
954
1315
|
|
|
955
1316
|
for (const req of QUESTION_COMPONENT_REQUIRED[name]) {
|
|
956
|
-
if (!props.has(req)) {
|
|
1317
|
+
if (!hasSpread && !props.has(req)) {
|
|
957
1318
|
errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
|
|
958
1319
|
}
|
|
959
1320
|
}
|
|
960
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
|
+
|
|
961
1335
|
const idProp = props.get('id');
|
|
962
1336
|
if (idProp?.kind === 'string') {
|
|
963
1337
|
if (seenIds.has(idProp.value)) {
|
|
964
1338
|
errors.push(
|
|
965
|
-
`${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`,
|
|
966
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);
|
|
967
1357
|
}
|
|
968
1358
|
seenIds.add(idProp.value);
|
|
969
1359
|
}
|
|
970
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
|
+
|
|
971
1381
|
if (name === 'MultipleChoice') {
|
|
972
1382
|
const options = staticArray(props.get('options'));
|
|
973
1383
|
const correct = staticNumber(props.get('correct'));
|
|
974
1384
|
if (options && correct !== null) {
|
|
975
|
-
if (
|
|
1385
|
+
if (
|
|
1386
|
+
!Number.isInteger(correct) ||
|
|
1387
|
+
correct < 0 ||
|
|
1388
|
+
correct >= options.length
|
|
1389
|
+
) {
|
|
976
1390
|
errors.push(
|
|
977
|
-
`${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})`,
|
|
978
1392
|
);
|
|
979
1393
|
}
|
|
980
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
|
+
}
|
|
981
1401
|
} else if (name === 'Sorting') {
|
|
982
1402
|
const items = staticArray(props.get('items'));
|
|
983
1403
|
const targets = staticArray(props.get('targets'));
|
|
984
1404
|
const correct = staticArray(props.get('correct'));
|
|
985
1405
|
if (items && correct && correct.length !== items.length) {
|
|
986
1406
|
errors.push(
|
|
987
|
-
`${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`,
|
|
988
1408
|
);
|
|
989
1409
|
}
|
|
990
1410
|
if (targets && correct) {
|
|
@@ -996,7 +1416,7 @@ function validateQuestionComponents(
|
|
|
996
1416
|
idx >= targets.length
|
|
997
1417
|
) {
|
|
998
1418
|
errors.push(
|
|
999
|
-
`${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})`,
|
|
1000
1420
|
);
|
|
1001
1421
|
break;
|
|
1002
1422
|
}
|
|
@@ -1010,11 +1430,11 @@ function validateQuestionComponents(
|
|
|
1010
1430
|
typeof p !== 'object' ||
|
|
1011
1431
|
p === null ||
|
|
1012
1432
|
typeof (p as { left?: unknown }).left !== 'string' ||
|
|
1013
|
-
typeof (p as { right?: unknown }).right !== 'string'
|
|
1433
|
+
typeof (p as { right?: unknown }).right !== 'string',
|
|
1014
1434
|
);
|
|
1015
1435
|
if (bad) {
|
|
1016
1436
|
errors.push(
|
|
1017
|
-
`${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`,
|
|
1018
1438
|
);
|
|
1019
1439
|
}
|
|
1020
1440
|
}
|
|
@@ -1025,7 +1445,7 @@ function validateQuestionComponents(
|
|
|
1025
1445
|
errors.push(`${fileRel}: <FillInTheBlank> answers must not be empty`);
|
|
1026
1446
|
} else if (answers.some((a) => typeof a !== 'string')) {
|
|
1027
1447
|
errors.push(
|
|
1028
|
-
`${fileRel}: <FillInTheBlank> answers must be an array of strings
|
|
1448
|
+
`${fileRel}: <FillInTheBlank> answers must be an array of strings`,
|
|
1029
1449
|
);
|
|
1030
1450
|
}
|
|
1031
1451
|
}
|
|
@@ -1033,6 +1453,156 @@ function validateQuestionComponents(
|
|
|
1033
1453
|
}
|
|
1034
1454
|
}
|
|
1035
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
|
+
|
|
1036
1606
|
// ---------- Contract Bypass Detection ----------
|
|
1037
1607
|
|
|
1038
1608
|
const QUIZ_COMPLETE_DISPATCH_RE =
|
|
@@ -1040,7 +1610,7 @@ const QUIZ_COMPLETE_DISPATCH_RE =
|
|
|
1040
1610
|
const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
|
|
1041
1611
|
const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
|
|
1042
1612
|
const HAS_QUESTION_TAG_RE = new RegExp(
|
|
1043
|
-
`<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])
|
|
1613
|
+
`<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`,
|
|
1044
1614
|
);
|
|
1045
1615
|
// Custom widget imported from a local `.svelte` file may wrap useQuestion.
|
|
1046
1616
|
// Treat its presence as enough to suppress the "no questions" warning —
|
|
@@ -1055,18 +1625,18 @@ const HAS_LOCAL_SVELTE_IMPORT_RE = /from\s+['"][^'"]+\.svelte['"]/;
|
|
|
1055
1625
|
function validateContractBypass(
|
|
1056
1626
|
content: string,
|
|
1057
1627
|
fileRel: string,
|
|
1058
|
-
errors: string[]
|
|
1628
|
+
errors: string[],
|
|
1059
1629
|
): void {
|
|
1060
1630
|
if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) {
|
|
1061
1631
|
errors.push(
|
|
1062
1632
|
`${fileRel}: dispatches "tessera-quiz-complete" directly — submit through ` +
|
|
1063
|
-
`useQuiz().submit() so the result reaches the LMS
|
|
1633
|
+
`useQuiz().submit() so the result reaches the LMS`,
|
|
1064
1634
|
);
|
|
1065
1635
|
}
|
|
1066
1636
|
if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) {
|
|
1067
1637
|
errors.push(
|
|
1068
1638
|
`${fileRel}: imports from tessera-learn/runtime/* — use the public hooks ` +
|
|
1069
|
-
`(useQuiz, useQuestion, useNavigation, …) instead
|
|
1639
|
+
`(useQuiz, useQuestion, useNavigation, …) instead`,
|
|
1070
1640
|
);
|
|
1071
1641
|
}
|
|
1072
1642
|
}
|
|
@@ -1081,7 +1651,7 @@ function collectAssetRefs(content: string): string[] {
|
|
|
1081
1651
|
let match: RegExpExecArray | null;
|
|
1082
1652
|
ASSET_REF_RE.lastIndex = 0;
|
|
1083
1653
|
while ((match = ASSET_REF_RE.exec(content)) !== null) {
|
|
1084
|
-
seen.add(match[1]);
|
|
1654
|
+
seen.add(match[1].replace(/[?#].*$/, ''));
|
|
1085
1655
|
}
|
|
1086
1656
|
return [...seen];
|
|
1087
1657
|
}
|
|
@@ -1091,7 +1661,7 @@ function validateAssetRefs(
|
|
|
1091
1661
|
fileRel: string,
|
|
1092
1662
|
assetsDir: string,
|
|
1093
1663
|
warnings: string[],
|
|
1094
|
-
existsCache: Map<string, boolean
|
|
1664
|
+
existsCache: Map<string, boolean>,
|
|
1095
1665
|
): void {
|
|
1096
1666
|
for (const assetPath of collectAssetRefs(content)) {
|
|
1097
1667
|
const fullAssetPath = resolve(assetsDir, assetPath);
|
|
@@ -1102,7 +1672,7 @@ function validateAssetRefs(
|
|
|
1102
1672
|
}
|
|
1103
1673
|
if (!exists) {
|
|
1104
1674
|
warnings.push(
|
|
1105
|
-
`${fileRel}: "$assets/${assetPath}" not found in assets/ directory
|
|
1675
|
+
`${fileRel}: "$assets/${assetPath}" not found in assets/ directory`,
|
|
1106
1676
|
);
|
|
1107
1677
|
}
|
|
1108
1678
|
}
|
|
@@ -1114,22 +1684,37 @@ function crossValidate(
|
|
|
1114
1684
|
config: ParsedConfig,
|
|
1115
1685
|
pageResults: PagesValidationResult,
|
|
1116
1686
|
errors: string[],
|
|
1117
|
-
warnings: string[]
|
|
1687
|
+
warnings: string[],
|
|
1118
1688
|
): void {
|
|
1119
1689
|
// completion.mode "quiz" but no graded quizzes
|
|
1120
1690
|
if (config.completion?.mode === 'quiz' && !pageResults.hasGradedQuiz) {
|
|
1121
1691
|
errors.push(
|
|
1122
|
-
'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.',
|
|
1123
1704
|
);
|
|
1124
1705
|
}
|
|
1125
1706
|
|
|
1126
1707
|
const isManual = config.completion?.mode === 'manual';
|
|
1127
1708
|
const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
|
|
1128
1709
|
|
|
1129
|
-
if (
|
|
1710
|
+
if (
|
|
1711
|
+
isManual &&
|
|
1712
|
+
config.completion?.trigger === 'page' &&
|
|
1713
|
+
completesOnPages.length === 0
|
|
1714
|
+
) {
|
|
1130
1715
|
errors.push(
|
|
1131
1716
|
'completion.mode is "manual" with trigger: "page", but no page declares pageConfig.completesOn: "view". ' +
|
|
1132
|
-
'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.',
|
|
1133
1718
|
);
|
|
1134
1719
|
}
|
|
1135
1720
|
|
|
@@ -1139,8 +1724,8 @@ function crossValidate(
|
|
|
1139
1724
|
warnings.push(
|
|
1140
1725
|
`${page.fileRel}: quiz.graded is true under completion.mode: "manual". ` +
|
|
1141
1726
|
'The score will be reported to the LMS for transcripts, but it will not drive ' +
|
|
1142
|
-
|
|
1143
|
-
'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.',
|
|
1144
1729
|
);
|
|
1145
1730
|
}
|
|
1146
1731
|
}
|
|
@@ -1148,20 +1733,20 @@ function crossValidate(
|
|
|
1148
1733
|
|
|
1149
1734
|
if (isManual && config.completion?.percentageThreshold !== undefined) {
|
|
1150
1735
|
warnings.push(
|
|
1151
|
-
'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"'
|
|
1736
|
+
'course.config.js: "completion.percentageThreshold" is ignored under completion.mode: "manual"',
|
|
1152
1737
|
);
|
|
1153
1738
|
}
|
|
1154
1739
|
if (!isManual) {
|
|
1155
1740
|
for (const page of completesOnPages) {
|
|
1156
1741
|
warnings.push(
|
|
1157
|
-
`${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'}"`,
|
|
1158
1743
|
);
|
|
1159
1744
|
}
|
|
1160
1745
|
}
|
|
1161
1746
|
for (const page of pageResults.pages) {
|
|
1162
1747
|
if (page.completesOnView && page.hasQuiz) {
|
|
1163
1748
|
warnings.push(
|
|
1164
|
-
`${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`,
|
|
1165
1750
|
);
|
|
1166
1751
|
}
|
|
1167
1752
|
}
|
|
@@ -1170,7 +1755,7 @@ function crossValidate(
|
|
|
1170
1755
|
const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
|
|
1171
1756
|
if (firstPage?.completesOnView) {
|
|
1172
1757
|
warnings.push(
|
|
1173
|
-
`${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.`,
|
|
1174
1759
|
);
|
|
1175
1760
|
}
|
|
1176
1761
|
}
|
|
@@ -1192,11 +1777,11 @@ function crossValidate(
|
|
|
1192
1777
|
for (let i = 0; i < pageResults.totalPages; i++) {
|
|
1193
1778
|
visitedChars += String(i).length + 1; // digit chars + comma
|
|
1194
1779
|
}
|
|
1195
|
-
const overhead = 60;
|
|
1196
|
-
const quizBytes = pageResults.totalQuizzes * 15;
|
|
1197
|
-
const chunkBytes = pageResults.totalPages * 12;
|
|
1198
|
-
const standaloneBytes = pageResults.totalPages * 30
|
|
1199
|
-
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
|
|
1200
1785
|
const estimatedSize =
|
|
1201
1786
|
overhead +
|
|
1202
1787
|
visitedChars +
|
|
@@ -1207,7 +1792,7 @@ function crossValidate(
|
|
|
1207
1792
|
|
|
1208
1793
|
if (estimatedSize > 3200) {
|
|
1209
1794
|
warnings.push(
|
|
1210
|
-
`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".`,
|
|
1211
1796
|
);
|
|
1212
1797
|
}
|
|
1213
1798
|
}
|