tessera-learn 0.0.5 → 0.0.7
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/dist/plugin/cli.d.ts +1 -0
- package/dist/plugin/cli.js +18 -0
- package/dist/plugin/cli.js.map +1 -0
- package/dist/plugin/index.js +9 -730
- package/dist/plugin/index.js.map +1 -1
- package/dist/validation-B4UhCY5y.js +911 -0
- package/dist/validation-B4UhCY5y.js.map +1 -0
- package/package.json +4 -2
- package/src/plugin/cli.ts +30 -0
- package/src/plugin/export.ts +12 -1
- package/src/plugin/validation.ts +336 -62
- package/src/runtime/adapters/index.ts +1 -1
- package/src/runtime/adapters/retry.ts +86 -15
- package/src/runtime/adapters/scorm-base.ts +90 -46
- package/src/runtime/adapters/scorm12.ts +36 -11
- package/src/runtime/adapters/scorm2004.ts +129 -26
- package/src/runtime/hooks.svelte.ts +22 -1
- package/src/runtime/interaction-format.ts +83 -48
- package/AGENTS.md +0 -1362
package/dist/plugin/index.js
CHANGED
|
@@ -1,736 +1,11 @@
|
|
|
1
|
+
import { n as extractDefaultExportObjectLiteral, r as generateManifest, t as validateProject } from "../validation-B4UhCY5y.js";
|
|
1
2
|
import { svelte } from "@sveltejs/vite-plugin-svelte";
|
|
2
3
|
import { fileURLToPath } from "node:url";
|
|
3
|
-
import {
|
|
4
|
+
import { dirname, resolve } from "node:path";
|
|
4
5
|
import { cpSync, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
5
6
|
import JSON5 from "json5";
|
|
6
7
|
import { createHash } from "node:crypto";
|
|
7
8
|
import { ZipArchive } from "archiver";
|
|
8
|
-
//#region src/plugin/manifest.ts
|
|
9
|
-
/** Append `.svelte` if not already present. Both bare and suffixed names are accepted in author config. */
|
|
10
|
-
function ensureSvelteSuffix(name) {
|
|
11
|
-
return name.endsWith(".svelte") ? name : `${name}.svelte`;
|
|
12
|
-
}
|
|
13
|
-
/**
|
|
14
|
-
* Module-level cache of source file contents keyed by absolute path with
|
|
15
|
-
* mtime invalidation. Both `validateProject` and `generateManifest` read the
|
|
16
|
-
* same .svelte / _meta.js / course.config.js files during a single build;
|
|
17
|
-
* sharing the read avoids the second disk hit (and matters most on cold-cache
|
|
18
|
-
* CI runs and large courses).
|
|
19
|
-
*/
|
|
20
|
-
const fileContentCache = /* @__PURE__ */ new Map();
|
|
21
|
-
function readSourceFileCached(filePath) {
|
|
22
|
-
const stat = statSync(filePath);
|
|
23
|
-
const cached = fileContentCache.get(filePath);
|
|
24
|
-
if (cached && cached.mtimeMs === stat.mtimeMs) return cached.content;
|
|
25
|
-
const content = readFileSync(filePath, "utf-8");
|
|
26
|
-
fileContentCache.set(filePath, {
|
|
27
|
-
mtimeMs: stat.mtimeMs,
|
|
28
|
-
content
|
|
29
|
-
});
|
|
30
|
-
return content;
|
|
31
|
-
}
|
|
32
|
-
/** Strip numeric prefix and hyphen: "01-introduction" → "introduction" */
|
|
33
|
-
function stripPrefix(name) {
|
|
34
|
-
return name.replace(/^\d+-/, "");
|
|
35
|
-
}
|
|
36
|
-
/** Title-case a slug: "getting-started" → "Getting Started" */
|
|
37
|
-
function titleCase(slug) {
|
|
38
|
-
return slug.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
39
|
-
}
|
|
40
|
-
/** Derive slug from folder/file name */
|
|
41
|
-
function deriveSlug(name, isFile = false) {
|
|
42
|
-
if (isFile) return basename(name, extname(name));
|
|
43
|
-
return stripPrefix(name);
|
|
44
|
-
}
|
|
45
|
-
/** Matches both Svelte 5 `<script module>` and legacy `<script context="module">`. */
|
|
46
|
-
const MODULE_SCRIPT_RE = /<script\s+(?:context\s*=\s*["']module["']|module)[^>]*>([\s\S]*?)<\/script>/;
|
|
47
|
-
/** Matches `export const pageConfig =` (RHS is read separately). */
|
|
48
|
-
const PAGE_CONFIG_EXPORT_RE = /export\s+const\s+pageConfig\s*=\s*/;
|
|
49
|
-
/** Matches `export default ` (RHS is read separately). */
|
|
50
|
-
const DEFAULT_EXPORT_RE = /export\s+default\s*/;
|
|
51
|
-
/**
|
|
52
|
-
* Locate `export default { ... }` and return the object literal substring,
|
|
53
|
-
* or null if no balanced object literal follows the `export default` keyword.
|
|
54
|
-
* Used by both manifest extraction and project validation.
|
|
55
|
-
*/
|
|
56
|
-
function extractDefaultExportObjectLiteral(source) {
|
|
57
|
-
const match = source.match(DEFAULT_EXPORT_RE);
|
|
58
|
-
if (!match || match.index === void 0) return null;
|
|
59
|
-
const startIndex = source.indexOf("{", match.index);
|
|
60
|
-
if (startIndex < 0) return null;
|
|
61
|
-
return extractObjectLiteral(source, startIndex);
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Read a _meta.js file and extract its default export object.
|
|
65
|
-
* Uses the same JSON5 approach as pageConfig extraction — find the object literal
|
|
66
|
-
* after `export default` and parse it.
|
|
67
|
-
*/
|
|
68
|
-
function readMetaFile(metaPath) {
|
|
69
|
-
if (!existsSync(metaPath)) return {};
|
|
70
|
-
const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
|
|
71
|
-
if (!objectStr) return {};
|
|
72
|
-
try {
|
|
73
|
-
return JSON5.parse(objectStr);
|
|
74
|
-
} catch {
|
|
75
|
-
return {};
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
/** Source-level pageConfig extraction shared by manifest generation and build-time validation. */
|
|
79
|
-
function parsePageConfigFromSource(content) {
|
|
80
|
-
const moduleScriptMatch = content.match(MODULE_SCRIPT_RE);
|
|
81
|
-
if (!moduleScriptMatch) return { kind: "none" };
|
|
82
|
-
const scriptContent = moduleScriptMatch[1];
|
|
83
|
-
const configMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
|
|
84
|
-
if (!configMatch || configMatch.index === void 0) return { kind: "none" };
|
|
85
|
-
if (!scriptContent.slice(configMatch.index + configMatch[0].length).trimStart().startsWith("{")) return { kind: "invalid" };
|
|
86
|
-
const startIndex = scriptContent.indexOf("{", configMatch.index + configMatch[0].length);
|
|
87
|
-
if (startIndex < 0) return { kind: "invalid" };
|
|
88
|
-
const objectStr = extractObjectLiteral(scriptContent, startIndex);
|
|
89
|
-
if (!objectStr) return { kind: "invalid" };
|
|
90
|
-
try {
|
|
91
|
-
return {
|
|
92
|
-
kind: "ok",
|
|
93
|
-
value: JSON5.parse(objectStr)
|
|
94
|
-
};
|
|
95
|
-
} catch {
|
|
96
|
-
return { kind: "invalid" };
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
/** Extract pageConfig from a .svelte file. Throws on parse failure. */
|
|
100
|
-
function extractPageConfig(filePath) {
|
|
101
|
-
const result = parsePageConfigFromSource(readSourceFileCached(filePath));
|
|
102
|
-
if (result.kind === "ok") return result.value;
|
|
103
|
-
if (result.kind === "invalid") throw new Error(`${filePath}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
|
|
104
|
-
return {};
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* Extract an object literal from source starting at the opening brace.
|
|
108
|
-
* Tracks brace depth to find the matching closing brace.
|
|
109
|
-
*/
|
|
110
|
-
function extractObjectLiteral(source, startIndex) {
|
|
111
|
-
if (source[startIndex] !== "{") return null;
|
|
112
|
-
let depth = 0;
|
|
113
|
-
let inString = null;
|
|
114
|
-
let escaped = false;
|
|
115
|
-
for (let i = startIndex; i < source.length; i++) {
|
|
116
|
-
const char = source[i];
|
|
117
|
-
if (escaped) {
|
|
118
|
-
escaped = false;
|
|
119
|
-
continue;
|
|
120
|
-
}
|
|
121
|
-
if (char === "\\" && inString) {
|
|
122
|
-
escaped = true;
|
|
123
|
-
continue;
|
|
124
|
-
}
|
|
125
|
-
if (inString) {
|
|
126
|
-
if (char === inString) inString = null;
|
|
127
|
-
continue;
|
|
128
|
-
}
|
|
129
|
-
if (char === "\"" || char === "'" || char === "`") {
|
|
130
|
-
inString = char;
|
|
131
|
-
continue;
|
|
132
|
-
}
|
|
133
|
-
if (char === "/" && i + 1 < source.length && source[i + 1] === "/") {
|
|
134
|
-
const newline = source.indexOf("\n", i);
|
|
135
|
-
i = newline === -1 ? source.length : newline;
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
if (char === "/" && i + 1 < source.length && source[i + 1] === "*") {
|
|
139
|
-
const end = source.indexOf("*/", i + 2);
|
|
140
|
-
i = end === -1 ? source.length : end + 1;
|
|
141
|
-
continue;
|
|
142
|
-
}
|
|
143
|
-
if (char === "{") depth++;
|
|
144
|
-
if (char === "}") {
|
|
145
|
-
depth--;
|
|
146
|
-
if (depth === 0) return source.slice(startIndex, i + 1);
|
|
147
|
-
}
|
|
148
|
-
}
|
|
149
|
-
return null;
|
|
150
|
-
}
|
|
151
|
-
/**
|
|
152
|
-
* Get sorted subdirectories of a given path.
|
|
153
|
-
*/
|
|
154
|
-
function getSortedDirs(dirPath) {
|
|
155
|
-
if (!existsSync(dirPath)) return [];
|
|
156
|
-
return readdirSync(dirPath).filter((name) => {
|
|
157
|
-
return statSync(resolve(dirPath, name)).isDirectory() && !name.startsWith(".");
|
|
158
|
-
}).sort();
|
|
159
|
-
}
|
|
160
|
-
/**
|
|
161
|
-
* Get .svelte files in a directory.
|
|
162
|
-
*/
|
|
163
|
-
function getSvelteFiles(dirPath) {
|
|
164
|
-
if (!existsSync(dirPath)) return [];
|
|
165
|
-
return readdirSync(dirPath).filter((name) => name.endsWith(".svelte")).sort();
|
|
166
|
-
}
|
|
167
|
-
/**
|
|
168
|
-
* Generate a course manifest by scanning the pages/ directory.
|
|
169
|
-
*/
|
|
170
|
-
function generateManifest(pagesDir) {
|
|
171
|
-
const sections = [];
|
|
172
|
-
const flatPages = [];
|
|
173
|
-
let pageIndex = 0;
|
|
174
|
-
const sectionDirs = getSortedDirs(pagesDir);
|
|
175
|
-
for (const sectionName of sectionDirs) {
|
|
176
|
-
const sectionPath = resolve(pagesDir, sectionName);
|
|
177
|
-
const sectionMeta = readMetaFile(resolve(sectionPath, "_meta.js"));
|
|
178
|
-
const sectionSlug = deriveSlug(sectionName);
|
|
179
|
-
const section = {
|
|
180
|
-
title: sectionMeta.title || titleCase(sectionSlug),
|
|
181
|
-
slug: sectionSlug,
|
|
182
|
-
lessons: []
|
|
183
|
-
};
|
|
184
|
-
const lessonDirs = getSortedDirs(sectionPath);
|
|
185
|
-
for (const lessonName of lessonDirs) {
|
|
186
|
-
const lessonPath = resolve(sectionPath, lessonName);
|
|
187
|
-
const lessonMeta = readMetaFile(resolve(lessonPath, "_meta.js"));
|
|
188
|
-
const lessonSlug = deriveSlug(lessonName);
|
|
189
|
-
const lesson = {
|
|
190
|
-
title: lessonMeta.title || titleCase(lessonSlug),
|
|
191
|
-
slug: lessonSlug,
|
|
192
|
-
pages: []
|
|
193
|
-
};
|
|
194
|
-
const orderedFiles = orderPageFiles(getSvelteFiles(lessonPath), lessonMeta.pages);
|
|
195
|
-
for (const fileName of orderedFiles) {
|
|
196
|
-
const filePath = resolve(lessonPath, fileName);
|
|
197
|
-
const pageSlug = deriveSlug(fileName, true);
|
|
198
|
-
let pageConfig = {};
|
|
199
|
-
try {
|
|
200
|
-
pageConfig = extractPageConfig(filePath);
|
|
201
|
-
} catch (e) {
|
|
202
|
-
console.warn(`[tessera warning] ${e.message}`);
|
|
203
|
-
}
|
|
204
|
-
const relativePath = `/pages/${sectionName}/${lessonName}/${fileName}`;
|
|
205
|
-
const page = {
|
|
206
|
-
index: pageIndex,
|
|
207
|
-
title: pageConfig.title || titleCase(pageSlug),
|
|
208
|
-
slug: pageSlug,
|
|
209
|
-
importPath: relativePath,
|
|
210
|
-
quiz: pageConfig.quiz || null,
|
|
211
|
-
...pageConfig.completesOn === "view" ? { completesOn: "view" } : {}
|
|
212
|
-
};
|
|
213
|
-
lesson.pages.push(page);
|
|
214
|
-
flatPages.push(page);
|
|
215
|
-
pageIndex++;
|
|
216
|
-
}
|
|
217
|
-
section.lessons.push(lesson);
|
|
218
|
-
}
|
|
219
|
-
sections.push(section);
|
|
220
|
-
}
|
|
221
|
-
return {
|
|
222
|
-
sections,
|
|
223
|
-
pages: flatPages,
|
|
224
|
-
totalPages: flatPages.length
|
|
225
|
-
};
|
|
226
|
-
}
|
|
227
|
-
/**
|
|
228
|
-
* Order .svelte files: listed in `pages` array first (in order), then unlisted appended alphabetically.
|
|
229
|
-
*/
|
|
230
|
-
function orderPageFiles(allFiles, pagesArray) {
|
|
231
|
-
if (!pagesArray || pagesArray.length === 0) return allFiles;
|
|
232
|
-
const listed = pagesArray.map(ensureSvelteSuffix);
|
|
233
|
-
const listedSet = new Set(listed);
|
|
234
|
-
const unlisted = allFiles.filter((f) => !listedSet.has(f)).sort();
|
|
235
|
-
return [...listed.filter((f) => allFiles.includes(f)), ...unlisted];
|
|
236
|
-
}
|
|
237
|
-
//#endregion
|
|
238
|
-
//#region src/runtime/xapi/agent-rules.ts
|
|
239
|
-
/**
|
|
240
|
-
* xAPI Identified Agent and Basic-auth credential validation rules.
|
|
241
|
-
*
|
|
242
|
-
* Pure logic — no Svelte/runtime imports. Imported by both `publisher.ts`
|
|
243
|
-
* (runtime validation of resolved actor / auth) and `plugin/validation.ts`
|
|
244
|
-
* (build-time validation of static `course.config.js` actor / auth).
|
|
245
|
-
* Keeping the rules in one place prevents the two callsites from drifting.
|
|
246
|
-
*/
|
|
247
|
-
/**
|
|
248
|
-
* Validate that a candidate is an Identified Agent per xAPI 1.0.3.
|
|
249
|
-
* Returns null on success or a human-readable error suffix on failure.
|
|
250
|
-
*
|
|
251
|
-
* Suffixes are prefix-friendly: callers concatenate their own label
|
|
252
|
-
* (`xapi.actor`, `xapi[0].actor`, etc.) with a single space — no "actor"
|
|
253
|
-
* appears in the suffix to avoid doubling.
|
|
254
|
-
*/
|
|
255
|
-
function validateAgent(actor) {
|
|
256
|
-
if (!actor || typeof actor !== "object") return "must be an object";
|
|
257
|
-
const a = actor;
|
|
258
|
-
if (Array.isArray(a.member) && a.member.length > 0) return "is a Group (has `member`); v1 supports Identified Agents only";
|
|
259
|
-
let count = 0;
|
|
260
|
-
if (a.mbox !== void 0) count++;
|
|
261
|
-
if (a.mbox_sha1sum !== void 0) count++;
|
|
262
|
-
if (a.openid !== void 0) count++;
|
|
263
|
-
if (a.account !== void 0) count++;
|
|
264
|
-
if (count === 0) return "must have one of mbox, mbox_sha1sum, openid, or account (Identified Agent rule)";
|
|
265
|
-
if (count > 1) return "must have exactly one IFI (mbox / mbox_sha1sum / openid / account), not multiple";
|
|
266
|
-
if (a.mbox !== void 0) {
|
|
267
|
-
if (typeof a.mbox !== "string" || !a.mbox.startsWith("mailto:")) return ".mbox must be a string starting with \"mailto:\"";
|
|
268
|
-
}
|
|
269
|
-
if (a.mbox_sha1sum !== void 0) {
|
|
270
|
-
if (typeof a.mbox_sha1sum !== "string" || !/^[0-9a-f]{40}$/i.test(a.mbox_sha1sum)) return ".mbox_sha1sum must be a 40-character hex string";
|
|
271
|
-
}
|
|
272
|
-
if (a.openid !== void 0) {
|
|
273
|
-
if (typeof a.openid !== "string" || !a.openid) return ".openid must be a non-empty string";
|
|
274
|
-
try {
|
|
275
|
-
new URL(a.openid);
|
|
276
|
-
} catch {
|
|
277
|
-
return ".openid must be an absolute URI";
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
if (a.account !== void 0) {
|
|
281
|
-
const acc = a.account;
|
|
282
|
-
if (!acc || typeof acc !== "object") return ".account must be an object with homePage and name";
|
|
283
|
-
if (typeof acc.homePage !== "string" || !acc.homePage) return ".account.homePage must be a non-empty string";
|
|
284
|
-
try {
|
|
285
|
-
new URL(acc.homePage);
|
|
286
|
-
} catch {
|
|
287
|
-
return ".account.homePage must be an absolute URL";
|
|
288
|
-
}
|
|
289
|
-
if (typeof acc.name !== "string" || !acc.name) return ".account.name must be a non-empty string";
|
|
290
|
-
}
|
|
291
|
-
return null;
|
|
292
|
-
}
|
|
293
|
-
//#endregion
|
|
294
|
-
//#region src/plugin/validation.ts
|
|
295
|
-
const KNOWN_CONFIG_FIELDS = new Set([
|
|
296
|
-
"title",
|
|
297
|
-
"description",
|
|
298
|
-
"author",
|
|
299
|
-
"version",
|
|
300
|
-
"branding",
|
|
301
|
-
"navigation",
|
|
302
|
-
"completion",
|
|
303
|
-
"scoring",
|
|
304
|
-
"export",
|
|
305
|
-
"chrome",
|
|
306
|
-
"xapi"
|
|
307
|
-
]);
|
|
308
|
-
const VALID_NAV_MODES = ["free", "sequential"];
|
|
309
|
-
const VALID_COMPLETION_MODES = [
|
|
310
|
-
"quiz",
|
|
311
|
-
"percentage",
|
|
312
|
-
"manual"
|
|
313
|
-
];
|
|
314
|
-
const VALID_EXPORT_STANDARDS = [
|
|
315
|
-
"web",
|
|
316
|
-
"scorm12",
|
|
317
|
-
"scorm2004",
|
|
318
|
-
"cmi5"
|
|
319
|
-
];
|
|
320
|
-
const VALID_MANUAL_TRIGGERS = ["page"];
|
|
321
|
-
const VALID_REQUIRE_SUCCESS_STATUS = ["passed", "failed"];
|
|
322
|
-
/**
|
|
323
|
-
* Validate a Tessera project at the given root.
|
|
324
|
-
* Returns errors (block build) and warnings (informational).
|
|
325
|
-
*/
|
|
326
|
-
function validateProject(projectRoot) {
|
|
327
|
-
const errors = [];
|
|
328
|
-
const warnings = [];
|
|
329
|
-
const configPath = resolve(projectRoot, "course.config.js");
|
|
330
|
-
if (!existsSync(configPath)) {
|
|
331
|
-
errors.push("course.config.js not found in project root");
|
|
332
|
-
return {
|
|
333
|
-
errors,
|
|
334
|
-
warnings
|
|
335
|
-
};
|
|
336
|
-
}
|
|
337
|
-
const config = parseConfig(configPath, errors, warnings);
|
|
338
|
-
const pageResults = validatePages(resolve(projectRoot, "pages"), resolve(projectRoot, "assets"), projectRoot);
|
|
339
|
-
errors.push(...pageResults.errors);
|
|
340
|
-
warnings.push(...pageResults.warnings);
|
|
341
|
-
if (config) crossValidate(config, pageResults, errors, warnings);
|
|
342
|
-
return {
|
|
343
|
-
errors,
|
|
344
|
-
warnings
|
|
345
|
-
};
|
|
346
|
-
}
|
|
347
|
-
function parseConfig(configPath, errors, warnings) {
|
|
348
|
-
const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
|
|
349
|
-
if (!objectStr) {
|
|
350
|
-
errors.push("course.config.js: could not parse — must use `export default { ... }` syntax");
|
|
351
|
-
return null;
|
|
352
|
-
}
|
|
353
|
-
let config;
|
|
354
|
-
try {
|
|
355
|
-
config = JSON5.parse(objectStr);
|
|
356
|
-
} catch {
|
|
357
|
-
errors.push("course.config.js: syntax error — must export a static object literal");
|
|
358
|
-
return null;
|
|
359
|
-
}
|
|
360
|
-
for (const key of Object.keys(config)) if (!KNOWN_CONFIG_FIELDS.has(key)) warnings.push(`course.config.js: unknown field "${key}" — will be ignored`);
|
|
361
|
-
if (config.navigation?.mode !== void 0) {
|
|
362
|
-
if (!VALID_NAV_MODES.includes(config.navigation.mode)) errors.push(`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`);
|
|
363
|
-
}
|
|
364
|
-
if (config.completion?.mode !== void 0) {
|
|
365
|
-
if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) errors.push(`course.config.js: "completion.mode" must be "quiz", "percentage", or "manual", got "${config.completion.mode}"`);
|
|
366
|
-
}
|
|
367
|
-
if (config.completion?.trigger !== void 0) {
|
|
368
|
-
if (config.completion.mode !== "manual") warnings.push(`course.config.js: "completion.trigger" is ignored unless completion.mode is "manual"`);
|
|
369
|
-
else if (!VALID_MANUAL_TRIGGERS.includes(config.completion.trigger)) errors.push(`course.config.js: "completion.trigger" must be "page" or omitted, got "${config.completion.trigger}"`);
|
|
370
|
-
}
|
|
371
|
-
if (config.completion?.requireSuccessStatus !== void 0) {
|
|
372
|
-
if (config.completion.mode !== "manual") warnings.push(`course.config.js: "completion.requireSuccessStatus" is ignored unless completion.mode is "manual"`);
|
|
373
|
-
else if (!VALID_REQUIRE_SUCCESS_STATUS.includes(config.completion.requireSuccessStatus)) errors.push(`course.config.js: "completion.requireSuccessStatus" must be "passed" or "failed" (omit for "unknown"), got "${config.completion.requireSuccessStatus}"`);
|
|
374
|
-
}
|
|
375
|
-
if (config.export?.standard !== void 0) {
|
|
376
|
-
if (!VALID_EXPORT_STANDARDS.includes(config.export.standard)) errors.push(`course.config.js: "export.standard" must be "web", "scorm12", "scorm2004", or "cmi5", got "${config.export.standard}"`);
|
|
377
|
-
}
|
|
378
|
-
if (config.scoring?.passingScore !== void 0) {
|
|
379
|
-
const score = config.scoring.passingScore;
|
|
380
|
-
if (typeof score !== "number" || score < 0 || score > 100) errors.push(`course.config.js: "scoring.passingScore" must be 0–100, got ${score}`);
|
|
381
|
-
}
|
|
382
|
-
if (config.completion?.percentageThreshold !== void 0) {
|
|
383
|
-
const threshold = config.completion.percentageThreshold;
|
|
384
|
-
if (typeof threshold !== "number" || threshold < 0 || threshold > 100) errors.push(`course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`);
|
|
385
|
-
}
|
|
386
|
-
if (config.xapi !== void 0) validateXAPIConfig(config.xapi, config.export?.standard ?? "web", errors, warnings);
|
|
387
|
-
return config;
|
|
388
|
-
}
|
|
389
|
-
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
|
|
390
|
-
function validateXAPIConfig(raw, standard, errors, warnings) {
|
|
391
|
-
if (raw === void 0 || raw === null) return;
|
|
392
|
-
const entries = Array.isArray(raw) ? raw : [raw];
|
|
393
|
-
if (Array.isArray(raw)) {
|
|
394
|
-
if (entries.length === 0) {
|
|
395
|
-
errors.push("course.config.js: xapi must contain at least one destination, or be omitted");
|
|
396
|
-
return;
|
|
397
|
-
}
|
|
398
|
-
if (entries.filter((e) => e && typeof e === "object" && e.endpoint === "lms").length > 1) errors.push("course.config.js: xapi has multiple entries with endpoint: 'lms' — only one cmi5 launch-inherited destination is allowed");
|
|
399
|
-
const seen = /* @__PURE__ */ new Map();
|
|
400
|
-
for (const e of entries) if (e && typeof e === "object") {
|
|
401
|
-
const ep = e.endpoint;
|
|
402
|
-
if (typeof ep === "string" && ep !== "lms") seen.set(ep, (seen.get(ep) ?? 0) + 1);
|
|
403
|
-
}
|
|
404
|
-
for (const [ep, count] of seen) if (count > 1) warnings.push(`course.config.js: xapi has ${count} entries with endpoint "${ep}" — usually a copy-paste mistake; fan-out to the same LRS with different actors/activityIds is supported but uncommon.`);
|
|
405
|
-
} else if (typeof raw !== "object") {
|
|
406
|
-
errors.push("course.config.js: xapi must be an object or an array of objects");
|
|
407
|
-
return;
|
|
408
|
-
}
|
|
409
|
-
for (let i = 0; i < entries.length; i++) {
|
|
410
|
-
const entry = entries[i];
|
|
411
|
-
const label = Array.isArray(raw) ? `xapi[${i}]` : "xapi";
|
|
412
|
-
if (!entry || typeof entry !== "object") {
|
|
413
|
-
errors.push(`course.config.js: ${label} must be an object`);
|
|
414
|
-
continue;
|
|
415
|
-
}
|
|
416
|
-
validateSingleXAPIEntry(entry, label, standard, errors, warnings);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
|
|
420
|
-
const endpoint = entry.endpoint;
|
|
421
|
-
if (endpoint === void 0) {
|
|
422
|
-
errors.push(`course.config.js: ${label}.endpoint is required`);
|
|
423
|
-
return;
|
|
424
|
-
}
|
|
425
|
-
if (typeof endpoint !== "string") {
|
|
426
|
-
errors.push(`course.config.js: ${label}.endpoint must be a string`);
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
if (endpoint === "lms") {
|
|
430
|
-
if (standard !== "cmi5") errors.push(`course.config.js: ${label}.endpoint: 'lms' requires export.standard: 'cmi5' (you have "${standard}"). Either change the export standard or specify an explicit LRS endpoint.`);
|
|
431
|
-
for (const f of [
|
|
432
|
-
"auth",
|
|
433
|
-
"actor",
|
|
434
|
-
"activityId",
|
|
435
|
-
"registration",
|
|
436
|
-
"actorAccountHomePage"
|
|
437
|
-
]) if (entry[f] !== void 0) errors.push(`course.config.js: ${label}.${f} must be omitted when ${label}.endpoint is 'lms' — it is inherited from the cmi5 launch.`);
|
|
438
|
-
return;
|
|
439
|
-
}
|
|
440
|
-
let url;
|
|
441
|
-
try {
|
|
442
|
-
url = new URL(endpoint);
|
|
443
|
-
} catch {
|
|
444
|
-
errors.push(`course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"`);
|
|
445
|
-
return;
|
|
446
|
-
}
|
|
447
|
-
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
448
|
-
errors.push(`course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"`);
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
if (url.protocol === "http:" && process.env.NODE_ENV === "production") warnings.push(`course.config.js: ${label}.endpoint uses http:; LRS credentials will travel in cleartext. Use https in production.`);
|
|
452
|
-
if (!endpoint.endsWith("/")) warnings.push(`course.config.js: ${label}.endpoint should end with a slash to avoid concatenation surprises (e.g. 'https://lrs.example.com/xapi/' not 'https://lrs.example.com/xapi'). Runtime normalizes regardless.`);
|
|
453
|
-
const auth = entry.auth;
|
|
454
|
-
if (auth === void 0) errors.push(`course.config.js: ${label}.auth is required`);
|
|
455
|
-
else if (typeof auth === "string") if (!auth) errors.push(`course.config.js: ${label}.auth must be a non-empty string`);
|
|
456
|
-
else if (/^basic\s/i.test(auth)) errors.push(`course.config.js: ${label}.auth must be the Basic credential value only, not the full header. Drop the 'Basic ' prefix.`);
|
|
457
|
-
else if (/^bearer\s/i.test(auth)) errors.push(`course.config.js: ${label}.auth: Bearer/OAuth credentials are not supported in v1. Use Basic auth, or wrap your token-exchange in an auth function that returns a Basic credential.`);
|
|
458
|
-
else warnings.push(`course.config.js: ${label}.auth is a static string and will be embedded in the bundle. For production, pass a function that fetches a short-lived token from a server endpoint.`);
|
|
459
|
-
else if (typeof auth !== "function") errors.push(`course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`);
|
|
460
|
-
const activityId = entry.activityId;
|
|
461
|
-
if (activityId === void 0 || activityId === "") errors.push(`course.config.js: ${label}.activityId is required`);
|
|
462
|
-
else if (typeof activityId !== "string") errors.push(`course.config.js: ${label}.activityId must be a string`);
|
|
463
|
-
else try {
|
|
464
|
-
new URL(activityId);
|
|
465
|
-
} catch {
|
|
466
|
-
errors.push(`course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"`);
|
|
467
|
-
}
|
|
468
|
-
const actor = entry.actor;
|
|
469
|
-
if (actor === void 0) {
|
|
470
|
-
if (standard === "web") errors.push(`course.config.js: ${label}.actor is required for web export — there is no LMS to derive a learner identity from. Provide either a static actor object or a function that resolves one (e.g. from your auth system).`);
|
|
471
|
-
} else if (typeof actor === "object" && actor !== null) {
|
|
472
|
-
const err = validateAgent(actor);
|
|
473
|
-
if (err) {
|
|
474
|
-
const joined = err.startsWith(".") ? `${label}.actor${err}` : `${label}.actor ${err}`;
|
|
475
|
-
errors.push(`course.config.js: ${joined}`);
|
|
476
|
-
}
|
|
477
|
-
} else if (typeof actor !== "function") errors.push(`course.config.js: ${label}.actor must be an object or function, got ${typeof actor}`);
|
|
478
|
-
const aahp = entry.actorAccountHomePage;
|
|
479
|
-
if (aahp !== void 0) {
|
|
480
|
-
if (typeof aahp !== "string") errors.push(`course.config.js: ${label}.actorAccountHomePage must be a string`);
|
|
481
|
-
else try {
|
|
482
|
-
new URL(aahp);
|
|
483
|
-
} catch {
|
|
484
|
-
errors.push(`course.config.js: ${label}.actorAccountHomePage must be an absolute URL`);
|
|
485
|
-
}
|
|
486
|
-
if (actor !== void 0) warnings.push(`course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`);
|
|
487
|
-
if (standard === "cmi5" || standard === "web") warnings.push(`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`);
|
|
488
|
-
}
|
|
489
|
-
if (actor === void 0 && (standard === "scorm12" || standard === "scorm2004") && typeof activityId === "string") {
|
|
490
|
-
let isHttp = false;
|
|
491
|
-
try {
|
|
492
|
-
const u = new URL(activityId);
|
|
493
|
-
isHttp = u.protocol === "http:" || u.protocol === "https:";
|
|
494
|
-
} catch {
|
|
495
|
-
isHttp = false;
|
|
496
|
-
}
|
|
497
|
-
if (!isHttp && aahp === void 0) errors.push(`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. Provide ${label}.actorAccountHomePage explicitly.`);
|
|
498
|
-
}
|
|
499
|
-
const registration = entry.registration;
|
|
500
|
-
if (registration !== void 0) {
|
|
501
|
-
if (typeof registration !== "string" || !UUID_RE.test(registration)) errors.push(`course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`);
|
|
502
|
-
if (standard !== "cmi5") warnings.push(`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.`);
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
function validatePages(pagesDir, assetsDir, projectRoot) {
|
|
506
|
-
const errors = [];
|
|
507
|
-
const warnings = [];
|
|
508
|
-
const pages = [];
|
|
509
|
-
let totalPages = 0;
|
|
510
|
-
let totalQuizzes = 0;
|
|
511
|
-
let hasGradedQuiz = false;
|
|
512
|
-
const assetExistsCache = /* @__PURE__ */ new Map();
|
|
513
|
-
if (!existsSync(pagesDir)) {
|
|
514
|
-
errors.push("No pages found. Create at least one section with a lesson and page in pages/");
|
|
515
|
-
return {
|
|
516
|
-
errors,
|
|
517
|
-
warnings,
|
|
518
|
-
totalPages: 0,
|
|
519
|
-
totalQuizzes: 0,
|
|
520
|
-
hasGradedQuiz: false,
|
|
521
|
-
pages
|
|
522
|
-
};
|
|
523
|
-
}
|
|
524
|
-
const topLevelEntries = readdirSync(pagesDir);
|
|
525
|
-
for (const entry of topLevelEntries) {
|
|
526
|
-
const fullPath = resolve(pagesDir, entry);
|
|
527
|
-
if (entry.endsWith(".svelte") && statSync(fullPath).isFile()) {
|
|
528
|
-
const relPath = relative(projectRoot, fullPath);
|
|
529
|
-
warnings.push(`${relPath}: this file is outside the section/lesson structure and will be ignored`);
|
|
530
|
-
}
|
|
531
|
-
}
|
|
532
|
-
const sectionDirs = topLevelEntries.filter((name) => {
|
|
533
|
-
return statSync(resolve(pagesDir, name)).isDirectory() && !name.startsWith(".");
|
|
534
|
-
}).sort();
|
|
535
|
-
if (sectionDirs.length === 0) {
|
|
536
|
-
errors.push("No pages found. Create at least one section with a lesson and page in pages/");
|
|
537
|
-
return {
|
|
538
|
-
errors,
|
|
539
|
-
warnings,
|
|
540
|
-
totalPages: 0,
|
|
541
|
-
totalQuizzes: 0,
|
|
542
|
-
hasGradedQuiz: false,
|
|
543
|
-
pages
|
|
544
|
-
};
|
|
545
|
-
}
|
|
546
|
-
for (const sectionName of sectionDirs) {
|
|
547
|
-
const sectionPath = resolve(pagesDir, sectionName);
|
|
548
|
-
const sectionRel = relative(projectRoot, sectionPath);
|
|
549
|
-
const sectionMeta = validateMetaFile(resolve(sectionPath, "_meta.js"), sectionRel, errors);
|
|
550
|
-
const sectionEntries = readdirSync(sectionPath);
|
|
551
|
-
const sectionSvelteFiles = sectionEntries.filter((name) => {
|
|
552
|
-
const full = resolve(sectionPath, name);
|
|
553
|
-
return name.endsWith(".svelte") && statSync(full).isFile();
|
|
554
|
-
}).sort();
|
|
555
|
-
if (sectionMeta?.pages) for (const pageName of sectionMeta.pages) {
|
|
556
|
-
const fileName = ensureSvelteSuffix(pageName);
|
|
557
|
-
if (!sectionSvelteFiles.includes(fileName)) {
|
|
558
|
-
const metaRel = relative(projectRoot, resolve(sectionPath, "_meta.js"));
|
|
559
|
-
errors.push(`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
|
|
560
|
-
}
|
|
561
|
-
}
|
|
562
|
-
for (const fileName of sectionSvelteFiles) {
|
|
563
|
-
const filePath = resolve(sectionPath, fileName);
|
|
564
|
-
const fileRel = relative(projectRoot, filePath);
|
|
565
|
-
const content = readSourceFileCached(filePath);
|
|
566
|
-
const pageConfig = validatePageConfig(content, fileRel, errors);
|
|
567
|
-
const navIndex = totalPages;
|
|
568
|
-
totalPages++;
|
|
569
|
-
let pageHasGradedQuiz = false;
|
|
570
|
-
if (pageConfig?.quiz) {
|
|
571
|
-
totalQuizzes++;
|
|
572
|
-
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
573
|
-
if (pageConfig.quiz.graded === true) {
|
|
574
|
-
hasGradedQuiz = true;
|
|
575
|
-
pageHasGradedQuiz = true;
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
579
|
-
pages.push({
|
|
580
|
-
fileRel,
|
|
581
|
-
navIndex,
|
|
582
|
-
hasGradedQuiz: pageHasGradedQuiz,
|
|
583
|
-
hasQuiz: !!pageConfig?.quiz,
|
|
584
|
-
completesOnView
|
|
585
|
-
});
|
|
586
|
-
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
587
|
-
}
|
|
588
|
-
const lessonDirs = sectionEntries.filter((name) => {
|
|
589
|
-
return statSync(resolve(sectionPath, name)).isDirectory() && !name.startsWith(".");
|
|
590
|
-
}).sort();
|
|
591
|
-
for (const lessonName of lessonDirs) {
|
|
592
|
-
const lessonPath = resolve(sectionPath, lessonName);
|
|
593
|
-
const lessonRel = relative(projectRoot, lessonPath);
|
|
594
|
-
const meta = validateMetaFile(resolve(lessonPath, "_meta.js"), lessonRel, errors);
|
|
595
|
-
const svelteFiles = readdirSync(lessonPath).filter((name) => name.endsWith(".svelte")).sort();
|
|
596
|
-
if (meta?.pages) for (const pageName of meta.pages) {
|
|
597
|
-
const fileName = ensureSvelteSuffix(pageName);
|
|
598
|
-
if (!svelteFiles.includes(fileName)) {
|
|
599
|
-
const metaRel = relative(projectRoot, resolve(lessonPath, "_meta.js"));
|
|
600
|
-
errors.push(`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
if (meta?.pages && meta.pages.length > 0) {
|
|
604
|
-
const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
|
|
605
|
-
for (const file of svelteFiles) if (!listedSet.has(file)) {
|
|
606
|
-
const relPath = relative(projectRoot, resolve(lessonPath, file));
|
|
607
|
-
warnings.push(`${relPath}: not listed in _meta.js pages array — will be appended at end`);
|
|
608
|
-
}
|
|
609
|
-
}
|
|
610
|
-
for (const fileName of svelteFiles) {
|
|
611
|
-
const filePath = resolve(lessonPath, fileName);
|
|
612
|
-
const fileRel = relative(projectRoot, filePath);
|
|
613
|
-
const content = readSourceFileCached(filePath);
|
|
614
|
-
const pageConfig = validatePageConfig(content, fileRel, errors);
|
|
615
|
-
const navIndex = totalPages;
|
|
616
|
-
totalPages++;
|
|
617
|
-
let pageHasGradedQuiz = false;
|
|
618
|
-
if (pageConfig?.quiz) {
|
|
619
|
-
totalQuizzes++;
|
|
620
|
-
validateQuizConfig(pageConfig.quiz, fileRel, errors);
|
|
621
|
-
if (pageConfig.quiz.graded === true) {
|
|
622
|
-
hasGradedQuiz = true;
|
|
623
|
-
pageHasGradedQuiz = true;
|
|
624
|
-
}
|
|
625
|
-
}
|
|
626
|
-
const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
|
|
627
|
-
pages.push({
|
|
628
|
-
fileRel,
|
|
629
|
-
navIndex,
|
|
630
|
-
hasGradedQuiz: pageHasGradedQuiz,
|
|
631
|
-
hasQuiz: !!pageConfig?.quiz,
|
|
632
|
-
completesOnView
|
|
633
|
-
});
|
|
634
|
-
validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
|
|
635
|
-
}
|
|
636
|
-
}
|
|
637
|
-
}
|
|
638
|
-
if (totalPages === 0) errors.push("No pages found. Create at least one section with a lesson and page in pages/");
|
|
639
|
-
return {
|
|
640
|
-
errors,
|
|
641
|
-
warnings,
|
|
642
|
-
totalPages,
|
|
643
|
-
totalQuizzes,
|
|
644
|
-
hasGradedQuiz,
|
|
645
|
-
pages
|
|
646
|
-
};
|
|
647
|
-
}
|
|
648
|
-
function validateMetaFile(metaPath, parentRel, errors) {
|
|
649
|
-
if (!existsSync(metaPath)) return null;
|
|
650
|
-
const metaRel = `${parentRel}/_meta.js`;
|
|
651
|
-
const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
|
|
652
|
-
if (!objectStr) {
|
|
653
|
-
errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
|
|
654
|
-
return null;
|
|
655
|
-
}
|
|
656
|
-
let meta;
|
|
657
|
-
try {
|
|
658
|
-
meta = JSON5.parse(objectStr);
|
|
659
|
-
} catch {
|
|
660
|
-
errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
|
|
661
|
-
return null;
|
|
662
|
-
}
|
|
663
|
-
if (!meta.title) errors.push(`${metaRel}: missing required "title" field`);
|
|
664
|
-
return meta;
|
|
665
|
-
}
|
|
666
|
-
function validatePageConfig(content, fileRel, errors) {
|
|
667
|
-
const result = parsePageConfigFromSource(content);
|
|
668
|
-
if (result.kind === "ok") return result.value;
|
|
669
|
-
if (result.kind === "invalid") errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
|
|
670
|
-
return null;
|
|
671
|
-
}
|
|
672
|
-
function validateCompletesOn(pageConfig, fileRel, errors) {
|
|
673
|
-
if (!pageConfig || pageConfig.completesOn === void 0) return false;
|
|
674
|
-
if (pageConfig.completesOn === "view") return true;
|
|
675
|
-
errors.push(`${fileRel}: pageConfig.completesOn must be "view", got ${JSON.stringify(pageConfig.completesOn)}`);
|
|
676
|
-
return false;
|
|
677
|
-
}
|
|
678
|
-
function validateQuizConfig(quiz, fileRel, errors) {
|
|
679
|
-
if (!quiz || typeof quiz !== "object") return;
|
|
680
|
-
const cfg = quiz;
|
|
681
|
-
if (cfg.maxAttempts !== void 0) {
|
|
682
|
-
const val = cfg.maxAttempts;
|
|
683
|
-
if (val !== Infinity && (typeof val !== "number" || val <= 0 || !Number.isFinite(val))) errors.push(`${fileRel}: quiz.maxAttempts must be a positive number or Infinity, got ${String(val)}`);
|
|
684
|
-
}
|
|
685
|
-
if (cfg.graded !== void 0 && typeof cfg.graded !== "boolean") errors.push(`${fileRel}: quiz.graded must be a boolean, got ${typeof cfg.graded}`);
|
|
686
|
-
}
|
|
687
|
-
const ASSET_REF_RE = /\$assets\/([^\s"'`)]+)/g;
|
|
688
|
-
/** Match $assets/... refs in any context (src attrs, import statements, url() etc) and dedupe. */
|
|
689
|
-
function collectAssetRefs(content) {
|
|
690
|
-
const seen = /* @__PURE__ */ new Set();
|
|
691
|
-
let match;
|
|
692
|
-
ASSET_REF_RE.lastIndex = 0;
|
|
693
|
-
while ((match = ASSET_REF_RE.exec(content)) !== null) seen.add(match[1]);
|
|
694
|
-
return [...seen];
|
|
695
|
-
}
|
|
696
|
-
function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
|
|
697
|
-
for (const assetPath of collectAssetRefs(content)) {
|
|
698
|
-
const fullAssetPath = resolve(assetsDir, assetPath);
|
|
699
|
-
let exists = existsCache.get(fullAssetPath);
|
|
700
|
-
if (exists === void 0) {
|
|
701
|
-
exists = existsSync(fullAssetPath);
|
|
702
|
-
existsCache.set(fullAssetPath, exists);
|
|
703
|
-
}
|
|
704
|
-
if (!exists) warnings.push(`${fileRel}: "$assets/${assetPath}" not found in assets/ directory`);
|
|
705
|
-
}
|
|
706
|
-
}
|
|
707
|
-
function crossValidate(config, pageResults, errors, warnings) {
|
|
708
|
-
if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
|
|
709
|
-
const isManual = config.completion?.mode === "manual";
|
|
710
|
-
const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
|
|
711
|
-
if (isManual && config.completion?.trigger === "page" && completesOnPages.length === 0) errors.push("completion.mode is \"manual\" with trigger: \"page\", but no page declares pageConfig.completesOn: \"view\". Either add a completesOn page or remove the trigger field to drop the static check.");
|
|
712
|
-
if (isManual) {
|
|
713
|
-
for (const page of pageResults.pages) if (page.hasGradedQuiz) warnings.push(`${page.fileRel}: quiz.graded is true under completion.mode: "manual". The score will be reported to the LMS for transcripts, but it will not drive completion or success status — \`markComplete()\` / completesOn does. If that's not what you want, set graded: false or change completion.mode.`);
|
|
714
|
-
}
|
|
715
|
-
if (isManual && config.completion?.percentageThreshold !== void 0) warnings.push("course.config.js: \"completion.percentageThreshold\" is ignored under completion.mode: \"manual\"");
|
|
716
|
-
if (!isManual) for (const page of completesOnPages) warnings.push(`${page.fileRel}: pageConfig.completesOn is ignored — completion.mode is "${config.completion?.mode ?? "percentage"}"`);
|
|
717
|
-
for (const page of pageResults.pages) if (page.completesOnView && page.hasQuiz) warnings.push(`${page.fileRel}: completion fires on view, before the quiz can be answered — likely a mistake`);
|
|
718
|
-
if (isManual) {
|
|
719
|
-
const firstPage = pageResults.pages.find((p) => p.navIndex === 0);
|
|
720
|
-
if (firstPage?.completesOnView) warnings.push(`${firstPage.fileRel}: pageConfig.completesOn: "view" is on the first page — the course will complete immediately on launch, before the learner sees any other content.`);
|
|
721
|
-
}
|
|
722
|
-
if (config.export?.standard === "scorm12") {
|
|
723
|
-
let visitedChars = 0;
|
|
724
|
-
for (let i = 0; i < pageResults.totalPages; i++) visitedChars += String(i).length + 1;
|
|
725
|
-
const overhead = 60;
|
|
726
|
-
const quizBytes = pageResults.totalQuizzes * 15;
|
|
727
|
-
const chunkBytes = pageResults.totalPages * 12;
|
|
728
|
-
const standaloneBytes = pageResults.totalPages * 30;
|
|
729
|
-
const estimatedSize = overhead + visitedChars + quizBytes + chunkBytes + standaloneBytes + 256;
|
|
730
|
-
if (estimatedSize > 3200) warnings.push(`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".`);
|
|
731
|
-
}
|
|
732
|
-
}
|
|
733
|
-
//#endregion
|
|
734
9
|
//#region src/runtime/slugify.ts
|
|
735
10
|
/**
|
|
736
11
|
* Slugify a string for use as a URL-safe / filename-safe identifier.
|
|
@@ -785,13 +60,15 @@ const SCORM_DIALECTS = {
|
|
|
785
60
|
rootNs: "http://www.imsproject.org/xsd/imscp_rootv1p1p2",
|
|
786
61
|
adlcpNs: "http://www.adlnet.org/xsd/adlcp_rootv1p2",
|
|
787
62
|
schemaversion: "1.2",
|
|
788
|
-
scormTypeAttr: "scormtype"
|
|
63
|
+
scormTypeAttr: "scormtype",
|
|
64
|
+
schemaLocation: "http://www.imsproject.org/xsd/imscp_rootv1p1p2 imscp_rootv1p1p2.xsd http://www.imsglobal.org/xsd/imsmd_rootv1p2p1 imsmd_rootv1p2p1.xsd http://www.adlnet.org/xsd/adlcp_rootv1p2 adlcp_rootv1p2.xsd"
|
|
789
65
|
},
|
|
790
66
|
"2004": {
|
|
791
67
|
rootNs: "http://www.imsglobal.org/xsd/imscp_v1p1",
|
|
792
68
|
adlcpNs: "http://www.adlnet.org/xsd/adlcp_v1p3",
|
|
793
69
|
schemaversion: "2004 4th Edition",
|
|
794
|
-
scormTypeAttr: "scormType"
|
|
70
|
+
scormTypeAttr: "scormType",
|
|
71
|
+
schemaLocation: "http://www.imsglobal.org/xsd/imscp_v1p1 imscp_v1p1.xsd http://www.adlnet.org/xsd/adlcp_v1p3 adlcp_v1p3.xsd"
|
|
795
72
|
}
|
|
796
73
|
};
|
|
797
74
|
function generateScormManifest(version, config, distDir) {
|
|
@@ -801,7 +78,9 @@ function generateScormManifest(version, config, distDir) {
|
|
|
801
78
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
802
79
|
<manifest identifier="tessera-course" version="1.0"
|
|
803
80
|
xmlns="${dialect.rootNs}"
|
|
804
|
-
xmlns:adlcp="${dialect.adlcpNs}"
|
|
81
|
+
xmlns:adlcp="${dialect.adlcpNs}"
|
|
82
|
+
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
83
|
+
xsi:schemaLocation="${dialect.schemaLocation}">
|
|
805
84
|
<metadata>
|
|
806
85
|
<schema>ADL SCORM</schema>
|
|
807
86
|
<schemaversion>${dialect.schemaversion}</schemaversion>
|