tessera-learn 0.0.13 → 0.1.0

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 (56) hide show
  1. package/AGENTS.md +1744 -0
  2. package/README.md +2 -2
  3. package/dist/{validation-B-xTvM9B.js → audit-CzKAXy3Y.js} +591 -268
  4. package/dist/audit-CzKAXy3Y.js.map +1 -0
  5. package/dist/build-commands-D101M_qb.js +27 -0
  6. package/dist/build-commands-D101M_qb.js.map +1 -0
  7. package/dist/inline-config-DYHT51G8.js +29 -0
  8. package/dist/inline-config-DYHT51G8.js.map +1 -0
  9. package/dist/plugin/cli.d.ts +5 -1
  10. package/dist/plugin/cli.d.ts.map +1 -0
  11. package/dist/plugin/cli.js +108 -15
  12. package/dist/plugin/cli.js.map +1 -1
  13. package/dist/plugin/index.d.ts.map +1 -1
  14. package/dist/plugin/index.js +2 -763
  15. package/dist/plugin-y35ym9A3.js +744 -0
  16. package/dist/plugin-y35ym9A3.js.map +1 -0
  17. package/package.json +12 -9
  18. package/src/components/FillInTheBlank.svelte +2 -2
  19. package/src/components/Matching.svelte +2 -2
  20. package/src/components/MultipleChoice.svelte +2 -2
  21. package/src/components/RevealModal.svelte +48 -103
  22. package/src/components/Sorting.svelte +2 -2
  23. package/src/components/util.ts +9 -0
  24. package/src/plugin/a11y/audit.ts +35 -8
  25. package/src/plugin/a11y-cli.ts +35 -22
  26. package/src/plugin/ast.ts +276 -0
  27. package/src/plugin/build-commands.ts +25 -0
  28. package/src/plugin/cli.ts +53 -21
  29. package/src/plugin/index.ts +87 -122
  30. package/src/plugin/inline-config.ts +43 -0
  31. package/src/plugin/manifest.ts +103 -136
  32. package/src/plugin/package-root.ts +24 -0
  33. package/src/plugin/quiz.ts +8 -9
  34. package/src/plugin/validate-cli.ts +30 -0
  35. package/src/plugin/validation.ts +152 -244
  36. package/src/runtime/App.svelte +11 -97
  37. package/src/runtime/Sidebar.svelte +3 -1
  38. package/src/runtime/adapters/cmi5.ts +6 -10
  39. package/src/runtime/adapters/format.ts +6 -0
  40. package/src/runtime/adapters/retry.ts +1 -1
  41. package/src/runtime/adapters/scorm2004.ts +2 -4
  42. package/src/runtime/branding.ts +90 -0
  43. package/src/runtime/defaults.ts +3 -0
  44. package/src/runtime/hooks.svelte.ts +16 -53
  45. package/src/runtime/interaction-format.ts +3 -8
  46. package/src/runtime/progress.svelte.ts +47 -83
  47. package/src/runtime/xapi/derive-actor.ts +41 -48
  48. package/src/runtime/xapi/publisher.ts +14 -14
  49. package/src/runtime/xapi/setup.ts +39 -46
  50. package/dist/audit-BBJpQGqb.js +0 -204
  51. package/dist/audit-BBJpQGqb.js.map +0 -1
  52. package/dist/plugin/a11y-cli.d.ts +0 -1
  53. package/dist/plugin/a11y-cli.js +0 -36
  54. package/dist/plugin/a11y-cli.js.map +0 -1
  55. package/dist/plugin/index.js.map +0 -1
  56. package/dist/validation-B-xTvM9B.js.map +0 -1
@@ -3,17 +3,26 @@ import { resolve, relative } from 'node:path';
3
3
  import JSON5 from 'json5';
4
4
  import {
5
5
  extractDefaultExportObjectLiteral,
6
- extractObjectLiteral,
7
6
  parsePageConfigFromSource,
8
7
  readSourceFileCached,
9
8
  ensureSvelteSuffix,
10
9
  readCourseConfig,
10
+ orderPageFiles,
11
+ walkPages,
12
+ type WalkedLesson,
11
13
  } from './manifest.js';
14
+ import {
15
+ clearParseCache,
16
+ findComponents,
17
+ getParseError,
18
+ type PropValue,
19
+ } from './ast.js';
12
20
  import {
13
21
  validateAgent,
14
22
  validateAuthCredential,
15
23
  joinFieldError,
16
24
  } from '../runtime/xapi/agent-rules.js';
25
+ import { httpOrigin } from '../runtime/xapi/derive-actor.js';
17
26
  import { shortIdentifier } from '../runtime/interaction-format.js';
18
27
  import { FEEDBACK_MODES, RETRY_MODES } from '../runtime/types.js';
19
28
  import { contrastRatio } from './a11y/contrast.js';
@@ -174,6 +183,7 @@ const VALID_RETRY_MODES: readonly string[] = RETRY_MODES;
174
183
  * Returns errors (block build) and warnings (informational).
175
184
  */
176
185
  export function validateProject(projectRoot: string): ValidationResult {
186
+ clearParseCache();
177
187
  const errors: string[] = [];
178
188
  const warnings: string[] = [];
179
189
 
@@ -246,12 +256,10 @@ function parseConfig(
246
256
  if (!read.ok) {
247
257
  // 'missing' can't occur — validateProject checks existsSync first.
248
258
  if (read.reason === 'no-export') {
249
- errors.push(
250
- 'course.config.js: could not parse — must use `export default { ... }` syntax',
251
- );
259
+ errors.push('course.config.js: must use `export default { ... }` syntax');
252
260
  } else if (read.reason === 'parse-error') {
253
261
  errors.push(
254
- 'course.config.js: syntax errormust export a static object literal',
262
+ 'course.config.js: could not parse JavaScript syntax error',
255
263
  );
256
264
  }
257
265
  return null;
@@ -426,10 +434,14 @@ function isPlausibleColor(value: string): boolean {
426
434
  * on primaryColor. Runtime failures are mild: an unresolved logo ships a broken
427
435
  * <img src>, an unparseable color falls back to theme defaults.
428
436
  */
437
+ function describeType(raw: unknown): string {
438
+ return raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw;
439
+ }
440
+
429
441
  function validateBranding(raw: unknown, warnings: string[]): void {
430
442
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
431
443
  warnings.push(
432
- `course.config.js: "branding" must be an object, got ${raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw} — will be ignored`,
444
+ `course.config.js: "branding" must be an object, got ${describeType(raw)} — will be ignored`,
433
445
  );
434
446
  return;
435
447
  }
@@ -488,7 +500,7 @@ function validateBranding(raw: unknown, warnings: string[]): void {
488
500
  function validateA11yConfig(raw: unknown, errors: string[]): void {
489
501
  if (raw === null || typeof raw !== 'object' || Array.isArray(raw)) {
490
502
  errors.push(
491
- `course.config.js: "a11y" must be an object, got ${raw === null ? 'null' : Array.isArray(raw) ? 'array' : typeof raw}`,
503
+ `course.config.js: "a11y" must be an object, got ${describeType(raw)}`,
492
504
  );
493
505
  return;
494
506
  }
@@ -764,21 +776,14 @@ function validateSingleXAPIEntry(
764
776
  if (
765
777
  actor === undefined &&
766
778
  (standard === 'scorm12' || standard === 'scorm2004') &&
767
- typeof activityId === 'string'
779
+ typeof activityId === 'string' &&
780
+ httpOrigin(activityId) === null &&
781
+ aahp === undefined
768
782
  ) {
769
- let isHttp: boolean;
770
- try {
771
- const u = new URL(activityId);
772
- isHttp = u.protocol === 'http:' || u.protocol === 'https:';
773
- } catch {
774
- isHttp = false;
775
- }
776
- if (!isHttp && aahp === undefined) {
777
- errors.push(
778
- `course.config.js: ${label}.activityId is not an http(s) URL, so its origin can't be used as the SCORM actor's account.homePage. ` +
779
- `Provide ${label}.actorAccountHomePage explicitly.`,
780
- );
781
- }
783
+ errors.push(
784
+ `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. ` +
785
+ `Provide ${label}.actorAccountHomePage explicitly.`,
786
+ );
782
787
  }
783
788
 
784
789
  // registration — optional UUID v4.
@@ -811,6 +816,7 @@ interface PagesValidationResult extends ValidationResult {
811
816
  totalPages: number;
812
817
  totalQuizzes: number;
813
818
  hasGradedQuiz: boolean;
819
+ hasParseErrors: boolean;
814
820
  pages: PageInfo[];
815
821
  }
816
822
 
@@ -828,10 +834,32 @@ function validatePageFile(
828
834
  warnings: string[],
829
835
  assetExistsCache: Map<string, boolean>,
830
836
  exportStandard?: string,
831
- ): { page: PageInfo; isQuiz: boolean; isGradedQuiz: boolean } {
837
+ ): {
838
+ page: PageInfo;
839
+ isQuiz: boolean;
840
+ isGradedQuiz: boolean;
841
+ parseError: boolean;
842
+ } {
832
843
  const fileRel = relative(projectRoot, filePath);
833
844
  const content = readSourceFileCached(filePath);
834
845
 
846
+ const parseError = getParseError(content);
847
+ if (parseError) {
848
+ errors.push(`${fileRel}: could not parse — ${parseError}`);
849
+ return {
850
+ page: {
851
+ fileRel,
852
+ navIndex,
853
+ hasGradedQuiz: false,
854
+ hasQuiz: false,
855
+ completesOnView: false,
856
+ },
857
+ isQuiz: false,
858
+ isGradedQuiz: false,
859
+ parseError: true,
860
+ };
861
+ }
862
+
835
863
  const pageConfig = validatePageConfig(content, fileRel, errors);
836
864
 
837
865
  const isQuiz = !!pageConfig?.quiz;
@@ -878,6 +906,7 @@ function validatePageFile(
878
906
  },
879
907
  isQuiz,
880
908
  isGradedQuiz,
909
+ parseError: false,
881
910
  };
882
911
  }
883
912
 
@@ -893,98 +922,69 @@ function validatePages(
893
922
  let totalPages = 0;
894
923
  let totalQuizzes = 0;
895
924
  let hasGradedQuiz = false;
925
+ let hasParseErrors = false;
896
926
  // One existsSync per unique asset for the whole pass.
897
927
  const assetExistsCache = new Map<string, boolean>();
898
928
 
899
- if (!existsSync(pagesDir)) {
929
+ const noPages = (): PagesValidationResult => {
900
930
  errors.push(
901
931
  'No pages found. Create at least one section with a lesson and page in pages/',
902
932
  );
903
933
  return {
904
934
  errors,
905
935
  warnings,
906
- totalPages: 0,
907
- totalQuizzes: 0,
908
- hasGradedQuiz: false,
936
+ totalPages,
937
+ totalQuizzes,
938
+ hasGradedQuiz,
939
+ hasParseErrors,
909
940
  pages,
910
941
  };
911
- }
942
+ };
912
943
 
913
- const topLevelEntries = readdirSync(pagesDir);
944
+ if (!existsSync(pagesDir)) return noPages();
914
945
 
915
- // Check for stray .svelte files at pages/ root
916
- for (const entry of topLevelEntries) {
946
+ // walkPages only descends into section dirs, so scan pages/ root separately.
947
+ for (const entry of readdirSync(pagesDir)) {
917
948
  const fullPath = resolve(pagesDir, entry);
918
949
  if (entry.endsWith('.svelte') && statSync(fullPath).isFile()) {
919
- const relPath = relative(projectRoot, fullPath);
920
950
  warnings.push(
921
- `${relPath}: this file is outside the section/lesson structure and will be ignored`,
951
+ `${relative(projectRoot, fullPath)}: this file is outside the section/lesson structure and will be ignored`,
922
952
  );
923
953
  }
924
954
  }
925
955
 
926
- // Get section directories
927
- const sectionDirs = topLevelEntries
928
- .filter((name) => {
929
- const full = resolve(pagesDir, name);
930
- return statSync(full).isDirectory() && !name.startsWith('.');
931
- })
932
- .sort();
933
-
934
- if (sectionDirs.length === 0) {
935
- errors.push(
936
- 'No pages found. Create at least one section with a lesson and page in pages/',
937
- );
938
- return {
939
- errors,
940
- warnings,
941
- totalPages: 0,
942
- totalQuizzes: 0,
943
- hasGradedQuiz: false,
944
- pages,
945
- };
946
- }
947
-
948
- for (const sectionName of sectionDirs) {
949
- const sectionPath = resolve(pagesDir, sectionName);
950
- const sectionRel = relative(projectRoot, sectionPath);
951
- const pagesBeforeSection = totalPages;
952
-
953
- // Validate section _meta.js
954
- const sectionMeta = validateMetaFile(
955
- resolve(sectionPath, '_meta.js'),
956
- sectionRel,
957
- errors,
958
- );
956
+ const sections = walkPages(pagesDir);
957
+ if (sections.length === 0) return noPages();
959
958
 
960
- // Flat mode: .svelte files directly at section level are pages of an
961
- // implicit single lesson. Validate them just like lesson-level pages.
962
- const sectionEntries = readdirSync(sectionPath);
963
- const sectionSvelteFiles = sectionEntries
964
- .filter((name) => {
965
- const full = resolve(sectionPath, name);
966
- return name.endsWith('.svelte') && statSync(full).isFile();
967
- })
968
- .sort();
969
-
970
- if (sectionMeta?.pages) {
971
- for (const pageName of sectionMeta.pages) {
959
+ // For a flat lesson `meta` is the section's _meta. Same ordering as generateManifest.
960
+ const validateLesson = (
961
+ lesson: WalkedLesson,
962
+ meta: { pages?: string[] } | null,
963
+ ): void => {
964
+ if (meta?.pages) {
965
+ for (const pageName of meta.pages) {
972
966
  const fileName = ensureSvelteSuffix(pageName);
973
- if (!sectionSvelteFiles.includes(fileName)) {
974
- const metaRel = relative(
975
- projectRoot,
976
- resolve(sectionPath, '_meta.js'),
977
- );
967
+ if (!lesson.files.includes(fileName)) {
978
968
  errors.push(
979
- `${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`,
969
+ `${relative(projectRoot, lesson.metaPath)}: pages array lists "${pageName}" but ${fileName} not found in this directory`,
970
+ );
971
+ }
972
+ }
973
+ }
974
+ if (meta?.pages && meta.pages.length > 0) {
975
+ const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
976
+ for (const file of lesson.files) {
977
+ if (!listedSet.has(file)) {
978
+ warnings.push(
979
+ `${relative(projectRoot, resolve(lesson.dir, file))}: not listed in _meta.js pages array — will be appended at end`,
980
980
  );
981
981
  }
982
982
  }
983
983
  }
984
984
 
985
- for (const fileName of sectionSvelteFiles) {
985
+ for (const fileName of orderPageFiles(lesson.files, meta?.pages)) {
986
986
  const result = validatePageFile(
987
- resolve(sectionPath, fileName),
987
+ resolve(lesson.dir, fileName),
988
988
  projectRoot,
989
989
  assetsDir,
990
990
  totalPages,
@@ -996,78 +996,28 @@ function validatePages(
996
996
  totalPages++;
997
997
  if (result.isQuiz) totalQuizzes++;
998
998
  if (result.isGradedQuiz) hasGradedQuiz = true;
999
+ if (result.parseError) hasParseErrors = true;
999
1000
  pages.push(result.page);
1000
1001
  }
1002
+ };
1001
1003
 
1002
- // Get lesson directories
1003
- const lessonDirs = sectionEntries
1004
- .filter((name) => {
1005
- const full = resolve(sectionPath, name);
1006
- return statSync(full).isDirectory() && !name.startsWith('.');
1007
- })
1008
- .sort();
1009
-
1010
- for (const lessonName of lessonDirs) {
1011
- const lessonPath = resolve(sectionPath, lessonName);
1012
- const lessonRel = relative(projectRoot, lessonPath);
1013
-
1014
- // Validate lesson _meta.js
1015
- const meta = validateMetaFile(
1016
- resolve(lessonPath, '_meta.js'),
1017
- lessonRel,
1018
- errors,
1019
- );
1020
-
1021
- // Get .svelte files
1022
- const svelteFiles = readdirSync(lessonPath)
1023
- .filter((name) => name.endsWith('.svelte'))
1024
- .sort();
1025
-
1026
- // Check pages array references
1027
- if (meta?.pages) {
1028
- for (const pageName of meta.pages) {
1029
- const fileName = ensureSvelteSuffix(pageName);
1030
- if (!svelteFiles.includes(fileName)) {
1031
- const metaRel = relative(
1032
- projectRoot,
1033
- resolve(lessonPath, '_meta.js'),
1034
- );
1035
- errors.push(
1036
- `${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`,
1037
- );
1038
- }
1039
- }
1040
- }
1004
+ for (const section of sections) {
1005
+ const sectionRel = relative(projectRoot, section.dir);
1006
+ const pagesBeforeSection = totalPages;
1041
1007
 
1042
- // Check for unlisted .svelte files
1043
- if (meta?.pages && meta.pages.length > 0) {
1044
- const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
1045
- for (const file of svelteFiles) {
1046
- if (!listedSet.has(file)) {
1047
- const relPath = relative(projectRoot, resolve(lessonPath, file));
1048
- warnings.push(
1049
- `${relPath}: not listed in _meta.js pages array — will be appended at end`,
1050
- );
1051
- }
1052
- }
1053
- }
1008
+ const sectionMeta = validateMetaFile(section.metaPath, sectionRel, errors);
1054
1009
 
1055
- // Validate each .svelte file
1056
- for (const fileName of svelteFiles) {
1057
- const result = validatePageFile(
1058
- resolve(lessonPath, fileName),
1059
- projectRoot,
1060
- assetsDir,
1061
- totalPages,
1010
+ for (const lesson of section.lessons) {
1011
+ if (lesson.name === null) {
1012
+ // Flat lesson uses the section _meta, already validated above.
1013
+ validateLesson(lesson, sectionMeta);
1014
+ } else {
1015
+ const meta = validateMetaFile(
1016
+ lesson.metaPath,
1017
+ relative(projectRoot, lesson.dir),
1062
1018
  errors,
1063
- warnings,
1064
- assetExistsCache,
1065
- exportStandard,
1066
1019
  );
1067
- totalPages++;
1068
- if (result.isQuiz) totalQuizzes++;
1069
- if (result.isGradedQuiz) hasGradedQuiz = true;
1070
- pages.push(result.page);
1020
+ validateLesson(lesson, meta);
1071
1021
  }
1072
1022
  }
1073
1023
 
@@ -1079,13 +1029,17 @@ function validatePages(
1079
1029
  }
1080
1030
  }
1081
1031
 
1082
- if (totalPages === 0) {
1083
- errors.push(
1084
- 'No pages found. Create at least one section with a lesson and page in pages/',
1085
- );
1086
- }
1032
+ if (totalPages === 0) return noPages();
1087
1033
 
1088
- return { errors, warnings, totalPages, totalQuizzes, hasGradedQuiz, pages };
1034
+ return {
1035
+ errors,
1036
+ warnings,
1037
+ totalPages,
1038
+ totalQuizzes,
1039
+ hasGradedQuiz,
1040
+ hasParseErrors,
1041
+ pages,
1042
+ };
1089
1043
  }
1090
1044
 
1091
1045
  // ---------- _meta.js Validation ----------
@@ -1098,11 +1052,15 @@ function validateMetaFile(
1098
1052
  if (!existsSync(metaPath)) return null;
1099
1053
 
1100
1054
  const metaRel = `${parentRel}/_meta.js`;
1101
- const objectStr = extractDefaultExportObjectLiteral(
1055
+ const result = extractDefaultExportObjectLiteral(
1102
1056
  readSourceFileCached(metaPath),
1103
1057
  );
1104
1058
 
1105
- if (!objectStr) {
1059
+ if (result.kind === 'parse-error') {
1060
+ errors.push(`${metaRel}: could not parse — JavaScript syntax error`);
1061
+ return null;
1062
+ }
1063
+ if (result.kind !== 'literal') {
1106
1064
  errors.push(
1107
1065
  `${metaRel}: syntax error — must export default { title: "..." }`,
1108
1066
  );
@@ -1111,7 +1069,7 @@ function validateMetaFile(
1111
1069
 
1112
1070
  let meta: { title?: string; pages?: string[] };
1113
1071
  try {
1114
- meta = JSON5.parse(objectStr);
1072
+ meta = JSON5.parse(result.text);
1115
1073
  } catch {
1116
1074
  errors.push(
1117
1075
  `${metaRel}: syntax error — must export default { title: "..." }`,
@@ -1213,68 +1171,6 @@ const QUESTION_COMPONENT_REQUIRED: Record<string, string[]> = {
1213
1171
  Sorting: ['question', 'items', 'targets', 'correct'],
1214
1172
  };
1215
1173
 
1216
- type PropValue =
1217
- | { kind: 'string'; value: string }
1218
- | { kind: 'expr'; raw: string }
1219
- | { kind: 'bool' };
1220
-
1221
- /**
1222
- * Parse the props of an opening tag starting just after the component name.
1223
- * Returns null if the tag can't be parsed cleanly — callers then skip it
1224
- * rather than risk a false positive.
1225
- */
1226
- function parseTagProps(
1227
- content: string,
1228
- start: number,
1229
- ): { props: Map<string, PropValue>; hasSpread: boolean } | null {
1230
- const props = new Map<string, PropValue>();
1231
- let hasSpread = false;
1232
- let i = start;
1233
- while (i < content.length) {
1234
- while (i < content.length && /\s/.test(content[i])) i++;
1235
- if (i >= content.length) return null;
1236
- const c = content[i];
1237
- if (c === '>') return { props, hasSpread };
1238
- if (c === '/' && content[i + 1] === '>') return { props, hasSpread };
1239
- // Spread / shorthand expression — skip the whole {...} block, but record
1240
- // that unseen props may be supplied here so callers can suppress
1241
- // false-positive "missing required prop / alt / title" diagnostics.
1242
- if (c === '{') {
1243
- const block = extractObjectLiteral(content, i);
1244
- if (!block) return null;
1245
- hasSpread = true;
1246
- i += block.length;
1247
- continue;
1248
- }
1249
- const nameMatch = /^[A-Za-z_][\w-]*/.exec(content.slice(i));
1250
- if (!nameMatch) return null;
1251
- const propName = nameMatch[0];
1252
- i += propName.length;
1253
- while (i < content.length && /\s/.test(content[i])) i++;
1254
- if (content[i] !== '=') {
1255
- props.set(propName, { kind: 'bool' });
1256
- continue;
1257
- }
1258
- i++;
1259
- while (i < content.length && /\s/.test(content[i])) i++;
1260
- const v = content[i];
1261
- if (v === '"' || v === "'") {
1262
- const end = content.indexOf(v, i + 1);
1263
- if (end === -1) return null;
1264
- props.set(propName, { kind: 'string', value: content.slice(i + 1, end) });
1265
- i = end + 1;
1266
- } else if (v === '{') {
1267
- const block = extractObjectLiteral(content, i);
1268
- if (!block) return null;
1269
- props.set(propName, { kind: 'expr', raw: block.slice(1, -1).trim() });
1270
- i += block.length;
1271
- } else {
1272
- return null;
1273
- }
1274
- }
1275
- return null;
1276
- }
1277
-
1278
1174
  function staticArray(prop: PropValue | undefined): unknown[] | null {
1279
1175
  if (prop?.kind !== 'expr' || !prop.raw.startsWith('[')) return null;
1280
1176
  try {
@@ -1302,17 +1198,14 @@ function validateQuestionComponents(
1302
1198
  warnings: string[],
1303
1199
  exportStandard?: string,
1304
1200
  ): void {
1305
- const names = Object.keys(QUESTION_COMPONENT_REQUIRED).join('|');
1306
- const tagStartRe = new RegExp(`<(${names})(?=[\\s/>])`, 'g');
1201
+ const components = findComponents(
1202
+ content,
1203
+ new Set(Object.keys(QUESTION_COMPONENT_REQUIRED)),
1204
+ );
1205
+ if (!components) return;
1307
1206
  const seenIds = new Set<string>();
1308
1207
  const seenSanitized = new Set<string>();
1309
- let m: RegExpExecArray | null;
1310
- while ((m = tagStartRe.exec(content)) !== null) {
1311
- const name = m[1];
1312
- const parsed = parseTagProps(content, m.index + m[0].length);
1313
- if (!parsed) continue;
1314
- const { props, hasSpread } = parsed;
1315
-
1208
+ for (const { name, props, hasSpread } of components) {
1316
1209
  for (const req of QUESTION_COMPONENT_REQUIRED[name]) {
1317
1210
  if (!hasSpread && !props.has(req)) {
1318
1211
  errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
@@ -1458,6 +1351,21 @@ function validateQuestionComponents(
1458
1351
  /** Remove HTML/Svelte comments so commented-out markup isn't scanned as live. */
1459
1352
  const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
1460
1353
 
1354
+ const SCRIPT_STYLE_RE = /<(script|style)\b[\s\S]*?<\/\1>/gi;
1355
+
1356
+ // Loop until stable: one pass can leave a reconstructed tag behind (e.g. `<scr<script></script>ipt>`).
1357
+ function stripRepeated(input: string, patterns: RegExp[]): string {
1358
+ let out = input;
1359
+ for (const pattern of patterns) {
1360
+ let prev: string;
1361
+ do {
1362
+ prev = out;
1363
+ out = out.replace(pattern, '');
1364
+ } while (out !== prev);
1365
+ }
1366
+ return out;
1367
+ }
1368
+
1461
1369
  /**
1462
1370
  * Sibling to validateQuestionComponents kept out of QUESTION_COMPONENT_REQUIRED
1463
1371
  * so media isn't treated as gradable questions. Declares `warnings` directly.
@@ -1469,15 +1377,12 @@ function validateMediaComponents(
1469
1377
  errors: string[],
1470
1378
  warnings: string[],
1471
1379
  ): void {
1472
- const scan = content.replace(HTML_COMMENT_RE, '');
1473
- const tagStartRe = /<(Image|Video|Audio)(?=[\s/>])/g;
1474
- let m: RegExpExecArray | null;
1475
- while ((m = tagStartRe.exec(scan)) !== null) {
1476
- const name = m[1];
1477
- const parsed = parseTagProps(scan, m.index + m[0].length);
1478
- if (!parsed) continue;
1479
- const { props, hasSpread } = parsed;
1480
-
1380
+ const components = findComponents(
1381
+ content,
1382
+ new Set(['Image', 'Video', 'Audio']),
1383
+ );
1384
+ if (!components) return;
1385
+ for (const { name, props, hasSpread } of components) {
1481
1386
  if (name === 'Image') {
1482
1387
  const alt = props.get('alt');
1483
1388
  const decorative = props.get('decorative');
@@ -1585,9 +1490,7 @@ function validateHeadingOrder(
1585
1490
  fileRel: string,
1586
1491
  warnings: string[],
1587
1492
  ): void {
1588
- const html = content
1589
- .replace(/<(script|style)\b[\s\S]*?<\/\1>/gi, '')
1590
- .replace(HTML_COMMENT_RE, '');
1493
+ const html = stripRepeated(content, [SCRIPT_STYLE_RE, HTML_COMMENT_RE]);
1591
1494
  const levels = [...html.matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
1592
1495
  let prevSeen: number | null = null;
1593
1496
  for (const level of levels) {
@@ -1687,7 +1590,11 @@ function crossValidate(
1687
1590
  warnings: string[],
1688
1591
  ): void {
1689
1592
  // completion.mode "quiz" but no graded quizzes
1690
- if (config.completion?.mode === 'quiz' && !pageResults.hasGradedQuiz) {
1593
+ if (
1594
+ config.completion?.mode === 'quiz' &&
1595
+ !pageResults.hasGradedQuiz &&
1596
+ !pageResults.hasParseErrors
1597
+ ) {
1691
1598
  errors.push(
1692
1599
  'completion.mode is "quiz" but no pages have quiz config with graded: true',
1693
1600
  );
@@ -1710,7 +1617,8 @@ function crossValidate(
1710
1617
  if (
1711
1618
  isManual &&
1712
1619
  config.completion?.trigger === 'page' &&
1713
- completesOnPages.length === 0
1620
+ completesOnPages.length === 0 &&
1621
+ !pageResults.hasParseErrors
1714
1622
  ) {
1715
1623
  errors.push(
1716
1624
  'completion.mode is "manual" with trigger: "page", but no page declares pageConfig.completesOn: "view". ' +