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.
- package/AGENTS.md +1744 -0
- package/README.md +2 -2
- package/dist/{validation-B-xTvM9B.js → audit-CzKAXy3Y.js} +591 -268
- package/dist/audit-CzKAXy3Y.js.map +1 -0
- package/dist/build-commands-D101M_qb.js +27 -0
- package/dist/build-commands-D101M_qb.js.map +1 -0
- package/dist/inline-config-DYHT51G8.js +29 -0
- package/dist/inline-config-DYHT51G8.js.map +1 -0
- package/dist/plugin/cli.d.ts +5 -1
- package/dist/plugin/cli.d.ts.map +1 -0
- package/dist/plugin/cli.js +108 -15
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -763
- package/dist/plugin-y35ym9A3.js +744 -0
- package/dist/plugin-y35ym9A3.js.map +1 -0
- package/package.json +12 -9
- package/src/components/FillInTheBlank.svelte +2 -2
- package/src/components/Matching.svelte +2 -2
- package/src/components/MultipleChoice.svelte +2 -2
- package/src/components/RevealModal.svelte +48 -103
- package/src/components/Sorting.svelte +2 -2
- package/src/components/util.ts +9 -0
- package/src/plugin/a11y/audit.ts +35 -8
- package/src/plugin/a11y-cli.ts +35 -22
- package/src/plugin/ast.ts +276 -0
- package/src/plugin/build-commands.ts +25 -0
- package/src/plugin/cli.ts +53 -21
- package/src/plugin/index.ts +87 -122
- package/src/plugin/inline-config.ts +43 -0
- package/src/plugin/manifest.ts +103 -136
- package/src/plugin/package-root.ts +24 -0
- package/src/plugin/quiz.ts +8 -9
- package/src/plugin/validate-cli.ts +30 -0
- package/src/plugin/validation.ts +152 -244
- package/src/runtime/App.svelte +11 -97
- package/src/runtime/Sidebar.svelte +3 -1
- package/src/runtime/adapters/cmi5.ts +6 -10
- package/src/runtime/adapters/format.ts +6 -0
- package/src/runtime/adapters/retry.ts +1 -1
- package/src/runtime/adapters/scorm2004.ts +2 -4
- package/src/runtime/branding.ts +90 -0
- package/src/runtime/defaults.ts +3 -0
- package/src/runtime/hooks.svelte.ts +16 -53
- package/src/runtime/interaction-format.ts +3 -8
- package/src/runtime/progress.svelte.ts +47 -83
- package/src/runtime/xapi/derive-actor.ts +41 -48
- package/src/runtime/xapi/publisher.ts +14 -14
- package/src/runtime/xapi/setup.ts +39 -46
- package/dist/audit-BBJpQGqb.js +0 -204
- package/dist/audit-BBJpQGqb.js.map +0 -1
- package/dist/plugin/a11y-cli.d.ts +0 -1
- package/dist/plugin/a11y-cli.js +0 -36
- package/dist/plugin/a11y-cli.js.map +0 -1
- package/dist/plugin/index.js.map +0 -1
- package/dist/validation-B-xTvM9B.js.map +0 -1
package/src/plugin/validation.ts
CHANGED
|
@@ -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:
|
|
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 ${
|
|
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 ${
|
|
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
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
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
|
-
): {
|
|
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
|
-
|
|
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
|
|
907
|
-
totalQuizzes
|
|
908
|
-
hasGradedQuiz
|
|
936
|
+
totalPages,
|
|
937
|
+
totalQuizzes,
|
|
938
|
+
hasGradedQuiz,
|
|
939
|
+
hasParseErrors,
|
|
909
940
|
pages,
|
|
910
941
|
};
|
|
911
|
-
}
|
|
942
|
+
};
|
|
912
943
|
|
|
913
|
-
|
|
944
|
+
if (!existsSync(pagesDir)) return noPages();
|
|
914
945
|
|
|
915
|
-
//
|
|
916
|
-
for (const entry of
|
|
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
|
-
`${
|
|
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
|
-
|
|
927
|
-
|
|
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
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
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 (!
|
|
974
|
-
const metaRel = relative(
|
|
975
|
-
projectRoot,
|
|
976
|
-
resolve(sectionPath, '_meta.js'),
|
|
977
|
-
);
|
|
967
|
+
if (!lesson.files.includes(fileName)) {
|
|
978
968
|
errors.push(
|
|
979
|
-
`${
|
|
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
|
|
985
|
+
for (const fileName of orderPageFiles(lesson.files, meta?.pages)) {
|
|
986
986
|
const result = validatePageFile(
|
|
987
|
-
resolve(
|
|
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
|
-
|
|
1003
|
-
const
|
|
1004
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
|
1055
|
+
const result = extractDefaultExportObjectLiteral(
|
|
1102
1056
|
readSourceFileCached(metaPath),
|
|
1103
1057
|
);
|
|
1104
1058
|
|
|
1105
|
-
if (
|
|
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(
|
|
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
|
|
1306
|
-
|
|
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
|
-
|
|
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
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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 (
|
|
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". ' +
|