tessera-learn 0.0.6 → 0.0.8

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.
@@ -64,7 +64,15 @@ export function validateProject(projectRoot: string): ValidationResult {
64
64
  errors.push(...pageResults.errors);
65
65
  warnings.push(...pageResults.warnings);
66
66
 
67
- // 4. Cross-cutting validations
67
+ // 4. Contract-bypass checks on project-root shell files
68
+ for (const shellFile of ['layout.svelte', 'quiz.svelte']) {
69
+ const shellPath = resolve(projectRoot, shellFile);
70
+ if (existsSync(shellPath)) {
71
+ validateContractBypass(readSourceFileCached(shellPath), shellFile, errors);
72
+ }
73
+ }
74
+
75
+ // 5. Cross-cutting validations
68
76
  if (config) {
69
77
  crossValidate(config, pageResults, errors, warnings);
70
78
  }
@@ -498,6 +506,57 @@ interface PagesValidationResult extends ValidationResult {
498
506
  pages: PageInfo[];
499
507
  }
500
508
 
509
+ /**
510
+ * Validate a single page .svelte file. Used for both section-level (flat) and
511
+ * lesson-level pages — the validation is identical, only the containing
512
+ * directory differs.
513
+ */
514
+ function validatePageFile(
515
+ filePath: string,
516
+ projectRoot: string,
517
+ assetsDir: string,
518
+ navIndex: number,
519
+ errors: string[],
520
+ warnings: string[],
521
+ assetExistsCache: Map<string, boolean>
522
+ ): { page: PageInfo; isQuiz: boolean; isGradedQuiz: boolean } {
523
+ const fileRel = relative(projectRoot, filePath);
524
+ const content = readSourceFileCached(filePath);
525
+
526
+ const pageConfig = validatePageConfig(content, fileRel, errors);
527
+
528
+ const isQuiz = !!pageConfig?.quiz;
529
+ let isGradedQuiz = false;
530
+ if (pageConfig?.quiz) {
531
+ validateQuizConfig(pageConfig.quiz, fileRel, errors);
532
+ if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
533
+ isGradedQuiz = true;
534
+ }
535
+ }
536
+
537
+ const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
538
+
539
+ validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
540
+ validateQuestionComponents(content, fileRel, errors);
541
+ validateContractBypass(content, fileRel, errors);
542
+ if (
543
+ pageConfig?.quiz &&
544
+ !HAS_USE_QUESTION_RE.test(content) &&
545
+ !HAS_QUESTION_TAG_RE.test(content)
546
+ ) {
547
+ warnings.push(
548
+ `${fileRel}: quiz page has no question components or useQuestion() calls — ` +
549
+ `the quiz will have nothing to score`
550
+ );
551
+ }
552
+
553
+ return {
554
+ page: { fileRel, navIndex, hasGradedQuiz: isGradedQuiz, hasQuiz: isQuiz, completesOnView },
555
+ isQuiz,
556
+ isGradedQuiz,
557
+ };
558
+ }
559
+
501
560
  function validatePages(
502
561
  pagesDir: string,
503
562
  assetsDir: string,
@@ -581,34 +640,19 @@ function validatePages(
581
640
  }
582
641
 
583
642
  for (const fileName of sectionSvelteFiles) {
584
- const filePath = resolve(sectionPath, fileName);
585
- const fileRel = relative(projectRoot, filePath);
586
- const content = readSourceFileCached(filePath);
587
-
588
- const pageConfig = validatePageConfig(content, fileRel, errors);
589
- const navIndex = totalPages;
643
+ const result = validatePageFile(
644
+ resolve(sectionPath, fileName),
645
+ projectRoot,
646
+ assetsDir,
647
+ totalPages,
648
+ errors,
649
+ warnings,
650
+ assetExistsCache
651
+ );
590
652
  totalPages++;
591
-
592
- let pageHasGradedQuiz = false;
593
- if (pageConfig?.quiz) {
594
- totalQuizzes++;
595
- validateQuizConfig(pageConfig.quiz, fileRel, errors);
596
- if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
597
- hasGradedQuiz = true;
598
- pageHasGradedQuiz = true;
599
- }
600
- }
601
-
602
- const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
603
- pages.push({
604
- fileRel,
605
- navIndex,
606
- hasGradedQuiz: pageHasGradedQuiz,
607
- hasQuiz: !!pageConfig?.quiz,
608
- completesOnView,
609
- });
610
-
611
- validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
653
+ if (result.isQuiz) totalQuizzes++;
654
+ if (result.isGradedQuiz) hasGradedQuiz = true;
655
+ pages.push(result.page);
612
656
  }
613
657
 
614
658
  // Get lesson directories
@@ -663,38 +707,19 @@ function validatePages(
663
707
 
664
708
  // Validate each .svelte file
665
709
  for (const fileName of svelteFiles) {
666
- const filePath = resolve(lessonPath, fileName);
667
- const fileRel = relative(projectRoot, filePath);
668
- const content = readSourceFileCached(filePath);
669
-
670
- const pageConfig = validatePageConfig(content, fileRel, errors);
671
- const navIndex = totalPages;
710
+ const result = validatePageFile(
711
+ resolve(lessonPath, fileName),
712
+ projectRoot,
713
+ assetsDir,
714
+ totalPages,
715
+ errors,
716
+ warnings,
717
+ assetExistsCache
718
+ );
672
719
  totalPages++;
673
-
674
- let pageHasGradedQuiz = false;
675
- if (pageConfig?.quiz) {
676
- totalQuizzes++;
677
-
678
- // Validate quiz config
679
- validateQuizConfig(pageConfig.quiz, fileRel, errors);
680
-
681
- if ((pageConfig.quiz as { graded?: unknown }).graded === true) {
682
- hasGradedQuiz = true;
683
- pageHasGradedQuiz = true;
684
- }
685
- }
686
-
687
- const completesOnView = validateCompletesOn(pageConfig, fileRel, errors);
688
- pages.push({
689
- fileRel,
690
- navIndex,
691
- hasGradedQuiz: pageHasGradedQuiz,
692
- hasQuiz: !!pageConfig?.quiz,
693
- completesOnView,
694
- });
695
-
696
- // Check $assets references
697
- validateAssetRefs(content, fileRel, assetsDir, warnings, assetExistsCache);
720
+ if (result.isQuiz) totalQuizzes++;
721
+ if (result.isGradedQuiz) hasGradedQuiz = true;
722
+ pages.push(result.page);
698
723
  }
699
724
  }
700
725
  }
@@ -774,7 +799,7 @@ function validateCompletesOn(
774
799
 
775
800
  function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): void {
776
801
  if (!quiz || typeof quiz !== 'object') return;
777
- const cfg = quiz as { maxAttempts?: unknown; graded?: unknown };
802
+ const cfg = quiz as Record<string, unknown>;
778
803
 
779
804
  if (cfg.maxAttempts !== undefined) {
780
805
  const val = cfg.maxAttempts;
@@ -785,9 +810,258 @@ function validateQuizConfig(quiz: unknown, fileRel: string, errors: string[]): v
785
810
  }
786
811
  }
787
812
 
788
- if (cfg.graded !== undefined && typeof cfg.graded !== 'boolean') {
813
+ for (const field of ['graded', 'gatesProgress', 'showFeedback']) {
814
+ if (cfg[field] !== undefined && typeof cfg[field] !== 'boolean') {
815
+ errors.push(
816
+ `${fileRel}: quiz.${field} must be a boolean, got ${typeof cfg[field]}`
817
+ );
818
+ }
819
+ }
820
+ }
821
+
822
+ // ---------- Question Component Validation ----------
823
+
824
+ const QUESTION_COMPONENT_REQUIRED: Record<string, string[]> = {
825
+ MultipleChoice: ['question', 'options', 'correct'],
826
+ FillInTheBlank: ['question', 'answers'],
827
+ Matching: ['question', 'pairs'],
828
+ Sorting: ['question', 'items', 'targets', 'correct'],
829
+ };
830
+
831
+ type PropValue =
832
+ | { kind: 'string'; value: string }
833
+ | { kind: 'expr'; raw: string }
834
+ | { kind: 'bool' };
835
+
836
+ /** Extract a balanced {...} or [...] span starting at startIndex, or null. */
837
+ function extractBalanced(source: string, startIndex: number): string | null {
838
+ const open = source[startIndex];
839
+ if (open !== '{' && open !== '[') return null;
840
+ let depth = 0;
841
+ let inString: string | null = null;
842
+ let escaped = false;
843
+ for (let i = startIndex; i < source.length; i++) {
844
+ const char = source[i];
845
+ if (escaped) {
846
+ escaped = false;
847
+ continue;
848
+ }
849
+ if (char === '\\' && inString) {
850
+ escaped = true;
851
+ continue;
852
+ }
853
+ if (inString) {
854
+ if (char === inString) inString = null;
855
+ continue;
856
+ }
857
+ if (char === '"' || char === "'" || char === '`') {
858
+ inString = char;
859
+ continue;
860
+ }
861
+ if (char === '{' || char === '[') depth++;
862
+ if (char === '}' || char === ']') {
863
+ depth--;
864
+ if (depth === 0) return source.slice(startIndex, i + 1);
865
+ }
866
+ }
867
+ return null;
868
+ }
869
+
870
+ /**
871
+ * Parse the props of an opening tag starting just after the component name.
872
+ * Returns null if the tag can't be parsed cleanly — callers then skip it
873
+ * rather than risk a false positive.
874
+ */
875
+ function parseTagProps(content: string, start: number): Map<string, PropValue> | null {
876
+ const props = new Map<string, PropValue>();
877
+ let i = start;
878
+ while (i < content.length) {
879
+ while (i < content.length && /\s/.test(content[i])) i++;
880
+ if (i >= content.length) return null;
881
+ const c = content[i];
882
+ if (c === '>') return props;
883
+ if (c === '/' && content[i + 1] === '>') return props;
884
+ // Spread / shorthand expression — skip the whole {...} block.
885
+ if (c === '{') {
886
+ const block = extractBalanced(content, i);
887
+ if (!block) return null;
888
+ i += block.length;
889
+ continue;
890
+ }
891
+ const nameMatch = /^[A-Za-z_][\w-]*/.exec(content.slice(i));
892
+ if (!nameMatch) return null;
893
+ const propName = nameMatch[0];
894
+ i += propName.length;
895
+ while (i < content.length && /\s/.test(content[i])) i++;
896
+ if (content[i] !== '=') {
897
+ props.set(propName, { kind: 'bool' });
898
+ continue;
899
+ }
900
+ i++;
901
+ while (i < content.length && /\s/.test(content[i])) i++;
902
+ const v = content[i];
903
+ if (v === '"' || v === "'") {
904
+ const end = content.indexOf(v, i + 1);
905
+ if (end === -1) return null;
906
+ props.set(propName, { kind: 'string', value: content.slice(i + 1, end) });
907
+ i = end + 1;
908
+ } else if (v === '{') {
909
+ const block = extractBalanced(content, i);
910
+ if (!block) return null;
911
+ props.set(propName, { kind: 'expr', raw: block.slice(1, -1).trim() });
912
+ i += block.length;
913
+ } else {
914
+ return null;
915
+ }
916
+ }
917
+ return null;
918
+ }
919
+
920
+ function staticArray(prop: PropValue | undefined): unknown[] | null {
921
+ if (prop?.kind !== 'expr' || !prop.raw.startsWith('[')) return null;
922
+ try {
923
+ const parsed = JSON5.parse(prop.raw);
924
+ return Array.isArray(parsed) ? parsed : null;
925
+ } catch {
926
+ return null;
927
+ }
928
+ }
929
+
930
+ function staticNumber(prop: PropValue | undefined): number | null {
931
+ if (prop?.kind !== 'expr') return null;
932
+ try {
933
+ const parsed = JSON5.parse(prop.raw);
934
+ return typeof parsed === 'number' ? parsed : null;
935
+ } catch {
936
+ return null;
937
+ }
938
+ }
939
+
940
+ function validateQuestionComponents(
941
+ content: string,
942
+ fileRel: string,
943
+ errors: string[]
944
+ ): void {
945
+ const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join('|');
946
+ const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, 'g');
947
+ const seenIds = new Set<string>();
948
+ let m: RegExpExecArray | null;
949
+ while ((m = tagStartRe.exec(content)) !== null) {
950
+ const name = m[1];
951
+ const props = parseTagProps(content, m.index + m[0].length);
952
+ if (!props) continue;
953
+
954
+ for (const req of QUESTION_COMPONENT_REQUIRED[name]) {
955
+ if (!props.has(req)) {
956
+ errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
957
+ }
958
+ }
959
+
960
+ const idProp = props.get('id');
961
+ if (idProp?.kind === 'string') {
962
+ if (seenIds.has(idProp.value)) {
963
+ errors.push(
964
+ `${fileRel}: duplicate question id "${idProp.value}" — each question on a page needs a unique id`
965
+ );
966
+ }
967
+ seenIds.add(idProp.value);
968
+ }
969
+
970
+ if (name === 'MultipleChoice') {
971
+ const options = staticArray(props.get('options'));
972
+ const correct = staticNumber(props.get('correct'));
973
+ if (options && correct !== null) {
974
+ if (!Number.isInteger(correct) || correct < 0 || correct >= options.length) {
975
+ errors.push(
976
+ `${fileRel}: <MultipleChoice> correct={${correct}} is out of range for ${options.length} options (valid: 0–${options.length - 1})`
977
+ );
978
+ }
979
+ }
980
+ } else if (name === 'Sorting') {
981
+ const items = staticArray(props.get('items'));
982
+ const targets = staticArray(props.get('targets'));
983
+ const correct = staticArray(props.get('correct'));
984
+ if (items && correct && correct.length !== items.length) {
985
+ errors.push(
986
+ `${fileRel}: <Sorting> correct has ${correct.length} entries but items has ${items.length} — they must be parallel arrays`
987
+ );
988
+ }
989
+ if (targets && correct) {
990
+ for (const idx of correct) {
991
+ if (
992
+ typeof idx !== 'number' ||
993
+ !Number.isInteger(idx) ||
994
+ idx < 0 ||
995
+ idx >= targets.length
996
+ ) {
997
+ errors.push(
998
+ `${fileRel}: <Sorting> correct contains ${JSON.stringify(idx)}, out of range for ${targets.length} targets (valid: 0–${targets.length - 1})`
999
+ );
1000
+ break;
1001
+ }
1002
+ }
1003
+ }
1004
+ } else if (name === 'Matching') {
1005
+ const pairs = staticArray(props.get('pairs'));
1006
+ if (pairs) {
1007
+ const bad = pairs.some(
1008
+ (p) =>
1009
+ typeof p !== 'object' ||
1010
+ p === null ||
1011
+ typeof (p as { left?: unknown }).left !== 'string' ||
1012
+ typeof (p as { right?: unknown }).right !== 'string'
1013
+ );
1014
+ if (bad) {
1015
+ errors.push(
1016
+ `${fileRel}: <Matching> pairs must be an array of { left: string, right: string } objects`
1017
+ );
1018
+ }
1019
+ }
1020
+ } else if (name === 'FillInTheBlank') {
1021
+ const answers = staticArray(props.get('answers'));
1022
+ if (answers) {
1023
+ if (answers.length === 0) {
1024
+ errors.push(`${fileRel}: <FillInTheBlank> answers must not be empty`);
1025
+ } else if (answers.some((a) => typeof a !== 'string')) {
1026
+ errors.push(
1027
+ `${fileRel}: <FillInTheBlank> answers must be an array of strings`
1028
+ );
1029
+ }
1030
+ }
1031
+ }
1032
+ }
1033
+ }
1034
+
1035
+ // ---------- Contract Bypass Detection ----------
1036
+
1037
+ const QUIZ_COMPLETE_DISPATCH_RE =
1038
+ /(?:new\s+CustomEvent\s*\(\s*['"]tessera-quiz-complete['"]|dispatchEvent\s*\([\s\S]{0,120}tessera-quiz-complete)/;
1039
+ const RUNTIME_INTERNAL_IMPORT_RE = /from\s+['"]tessera-learn\/runtime\//;
1040
+ const HAS_USE_QUESTION_RE = /\buseQuestion\s*\(/;
1041
+ const HAS_QUESTION_TAG_RE = new RegExp(
1042
+ `<(${Object.keys(QUESTION_COMPONENT_REQUIRED).join('|')})(?=[\\s/>])`
1043
+ );
1044
+
1045
+ /**
1046
+ * Detect ways an author file can bypass the LMS data contract. These check
1047
+ * source text for known escape hatches — they never inspect course content,
1048
+ * so they constrain how you wire things up, not what you build.
1049
+ */
1050
+ function validateContractBypass(
1051
+ content: string,
1052
+ fileRel: string,
1053
+ errors: string[]
1054
+ ): void {
1055
+ if (QUIZ_COMPLETE_DISPATCH_RE.test(content)) {
1056
+ errors.push(
1057
+ `${fileRel}: dispatches "tessera-quiz-complete" directly — submit through ` +
1058
+ `useQuiz().submit() so the result reaches the LMS`
1059
+ );
1060
+ }
1061
+ if (RUNTIME_INTERNAL_IMPORT_RE.test(content)) {
789
1062
  errors.push(
790
- `${fileRel}: quiz.graded must be a boolean, got ${typeof cfg.graded}`
1063
+ `${fileRel}: imports from tessera-learn/runtime/* use the public hooks ` +
1064
+ `(useQuiz, useQuestion, useNavigation, …) instead`
791
1065
  );
792
1066
  }
793
1067
  }
@@ -36,7 +36,7 @@ function missingApiError(
36
36
  standard,
37
37
  `Tessera: this course is configured for ${label} but ${detail} ` +
38
38
  `The course must be launched from an LMS that provides the ${label} runtime. ` +
39
- `If you are testing locally, run \`npm run preview\` instead, or set export.standard to "web".`
39
+ `If you are testing locally, run \`npm run dev\` instead, or set export.standard to "web".`
40
40
  );
41
41
  }
42
42
 
@@ -1,4 +1,4 @@
1
- import { getContext, setContext, onDestroy } from 'svelte';
1
+ import { getContext, setContext, onDestroy, onMount, tick } from 'svelte';
2
2
  import type { Interaction } from './interaction.js';
3
3
  import { isCorrect as isCorrectInteraction } from './interaction.js';
4
4
  import {
@@ -311,6 +311,20 @@ export function __warnUnsubmittedQuiz(stats: {
311
311
  );
312
312
  }
313
313
 
314
+ /**
315
+ * Dev warning helper for a quiz host that mounts with no questions registered
316
+ * through useQuestion(). Such a page has a quiz wrapper but nothing the runtime
317
+ * can score or report to the LMS. Exported so tests can exercise the warning
318
+ * without depending on jsdom mount timing under vitest.
319
+ */
320
+ export function __warnEmptyQuiz(questionsCount: number): void {
321
+ if (questionsCount > 0) return;
322
+ console.warn(
323
+ '[tessera] useQuiz: quiz mounted with no registered questions. Question widgets ' +
324
+ 'must call useQuestion() to be scored and reported to the LMS.'
325
+ );
326
+ }
327
+
314
328
  /**
315
329
  * Programmatic quiz orchestration for custom quiz shells. Returns a handle
316
330
  * exposing the same state machine `<Quiz>` runs internally — register
@@ -585,6 +599,13 @@ export function useQuiz(opts: { element: () => HTMLElement | null }): UseQuizHan
585
599
  get isLockedCorrect() { return (i: number) => lockedCorrect.has(i); },
586
600
  });
587
601
 
602
+ onMount(() => {
603
+ if (!import.meta.env?.DEV) return;
604
+ // Questions register synchronously as child widgets initialise; a tick()
605
+ // also covers any effect-driven registration before we check.
606
+ void tick().then(() => __warnEmptyQuiz(questions.length));
607
+ });
608
+
588
609
  onDestroy(() => {
589
610
  __warnUnsubmittedQuiz({
590
611
  questionsCount: questions.length,