tessera-learn 0.0.1

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.
Files changed (71) hide show
  1. package/AGENTS.md +1228 -0
  2. package/LICENSE +21 -0
  3. package/README.md +21 -0
  4. package/dist/plugin/index.d.ts +7 -0
  5. package/dist/plugin/index.d.ts.map +1 -0
  6. package/dist/plugin/index.js +1239 -0
  7. package/dist/plugin/index.js.map +1 -0
  8. package/package.json +77 -0
  9. package/src/archiver.d.ts +27 -0
  10. package/src/components/Accordion.svelte +32 -0
  11. package/src/components/AccordionItem.svelte +144 -0
  12. package/src/components/Audio.svelte +38 -0
  13. package/src/components/Callout.svelte +81 -0
  14. package/src/components/Carousel.svelte +194 -0
  15. package/src/components/CarouselSlide.svelte +32 -0
  16. package/src/components/DefaultLayout.svelte +108 -0
  17. package/src/components/FillInTheBlank.svelte +345 -0
  18. package/src/components/Image.svelte +47 -0
  19. package/src/components/Matching.svelte +513 -0
  20. package/src/components/MultipleChoice.svelte +363 -0
  21. package/src/components/Quiz.svelte +569 -0
  22. package/src/components/RevealModal.svelte +228 -0
  23. package/src/components/Sorting.svelte +663 -0
  24. package/src/components/Video.svelte +118 -0
  25. package/src/components/index.ts +15 -0
  26. package/src/components/quiz-payload.ts +71 -0
  27. package/src/components/util.ts +24 -0
  28. package/src/index.ts +56 -0
  29. package/src/plugin/export.ts +264 -0
  30. package/src/plugin/index.ts +464 -0
  31. package/src/plugin/layout.ts +55 -0
  32. package/src/plugin/manifest.ts +330 -0
  33. package/src/plugin/quiz.ts +65 -0
  34. package/src/plugin/validation.ts +838 -0
  35. package/src/runtime/App.svelte +435 -0
  36. package/src/runtime/ErrorPage.svelte +14 -0
  37. package/src/runtime/LoadingSkeleton.svelte +26 -0
  38. package/src/runtime/Sidebar.svelte +76 -0
  39. package/src/runtime/access.ts +55 -0
  40. package/src/runtime/adapters/cmi5.ts +341 -0
  41. package/src/runtime/adapters/discovery.ts +38 -0
  42. package/src/runtime/adapters/index.ts +99 -0
  43. package/src/runtime/adapters/retry.ts +284 -0
  44. package/src/runtime/adapters/scorm12.ts +172 -0
  45. package/src/runtime/adapters/scorm2004.ts +162 -0
  46. package/src/runtime/adapters/web.ts +62 -0
  47. package/src/runtime/contexts.ts +76 -0
  48. package/src/runtime/duration.ts +29 -0
  49. package/src/runtime/hooks.svelte.ts +543 -0
  50. package/src/runtime/interaction-format.ts +132 -0
  51. package/src/runtime/interaction.ts +96 -0
  52. package/src/runtime/navigation.svelte.ts +117 -0
  53. package/src/runtime/persistence.ts +56 -0
  54. package/src/runtime/progress.svelte.ts +168 -0
  55. package/src/runtime/quiz-policy.ts +227 -0
  56. package/src/runtime/slugify.ts +17 -0
  57. package/src/runtime/types.ts +92 -0
  58. package/src/runtime/xapi/agent-rules.ts +93 -0
  59. package/src/runtime/xapi/client.ts +133 -0
  60. package/src/runtime/xapi/derive-actor.ts +90 -0
  61. package/src/runtime/xapi/publisher.ts +604 -0
  62. package/src/runtime/xapi/registry.ts +38 -0
  63. package/src/runtime/xapi/setup.ts +250 -0
  64. package/src/runtime/xapi/types.ts +106 -0
  65. package/src/runtime/xapi/uuid.ts +21 -0
  66. package/src/runtime/xapi/validation.ts +71 -0
  67. package/src/runtime/xapi/version.ts +23 -0
  68. package/src/virtual.d.ts +16 -0
  69. package/styles/base.css +194 -0
  70. package/styles/layout.css +408 -0
  71. package/styles/theme.css +36 -0
@@ -0,0 +1,1239 @@
1
+ import { svelte } from "@sveltejs/vite-plugin-svelte";
2
+ import { fileURLToPath } from "node:url";
3
+ import { basename, dirname, extname, relative, resolve } from "node:path";
4
+ import { cpSync, createWriteStream, existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs";
5
+ import JSON5 from "json5";
6
+ import { createHash } from "node:crypto";
7
+ import { ZipArchive } from "archiver";
8
+ //#region src/plugin/manifest.ts
9
+ /**
10
+ * Module-level cache of source file contents keyed by absolute path with
11
+ * mtime invalidation. Both `validateProject` and `generateManifest` read the
12
+ * same .svelte / _meta.js / course.config.js files during a single build;
13
+ * sharing the read avoids the second disk hit (and matters most on cold-cache
14
+ * CI runs and large courses).
15
+ */
16
+ const fileContentCache = /* @__PURE__ */ new Map();
17
+ function readSourceFileCached(filePath) {
18
+ const stat = statSync(filePath);
19
+ const cached = fileContentCache.get(filePath);
20
+ if (cached && cached.mtimeMs === stat.mtimeMs) return cached.content;
21
+ const content = readFileSync(filePath, "utf-8");
22
+ fileContentCache.set(filePath, {
23
+ mtimeMs: stat.mtimeMs,
24
+ content
25
+ });
26
+ return content;
27
+ }
28
+ /** Strip numeric prefix and hyphen: "01-introduction" → "introduction" */
29
+ function stripPrefix(name) {
30
+ return name.replace(/^\d+-/, "");
31
+ }
32
+ /** Title-case a slug: "getting-started" → "Getting Started" */
33
+ function titleCase(slug) {
34
+ return slug.split("-").map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
35
+ }
36
+ /** Derive slug from folder/file name */
37
+ function deriveSlug(name, isFile = false) {
38
+ if (isFile) return basename(name, extname(name));
39
+ return stripPrefix(name);
40
+ }
41
+ /** Matches both Svelte 5 `<script module>` and legacy `<script context="module">`. */
42
+ const MODULE_SCRIPT_RE = /<script\s+(?:context\s*=\s*["']module["']|module)[^>]*>([\s\S]*?)<\/script>/;
43
+ /** Matches `export const pageConfig =` (RHS is read separately). */
44
+ const PAGE_CONFIG_EXPORT_RE = /export\s+const\s+pageConfig\s*=\s*/;
45
+ /** Matches `export default ` (RHS is read separately). */
46
+ const DEFAULT_EXPORT_RE = /export\s+default\s*/;
47
+ /**
48
+ * Locate `export default { ... }` and return the object literal substring,
49
+ * or null if no balanced object literal follows the `export default` keyword.
50
+ * Used by both manifest extraction and project validation.
51
+ */
52
+ function extractDefaultExportObjectLiteral(source) {
53
+ const match = source.match(DEFAULT_EXPORT_RE);
54
+ if (!match || match.index === void 0) return null;
55
+ const startIndex = source.indexOf("{", match.index);
56
+ if (startIndex < 0) return null;
57
+ return extractObjectLiteral(source, startIndex);
58
+ }
59
+ /**
60
+ * Read a _meta.js file and extract its default export object.
61
+ * Uses the same JSON5 approach as pageConfig extraction — find the object literal
62
+ * after `export default` and parse it.
63
+ */
64
+ function readMetaFile(metaPath) {
65
+ if (!existsSync(metaPath)) return {};
66
+ const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
67
+ if (!objectStr) return {};
68
+ try {
69
+ return JSON5.parse(objectStr);
70
+ } catch {
71
+ return {};
72
+ }
73
+ }
74
+ /**
75
+ * Extract pageConfig from a .svelte file's module script block.
76
+ */
77
+ function extractPageConfig(filePath) {
78
+ const moduleScriptMatch = readSourceFileCached(filePath).match(MODULE_SCRIPT_RE);
79
+ if (!moduleScriptMatch) return {};
80
+ const scriptContent = moduleScriptMatch[1];
81
+ const configMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
82
+ if (!configMatch || configMatch.index === void 0) return {};
83
+ const startIndex = scriptContent.indexOf("{", configMatch.index + configMatch[0].length);
84
+ if (startIndex < 0) return {};
85
+ const objectStr = extractObjectLiteral(scriptContent, startIndex);
86
+ if (!objectStr) return {};
87
+ try {
88
+ return JSON5.parse(objectStr);
89
+ } catch {
90
+ throw new Error(`${filePath}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
91
+ }
92
+ }
93
+ /**
94
+ * Extract an object literal from source starting at the opening brace.
95
+ * Tracks brace depth to find the matching closing brace.
96
+ */
97
+ function extractObjectLiteral(source, startIndex) {
98
+ if (source[startIndex] !== "{") return null;
99
+ let depth = 0;
100
+ let inString = null;
101
+ let escaped = false;
102
+ for (let i = startIndex; i < source.length; i++) {
103
+ const char = source[i];
104
+ if (escaped) {
105
+ escaped = false;
106
+ continue;
107
+ }
108
+ if (char === "\\" && inString) {
109
+ escaped = true;
110
+ continue;
111
+ }
112
+ if (inString) {
113
+ if (char === inString) inString = null;
114
+ continue;
115
+ }
116
+ if (char === "\"" || char === "'" || char === "`") {
117
+ inString = char;
118
+ continue;
119
+ }
120
+ if (char === "/" && i + 1 < source.length && source[i + 1] === "/") {
121
+ const newline = source.indexOf("\n", i);
122
+ i = newline === -1 ? source.length : newline;
123
+ continue;
124
+ }
125
+ if (char === "/" && i + 1 < source.length && source[i + 1] === "*") {
126
+ const end = source.indexOf("*/", i + 2);
127
+ i = end === -1 ? source.length : end + 1;
128
+ continue;
129
+ }
130
+ if (char === "{") depth++;
131
+ if (char === "}") {
132
+ depth--;
133
+ if (depth === 0) return source.slice(startIndex, i + 1);
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+ /**
139
+ * Get sorted subdirectories of a given path.
140
+ */
141
+ function getSortedDirs(dirPath) {
142
+ if (!existsSync(dirPath)) return [];
143
+ return readdirSync(dirPath).filter((name) => {
144
+ return statSync(resolve(dirPath, name)).isDirectory() && !name.startsWith(".");
145
+ }).sort();
146
+ }
147
+ /**
148
+ * Get .svelte files in a directory.
149
+ */
150
+ function getSvelteFiles(dirPath) {
151
+ if (!existsSync(dirPath)) return [];
152
+ return readdirSync(dirPath).filter((name) => name.endsWith(".svelte")).sort();
153
+ }
154
+ /**
155
+ * Generate a course manifest by scanning the pages/ directory.
156
+ */
157
+ function generateManifest(pagesDir) {
158
+ const sections = [];
159
+ const flatPages = [];
160
+ let pageIndex = 0;
161
+ const sectionDirs = getSortedDirs(pagesDir);
162
+ for (const sectionName of sectionDirs) {
163
+ const sectionPath = resolve(pagesDir, sectionName);
164
+ const sectionMeta = readMetaFile(resolve(sectionPath, "_meta.js"));
165
+ const sectionSlug = deriveSlug(sectionName);
166
+ const section = {
167
+ title: sectionMeta.title || titleCase(sectionSlug),
168
+ slug: sectionSlug,
169
+ lessons: []
170
+ };
171
+ const lessonDirs = getSortedDirs(sectionPath);
172
+ for (const lessonName of lessonDirs) {
173
+ const lessonPath = resolve(sectionPath, lessonName);
174
+ const lessonMeta = readMetaFile(resolve(lessonPath, "_meta.js"));
175
+ const lessonSlug = deriveSlug(lessonName);
176
+ const lesson = {
177
+ title: lessonMeta.title || titleCase(lessonSlug),
178
+ slug: lessonSlug,
179
+ pages: []
180
+ };
181
+ const orderedFiles = orderPageFiles(getSvelteFiles(lessonPath), lessonMeta.pages);
182
+ for (const fileName of orderedFiles) {
183
+ const filePath = resolve(lessonPath, fileName);
184
+ const pageSlug = deriveSlug(fileName, true);
185
+ let pageConfig = {};
186
+ try {
187
+ pageConfig = extractPageConfig(filePath);
188
+ } catch (e) {
189
+ console.warn(`[tessera warning] ${e.message}`);
190
+ }
191
+ const relativePath = `/pages/${sectionName}/${lessonName}/${fileName}`;
192
+ const page = {
193
+ index: pageIndex,
194
+ title: pageConfig.title || titleCase(pageSlug),
195
+ slug: pageSlug,
196
+ importPath: relativePath,
197
+ quiz: pageConfig.quiz || null
198
+ };
199
+ lesson.pages.push(page);
200
+ flatPages.push(page);
201
+ pageIndex++;
202
+ }
203
+ section.lessons.push(lesson);
204
+ }
205
+ sections.push(section);
206
+ }
207
+ return {
208
+ sections,
209
+ pages: flatPages,
210
+ totalPages: flatPages.length
211
+ };
212
+ }
213
+ /**
214
+ * Order .svelte files: listed in `pages` array first (in order), then unlisted appended alphabetically.
215
+ */
216
+ function orderPageFiles(allFiles, pagesArray) {
217
+ if (!pagesArray || pagesArray.length === 0) return allFiles;
218
+ const listed = pagesArray.map((name) => name.endsWith(".svelte") ? name : `${name}.svelte`);
219
+ const listedSet = new Set(listed);
220
+ const unlisted = allFiles.filter((f) => !listedSet.has(f)).sort();
221
+ return [...listed.filter((f) => allFiles.includes(f)), ...unlisted];
222
+ }
223
+ //#endregion
224
+ //#region src/runtime/xapi/agent-rules.ts
225
+ /**
226
+ * xAPI Identified Agent and Basic-auth credential validation rules.
227
+ *
228
+ * Pure logic — no Svelte/runtime imports. Imported by both `publisher.ts`
229
+ * (runtime validation of resolved actor / auth) and `plugin/validation.ts`
230
+ * (build-time validation of static `course.config.js` actor / auth).
231
+ * Keeping the rules in one place prevents the two callsites from drifting.
232
+ */
233
+ /**
234
+ * Validate that a candidate is an Identified Agent per xAPI 1.0.3.
235
+ * Returns null on success or a human-readable error suffix on failure.
236
+ *
237
+ * Suffixes are prefix-friendly: callers concatenate their own label
238
+ * (`xapi.actor`, `xapi[0].actor`, etc.) with a single space — no "actor"
239
+ * appears in the suffix to avoid doubling.
240
+ */
241
+ function validateAgent(actor) {
242
+ if (!actor || typeof actor !== "object") return "must be an object";
243
+ const a = actor;
244
+ if (Array.isArray(a.member) && a.member.length > 0) return "is a Group (has `member`); v1 supports Identified Agents only";
245
+ let count = 0;
246
+ if (a.mbox !== void 0) count++;
247
+ if (a.mbox_sha1sum !== void 0) count++;
248
+ if (a.openid !== void 0) count++;
249
+ if (a.account !== void 0) count++;
250
+ if (count === 0) return "must have one of mbox, mbox_sha1sum, openid, or account (Identified Agent rule)";
251
+ if (count > 1) return "must have exactly one IFI (mbox / mbox_sha1sum / openid / account), not multiple";
252
+ if (a.mbox !== void 0) {
253
+ if (typeof a.mbox !== "string" || !a.mbox.startsWith("mailto:")) return ".mbox must be a string starting with \"mailto:\"";
254
+ }
255
+ if (a.mbox_sha1sum !== void 0) {
256
+ 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";
257
+ }
258
+ if (a.openid !== void 0) {
259
+ if (typeof a.openid !== "string" || !a.openid) return ".openid must be a non-empty string";
260
+ try {
261
+ new URL(a.openid);
262
+ } catch {
263
+ return ".openid must be an absolute URI";
264
+ }
265
+ }
266
+ if (a.account !== void 0) {
267
+ const acc = a.account;
268
+ if (!acc || typeof acc !== "object") return ".account must be an object with homePage and name";
269
+ if (typeof acc.homePage !== "string" || !acc.homePage) return ".account.homePage must be a non-empty string";
270
+ try {
271
+ new URL(acc.homePage);
272
+ } catch {
273
+ return ".account.homePage must be an absolute URL";
274
+ }
275
+ if (typeof acc.name !== "string" || !acc.name) return ".account.name must be a non-empty string";
276
+ }
277
+ return null;
278
+ }
279
+ //#endregion
280
+ //#region src/plugin/validation.ts
281
+ const KNOWN_CONFIG_FIELDS = new Set([
282
+ "title",
283
+ "description",
284
+ "author",
285
+ "version",
286
+ "branding",
287
+ "navigation",
288
+ "completion",
289
+ "scoring",
290
+ "export",
291
+ "chrome",
292
+ "xapi"
293
+ ]);
294
+ const VALID_NAV_MODES = ["free", "sequential"];
295
+ const VALID_COMPLETION_MODES = ["quiz", "percentage"];
296
+ const VALID_EXPORT_STANDARDS = [
297
+ "web",
298
+ "scorm12",
299
+ "scorm2004",
300
+ "cmi5"
301
+ ];
302
+ /**
303
+ * Validate a Tessera project at the given root.
304
+ * Returns errors (block build) and warnings (informational).
305
+ */
306
+ function validateProject(projectRoot) {
307
+ const errors = [];
308
+ const warnings = [];
309
+ const configPath = resolve(projectRoot, "course.config.js");
310
+ if (!existsSync(configPath)) {
311
+ errors.push("course.config.js not found in project root");
312
+ return {
313
+ errors,
314
+ warnings
315
+ };
316
+ }
317
+ const config = parseConfig(configPath, errors, warnings);
318
+ const pageResults = validatePages(resolve(projectRoot, "pages"), resolve(projectRoot, "assets"), projectRoot);
319
+ errors.push(...pageResults.errors);
320
+ warnings.push(...pageResults.warnings);
321
+ if (config) crossValidate(config, pageResults, errors, warnings);
322
+ return {
323
+ errors,
324
+ warnings
325
+ };
326
+ }
327
+ function parseConfig(configPath, errors, warnings) {
328
+ const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
329
+ if (!objectStr) {
330
+ errors.push("course.config.js: could not parse — must use `export default { ... }` syntax");
331
+ return null;
332
+ }
333
+ let config;
334
+ try {
335
+ config = JSON5.parse(objectStr);
336
+ } catch {
337
+ errors.push("course.config.js: syntax error — must export a static object literal");
338
+ return null;
339
+ }
340
+ for (const key of Object.keys(config)) if (!KNOWN_CONFIG_FIELDS.has(key)) warnings.push(`course.config.js: unknown field "${key}" — will be ignored`);
341
+ if (config.navigation?.mode !== void 0) {
342
+ if (!VALID_NAV_MODES.includes(config.navigation.mode)) errors.push(`course.config.js: "navigation.mode" must be "free" or "sequential", got "${config.navigation.mode}"`);
343
+ }
344
+ if (config.completion?.mode !== void 0) {
345
+ if (!VALID_COMPLETION_MODES.includes(config.completion.mode)) errors.push(`course.config.js: "completion.mode" must be "quiz" or "percentage", got "${config.completion.mode}"`);
346
+ }
347
+ if (config.export?.standard !== void 0) {
348
+ 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}"`);
349
+ }
350
+ if (config.scoring?.passingScore !== void 0) {
351
+ const score = config.scoring.passingScore;
352
+ if (typeof score !== "number" || score < 0 || score > 100) errors.push(`course.config.js: "scoring.passingScore" must be 0–100, got ${score}`);
353
+ }
354
+ if (config.completion?.percentageThreshold !== void 0) {
355
+ const threshold = config.completion.percentageThreshold;
356
+ if (typeof threshold !== "number" || threshold < 0 || threshold > 100) errors.push(`course.config.js: "completion.percentageThreshold" must be 0–100, got ${threshold}`);
357
+ }
358
+ if (config.xapi !== void 0) validateXAPIConfig(config.xapi, config.export?.standard ?? "web", errors, warnings);
359
+ return config;
360
+ }
361
+ 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;
362
+ function validateXAPIConfig(raw, standard, errors, warnings) {
363
+ if (raw === void 0 || raw === null) return;
364
+ const entries = Array.isArray(raw) ? raw : [raw];
365
+ if (Array.isArray(raw)) {
366
+ if (entries.length === 0) {
367
+ errors.push("course.config.js: xapi must contain at least one destination, or be omitted");
368
+ return;
369
+ }
370
+ 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");
371
+ const seen = /* @__PURE__ */ new Map();
372
+ for (const e of entries) if (e && typeof e === "object") {
373
+ const ep = e.endpoint;
374
+ if (typeof ep === "string" && ep !== "lms") seen.set(ep, (seen.get(ep) ?? 0) + 1);
375
+ }
376
+ 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.`);
377
+ } else if (typeof raw !== "object") {
378
+ errors.push("course.config.js: xapi must be an object or an array of objects");
379
+ return;
380
+ }
381
+ for (let i = 0; i < entries.length; i++) {
382
+ const entry = entries[i];
383
+ const label = Array.isArray(raw) ? `xapi[${i}]` : "xapi";
384
+ if (!entry || typeof entry !== "object") {
385
+ errors.push(`course.config.js: ${label} must be an object`);
386
+ continue;
387
+ }
388
+ validateSingleXAPIEntry(entry, label, standard, errors, warnings);
389
+ }
390
+ }
391
+ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
392
+ const endpoint = entry.endpoint;
393
+ if (endpoint === void 0) {
394
+ errors.push(`course.config.js: ${label}.endpoint is required`);
395
+ return;
396
+ }
397
+ if (typeof endpoint !== "string") {
398
+ errors.push(`course.config.js: ${label}.endpoint must be a string`);
399
+ return;
400
+ }
401
+ if (endpoint === "lms") {
402
+ 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.`);
403
+ for (const f of [
404
+ "auth",
405
+ "actor",
406
+ "activityId",
407
+ "registration",
408
+ "actorAccountHomePage"
409
+ ]) 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.`);
410
+ return;
411
+ }
412
+ let url;
413
+ try {
414
+ url = new URL(endpoint);
415
+ } catch {
416
+ errors.push(`course.config.js: ${label}.endpoint must be an absolute http(s) URL, got "${endpoint}"`);
417
+ return;
418
+ }
419
+ if (url.protocol !== "http:" && url.protocol !== "https:") {
420
+ errors.push(`course.config.js: ${label}.endpoint must use http: or https:, got "${url.protocol}"`);
421
+ return;
422
+ }
423
+ 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.`);
424
+ 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.`);
425
+ const auth = entry.auth;
426
+ if (auth === void 0) errors.push(`course.config.js: ${label}.auth is required`);
427
+ else if (typeof auth === "string") if (!auth) errors.push(`course.config.js: ${label}.auth must be a non-empty string`);
428
+ 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.`);
429
+ 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.`);
430
+ 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.`);
431
+ else if (typeof auth !== "function") errors.push(`course.config.js: ${label}.auth must be a string or a function, got ${typeof auth}`);
432
+ const activityId = entry.activityId;
433
+ if (activityId === void 0 || activityId === "") errors.push(`course.config.js: ${label}.activityId is required`);
434
+ else if (typeof activityId !== "string") errors.push(`course.config.js: ${label}.activityId must be a string`);
435
+ else try {
436
+ new URL(activityId);
437
+ } catch {
438
+ errors.push(`course.config.js: ${label}.activityId must be an absolute IRI, got "${activityId}"`);
439
+ }
440
+ const actor = entry.actor;
441
+ if (actor === void 0) {
442
+ 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).`);
443
+ } else if (typeof actor === "object" && actor !== null) {
444
+ const err = validateStaticAgent(actor);
445
+ if (err) {
446
+ const joined = err.startsWith(".") ? `${label}.actor${err}` : `${label}.actor ${err}`;
447
+ errors.push(`course.config.js: ${joined}`);
448
+ }
449
+ } else if (typeof actor !== "function") errors.push(`course.config.js: ${label}.actor must be an object or function, got ${typeof actor}`);
450
+ const aahp = entry.actorAccountHomePage;
451
+ if (aahp !== void 0) {
452
+ if (typeof aahp !== "string") errors.push(`course.config.js: ${label}.actorAccountHomePage must be a string`);
453
+ else try {
454
+ new URL(aahp);
455
+ } catch {
456
+ errors.push(`course.config.js: ${label}.actorAccountHomePage must be an absolute URL`);
457
+ }
458
+ if (actor !== void 0) warnings.push(`course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`);
459
+ if (standard === "cmi5" || standard === "web") warnings.push(`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`);
460
+ }
461
+ if (actor === void 0 && (standard === "scorm12" || standard === "scorm2004") && typeof activityId === "string") {
462
+ let isHttp = false;
463
+ try {
464
+ const u = new URL(activityId);
465
+ isHttp = u.protocol === "http:" || u.protocol === "https:";
466
+ } catch {
467
+ isHttp = false;
468
+ }
469
+ 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.`);
470
+ }
471
+ const registration = entry.registration;
472
+ if (registration !== void 0) {
473
+ if (typeof registration !== "string" || !UUID_RE.test(registration)) errors.push(`course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`);
474
+ 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.`);
475
+ }
476
+ }
477
+ /**
478
+ * Build-time alias for the shared `validateAgent` rules. Suffixes are already
479
+ * prefix-friendly (no leading "actor"), so this is a straight pass-through —
480
+ * kept named so the call sites in this file stay readable.
481
+ */
482
+ const validateStaticAgent = validateAgent;
483
+ function validatePages(pagesDir, assetsDir, projectRoot) {
484
+ const errors = [];
485
+ const warnings = [];
486
+ let totalPages = 0;
487
+ let totalQuizzes = 0;
488
+ let hasGradedQuiz = false;
489
+ if (!existsSync(pagesDir)) {
490
+ errors.push("No pages found. Create at least one section with a lesson and page in pages/");
491
+ return {
492
+ errors,
493
+ warnings,
494
+ totalPages: 0,
495
+ totalQuizzes: 0,
496
+ hasGradedQuiz: false
497
+ };
498
+ }
499
+ const topLevelEntries = readdirSync(pagesDir);
500
+ for (const entry of topLevelEntries) {
501
+ const fullPath = resolve(pagesDir, entry);
502
+ if (entry.endsWith(".svelte") && statSync(fullPath).isFile()) {
503
+ const relPath = relative(projectRoot, fullPath);
504
+ warnings.push(`${relPath}: this file is outside the section/lesson structure and will be ignored`);
505
+ }
506
+ }
507
+ const sectionDirs = topLevelEntries.filter((name) => {
508
+ return statSync(resolve(pagesDir, name)).isDirectory() && !name.startsWith(".");
509
+ }).sort();
510
+ if (sectionDirs.length === 0) {
511
+ errors.push("No pages found. Create at least one section with a lesson and page in pages/");
512
+ return {
513
+ errors,
514
+ warnings,
515
+ totalPages: 0,
516
+ totalQuizzes: 0,
517
+ hasGradedQuiz: false
518
+ };
519
+ }
520
+ for (const sectionName of sectionDirs) {
521
+ const sectionPath = resolve(pagesDir, sectionName);
522
+ const sectionRel = relative(projectRoot, sectionPath);
523
+ const sectionMeta = validateMetaFile(resolve(sectionPath, "_meta.js"), sectionRel, errors);
524
+ const sectionEntries = readdirSync(sectionPath);
525
+ const sectionSvelteFiles = sectionEntries.filter((name) => {
526
+ const full = resolve(sectionPath, name);
527
+ return name.endsWith(".svelte") && statSync(full).isFile();
528
+ }).sort();
529
+ if (sectionMeta?.pages) for (const pageName of sectionMeta.pages) {
530
+ const fileName = pageName.endsWith(".svelte") ? pageName : `${pageName}.svelte`;
531
+ if (!sectionSvelteFiles.includes(fileName)) {
532
+ const metaRel = relative(projectRoot, resolve(sectionPath, "_meta.js"));
533
+ errors.push(`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
534
+ }
535
+ }
536
+ for (const fileName of sectionSvelteFiles) {
537
+ const filePath = resolve(sectionPath, fileName);
538
+ const fileRel = relative(projectRoot, filePath);
539
+ const content = readSourceFileCached(filePath);
540
+ const pageConfig = validatePageConfig(content, fileRel, errors);
541
+ totalPages++;
542
+ if (pageConfig?.quiz) {
543
+ totalQuizzes++;
544
+ validateQuizConfig(pageConfig.quiz, fileRel, errors);
545
+ if (pageConfig.quiz.graded === true) hasGradedQuiz = true;
546
+ }
547
+ validateAssetRefs(content, fileRel, assetsDir, warnings);
548
+ }
549
+ const lessonDirs = sectionEntries.filter((name) => {
550
+ return statSync(resolve(sectionPath, name)).isDirectory() && !name.startsWith(".");
551
+ }).sort();
552
+ for (const lessonName of lessonDirs) {
553
+ const lessonPath = resolve(sectionPath, lessonName);
554
+ const lessonRel = relative(projectRoot, lessonPath);
555
+ const meta = validateMetaFile(resolve(lessonPath, "_meta.js"), lessonRel, errors);
556
+ const svelteFiles = readdirSync(lessonPath).filter((name) => name.endsWith(".svelte")).sort();
557
+ if (meta?.pages) for (const pageName of meta.pages) {
558
+ const fileName = pageName.endsWith(".svelte") ? pageName : `${pageName}.svelte`;
559
+ if (!svelteFiles.includes(fileName)) {
560
+ const metaRel = relative(projectRoot, resolve(lessonPath, "_meta.js"));
561
+ errors.push(`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
562
+ }
563
+ }
564
+ if (meta?.pages && meta.pages.length > 0) {
565
+ const listedSet = new Set(meta.pages.map((p) => p.endsWith(".svelte") ? p : `${p}.svelte`));
566
+ for (const file of svelteFiles) if (!listedSet.has(file)) {
567
+ const relPath = relative(projectRoot, resolve(lessonPath, file));
568
+ warnings.push(`${relPath}: not listed in _meta.js pages array — will be appended at end`);
569
+ }
570
+ }
571
+ for (const fileName of svelteFiles) {
572
+ const filePath = resolve(lessonPath, fileName);
573
+ const fileRel = relative(projectRoot, filePath);
574
+ const content = readSourceFileCached(filePath);
575
+ const pageConfig = validatePageConfig(content, fileRel, errors);
576
+ totalPages++;
577
+ if (pageConfig?.quiz) {
578
+ totalQuizzes++;
579
+ validateQuizConfig(pageConfig.quiz, fileRel, errors);
580
+ if (pageConfig.quiz.graded === true) hasGradedQuiz = true;
581
+ }
582
+ validateAssetRefs(content, fileRel, assetsDir, warnings);
583
+ }
584
+ }
585
+ }
586
+ if (totalPages === 0) errors.push("No pages found. Create at least one section with a lesson and page in pages/");
587
+ return {
588
+ errors,
589
+ warnings,
590
+ totalPages,
591
+ totalQuizzes,
592
+ hasGradedQuiz
593
+ };
594
+ }
595
+ function validateMetaFile(metaPath, parentRel, errors) {
596
+ if (!existsSync(metaPath)) return null;
597
+ const metaRel = `${parentRel}/_meta.js`;
598
+ const objectStr = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
599
+ if (!objectStr) {
600
+ errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
601
+ return null;
602
+ }
603
+ let meta;
604
+ try {
605
+ meta = JSON5.parse(objectStr);
606
+ } catch {
607
+ errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
608
+ return null;
609
+ }
610
+ if (!meta.title) errors.push(`${metaRel}: missing required "title" field`);
611
+ return meta;
612
+ }
613
+ function validatePageConfig(content, fileRel, errors) {
614
+ const moduleScriptMatch = content.match(MODULE_SCRIPT_RE);
615
+ if (!moduleScriptMatch) return null;
616
+ const scriptContent = moduleScriptMatch[1];
617
+ const exportMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
618
+ if (!exportMatch || exportMatch.index === void 0) return null;
619
+ if (!scriptContent.slice(exportMatch.index + exportMatch[0].length).trimStart().startsWith("{")) {
620
+ errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
621
+ return null;
622
+ }
623
+ const objectStr = extractObjectLiteral(scriptContent, scriptContent.indexOf("{", exportMatch.index + exportMatch[0].length));
624
+ if (!objectStr) {
625
+ errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
626
+ return null;
627
+ }
628
+ try {
629
+ return JSON5.parse(objectStr);
630
+ } catch {
631
+ errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
632
+ return null;
633
+ }
634
+ }
635
+ function validateQuizConfig(quiz, fileRel, errors) {
636
+ if (!quiz || typeof quiz !== "object") return;
637
+ const cfg = quiz;
638
+ if (cfg.maxAttempts !== void 0) {
639
+ const val = cfg.maxAttempts;
640
+ 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)}`);
641
+ }
642
+ if (cfg.graded !== void 0 && typeof cfg.graded !== "boolean") errors.push(`${fileRel}: quiz.graded must be a boolean, got ${typeof cfg.graded}`);
643
+ }
644
+ function validateAssetRefs(content, fileRel, assetsDir, warnings) {
645
+ const assetRefPattern = /\$assets\/([^\s"'`)]+)/g;
646
+ let match;
647
+ while ((match = assetRefPattern.exec(content)) !== null) {
648
+ const assetPath = match[1];
649
+ if (!existsSync(resolve(assetsDir, assetPath))) warnings.push(`${fileRel}: "$assets/${assetPath}" not found in assets/ directory`);
650
+ }
651
+ }
652
+ function crossValidate(config, pageResults, errors, warnings) {
653
+ if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
654
+ if (config.export?.standard === "scorm12") {
655
+ let visitedChars = 0;
656
+ for (let i = 0; i < pageResults.totalPages; i++) visitedChars += String(i).length + 1;
657
+ const overhead = 60;
658
+ const quizBytes = pageResults.totalQuizzes * 15;
659
+ const chunkBytes = pageResults.totalPages * 12;
660
+ const standaloneBytes = pageResults.totalPages * 30;
661
+ const estimatedSize = overhead + visitedChars + quizBytes + chunkBytes + standaloneBytes + 256;
662
+ 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".`);
663
+ }
664
+ }
665
+ //#endregion
666
+ //#region src/runtime/slugify.ts
667
+ /**
668
+ * Slugify a string for use as a URL-safe / filename-safe identifier.
669
+ * "My Course Title" → "my-course-title"
670
+ *
671
+ * Shared by the runtime (`WebAdapter` localStorage key) and the build-time
672
+ * exporter (`runExport` zip filename). Both want identical, deterministic
673
+ * output so a course's storage key matches its package name.
674
+ */
675
+ function slugify(text) {
676
+ return text.toLowerCase().trim().replace(/[^\w\s-]/g, "").replace(/[\s_]+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
677
+ }
678
+ //#endregion
679
+ //#region src/plugin/export.ts
680
+ function escapeXml(str) {
681
+ return str.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&apos;");
682
+ }
683
+ /**
684
+ * Recursively collect all file paths relative to a directory.
685
+ */
686
+ function collectFiles(dir, base = "") {
687
+ const files = [];
688
+ if (!existsSync(dir)) return files;
689
+ for (const entry of readdirSync(dir)) {
690
+ const fullPath = resolve(dir, entry);
691
+ const relPath = base ? `${base}/${entry}` : entry;
692
+ if (statSync(fullPath).isDirectory()) files.push(...collectFiles(fullPath, relPath));
693
+ else files.push(relPath);
694
+ }
695
+ return files;
696
+ }
697
+ /**
698
+ * Derive a stable URN IRI from a seed string. cmi5 §13.1 / xs:anyURI
699
+ * require course / AU ids to be IRIs — bare hex or UUID-shaped strings
700
+ * (without correct version/variant bits) aren't conformant URNs and may
701
+ * be rejected by strict LMS importers.
702
+ *
703
+ * Hash the seed so the id survives rebuilds, then format as
704
+ * `urn:tessera:<kind>:<hex>`. The same seed always produces the same
705
+ * IRI, so existing LRS records are not orphaned by re-export.
706
+ */
707
+ function stableUrn(kind, seed) {
708
+ return `urn:tessera:${kind}:${createHash("sha256").update(seed).digest("hex").slice(0, 32)}`;
709
+ }
710
+ function formatSize(bytes) {
711
+ if (bytes < 1024) return `${bytes} B`;
712
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
713
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
714
+ }
715
+ function generateSCORM12Manifest(config, distDir) {
716
+ const title = escapeXml(config.title || "Tessera Course");
717
+ return `<?xml version="1.0" encoding="UTF-8"?>
718
+ <manifest identifier="tessera-course" version="1.0"
719
+ xmlns="http://www.imsproject.org/xsd/imscp_rootv1p1p2"
720
+ xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_rootv1p2">
721
+ <metadata>
722
+ <schema>ADL SCORM</schema>
723
+ <schemaversion>1.2</schemaversion>
724
+ </metadata>
725
+ <organizations default="org-1">
726
+ <organization identifier="org-1">
727
+ <title>${title}</title>
728
+ <item identifier="item-1" identifierref="res-1">
729
+ <title>${title}</title>
730
+ </item>
731
+ </organization>
732
+ </organizations>
733
+ <resources>
734
+ <resource identifier="res-1" type="webcontent" adlcp:scormtype="sco" href="index.html">
735
+ ${collectFiles(distDir).map((f) => ` <file href="${escapeXml(f)}" />`).join("\n")}
736
+ </resource>
737
+ </resources>
738
+ </manifest>`;
739
+ }
740
+ function generateSCORM2004Manifest(config, distDir) {
741
+ const title = escapeXml(config.title || "Tessera Course");
742
+ return `<?xml version="1.0" encoding="UTF-8"?>
743
+ <manifest identifier="tessera-course" version="1.0"
744
+ xmlns="http://www.imsglobal.org/xsd/imscp_v1p1"
745
+ xmlns:adlcp="http://www.adlnet.org/xsd/adlcp_v1p3">
746
+ <metadata>
747
+ <schema>ADL SCORM</schema>
748
+ <schemaversion>2004 4th Edition</schemaversion>
749
+ </metadata>
750
+ <organizations default="org-1">
751
+ <organization identifier="org-1">
752
+ <title>${title}</title>
753
+ <item identifier="item-1" identifierref="res-1">
754
+ <title>${title}</title>
755
+ </item>
756
+ </organization>
757
+ </organizations>
758
+ <resources>
759
+ <resource identifier="res-1" type="webcontent" adlcp:scormType="sco" href="index.html">
760
+ ${collectFiles(distDir).map((f) => ` <file href="${escapeXml(f)}" />`).join("\n")}
761
+ </resource>
762
+ </resources>
763
+ </manifest>`;
764
+ }
765
+ function generateCMI5Xml(config) {
766
+ const title = escapeXml(config.title || "Tessera Course");
767
+ const description = escapeXml(config.description || "");
768
+ const courseId = stableUrn("course", `tessera-course:${config.title || ""}`);
769
+ const auId = stableUrn("au", `tessera-au:${config.title || ""}`);
770
+ const masteryScore = (config.scoring?.passingScore ?? 70) / 100;
771
+ return `<?xml version="1.0" encoding="UTF-8"?>
772
+ <courseStructure xmlns="https://w3id.org/xapi/profiles/cmi5/v1/CourseStructure.xsd">
773
+ <course id="${courseId}">
774
+ <title><langstring lang="en-US">${title}</langstring></title>
775
+ <description><langstring lang="en-US">${description}</langstring></description>
776
+ </course>
777
+ <au id="${auId}" url="index.html" moveOn="${config.completion?.mode === "quiz" ? "CompletedAndPassed" : "Completed"}" masteryScore="${masteryScore}">
778
+ <title><langstring lang="en-US">${title}</langstring></title>
779
+ <description><langstring lang="en-US">${description}</langstring></description>
780
+ </au>
781
+ </courseStructure>`;
782
+ }
783
+ async function createZip(distDir, outputPath) {
784
+ return new Promise((res, reject) => {
785
+ const output = createWriteStream(outputPath);
786
+ const archive = new ZipArchive({ zlib: { level: 9 } });
787
+ output.on("close", () => {
788
+ res(archive.pointer());
789
+ });
790
+ output.on("error", reject);
791
+ archive.on("error", reject);
792
+ archive.pipe(output);
793
+ archive.directory(distDir, false);
794
+ archive.finalize();
795
+ });
796
+ }
797
+ /**
798
+ * Run the export process after Vite build completes.
799
+ * Writes manifest XML into dist/, then packages into ZIP if needed.
800
+ */
801
+ /** Remove any previously built zips for this package to prevent accumulation. */
802
+ function cleanOldZips(projectRoot, slug) {
803
+ try {
804
+ for (const f of readdirSync(projectRoot)) if (f.startsWith(`${slug}-`) && f.endsWith(".zip")) try {
805
+ unlinkSync(resolve(projectRoot, f));
806
+ } catch {}
807
+ } catch {}
808
+ }
809
+ async function runExport(projectRoot, config) {
810
+ const distDir = resolve(projectRoot, "dist");
811
+ const standard = config.export?.standard || "web";
812
+ const slug = slugify(config.title || "tessera-course") || "tessera-course";
813
+ const zipName = `${slug}-${config.version || "1.0.0"}.zip`;
814
+ const zipPath = resolve(projectRoot, zipName);
815
+ switch (standard) {
816
+ case "web": {
817
+ const files = collectFiles(distDir);
818
+ let totalSize = 0;
819
+ for (const f of files) totalSize += statSync(resolve(distDir, f)).size;
820
+ console.log(`✓ Web export: dist/ (${formatSize(totalSize)})`);
821
+ break;
822
+ }
823
+ case "scorm12": {
824
+ const manifest = generateSCORM12Manifest(config, distDir);
825
+ writeFileSync(resolve(distDir, "imsmanifest.xml"), manifest, "utf-8");
826
+ cleanOldZips(projectRoot, slug);
827
+ const zipSize = await createZip(distDir, zipPath);
828
+ console.log(`✓ SCORM 1.2 export: ${zipName} (${formatSize(zipSize)})`);
829
+ break;
830
+ }
831
+ case "scorm2004": {
832
+ const manifest = generateSCORM2004Manifest(config, distDir);
833
+ writeFileSync(resolve(distDir, "imsmanifest.xml"), manifest, "utf-8");
834
+ cleanOldZips(projectRoot, slug);
835
+ const zipSize = await createZip(distDir, zipPath);
836
+ console.log(`✓ SCORM 2004 export: ${zipName} (${formatSize(zipSize)})`);
837
+ break;
838
+ }
839
+ case "cmi5": {
840
+ const xml = generateCMI5Xml(config);
841
+ writeFileSync(resolve(distDir, "cmi5.xml"), xml, "utf-8");
842
+ cleanOldZips(projectRoot, slug);
843
+ const zipSize = await createZip(distDir, zipPath);
844
+ console.log(`✓ CMI5 export: ${zipName} (${formatSize(zipSize)})`);
845
+ break;
846
+ }
847
+ }
848
+ }
849
+ //#endregion
850
+ //#region src/plugin/layout.ts
851
+ const VIRTUAL_LAYOUT_ID = "virtual:tessera-layout";
852
+ const RESOLVED_LAYOUT_ID = "\0" + VIRTUAL_LAYOUT_ID;
853
+ function tesseraLayoutPlugin() {
854
+ let projectRoot;
855
+ return {
856
+ name: "tessera:layout",
857
+ enforce: "pre",
858
+ configResolved(config) {
859
+ projectRoot = config.root;
860
+ },
861
+ resolveId(id) {
862
+ if (id === VIRTUAL_LAYOUT_ID) return RESOLVED_LAYOUT_ID;
863
+ return null;
864
+ },
865
+ load(id) {
866
+ if (id !== RESOLVED_LAYOUT_ID) return null;
867
+ const layoutPath = resolve(projectRoot, "layout.svelte");
868
+ if (existsSync(layoutPath)) {
869
+ this.addWatchFile(layoutPath);
870
+ return `export { default } from '${layoutPath.replace(/\\/g, "/")}';`;
871
+ }
872
+ return `export default null;`;
873
+ },
874
+ configureServer(server) {
875
+ const layoutPath = resolve(projectRoot, "layout.svelte");
876
+ server.watcher.on("all", (event, filePath) => {
877
+ if (filePath !== layoutPath) return;
878
+ if (event !== "add" && event !== "unlink") return;
879
+ const mod = server.moduleGraph.getModuleById(RESOLVED_LAYOUT_ID);
880
+ if (mod) server.moduleGraph.invalidateModule(mod);
881
+ server.ws.send({ type: "full-reload" });
882
+ });
883
+ }
884
+ };
885
+ }
886
+ //#endregion
887
+ //#region src/plugin/quiz.ts
888
+ const VIRTUAL_QUIZ_ID = "virtual:tessera-quiz";
889
+ const RESOLVED_QUIZ_ID = "\0" + VIRTUAL_QUIZ_ID;
890
+ const __dirname$1 = dirname(fileURLToPath(import.meta.url));
891
+ /**
892
+ * Resolve the project's quiz shell.
893
+ * `projectRoot/quiz.svelte` overrides the built-in `<Quiz>` if it exists,
894
+ * otherwise the built-in is used. Mirrors `tesseraLayoutPlugin` (Phase 3A).
895
+ */
896
+ function tesseraQuizPlugin() {
897
+ let projectRoot;
898
+ const builtinQuiz = resolve(resolve(__dirname$1, "..", ".."), "src", "components", "Quiz.svelte");
899
+ return {
900
+ name: "tessera:quiz",
901
+ enforce: "pre",
902
+ configResolved(config) {
903
+ projectRoot = config.root;
904
+ },
905
+ resolveId(id) {
906
+ if (id === VIRTUAL_QUIZ_ID) return RESOLVED_QUIZ_ID;
907
+ return null;
908
+ },
909
+ load(id) {
910
+ if (id !== RESOLVED_QUIZ_ID) return null;
911
+ const userQuizPath = resolve(projectRoot, "quiz.svelte");
912
+ if (existsSync(userQuizPath)) {
913
+ this.addWatchFile(userQuizPath);
914
+ return `export { default } from '${userQuizPath.replace(/\\/g, "/")}';`;
915
+ }
916
+ return `export { default } from '${builtinQuiz.replace(/\\/g, "/")}';`;
917
+ },
918
+ configureServer(server) {
919
+ const userQuizPath = resolve(projectRoot, "quiz.svelte");
920
+ server.watcher.on("all", (event, filePath) => {
921
+ if (filePath !== userQuizPath) return;
922
+ if (event !== "add" && event !== "unlink") return;
923
+ const mod = server.moduleGraph.getModuleById(RESOLVED_QUIZ_ID);
924
+ if (mod) server.moduleGraph.invalidateModule(mod);
925
+ server.ws.send({ type: "full-reload" });
926
+ });
927
+ }
928
+ };
929
+ }
930
+ //#endregion
931
+ //#region src/plugin/index.ts
932
+ const __dirname = dirname(fileURLToPath(import.meta.url));
933
+ function resolveRuntimeDir() {
934
+ return resolve(resolve(__dirname, "..", ".."), "src", "runtime");
935
+ }
936
+ function resolveStylesDir() {
937
+ return resolve(resolve(__dirname, "..", ".."), "styles");
938
+ }
939
+ function tesseraPlugin() {
940
+ return [
941
+ svelte({ compilerOptions: { css: "injected" } }),
942
+ tesseraValidationPlugin(),
943
+ tesseraEntryPlugin(),
944
+ tesseraConfigPlugin(),
945
+ tesseraPagesPlugin(),
946
+ tesseraManifestPlugin(),
947
+ tesseraLayoutPlugin(),
948
+ tesseraQuizPlugin(),
949
+ tesseraExportPlugin()
950
+ ];
951
+ }
952
+ const VIRTUAL_ENTRY_ID = "virtual:tessera-entry";
953
+ const RESOLVED_ENTRY_ID = "\0" + VIRTUAL_ENTRY_ID;
954
+ const VIRTUAL_MAIN_ID = "/virtual:tessera-main";
955
+ const RESOLVED_MAIN_ID = "\0virtual:tessera-main";
956
+ function tesseraEntryPlugin() {
957
+ const runtimeDir = resolveRuntimeDir();
958
+ const stylesDir = resolveStylesDir();
959
+ const appSveltePath = resolve(runtimeDir, "App.svelte");
960
+ let projectRoot;
961
+ let isBuild = false;
962
+ return {
963
+ name: "tessera:entry",
964
+ enforce: "pre",
965
+ configResolved(config) {
966
+ projectRoot = config.root;
967
+ isBuild = config.command === "build";
968
+ },
969
+ buildStart() {
970
+ if (isBuild) writeFileSync(resolve(projectRoot, "index.html"), generateIndexHtml(), "utf-8");
971
+ },
972
+ closeBundle() {
973
+ if (isBuild) {
974
+ const htmlPath = resolve(projectRoot, "index.html");
975
+ if (existsSync(htmlPath)) try {
976
+ unlinkSync(htmlPath);
977
+ } catch {}
978
+ const assetsDir = resolve(projectRoot, "assets");
979
+ const distAssetsDir = resolve(projectRoot, "dist", "assets");
980
+ if (existsSync(assetsDir)) {
981
+ mkdirSync(distAssetsDir, { recursive: true });
982
+ cpSync(assetsDir, distAssetsDir, { recursive: true });
983
+ }
984
+ }
985
+ },
986
+ configureServer(server) {
987
+ return () => {
988
+ server.middlewares.use(async (req, res, next) => {
989
+ if (req.url === "/" || req.url === "/index.html") {
990
+ const html = generateIndexHtml();
991
+ const transformed = await server.transformIndexHtml(req.url, html);
992
+ res.setHeader("Content-Type", "text/html");
993
+ res.statusCode = 200;
994
+ res.end(transformed);
995
+ return;
996
+ }
997
+ next();
998
+ });
999
+ };
1000
+ },
1001
+ resolveId(id) {
1002
+ if (id === VIRTUAL_ENTRY_ID) return RESOLVED_ENTRY_ID;
1003
+ if (id === VIRTUAL_MAIN_ID || id === "virtual:tessera-main") return RESOLVED_MAIN_ID;
1004
+ return null;
1005
+ },
1006
+ load(id) {
1007
+ if (id === RESOLVED_ENTRY_ID || id === RESOLVED_MAIN_ID) return generateEntryScript(appSveltePath, stylesDir, projectRoot);
1008
+ return null;
1009
+ }
1010
+ };
1011
+ }
1012
+ function generateIndexHtml() {
1013
+ return `<!DOCTYPE html>
1014
+ <html lang="en">
1015
+ <head>
1016
+ <meta charset="UTF-8" />
1017
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
1018
+ <title>Tessera Course</title>
1019
+ </head>
1020
+ <body>
1021
+ <div id="tessera-root"></div>
1022
+ <script type="module" src="/virtual:tessera-main"><\/script>
1023
+ </body>
1024
+ </html>`;
1025
+ }
1026
+ function generateEntryScript(appSveltePath, frameworkStylesDir, projectRoot) {
1027
+ const normalizedPath = appSveltePath.replace(/\\/g, "/");
1028
+ const frameworkImports = [
1029
+ "theme.css",
1030
+ "base.css",
1031
+ "layout.css"
1032
+ ].map((file) => resolve(frameworkStylesDir, file).replace(/\\/g, "/")).filter((path) => existsSync(path)).map((path) => `import '${path}';`).join("\n");
1033
+ const userStylesDir = resolve(projectRoot, "styles");
1034
+ let userImports = "";
1035
+ if (existsSync(userStylesDir)) userImports = readdirSync(userStylesDir).filter((f) => f.endsWith(".css")).sort().map((f) => resolve(userStylesDir, f).replace(/\\/g, "/")).map((path) => `import '${path}';`).join("\n");
1036
+ return `// Framework styles
1037
+ ${frameworkImports}
1038
+ // User styles
1039
+ ${userImports}
1040
+
1041
+ import { mount } from 'svelte';
1042
+ import App from '${normalizedPath}';
1043
+
1044
+ mount(App, {
1045
+ target: document.getElementById('tessera-root'),
1046
+ });
1047
+ `;
1048
+ }
1049
+ const VIRTUAL_CONFIG_ID = "virtual:tessera-config";
1050
+ const RESOLVED_CONFIG_ID = "\0" + VIRTUAL_CONFIG_ID;
1051
+ function tesseraConfigPlugin() {
1052
+ let projectRoot;
1053
+ return {
1054
+ name: "tessera:config",
1055
+ enforce: "pre",
1056
+ config(config) {
1057
+ return {
1058
+ base: "./",
1059
+ resolve: { alias: { "$assets": resolve(config.root || process.cwd(), "assets") } }
1060
+ };
1061
+ },
1062
+ configResolved(config) {
1063
+ projectRoot = config.root;
1064
+ },
1065
+ resolveId(id) {
1066
+ if (id === VIRTUAL_CONFIG_ID) return RESOLVED_CONFIG_ID;
1067
+ return null;
1068
+ },
1069
+ load(id) {
1070
+ if (id === RESOLVED_CONFIG_ID) {
1071
+ const configPath = resolve(projectRoot, "course.config.js");
1072
+ let userConfig = {};
1073
+ if (existsSync(configPath)) {
1074
+ this.addWatchFile(configPath);
1075
+ const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, "utf-8"));
1076
+ if (objectStr) try {
1077
+ userConfig = JSON5.parse(objectStr);
1078
+ } catch {}
1079
+ }
1080
+ const merged = {
1081
+ title: userConfig.title || "Untitled Course",
1082
+ ...userConfig,
1083
+ navigation: {
1084
+ mode: "free",
1085
+ ...userConfig.navigation
1086
+ },
1087
+ completion: {
1088
+ mode: "percentage",
1089
+ percentageThreshold: 100,
1090
+ ...userConfig.completion
1091
+ },
1092
+ scoring: {
1093
+ passingScore: 70,
1094
+ ...userConfig.scoring
1095
+ },
1096
+ export: {
1097
+ standard: "web",
1098
+ ...userConfig.export
1099
+ }
1100
+ };
1101
+ return `export default ${JSON.stringify(merged)};`;
1102
+ }
1103
+ return null;
1104
+ }
1105
+ };
1106
+ }
1107
+ /** Register all _meta.js and .svelte files under pagesDir as watch files for build mode. */
1108
+ function addWatchFiles(ctx, dir) {
1109
+ if (!existsSync(dir)) return;
1110
+ for (const entry of readdirSync(dir)) {
1111
+ const full = resolve(dir, entry);
1112
+ if (statSync(full).isDirectory()) addWatchFiles(ctx, full);
1113
+ else if (entry.endsWith(".svelte") || entry === "_meta.js") ctx.addWatchFile(full);
1114
+ }
1115
+ }
1116
+ const VIRTUAL_PAGES_ID = "virtual:tessera-pages";
1117
+ const RESOLVED_PAGES_ID = "\0" + VIRTUAL_PAGES_ID;
1118
+ /**
1119
+ * Provides a virtual module that exports an import.meta.glob map for all .svelte
1120
+ * pages. This runs in the user's project context so the glob resolves against their
1121
+ * pages/ directory, and Vite can statically analyze it for code splitting.
1122
+ */
1123
+ function tesseraPagesPlugin() {
1124
+ return {
1125
+ name: "tessera:pages",
1126
+ enforce: "pre",
1127
+ resolveId(id) {
1128
+ if (id === VIRTUAL_PAGES_ID) return RESOLVED_PAGES_ID;
1129
+ return null;
1130
+ },
1131
+ load(id) {
1132
+ if (id === RESOLVED_PAGES_ID) return `export default import.meta.glob('/pages/**/*.svelte');`;
1133
+ return null;
1134
+ }
1135
+ };
1136
+ }
1137
+ function tesseraValidationPlugin() {
1138
+ let projectRoot;
1139
+ let isBuild = false;
1140
+ return {
1141
+ name: "tessera:validation",
1142
+ enforce: "pre",
1143
+ configResolved(config) {
1144
+ projectRoot = config.root;
1145
+ isBuild = config.command === "build";
1146
+ if (!isBuild) runValidation(projectRoot);
1147
+ },
1148
+ buildStart() {
1149
+ if (isBuild) runValidation(projectRoot);
1150
+ }
1151
+ };
1152
+ }
1153
+ function runValidation(projectRoot) {
1154
+ const { errors, warnings } = validateProject(projectRoot);
1155
+ for (const warning of warnings) console.warn(`\x1b[33m[tessera warning]\x1b[0m ${warning}`);
1156
+ if (errors.length > 0) {
1157
+ for (const error of errors) console.error(`\x1b[31m[tessera error]\x1b[0m ${error}`);
1158
+ throw new Error(`Tessera validation failed with ${errors.length} error(s). Fix the errors above to continue.`);
1159
+ }
1160
+ }
1161
+ function tesseraExportPlugin() {
1162
+ let projectRoot;
1163
+ let isBuild = false;
1164
+ return {
1165
+ name: "tessera:export",
1166
+ enforce: "post",
1167
+ configResolved(config) {
1168
+ projectRoot = config.root;
1169
+ isBuild = config.command === "build";
1170
+ },
1171
+ async closeBundle() {
1172
+ if (!isBuild) return;
1173
+ const configPath = resolve(projectRoot, "course.config.js");
1174
+ if (!existsSync(configPath)) throw new Error("[tessera:export] course.config.js not found at closeBundle. The file must exist for the export step to run.");
1175
+ const objectStr = extractDefaultExportObjectLiteral(readFileSync(configPath, "utf-8"));
1176
+ if (!objectStr) throw new Error("[tessera:export] course.config.js: could not locate `export default { ... }`. Cannot determine export.standard.");
1177
+ let config;
1178
+ try {
1179
+ config = JSON5.parse(objectStr);
1180
+ } catch (err) {
1181
+ throw new Error(`[tessera:export] course.config.js: failed to parse export-default object literal — ${err.message}`);
1182
+ }
1183
+ await runExport(projectRoot, config);
1184
+ }
1185
+ };
1186
+ }
1187
+ const VIRTUAL_MANIFEST_ID = "virtual:tessera-manifest";
1188
+ const RESOLVED_MANIFEST_ID = "\0" + VIRTUAL_MANIFEST_ID;
1189
+ function tesseraManifestPlugin() {
1190
+ let projectRoot;
1191
+ let pagesDir;
1192
+ let currentManifest = null;
1193
+ function buildManifest() {
1194
+ currentManifest = generateManifest(pagesDir);
1195
+ return currentManifest;
1196
+ }
1197
+ return {
1198
+ name: "tessera:manifest",
1199
+ enforce: "pre",
1200
+ configResolved(config) {
1201
+ projectRoot = config.root;
1202
+ pagesDir = resolve(projectRoot, "pages");
1203
+ },
1204
+ configureServer(devServer) {
1205
+ devServer.watcher.on("all", (event, filePath) => {
1206
+ if (!filePath.startsWith(pagesDir)) return;
1207
+ if (filePath.endsWith(".svelte") || filePath.endsWith("_meta.js") || event === "addDir" || event === "unlinkDir") {
1208
+ currentManifest = null;
1209
+ const mod = devServer.moduleGraph.getModuleById(RESOLVED_MANIFEST_ID);
1210
+ if (mod) {
1211
+ devServer.moduleGraph.invalidateModule(mod);
1212
+ devServer.ws.send({ type: "full-reload" });
1213
+ }
1214
+ console.log(`[tessera] Manifest rebuilt (${event}: ${filePath.replace(projectRoot, "")})`);
1215
+ }
1216
+ });
1217
+ },
1218
+ buildStart() {
1219
+ buildManifest();
1220
+ },
1221
+ resolveId(id) {
1222
+ if (id === VIRTUAL_MANIFEST_ID) return RESOLVED_MANIFEST_ID;
1223
+ return null;
1224
+ },
1225
+ load(id) {
1226
+ if (id === RESOLVED_MANIFEST_ID) {
1227
+ if (!currentManifest) buildManifest();
1228
+ addWatchFiles(this, pagesDir);
1229
+ const json = JSON.stringify(currentManifest, (_key, value) => value === Infinity ? 1e9 : value);
1230
+ return `export default JSON.parse(atob("${Buffer.from(json).toString("base64")}"));`;
1231
+ }
1232
+ return null;
1233
+ }
1234
+ };
1235
+ }
1236
+ //#endregion
1237
+ export { tesseraPlugin };
1238
+
1239
+ //# sourceMappingURL=index.js.map