tessera-learn 0.0.1 → 0.0.2

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.
@@ -6,6 +6,10 @@ import JSON5 from "json5";
6
6
  import { createHash } from "node:crypto";
7
7
  import { ZipArchive } from "archiver";
8
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
+ }
9
13
  /**
10
14
  * Module-level cache of source file contents keyed by absolute path with
11
15
  * mtime invalidation. Both `validateProject` and `generateManifest` read the
@@ -71,25 +75,34 @@ function readMetaFile(metaPath) {
71
75
  return {};
72
76
  }
73
77
  }
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 {};
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" };
80
82
  const scriptContent = moduleScriptMatch[1];
81
83
  const configMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
82
- if (!configMatch || configMatch.index === void 0) return {};
84
+ if (!configMatch || configMatch.index === void 0) return { kind: "none" };
85
+ if (!scriptContent.slice(configMatch.index + configMatch[0].length).trimStart().startsWith("{")) return { kind: "invalid" };
83
86
  const startIndex = scriptContent.indexOf("{", configMatch.index + configMatch[0].length);
84
- if (startIndex < 0) return {};
87
+ if (startIndex < 0) return { kind: "invalid" };
85
88
  const objectStr = extractObjectLiteral(scriptContent, startIndex);
86
- if (!objectStr) return {};
89
+ if (!objectStr) return { kind: "invalid" };
87
90
  try {
88
- return JSON5.parse(objectStr);
91
+ return {
92
+ kind: "ok",
93
+ value: JSON5.parse(objectStr)
94
+ };
89
95
  } catch {
90
- throw new Error(`${filePath}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
96
+ return { kind: "invalid" };
91
97
  }
92
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
+ }
93
106
  /**
94
107
  * Extract an object literal from source starting at the opening brace.
95
108
  * Tracks brace depth to find the matching closing brace.
@@ -215,7 +228,7 @@ function generateManifest(pagesDir) {
215
228
  */
216
229
  function orderPageFiles(allFiles, pagesArray) {
217
230
  if (!pagesArray || pagesArray.length === 0) return allFiles;
218
- const listed = pagesArray.map((name) => name.endsWith(".svelte") ? name : `${name}.svelte`);
231
+ const listed = pagesArray.map(ensureSvelteSuffix);
219
232
  const listedSet = new Set(listed);
220
233
  const unlisted = allFiles.filter((f) => !listedSet.has(f)).sort();
221
234
  return [...listed.filter((f) => allFiles.includes(f)), ...unlisted];
@@ -441,7 +454,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
441
454
  if (actor === void 0) {
442
455
  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
456
  } else if (typeof actor === "object" && actor !== null) {
444
- const err = validateStaticAgent(actor);
457
+ const err = validateAgent(actor);
445
458
  if (err) {
446
459
  const joined = err.startsWith(".") ? `${label}.actor${err}` : `${label}.actor ${err}`;
447
460
  errors.push(`course.config.js: ${joined}`);
@@ -474,18 +487,13 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
474
487
  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
488
  }
476
489
  }
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
490
  function validatePages(pagesDir, assetsDir, projectRoot) {
484
491
  const errors = [];
485
492
  const warnings = [];
486
493
  let totalPages = 0;
487
494
  let totalQuizzes = 0;
488
495
  let hasGradedQuiz = false;
496
+ const assetExistsCache = /* @__PURE__ */ new Map();
489
497
  if (!existsSync(pagesDir)) {
490
498
  errors.push("No pages found. Create at least one section with a lesson and page in pages/");
491
499
  return {
@@ -527,7 +535,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
527
535
  return name.endsWith(".svelte") && statSync(full).isFile();
528
536
  }).sort();
529
537
  if (sectionMeta?.pages) for (const pageName of sectionMeta.pages) {
530
- const fileName = pageName.endsWith(".svelte") ? pageName : `${pageName}.svelte`;
538
+ const fileName = ensureSvelteSuffix(pageName);
531
539
  if (!sectionSvelteFiles.includes(fileName)) {
532
540
  const metaRel = relative(projectRoot, resolve(sectionPath, "_meta.js"));
533
541
  errors.push(`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
@@ -544,7 +552,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
544
552
  validateQuizConfig(pageConfig.quiz, fileRel, errors);
545
553
  if (pageConfig.quiz.graded === true) hasGradedQuiz = true;
546
554
  }
547
- validateAssetRefs(content, fileRel, assetsDir, warnings);
555
+ validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
548
556
  }
549
557
  const lessonDirs = sectionEntries.filter((name) => {
550
558
  return statSync(resolve(sectionPath, name)).isDirectory() && !name.startsWith(".");
@@ -555,14 +563,14 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
555
563
  const meta = validateMetaFile(resolve(lessonPath, "_meta.js"), lessonRel, errors);
556
564
  const svelteFiles = readdirSync(lessonPath).filter((name) => name.endsWith(".svelte")).sort();
557
565
  if (meta?.pages) for (const pageName of meta.pages) {
558
- const fileName = pageName.endsWith(".svelte") ? pageName : `${pageName}.svelte`;
566
+ const fileName = ensureSvelteSuffix(pageName);
559
567
  if (!svelteFiles.includes(fileName)) {
560
568
  const metaRel = relative(projectRoot, resolve(lessonPath, "_meta.js"));
561
569
  errors.push(`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
562
570
  }
563
571
  }
564
572
  if (meta?.pages && meta.pages.length > 0) {
565
- const listedSet = new Set(meta.pages.map((p) => p.endsWith(".svelte") ? p : `${p}.svelte`));
573
+ const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
566
574
  for (const file of svelteFiles) if (!listedSet.has(file)) {
567
575
  const relPath = relative(projectRoot, resolve(lessonPath, file));
568
576
  warnings.push(`${relPath}: not listed in _meta.js pages array — will be appended at end`);
@@ -579,7 +587,7 @@ function validatePages(pagesDir, assetsDir, projectRoot) {
579
587
  validateQuizConfig(pageConfig.quiz, fileRel, errors);
580
588
  if (pageConfig.quiz.graded === true) hasGradedQuiz = true;
581
589
  }
582
- validateAssetRefs(content, fileRel, assetsDir, warnings);
590
+ validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
583
591
  }
584
592
  }
585
593
  }
@@ -611,26 +619,10 @@ function validateMetaFile(metaPath, parentRel, errors) {
611
619
  return meta;
612
620
  }
613
621
  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
- }
622
+ const result = parsePageConfigFromSource(content);
623
+ if (result.kind === "ok") return result.value;
624
+ if (result.kind === "invalid") errors.push(`${fileRel}: pageConfig must be a static object literal (no variables, function calls, or computed values)`);
625
+ return null;
634
626
  }
635
627
  function validateQuizConfig(quiz, fileRel, errors) {
636
628
  if (!quiz || typeof quiz !== "object") return;
@@ -641,12 +633,24 @@ function validateQuizConfig(quiz, fileRel, errors) {
641
633
  }
642
634
  if (cfg.graded !== void 0 && typeof cfg.graded !== "boolean") errors.push(`${fileRel}: quiz.graded must be a boolean, got ${typeof cfg.graded}`);
643
635
  }
644
- function validateAssetRefs(content, fileRel, assetsDir, warnings) {
645
- const assetRefPattern = /\$assets\/([^\s"'`)]+)/g;
636
+ const ASSET_REF_RE = /\$assets\/([^\s"'`)]+)/g;
637
+ /** Match $assets/... refs in any context (src attrs, import statements, url() etc) and dedupe. */
638
+ function collectAssetRefs(content) {
639
+ const seen = /* @__PURE__ */ new Set();
646
640
  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`);
641
+ ASSET_REF_RE.lastIndex = 0;
642
+ while ((match = ASSET_REF_RE.exec(content)) !== null) seen.add(match[1]);
643
+ return [...seen];
644
+ }
645
+ function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
646
+ for (const assetPath of collectAssetRefs(content)) {
647
+ const fullAssetPath = resolve(assetsDir, assetPath);
648
+ let exists = existsCache.get(fullAssetPath);
649
+ if (exists === void 0) {
650
+ exists = existsSync(fullAssetPath);
651
+ existsCache.set(fullAssetPath, exists);
652
+ }
653
+ if (!exists) warnings.push(`${fileRel}: "$assets/${assetPath}" not found in assets/ directory`);
650
654
  }
651
655
  }
652
656
  function crossValidate(config, pageResults, errors, warnings) {
@@ -712,15 +716,31 @@ function formatSize(bytes) {
712
716
  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
713
717
  return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
714
718
  }
715
- function generateSCORM12Manifest(config, distDir) {
719
+ const SCORM_DIALECTS = {
720
+ "1.2": {
721
+ rootNs: "http://www.imsproject.org/xsd/imscp_rootv1p1p2",
722
+ adlcpNs: "http://www.adlnet.org/xsd/adlcp_rootv1p2",
723
+ schemaversion: "1.2",
724
+ scormTypeAttr: "scormtype"
725
+ },
726
+ "2004": {
727
+ rootNs: "http://www.imsglobal.org/xsd/imscp_v1p1",
728
+ adlcpNs: "http://www.adlnet.org/xsd/adlcp_v1p3",
729
+ schemaversion: "2004 4th Edition",
730
+ scormTypeAttr: "scormType"
731
+ }
732
+ };
733
+ function generateScormManifest(version, config, distDir) {
734
+ const dialect = SCORM_DIALECTS[version];
716
735
  const title = escapeXml(config.title || "Tessera Course");
736
+ const fileElements = collectFiles(distDir).map((f) => ` <file href="${escapeXml(f)}" />`).join("\n");
717
737
  return `<?xml version="1.0" encoding="UTF-8"?>
718
738
  <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">
739
+ xmlns="${dialect.rootNs}"
740
+ xmlns:adlcp="${dialect.adlcpNs}">
721
741
  <metadata>
722
742
  <schema>ADL SCORM</schema>
723
- <schemaversion>1.2</schemaversion>
743
+ <schemaversion>${dialect.schemaversion}</schemaversion>
724
744
  </metadata>
725
745
  <organizations default="org-1">
726
746
  <organization identifier="org-1">
@@ -731,36 +751,17 @@ function generateSCORM12Manifest(config, distDir) {
731
751
  </organization>
732
752
  </organizations>
733
753
  <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")}
754
+ <resource identifier="res-1" type="webcontent" adlcp:${dialect.scormTypeAttr}="sco" href="index.html">
755
+ ${fileElements}
736
756
  </resource>
737
757
  </resources>
738
758
  </manifest>`;
739
759
  }
760
+ function generateSCORM12Manifest(config, distDir) {
761
+ return generateScormManifest("1.2", config, distDir);
762
+ }
740
763
  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
+ return generateScormManifest("2004", config, distDir);
764
765
  }
765
766
  function generateCMI5Xml(config) {
766
767
  const title = escapeXml(config.title || "Tessera Course");