tessera-learn 0.0.13 → 0.2.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 +1794 -0
- package/README.md +5 -5
- package/dist/{validation-B-xTvM9B.js → audit-BA5o0ick.js} +605 -269
- package/dist/audit-BA5o0ick.js.map +1 -0
- package/dist/build-commands-C0OnV-Vg.js +27 -0
- package/dist/build-commands-C0OnV-Vg.js.map +1 -0
- package/dist/inline-config-CroQ-_2Y.js +31 -0
- package/dist/inline-config-CroQ-_2Y.js.map +1 -0
- package/dist/plugin/cli.d.ts +9 -1
- package/dist/plugin/cli.d.ts.map +1 -0
- package/dist/plugin/cli.js +326 -17
- package/dist/plugin/cli.js.map +1 -1
- package/dist/plugin/index.d.ts +1 -1
- package/dist/plugin/index.d.ts.map +1 -1
- package/dist/plugin/index.js +2 -763
- package/dist/plugin-W_rk3Pit.js +731 -0
- package/dist/plugin-W_rk3Pit.js.map +1 -0
- package/package.json +21 -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 +40 -8
- package/src/plugin/a11y-cli.ts +39 -22
- package/src/plugin/ast.ts +276 -0
- package/src/plugin/build-commands.ts +31 -0
- package/src/plugin/cli.ts +96 -21
- package/src/plugin/course-root.ts +98 -0
- package/src/plugin/duplicate-cli.ts +74 -0
- package/src/plugin/index.ts +87 -122
- package/src/plugin/inline-config.ts +54 -0
- package/src/plugin/manifest.ts +103 -136
- package/src/plugin/new-cli.ts +51 -0
- package/src/plugin/package-root.ts +24 -0
- package/src/plugin/project-name.ts +29 -0
- package/src/plugin/quiz.ts +8 -9
- package/src/plugin/template-copy.ts +43 -0
- 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/templates/course/course.config.js +11 -0
- package/templates/course/layout.svelte +116 -0
- package/templates/course/pages/01-getting-started/01-welcome/_meta.js +1 -0
- package/templates/course/pages/01-getting-started/01-welcome/welcome.svelte +19 -0
- package/templates/course/pages/01-getting-started/_meta.js +1 -0
- package/templates/course/styles/custom.css +5 -0
- 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
|
@@ -1,6 +1,205 @@
|
|
|
1
|
-
import { basename, extname, relative, resolve } from "node:path";
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
1
|
+
import { basename, dirname, extname, relative, resolve } from "node:path";
|
|
2
|
+
import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
3
3
|
import JSON5 from "json5";
|
|
4
|
+
import { Parser } from "acorn";
|
|
5
|
+
import { tsPlugin } from "@sveltejs/acorn-typescript";
|
|
6
|
+
import { parse } from "svelte/compiler";
|
|
7
|
+
//#region src/plugin/ast.ts
|
|
8
|
+
const rootCache = /* @__PURE__ */ new Map();
|
|
9
|
+
/** Drop every cached root. Call at the start of a run to scope the cache. */
|
|
10
|
+
function clearParseCache() {
|
|
11
|
+
rootCache.clear();
|
|
12
|
+
}
|
|
13
|
+
function parseRoot(source) {
|
|
14
|
+
const cached = rootCache.get(source);
|
|
15
|
+
if (cached !== void 0) return cached;
|
|
16
|
+
let entry;
|
|
17
|
+
try {
|
|
18
|
+
entry = {
|
|
19
|
+
root: parse(source, { modern: true }),
|
|
20
|
+
error: null
|
|
21
|
+
};
|
|
22
|
+
} catch (error) {
|
|
23
|
+
entry = {
|
|
24
|
+
root: null,
|
|
25
|
+
error: (error instanceof Error ? error.message : String(error)).split("\n")[0].trim() || "parse error"
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
rootCache.set(source, entry);
|
|
29
|
+
return entry;
|
|
30
|
+
}
|
|
31
|
+
function collectComponents(root, names) {
|
|
32
|
+
const found = [];
|
|
33
|
+
const seen = /* @__PURE__ */ new Set();
|
|
34
|
+
const walk = (value) => {
|
|
35
|
+
if (!value || typeof value !== "object") return;
|
|
36
|
+
if (seen.has(value)) return;
|
|
37
|
+
seen.add(value);
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
for (const item of value) walk(item);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const node = value;
|
|
43
|
+
if (node.type === "Component" && names.has(node.name)) found.push(node);
|
|
44
|
+
for (const key of Object.keys(node)) {
|
|
45
|
+
if (key === "type") continue;
|
|
46
|
+
walk(node[key]);
|
|
47
|
+
}
|
|
48
|
+
};
|
|
49
|
+
walk(root);
|
|
50
|
+
return found.sort((a, b) => a.start - b.start);
|
|
51
|
+
}
|
|
52
|
+
function readProps(source, node) {
|
|
53
|
+
const props = /* @__PURE__ */ new Map();
|
|
54
|
+
let hasSpread = false;
|
|
55
|
+
const attributes = node.attributes ?? [];
|
|
56
|
+
for (const attr of attributes) {
|
|
57
|
+
if (attr.type === "SpreadAttribute") {
|
|
58
|
+
hasSpread = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (attr.type === "BindDirective") {
|
|
62
|
+
const expr = attr.expression;
|
|
63
|
+
if (expr) props.set(attr.name, {
|
|
64
|
+
kind: "expr",
|
|
65
|
+
raw: source.slice(expr.start, expr.end).trim()
|
|
66
|
+
});
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
if (attr.type !== "Attribute") continue;
|
|
70
|
+
const name = attr.name;
|
|
71
|
+
const value = attr.value;
|
|
72
|
+
if (value === true) props.set(name, { kind: "bool" });
|
|
73
|
+
else if (Array.isArray(value)) if (value.length === 0) props.set(name, {
|
|
74
|
+
kind: "string",
|
|
75
|
+
value: ""
|
|
76
|
+
});
|
|
77
|
+
else {
|
|
78
|
+
const first = value[0];
|
|
79
|
+
const last = value[value.length - 1];
|
|
80
|
+
props.set(name, {
|
|
81
|
+
kind: "string",
|
|
82
|
+
value: source.slice(first.start, last.end)
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
else if (value && typeof value === "object" && value.type === "ExpressionTag") {
|
|
86
|
+
const expr = value.expression;
|
|
87
|
+
props.set(name, {
|
|
88
|
+
kind: "expr",
|
|
89
|
+
raw: source.slice(expr.start, expr.end).trim()
|
|
90
|
+
});
|
|
91
|
+
if (source[attr.start] === "{") hasSpread = true;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
name: node.name,
|
|
96
|
+
props,
|
|
97
|
+
hasSpread
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Return a one-line message if `source` is not valid Svelte, else null. Lets
|
|
102
|
+
* the validator surface a real syntax error itself rather than only failing
|
|
103
|
+
* later in the compiler (and the compile-less CLI would otherwise miss it).
|
|
104
|
+
*/
|
|
105
|
+
function getParseError(source) {
|
|
106
|
+
return parseRoot(source).error;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Find every question/media component in a `.svelte` source, anywhere in the
|
|
110
|
+
* markup, with its props. Returns null if the source can't be parsed — callers
|
|
111
|
+
* then skip component validation, matching the old "skip when unsure" stance.
|
|
112
|
+
*/
|
|
113
|
+
function findComponents(source, names) {
|
|
114
|
+
const { root } = parseRoot(source);
|
|
115
|
+
if (!root) return null;
|
|
116
|
+
return collectComponents(root, names).map((node) => readProps(source, node));
|
|
117
|
+
}
|
|
118
|
+
const TsParser = Parser.extend(tsPlugin());
|
|
119
|
+
function parseJsModule(source) {
|
|
120
|
+
try {
|
|
121
|
+
return TsParser.parse(source, {
|
|
122
|
+
ecmaVersion: "latest",
|
|
123
|
+
sourceType: "module"
|
|
124
|
+
});
|
|
125
|
+
} catch {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function unwrapTsCast(node) {
|
|
130
|
+
let current = node;
|
|
131
|
+
while (current && (current.type === "TSAsExpression" || current.type === "TSSatisfiesExpression" || current.type === "TSTypeAssertion" || current.type === "TSNonNullExpression")) current = current.expression ?? null;
|
|
132
|
+
return current;
|
|
133
|
+
}
|
|
134
|
+
function findPageConfigInProgram(program, source) {
|
|
135
|
+
const body = program.body ?? [];
|
|
136
|
+
for (const node of body) {
|
|
137
|
+
if (node.type !== "ExportNamedDeclaration") continue;
|
|
138
|
+
const declaration = node.declaration;
|
|
139
|
+
if (!declaration || declaration.type !== "VariableDeclaration") continue;
|
|
140
|
+
for (const decl of declaration.declarations) {
|
|
141
|
+
const id = decl.id;
|
|
142
|
+
if (id.type !== "Identifier" || id.name !== "pageConfig") continue;
|
|
143
|
+
const init = unwrapTsCast(decl.init);
|
|
144
|
+
if (init && init.type === "ObjectExpression") return {
|
|
145
|
+
kind: "literal",
|
|
146
|
+
text: source.slice(init.start, init.end)
|
|
147
|
+
};
|
|
148
|
+
return { kind: "invalid" };
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return { kind: "none" };
|
|
152
|
+
}
|
|
153
|
+
/**
|
|
154
|
+
* Locate the `export default { ... }` object literal in a plain JS source.
|
|
155
|
+
* Returns a discriminated result so callers can tell parse failure from a
|
|
156
|
+
* missing or non-literal default export.
|
|
157
|
+
*/
|
|
158
|
+
function defaultExportObjectLiteral(jsSource) {
|
|
159
|
+
const program = parseJsModule(jsSource);
|
|
160
|
+
if (!program) return { kind: "parse-error" };
|
|
161
|
+
for (const node of program.body ?? []) {
|
|
162
|
+
if (node.type !== "ExportDefaultDeclaration") continue;
|
|
163
|
+
const decl = unwrapTsCast(node.declaration ?? null);
|
|
164
|
+
if (decl && decl.type === "ObjectExpression") return {
|
|
165
|
+
kind: "literal",
|
|
166
|
+
text: jsSource.slice(decl.start, decl.end)
|
|
167
|
+
};
|
|
168
|
+
return { kind: "invalid" };
|
|
169
|
+
}
|
|
170
|
+
return { kind: "none" };
|
|
171
|
+
}
|
|
172
|
+
const MODULE_SCRIPT_OPEN_RE = /<script\s+(?:context\s*=\s*["']module["']|module)[^>]*>/;
|
|
173
|
+
const SCRIPT_CLOSE = "<\/script>";
|
|
174
|
+
function pageConfigFromModuleScriptFallback(svelteSource) {
|
|
175
|
+
const open = svelteSource.match(MODULE_SCRIPT_OPEN_RE);
|
|
176
|
+
if (!open || open.index === void 0) return { kind: "none" };
|
|
177
|
+
const bodyStart = open.index + open[0].length;
|
|
178
|
+
let from = bodyStart;
|
|
179
|
+
while (true) {
|
|
180
|
+
const closeIdx = svelteSource.indexOf(SCRIPT_CLOSE, from);
|
|
181
|
+
if (closeIdx < 0) return { kind: "none" };
|
|
182
|
+
const body = svelteSource.slice(bodyStart, closeIdx);
|
|
183
|
+
const program = parseJsModule(body);
|
|
184
|
+
if (program) return findPageConfigInProgram(program, body);
|
|
185
|
+
from = closeIdx + 9;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Locate `export const pageConfig = { ... }` in a Svelte page's module script
|
|
190
|
+
* and return the object-literal text. Walks the page-level AST so TypeScript
|
|
191
|
+
* (`lang="ts"`) module scripts are handled by Svelte's own parser.
|
|
192
|
+
*/
|
|
193
|
+
function pageConfigLiteral(svelteSource) {
|
|
194
|
+
const { root } = parseRoot(svelteSource);
|
|
195
|
+
if (root) {
|
|
196
|
+
const program = root.module?.content;
|
|
197
|
+
if (!program) return { kind: "none" };
|
|
198
|
+
return findPageConfigInProgram(program, svelteSource);
|
|
199
|
+
}
|
|
200
|
+
return pageConfigFromModuleScriptFallback(svelteSource);
|
|
201
|
+
}
|
|
202
|
+
//#endregion
|
|
4
203
|
//#region src/plugin/manifest.ts
|
|
5
204
|
/** Append `.svelte` if not already present. Both bare and suffixed names are accepted in author config. */
|
|
6
205
|
function ensureSvelteSuffix(name) {
|
|
@@ -38,23 +237,14 @@ function deriveSlug(name, isFile = false) {
|
|
|
38
237
|
if (isFile) return basename(name, extname(name));
|
|
39
238
|
return stripPrefix(name);
|
|
40
239
|
}
|
|
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
240
|
/**
|
|
48
|
-
* Locate `export default { ... }` and return
|
|
49
|
-
*
|
|
50
|
-
* Used by both manifest extraction and project
|
|
241
|
+
* Locate `export default { ... }` and return its object-literal text. Returns
|
|
242
|
+
* a discriminated result so callers can tell parse failure from a missing or
|
|
243
|
+
* non-literal default export. Used by both manifest extraction and project
|
|
244
|
+
* validation.
|
|
51
245
|
*/
|
|
52
246
|
function extractDefaultExportObjectLiteral(source) {
|
|
53
|
-
|
|
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);
|
|
247
|
+
return defaultExportObjectLiteral(source);
|
|
58
248
|
}
|
|
59
249
|
/**
|
|
60
250
|
* Read and JSON5-parse the `export default { ... }` literal from a project's
|
|
@@ -69,15 +259,19 @@ function readCourseConfig(projectRoot) {
|
|
|
69
259
|
ok: false,
|
|
70
260
|
reason: "missing"
|
|
71
261
|
};
|
|
72
|
-
const
|
|
73
|
-
if (
|
|
262
|
+
const result = extractDefaultExportObjectLiteral(readSourceFileCached(configPath));
|
|
263
|
+
if (result.kind === "parse-error") return {
|
|
264
|
+
ok: false,
|
|
265
|
+
reason: "parse-error"
|
|
266
|
+
};
|
|
267
|
+
if (result.kind !== "literal") return {
|
|
74
268
|
ok: false,
|
|
75
269
|
reason: "no-export"
|
|
76
270
|
};
|
|
77
271
|
try {
|
|
78
272
|
return {
|
|
79
273
|
ok: true,
|
|
80
|
-
config: JSON5.parse(
|
|
274
|
+
config: JSON5.parse(result.text)
|
|
81
275
|
};
|
|
82
276
|
} catch (error) {
|
|
83
277
|
return {
|
|
@@ -94,30 +288,23 @@ function readCourseConfig(projectRoot) {
|
|
|
94
288
|
*/
|
|
95
289
|
function readMetaFile(metaPath) {
|
|
96
290
|
if (!existsSync(metaPath)) return {};
|
|
97
|
-
const
|
|
98
|
-
if (
|
|
291
|
+
const result = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
|
|
292
|
+
if (result.kind !== "literal") return {};
|
|
99
293
|
try {
|
|
100
|
-
return JSON5.parse(
|
|
294
|
+
return JSON5.parse(result.text);
|
|
101
295
|
} catch {
|
|
102
296
|
return {};
|
|
103
297
|
}
|
|
104
298
|
}
|
|
105
299
|
/** Source-level pageConfig extraction shared by manifest generation and build-time validation. */
|
|
106
300
|
function parsePageConfigFromSource(content) {
|
|
107
|
-
const
|
|
108
|
-
if (
|
|
109
|
-
|
|
110
|
-
const configMatch = scriptContent.match(PAGE_CONFIG_EXPORT_RE);
|
|
111
|
-
if (!configMatch || configMatch.index === void 0) return { kind: "none" };
|
|
112
|
-
if (!scriptContent.slice(configMatch.index + configMatch[0].length).trimStart().startsWith("{")) return { kind: "invalid" };
|
|
113
|
-
const startIndex = scriptContent.indexOf("{", configMatch.index + configMatch[0].length);
|
|
114
|
-
if (startIndex < 0) return { kind: "invalid" };
|
|
115
|
-
const objectStr = extractObjectLiteral(scriptContent, startIndex);
|
|
116
|
-
if (!objectStr) return { kind: "invalid" };
|
|
301
|
+
const literal = pageConfigLiteral(content);
|
|
302
|
+
if (literal.kind === "none") return { kind: "none" };
|
|
303
|
+
if (literal.kind === "invalid") return { kind: "invalid" };
|
|
117
304
|
try {
|
|
118
305
|
return {
|
|
119
306
|
kind: "ok",
|
|
120
|
-
value: JSON5.parse(
|
|
307
|
+
value: JSON5.parse(literal.text)
|
|
121
308
|
};
|
|
122
309
|
} catch {
|
|
123
310
|
return { kind: "invalid" };
|
|
@@ -131,54 +318,6 @@ function extractPageConfig(filePath) {
|
|
|
131
318
|
return {};
|
|
132
319
|
}
|
|
133
320
|
/**
|
|
134
|
-
* Extract a balanced `{...}` or `[...]` span starting at the opening bracket,
|
|
135
|
-
* skipping strings and comments. Returns the substring (inclusive) or null if
|
|
136
|
-
* the open char is wrong or no matching close is found. Shared by manifest
|
|
137
|
-
* extraction, _meta/pageConfig parsing, and the validator's tag-prop parser.
|
|
138
|
-
*/
|
|
139
|
-
function extractObjectLiteral(source, startIndex) {
|
|
140
|
-
const open = source[startIndex];
|
|
141
|
-
if (open !== "{" && open !== "[") return null;
|
|
142
|
-
let depth = 0;
|
|
143
|
-
let inString = null;
|
|
144
|
-
let escaped = false;
|
|
145
|
-
for (let i = startIndex; i < source.length; i++) {
|
|
146
|
-
const char = source[i];
|
|
147
|
-
if (escaped) {
|
|
148
|
-
escaped = false;
|
|
149
|
-
continue;
|
|
150
|
-
}
|
|
151
|
-
if (char === "\\" && inString) {
|
|
152
|
-
escaped = true;
|
|
153
|
-
continue;
|
|
154
|
-
}
|
|
155
|
-
if (inString) {
|
|
156
|
-
if (char === inString) inString = null;
|
|
157
|
-
continue;
|
|
158
|
-
}
|
|
159
|
-
if (char === "\"" || char === "'" || char === "`") {
|
|
160
|
-
inString = char;
|
|
161
|
-
continue;
|
|
162
|
-
}
|
|
163
|
-
if (char === "/" && i + 1 < source.length && source[i + 1] === "/") {
|
|
164
|
-
const newline = source.indexOf("\n", i);
|
|
165
|
-
i = newline === -1 ? source.length : newline;
|
|
166
|
-
continue;
|
|
167
|
-
}
|
|
168
|
-
if (char === "/" && i + 1 < source.length && source[i + 1] === "*") {
|
|
169
|
-
const end = source.indexOf("*/", i + 2);
|
|
170
|
-
i = end === -1 ? source.length : end + 1;
|
|
171
|
-
continue;
|
|
172
|
-
}
|
|
173
|
-
if (char === "{" || char === "[") depth++;
|
|
174
|
-
if (char === "}" || char === "]") {
|
|
175
|
-
depth--;
|
|
176
|
-
if (depth === 0) return source.slice(startIndex, i + 1);
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
return null;
|
|
180
|
-
}
|
|
181
|
-
/**
|
|
182
321
|
* Get sorted subdirectories of a given path.
|
|
183
322
|
*/
|
|
184
323
|
function getSortedDirs(dirPath) {
|
|
@@ -195,35 +334,70 @@ function getSvelteFiles(dirPath) {
|
|
|
195
334
|
return readdirSync(dirPath).filter((name) => name.endsWith(".svelte")).sort();
|
|
196
335
|
}
|
|
197
336
|
/**
|
|
337
|
+
* Enumerate the course's section → lesson → file structure. Section-level
|
|
338
|
+
* `.svelte` files become an implicit flat lesson (`name: null`) ordered before
|
|
339
|
+
* the section's explicit lesson directories. Shared by manifest generation and
|
|
340
|
+
* build-time validation so the two never disagree on which files are pages.
|
|
341
|
+
*/
|
|
342
|
+
function walkPages(pagesDir) {
|
|
343
|
+
const sections = [];
|
|
344
|
+
for (const sectionName of getSortedDirs(pagesDir)) {
|
|
345
|
+
const dir = resolve(pagesDir, sectionName);
|
|
346
|
+
const metaPath = resolve(dir, "_meta.js");
|
|
347
|
+
const lessons = [];
|
|
348
|
+
const flatFiles = getSvelteFiles(dir);
|
|
349
|
+
if (flatFiles.length > 0) lessons.push({
|
|
350
|
+
name: null,
|
|
351
|
+
dir,
|
|
352
|
+
metaPath,
|
|
353
|
+
files: flatFiles
|
|
354
|
+
});
|
|
355
|
+
for (const lessonName of getSortedDirs(dir)) {
|
|
356
|
+
const lessonDir = resolve(dir, lessonName);
|
|
357
|
+
lessons.push({
|
|
358
|
+
name: lessonName,
|
|
359
|
+
dir: lessonDir,
|
|
360
|
+
metaPath: resolve(lessonDir, "_meta.js"),
|
|
361
|
+
files: getSvelteFiles(lessonDir)
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
sections.push({
|
|
365
|
+
name: sectionName,
|
|
366
|
+
dir,
|
|
367
|
+
metaPath,
|
|
368
|
+
lessons
|
|
369
|
+
});
|
|
370
|
+
}
|
|
371
|
+
return sections;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
198
374
|
* Generate a course manifest by scanning the pages/ directory.
|
|
199
375
|
*/
|
|
200
376
|
function generateManifest(pagesDir) {
|
|
377
|
+
clearParseCache();
|
|
201
378
|
const sections = [];
|
|
202
379
|
const flatPages = [];
|
|
203
380
|
let pageIndex = 0;
|
|
204
|
-
const
|
|
205
|
-
|
|
206
|
-
const
|
|
207
|
-
const sectionMeta = readMetaFile(resolve(sectionPath, "_meta.js"));
|
|
208
|
-
const sectionSlug = deriveSlug(sectionName);
|
|
381
|
+
for (const walkedSection of walkPages(pagesDir)) {
|
|
382
|
+
const sectionMeta = readMetaFile(walkedSection.metaPath);
|
|
383
|
+
const sectionSlug = deriveSlug(walkedSection.name);
|
|
209
384
|
const section = {
|
|
210
385
|
title: sectionMeta.title || titleCase(sectionSlug),
|
|
211
386
|
slug: sectionSlug,
|
|
212
387
|
lessons: []
|
|
213
388
|
};
|
|
214
|
-
const
|
|
215
|
-
|
|
216
|
-
const
|
|
217
|
-
const
|
|
218
|
-
const
|
|
389
|
+
for (const walkedLesson of walkedSection.lessons) {
|
|
390
|
+
const isFlat = walkedLesson.name === null;
|
|
391
|
+
const lessonMeta = isFlat ? sectionMeta : readMetaFile(walkedLesson.metaPath);
|
|
392
|
+
const lessonSlug = isFlat ? sectionSlug : deriveSlug(walkedLesson.name);
|
|
393
|
+
const relDir = isFlat ? `/pages/${walkedSection.name}` : `/pages/${walkedSection.name}/${walkedLesson.name}`;
|
|
219
394
|
const lesson = {
|
|
220
|
-
title: lessonMeta.title || titleCase(lessonSlug),
|
|
395
|
+
title: isFlat ? "" : lessonMeta.title || titleCase(lessonSlug),
|
|
221
396
|
slug: lessonSlug,
|
|
222
397
|
pages: []
|
|
223
398
|
};
|
|
224
|
-
const
|
|
225
|
-
|
|
226
|
-
const filePath = resolve(lessonPath, fileName);
|
|
399
|
+
for (const fileName of orderPageFiles(walkedLesson.files, lessonMeta.pages)) {
|
|
400
|
+
const filePath = resolve(walkedLesson.dir, fileName);
|
|
227
401
|
const pageSlug = deriveSlug(fileName, true);
|
|
228
402
|
let pageConfig = {};
|
|
229
403
|
try {
|
|
@@ -231,12 +405,11 @@ function generateManifest(pagesDir) {
|
|
|
231
405
|
} catch (e) {
|
|
232
406
|
console.warn(`[tessera warning] ${e.message}`);
|
|
233
407
|
}
|
|
234
|
-
const relativePath = `/pages/${sectionName}/${lessonName}/${fileName}`;
|
|
235
408
|
const page = {
|
|
236
409
|
index: pageIndex,
|
|
237
410
|
title: pageConfig.title || titleCase(pageSlug),
|
|
238
411
|
slug: pageSlug,
|
|
239
|
-
importPath:
|
|
412
|
+
importPath: `${relDir}/${fileName}`,
|
|
240
413
|
quiz: pageConfig.quiz || null,
|
|
241
414
|
...pageConfig.completesOn === "view" ? { completesOn: "view" } : {}
|
|
242
415
|
};
|
|
@@ -336,6 +509,21 @@ function validateAuthCredential(auth) {
|
|
|
336
509
|
return null;
|
|
337
510
|
}
|
|
338
511
|
//#endregion
|
|
512
|
+
//#region src/runtime/xapi/derive-actor.ts
|
|
513
|
+
/**
|
|
514
|
+
* Origin of an http(s) URL, else null. Shared with the config validator, which
|
|
515
|
+
* predicts this result to know when `actorAccountHomePage` becomes required —
|
|
516
|
+
* one helper keeps the two in lockstep.
|
|
517
|
+
*/
|
|
518
|
+
function httpOrigin(url) {
|
|
519
|
+
try {
|
|
520
|
+
const parsed = new URL(url);
|
|
521
|
+
return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.origin : null;
|
|
522
|
+
} catch {
|
|
523
|
+
return null;
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
//#endregion
|
|
339
527
|
//#region src/runtime/interaction-format.ts
|
|
340
528
|
/**
|
|
341
529
|
* SCORM `short_identifier_type` / `CMIIdentifier`: alphanumerics +
|
|
@@ -554,6 +742,7 @@ const VALID_RETRY_MODES = RETRY_MODES;
|
|
|
554
742
|
* Returns errors (block build) and warnings (informational).
|
|
555
743
|
*/
|
|
556
744
|
function validateProject(projectRoot) {
|
|
745
|
+
clearParseCache();
|
|
557
746
|
const errors = [];
|
|
558
747
|
const warnings = [];
|
|
559
748
|
if (!existsSync(resolve(projectRoot, "course.config.js"))) {
|
|
@@ -582,8 +771,8 @@ function validateProject(projectRoot) {
|
|
|
582
771
|
function parseConfig(projectRoot, errors, warnings) {
|
|
583
772
|
const read = readCourseConfig(projectRoot);
|
|
584
773
|
if (!read.ok) {
|
|
585
|
-
if (read.reason === "no-export") errors.push("course.config.js:
|
|
586
|
-
else if (read.reason === "parse-error") errors.push("course.config.js:
|
|
774
|
+
if (read.reason === "no-export") errors.push("course.config.js: must use `export default { ... }` syntax");
|
|
775
|
+
else if (read.reason === "parse-error") errors.push("course.config.js: could not parse — JavaScript syntax error");
|
|
587
776
|
return null;
|
|
588
777
|
}
|
|
589
778
|
const config = read.config;
|
|
@@ -635,9 +824,12 @@ function isPlausibleColor(value) {
|
|
|
635
824
|
* on primaryColor. Runtime failures are mild: an unresolved logo ships a broken
|
|
636
825
|
* <img src>, an unparseable color falls back to theme defaults.
|
|
637
826
|
*/
|
|
827
|
+
function describeType(raw) {
|
|
828
|
+
return raw === null ? "null" : Array.isArray(raw) ? "array" : typeof raw;
|
|
829
|
+
}
|
|
638
830
|
function validateBranding(raw, warnings) {
|
|
639
831
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
640
|
-
warnings.push(`course.config.js: "branding" must be an object, got ${
|
|
832
|
+
warnings.push(`course.config.js: "branding" must be an object, got ${describeType(raw)} — will be ignored`);
|
|
641
833
|
return;
|
|
642
834
|
}
|
|
643
835
|
const branding = raw;
|
|
@@ -659,7 +851,7 @@ function validateBranding(raw, warnings) {
|
|
|
659
851
|
/** Shape-check the `a11y` block. Malformed values can't be silenced by `ignore`. */
|
|
660
852
|
function validateA11yConfig(raw, errors) {
|
|
661
853
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
|
662
|
-
errors.push(`course.config.js: "a11y" must be an object, got ${
|
|
854
|
+
errors.push(`course.config.js: "a11y" must be an object, got ${describeType(raw)}`);
|
|
663
855
|
return;
|
|
664
856
|
}
|
|
665
857
|
const a11y = raw;
|
|
@@ -766,16 +958,7 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
|
|
|
766
958
|
if (actor !== void 0) warnings.push(`course.config.js: ${label}.actorAccountHomePage is ignored when ${label}.actor is supplied explicitly.`);
|
|
767
959
|
if (standard === "cmi5" || standard === "web") warnings.push(`course.config.js: ${label}.actorAccountHomePage is only used under scorm12/scorm2004 actor synthesis; ignored under "${standard}".`);
|
|
768
960
|
}
|
|
769
|
-
if (actor === void 0 && (standard === "scorm12" || standard === "scorm2004") && typeof activityId === "string") {
|
|
770
|
-
let isHttp;
|
|
771
|
-
try {
|
|
772
|
-
const u = new URL(activityId);
|
|
773
|
-
isHttp = u.protocol === "http:" || u.protocol === "https:";
|
|
774
|
-
} catch {
|
|
775
|
-
isHttp = false;
|
|
776
|
-
}
|
|
777
|
-
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.`);
|
|
778
|
-
}
|
|
961
|
+
if (actor === void 0 && (standard === "scorm12" || standard === "scorm2004") && typeof activityId === "string" && httpOrigin(activityId) === null && 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.`);
|
|
779
962
|
const registration = entry.registration;
|
|
780
963
|
if (registration !== void 0) {
|
|
781
964
|
if (typeof registration !== "string" || !UUID_RE.test(registration)) errors.push(`course.config.js: ${label}.registration must be a UUID v4, got "${String(registration)}"`);
|
|
@@ -790,6 +973,22 @@ function validateSingleXAPIEntry(entry, label, standard, errors, warnings) {
|
|
|
790
973
|
function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, warnings, assetExistsCache, exportStandard) {
|
|
791
974
|
const fileRel = relative(projectRoot, filePath);
|
|
792
975
|
const content = readSourceFileCached(filePath);
|
|
976
|
+
const parseError = getParseError(content);
|
|
977
|
+
if (parseError) {
|
|
978
|
+
errors.push(`${fileRel}: could not parse — ${parseError}`);
|
|
979
|
+
return {
|
|
980
|
+
page: {
|
|
981
|
+
fileRel,
|
|
982
|
+
navIndex,
|
|
983
|
+
hasGradedQuiz: false,
|
|
984
|
+
hasQuiz: false,
|
|
985
|
+
completesOnView: false
|
|
986
|
+
},
|
|
987
|
+
isQuiz: false,
|
|
988
|
+
isGradedQuiz: false,
|
|
989
|
+
parseError: true
|
|
990
|
+
};
|
|
991
|
+
}
|
|
793
992
|
const pageConfig = validatePageConfig(content, fileRel, errors);
|
|
794
993
|
const isQuiz = !!pageConfig?.quiz;
|
|
795
994
|
let isGradedQuiz = false;
|
|
@@ -813,7 +1012,8 @@ function validatePageFile(filePath, projectRoot, assetsDir, navIndex, errors, wa
|
|
|
813
1012
|
completesOnView
|
|
814
1013
|
},
|
|
815
1014
|
isQuiz,
|
|
816
|
-
isGradedQuiz
|
|
1015
|
+
isGradedQuiz,
|
|
1016
|
+
parseError: false
|
|
817
1017
|
};
|
|
818
1018
|
}
|
|
819
1019
|
function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
|
|
@@ -823,117 +1023,79 @@ function validatePages(pagesDir, assetsDir, projectRoot, exportStandard) {
|
|
|
823
1023
|
let totalPages = 0;
|
|
824
1024
|
let totalQuizzes = 0;
|
|
825
1025
|
let hasGradedQuiz = false;
|
|
1026
|
+
let hasParseErrors = false;
|
|
826
1027
|
const assetExistsCache = /* @__PURE__ */ new Map();
|
|
827
|
-
|
|
1028
|
+
const noPages = () => {
|
|
828
1029
|
errors.push("No pages found. Create at least one section with a lesson and page in pages/");
|
|
829
1030
|
return {
|
|
830
1031
|
errors,
|
|
831
1032
|
warnings,
|
|
832
|
-
totalPages
|
|
833
|
-
totalQuizzes
|
|
834
|
-
hasGradedQuiz
|
|
1033
|
+
totalPages,
|
|
1034
|
+
totalQuizzes,
|
|
1035
|
+
hasGradedQuiz,
|
|
1036
|
+
hasParseErrors,
|
|
835
1037
|
pages
|
|
836
1038
|
};
|
|
837
|
-
}
|
|
838
|
-
|
|
839
|
-
for (const entry of
|
|
1039
|
+
};
|
|
1040
|
+
if (!existsSync(pagesDir)) return noPages();
|
|
1041
|
+
for (const entry of readdirSync(pagesDir)) {
|
|
840
1042
|
const fullPath = resolve(pagesDir, entry);
|
|
841
|
-
if (entry.endsWith(".svelte") && statSync(fullPath).isFile()) {
|
|
842
|
-
const relPath = relative(projectRoot, fullPath);
|
|
843
|
-
warnings.push(`${relPath}: this file is outside the section/lesson structure and will be ignored`);
|
|
844
|
-
}
|
|
1043
|
+
if (entry.endsWith(".svelte") && statSync(fullPath).isFile()) warnings.push(`${relative(projectRoot, fullPath)}: this file is outside the section/lesson structure and will be ignored`);
|
|
845
1044
|
}
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
errors.push("No pages found. Create at least one section with a lesson and page in pages/");
|
|
851
|
-
return {
|
|
852
|
-
errors,
|
|
853
|
-
warnings,
|
|
854
|
-
totalPages: 0,
|
|
855
|
-
totalQuizzes: 0,
|
|
856
|
-
hasGradedQuiz: false,
|
|
857
|
-
pages
|
|
858
|
-
};
|
|
859
|
-
}
|
|
860
|
-
for (const sectionName of sectionDirs) {
|
|
861
|
-
const sectionPath = resolve(pagesDir, sectionName);
|
|
862
|
-
const sectionRel = relative(projectRoot, sectionPath);
|
|
863
|
-
const pagesBeforeSection = totalPages;
|
|
864
|
-
const sectionMeta = validateMetaFile(resolve(sectionPath, "_meta.js"), sectionRel, errors);
|
|
865
|
-
const sectionEntries = readdirSync(sectionPath);
|
|
866
|
-
const sectionSvelteFiles = sectionEntries.filter((name) => {
|
|
867
|
-
const full = resolve(sectionPath, name);
|
|
868
|
-
return name.endsWith(".svelte") && statSync(full).isFile();
|
|
869
|
-
}).sort();
|
|
870
|
-
if (sectionMeta?.pages) for (const pageName of sectionMeta.pages) {
|
|
1045
|
+
const sections = walkPages(pagesDir);
|
|
1046
|
+
if (sections.length === 0) return noPages();
|
|
1047
|
+
const validateLesson = (lesson, meta) => {
|
|
1048
|
+
if (meta?.pages) for (const pageName of meta.pages) {
|
|
871
1049
|
const fileName = ensureSvelteSuffix(pageName);
|
|
872
|
-
if (!
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
1050
|
+
if (!lesson.files.includes(fileName)) errors.push(`${relative(projectRoot, lesson.metaPath)}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
|
|
1051
|
+
}
|
|
1052
|
+
if (meta?.pages && meta.pages.length > 0) {
|
|
1053
|
+
const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
|
|
1054
|
+
for (const file of lesson.files) if (!listedSet.has(file)) warnings.push(`${relative(projectRoot, resolve(lesson.dir, file))}: not listed in _meta.js pages array — will be appended at end`);
|
|
876
1055
|
}
|
|
877
|
-
for (const fileName of
|
|
878
|
-
const result = validatePageFile(resolve(
|
|
1056
|
+
for (const fileName of orderPageFiles(lesson.files, meta?.pages)) {
|
|
1057
|
+
const result = validatePageFile(resolve(lesson.dir, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache, exportStandard);
|
|
879
1058
|
totalPages++;
|
|
880
1059
|
if (result.isQuiz) totalQuizzes++;
|
|
881
1060
|
if (result.isGradedQuiz) hasGradedQuiz = true;
|
|
1061
|
+
if (result.parseError) hasParseErrors = true;
|
|
882
1062
|
pages.push(result.page);
|
|
883
1063
|
}
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
const svelteFiles = readdirSync(lessonPath).filter((name) => name.endsWith(".svelte")).sort();
|
|
892
|
-
if (meta?.pages) for (const pageName of meta.pages) {
|
|
893
|
-
const fileName = ensureSvelteSuffix(pageName);
|
|
894
|
-
if (!svelteFiles.includes(fileName)) {
|
|
895
|
-
const metaRel = relative(projectRoot, resolve(lessonPath, "_meta.js"));
|
|
896
|
-
errors.push(`${metaRel}: pages array lists "${pageName}" but ${fileName} not found in this directory`);
|
|
897
|
-
}
|
|
898
|
-
}
|
|
899
|
-
if (meta?.pages && meta.pages.length > 0) {
|
|
900
|
-
const listedSet = new Set(meta.pages.map(ensureSvelteSuffix));
|
|
901
|
-
for (const file of svelteFiles) if (!listedSet.has(file)) {
|
|
902
|
-
const relPath = relative(projectRoot, resolve(lessonPath, file));
|
|
903
|
-
warnings.push(`${relPath}: not listed in _meta.js pages array — will be appended at end`);
|
|
904
|
-
}
|
|
905
|
-
}
|
|
906
|
-
for (const fileName of svelteFiles) {
|
|
907
|
-
const result = validatePageFile(resolve(lessonPath, fileName), projectRoot, assetsDir, totalPages, errors, warnings, assetExistsCache, exportStandard);
|
|
908
|
-
totalPages++;
|
|
909
|
-
if (result.isQuiz) totalQuizzes++;
|
|
910
|
-
if (result.isGradedQuiz) hasGradedQuiz = true;
|
|
911
|
-
pages.push(result.page);
|
|
912
|
-
}
|
|
913
|
-
}
|
|
1064
|
+
};
|
|
1065
|
+
for (const section of sections) {
|
|
1066
|
+
const sectionRel = relative(projectRoot, section.dir);
|
|
1067
|
+
const pagesBeforeSection = totalPages;
|
|
1068
|
+
const sectionMeta = validateMetaFile(section.metaPath, sectionRel, errors);
|
|
1069
|
+
for (const lesson of section.lessons) if (lesson.name === null) validateLesson(lesson, sectionMeta);
|
|
1070
|
+
else validateLesson(lesson, validateMetaFile(lesson.metaPath, relative(projectRoot, lesson.dir), errors));
|
|
914
1071
|
if (totalPages === pagesBeforeSection) warnings.push(`${sectionRel}: section contributed no pages and will be empty`);
|
|
915
1072
|
}
|
|
916
|
-
if (totalPages === 0)
|
|
1073
|
+
if (totalPages === 0) return noPages();
|
|
917
1074
|
return {
|
|
918
1075
|
errors,
|
|
919
1076
|
warnings,
|
|
920
1077
|
totalPages,
|
|
921
1078
|
totalQuizzes,
|
|
922
1079
|
hasGradedQuiz,
|
|
1080
|
+
hasParseErrors,
|
|
923
1081
|
pages
|
|
924
1082
|
};
|
|
925
1083
|
}
|
|
926
1084
|
function validateMetaFile(metaPath, parentRel, errors) {
|
|
927
1085
|
if (!existsSync(metaPath)) return null;
|
|
928
1086
|
const metaRel = `${parentRel}/_meta.js`;
|
|
929
|
-
const
|
|
930
|
-
if (
|
|
1087
|
+
const result = extractDefaultExportObjectLiteral(readSourceFileCached(metaPath));
|
|
1088
|
+
if (result.kind === "parse-error") {
|
|
1089
|
+
errors.push(`${metaRel}: could not parse — JavaScript syntax error`);
|
|
1090
|
+
return null;
|
|
1091
|
+
}
|
|
1092
|
+
if (result.kind !== "literal") {
|
|
931
1093
|
errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
|
|
932
1094
|
return null;
|
|
933
1095
|
}
|
|
934
1096
|
let meta;
|
|
935
1097
|
try {
|
|
936
|
-
meta = JSON5.parse(
|
|
1098
|
+
meta = JSON5.parse(result.text);
|
|
937
1099
|
} catch {
|
|
938
1100
|
errors.push(`${metaRel}: syntax error — must export default { title: "..." }`);
|
|
939
1101
|
return null;
|
|
@@ -979,66 +1141,6 @@ const QUESTION_COMPONENT_REQUIRED = {
|
|
|
979
1141
|
"correct"
|
|
980
1142
|
]
|
|
981
1143
|
};
|
|
982
|
-
/**
|
|
983
|
-
* Parse the props of an opening tag starting just after the component name.
|
|
984
|
-
* Returns null if the tag can't be parsed cleanly — callers then skip it
|
|
985
|
-
* rather than risk a false positive.
|
|
986
|
-
*/
|
|
987
|
-
function parseTagProps(content, start) {
|
|
988
|
-
const props = /* @__PURE__ */ new Map();
|
|
989
|
-
let hasSpread = false;
|
|
990
|
-
let i = start;
|
|
991
|
-
while (i < content.length) {
|
|
992
|
-
while (i < content.length && /\s/.test(content[i])) i++;
|
|
993
|
-
if (i >= content.length) return null;
|
|
994
|
-
const c = content[i];
|
|
995
|
-
if (c === ">") return {
|
|
996
|
-
props,
|
|
997
|
-
hasSpread
|
|
998
|
-
};
|
|
999
|
-
if (c === "/" && content[i + 1] === ">") return {
|
|
1000
|
-
props,
|
|
1001
|
-
hasSpread
|
|
1002
|
-
};
|
|
1003
|
-
if (c === "{") {
|
|
1004
|
-
const block = extractObjectLiteral(content, i);
|
|
1005
|
-
if (!block) return null;
|
|
1006
|
-
hasSpread = true;
|
|
1007
|
-
i += block.length;
|
|
1008
|
-
continue;
|
|
1009
|
-
}
|
|
1010
|
-
const nameMatch = /^[A-Za-z_][\w-]*/.exec(content.slice(i));
|
|
1011
|
-
if (!nameMatch) return null;
|
|
1012
|
-
const propName = nameMatch[0];
|
|
1013
|
-
i += propName.length;
|
|
1014
|
-
while (i < content.length && /\s/.test(content[i])) i++;
|
|
1015
|
-
if (content[i] !== "=") {
|
|
1016
|
-
props.set(propName, { kind: "bool" });
|
|
1017
|
-
continue;
|
|
1018
|
-
}
|
|
1019
|
-
i++;
|
|
1020
|
-
while (i < content.length && /\s/.test(content[i])) i++;
|
|
1021
|
-
const v = content[i];
|
|
1022
|
-
if (v === "\"" || v === "'") {
|
|
1023
|
-
const end = content.indexOf(v, i + 1);
|
|
1024
|
-
if (end === -1) return null;
|
|
1025
|
-
props.set(propName, {
|
|
1026
|
-
kind: "string",
|
|
1027
|
-
value: content.slice(i + 1, end)
|
|
1028
|
-
});
|
|
1029
|
-
i = end + 1;
|
|
1030
|
-
} else if (v === "{") {
|
|
1031
|
-
const block = extractObjectLiteral(content, i);
|
|
1032
|
-
if (!block) return null;
|
|
1033
|
-
props.set(propName, {
|
|
1034
|
-
kind: "expr",
|
|
1035
|
-
raw: block.slice(1, -1).trim()
|
|
1036
|
-
});
|
|
1037
|
-
i += block.length;
|
|
1038
|
-
} else return null;
|
|
1039
|
-
}
|
|
1040
|
-
return null;
|
|
1041
|
-
}
|
|
1042
1144
|
function staticArray(prop) {
|
|
1043
1145
|
if (prop?.kind !== "expr" || !prop.raw.startsWith("[")) return null;
|
|
1044
1146
|
try {
|
|
@@ -1058,16 +1160,11 @@ function staticNumber(prop) {
|
|
|
1058
1160
|
}
|
|
1059
1161
|
}
|
|
1060
1162
|
function validateQuestionComponents(content, fileRel, errors, warnings, exportStandard) {
|
|
1061
|
-
const
|
|
1062
|
-
|
|
1163
|
+
const components = findComponents(content, new Set(Object.keys(QUESTION_COMPONENT_REQUIRED)));
|
|
1164
|
+
if (!components) return;
|
|
1063
1165
|
const seenIds = /* @__PURE__ */ new Set();
|
|
1064
1166
|
const seenSanitized = /* @__PURE__ */ new Set();
|
|
1065
|
-
|
|
1066
|
-
while ((m = tagStartRe.exec(content)) !== null) {
|
|
1067
|
-
const name = m[1];
|
|
1068
|
-
const parsed = parseTagProps(content, m.index + m[0].length);
|
|
1069
|
-
if (!parsed) continue;
|
|
1070
|
-
const { props, hasSpread } = parsed;
|
|
1167
|
+
for (const { name, props, hasSpread } of components) {
|
|
1071
1168
|
for (const req of QUESTION_COMPONENT_REQUIRED[name]) if (!hasSpread && !props.has(req)) errors.push(`${fileRel}: <${name}> is missing required prop "${req}"`);
|
|
1072
1169
|
for (const labelProp of ["options", "answers"]) if (staticArray(props.get(labelProp))?.some((e) => typeof e === "string" && e.trim() === "")) warnings.push(tag(A11Y_IDS.questionLabel, `${fileRel}: <${name}> has an empty ${labelProp === "options" ? "option" : "answer"} label`));
|
|
1073
1170
|
const idProp = props.get("id");
|
|
@@ -1125,20 +1222,31 @@ function validateQuestionComponents(content, fileRel, errors, warnings, exportSt
|
|
|
1125
1222
|
}
|
|
1126
1223
|
/** Remove HTML/Svelte comments so commented-out markup isn't scanned as live. */
|
|
1127
1224
|
const HTML_COMMENT_RE = /<!--[\s\S]*?-->/g;
|
|
1225
|
+
const SCRIPT_STYLE_RE = /<(script|style)\b[\s\S]*?<\/\1>/gi;
|
|
1226
|
+
function stripRepeated(input, patterns) {
|
|
1227
|
+
let out = input;
|
|
1228
|
+
for (const pattern of patterns) {
|
|
1229
|
+
let prev;
|
|
1230
|
+
do {
|
|
1231
|
+
prev = out;
|
|
1232
|
+
out = out.replace(pattern, "");
|
|
1233
|
+
} while (out !== prev);
|
|
1234
|
+
}
|
|
1235
|
+
return out;
|
|
1236
|
+
}
|
|
1128
1237
|
/**
|
|
1129
1238
|
* Sibling to validateQuestionComponents kept out of QUESTION_COMPONENT_REQUIRED
|
|
1130
1239
|
* so media isn't treated as gradable questions. Declares `warnings` directly.
|
|
1131
1240
|
* Non-static (kind 'expr') values are skipped, matching the rest of the linter.
|
|
1132
1241
|
*/
|
|
1133
1242
|
function validateMediaComponents(content, fileRel, errors, warnings) {
|
|
1134
|
-
const
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
const { props, hasSpread } = parsed;
|
|
1243
|
+
const components = findComponents(content, new Set([
|
|
1244
|
+
"Image",
|
|
1245
|
+
"Video",
|
|
1246
|
+
"Audio"
|
|
1247
|
+
]));
|
|
1248
|
+
if (!components) return;
|
|
1249
|
+
for (const { name, props, hasSpread } of components) {
|
|
1142
1250
|
if (name === "Image") {
|
|
1143
1251
|
const alt = props.get("alt");
|
|
1144
1252
|
const decorative = props.get("decorative");
|
|
@@ -1170,7 +1278,7 @@ function validateMediaComponents(content, fileRel, errors, warnings) {
|
|
|
1170
1278
|
* audit.
|
|
1171
1279
|
*/
|
|
1172
1280
|
function validateHeadingOrder(content, fileRel, warnings) {
|
|
1173
|
-
const levels = [...content
|
|
1281
|
+
const levels = [...stripRepeated(content, [SCRIPT_STYLE_RE, HTML_COMMENT_RE]).matchAll(/<h([1-6])\b/gi)].map((h) => Number(h[1]));
|
|
1174
1282
|
let prevSeen = null;
|
|
1175
1283
|
for (const level of levels) {
|
|
1176
1284
|
if (prevSeen !== null && level - prevSeen > 1) warnings.push(tag(A11Y_IDS.headingOrder, `${fileRel}: heading level jumps from h${prevSeen} to h${level} — don't skip levels (WCAG 1.3.1)`));
|
|
@@ -1212,11 +1320,11 @@ function validateAssetRefs(content, fileRel, assetsDir, warnings, existsCache) {
|
|
|
1212
1320
|
}
|
|
1213
1321
|
}
|
|
1214
1322
|
function crossValidate(config, pageResults, errors, warnings) {
|
|
1215
|
-
if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
|
|
1323
|
+
if (config.completion?.mode === "quiz" && !pageResults.hasGradedQuiz && !pageResults.hasParseErrors) errors.push("completion.mode is \"quiz\" but no pages have quiz config with graded: true");
|
|
1216
1324
|
if (config.completion?.mode === "quiz" && config.scoring?.passingScore === void 0) warnings.push("completion.mode is \"quiz\" but scoring.passingScore is not set — defaulting to 70%. Set it explicitly to be sure.");
|
|
1217
1325
|
const isManual = config.completion?.mode === "manual";
|
|
1218
1326
|
const completesOnPages = pageResults.pages.filter((p) => p.completesOnView);
|
|
1219
|
-
if (isManual && config.completion?.trigger === "page" && completesOnPages.length === 0) errors.push("completion.mode is \"manual\" with trigger: \"page\", but no page declares pageConfig.completesOn: \"view\". Either add a completesOn page or remove the trigger field to drop the static check.");
|
|
1327
|
+
if (isManual && config.completion?.trigger === "page" && completesOnPages.length === 0 && !pageResults.hasParseErrors) errors.push("completion.mode is \"manual\" with trigger: \"page\", but no page declares pageConfig.completesOn: \"view\". Either add a completesOn page or remove the trigger field to drop the static check.");
|
|
1220
1328
|
if (isManual) {
|
|
1221
1329
|
for (const page of pageResults.pages) if (page.hasGradedQuiz) warnings.push(`${page.fileRel}: quiz.graded is true under completion.mode: "manual". The score will be reported to the LMS for transcripts, but it will not drive completion or success status — \`markComplete()\` / completesOn does. If that's not what you want, set graded: false or change completion.mode.`);
|
|
1222
1330
|
}
|
|
@@ -1239,6 +1347,234 @@ function crossValidate(config, pageResults, errors, warnings) {
|
|
|
1239
1347
|
}
|
|
1240
1348
|
}
|
|
1241
1349
|
//#endregion
|
|
1242
|
-
|
|
1350
|
+
//#region src/plugin/package-root.ts
|
|
1351
|
+
function resolvePackageRoot() {
|
|
1352
|
+
const dir = import.meta.dirname;
|
|
1353
|
+
for (let up = dir; up !== dirname(up); up = dirname(up)) {
|
|
1354
|
+
const pkgPath = resolve(up, "package.json");
|
|
1355
|
+
if (existsSync(pkgPath)) try {
|
|
1356
|
+
const { name } = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
1357
|
+
if (name === "tessera-learn") return up;
|
|
1358
|
+
} catch {}
|
|
1359
|
+
}
|
|
1360
|
+
return resolve(dir, "..", "..");
|
|
1361
|
+
}
|
|
1362
|
+
//#endregion
|
|
1363
|
+
//#region src/plugin/a11y/audit.ts
|
|
1364
|
+
const IMPACT_RANK = {
|
|
1365
|
+
minor: 1,
|
|
1366
|
+
moderate: 2,
|
|
1367
|
+
serious: 3,
|
|
1368
|
+
critical: 4
|
|
1369
|
+
};
|
|
1370
|
+
const AUDIT_ENV_FLAG = "TESSERA_A11Y_AUDIT";
|
|
1371
|
+
/** Map the `a11y.standard` enum to axe's cumulative `runOnly` tag list. */
|
|
1372
|
+
function axeTags(standard) {
|
|
1373
|
+
switch (standard) {
|
|
1374
|
+
case "wcag2a": return ["wcag2a"];
|
|
1375
|
+
case "wcag21aa": return [
|
|
1376
|
+
"wcag2a",
|
|
1377
|
+
"wcag2aa",
|
|
1378
|
+
"wcag21a",
|
|
1379
|
+
"wcag21aa"
|
|
1380
|
+
];
|
|
1381
|
+
default: return ["wcag2a", "wcag2aa"];
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
/** axe-applicable ignore entries: drop the Tier-1a/1b namespaces. */
|
|
1385
|
+
function axeIgnoreRules(ignore) {
|
|
1386
|
+
return ignore.filter((id) => !id.startsWith("tessera/") && !id.startsWith("a11y_"));
|
|
1387
|
+
}
|
|
1388
|
+
function isMissingBrowserError(message) {
|
|
1389
|
+
return /Executable doesn't exist|playwright install/i.test(message);
|
|
1390
|
+
}
|
|
1391
|
+
function isFailing(v, thresholdRank) {
|
|
1392
|
+
return !v.impact || IMPACT_RANK[v.impact] >= thresholdRank;
|
|
1393
|
+
}
|
|
1394
|
+
async function tryImport(specifier) {
|
|
1395
|
+
return import(specifier);
|
|
1396
|
+
}
|
|
1397
|
+
async function loadDeps() {
|
|
1398
|
+
let chromium;
|
|
1399
|
+
for (const spec of ["playwright", "@playwright/test"]) try {
|
|
1400
|
+
const mod = await tryImport(spec);
|
|
1401
|
+
if (mod.chromium) {
|
|
1402
|
+
chromium = mod.chromium;
|
|
1403
|
+
break;
|
|
1404
|
+
}
|
|
1405
|
+
} catch {}
|
|
1406
|
+
if (!chromium) return {
|
|
1407
|
+
ok: false,
|
|
1408
|
+
missing: "playwright"
|
|
1409
|
+
};
|
|
1410
|
+
try {
|
|
1411
|
+
const mod = await tryImport("@axe-core/playwright");
|
|
1412
|
+
if (!mod.default) return {
|
|
1413
|
+
ok: false,
|
|
1414
|
+
missing: "@axe-core/playwright"
|
|
1415
|
+
};
|
|
1416
|
+
return {
|
|
1417
|
+
ok: true,
|
|
1418
|
+
deps: {
|
|
1419
|
+
chromium,
|
|
1420
|
+
AxeBuilder: mod.default
|
|
1421
|
+
}
|
|
1422
|
+
};
|
|
1423
|
+
} catch {
|
|
1424
|
+
return {
|
|
1425
|
+
ok: false,
|
|
1426
|
+
missing: "@axe-core/playwright"
|
|
1427
|
+
};
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
/**
|
|
1431
|
+
* Run the Tier-2 runtime accessibility audit against a built course. Builds (or
|
|
1432
|
+
* reuses) dist/, serves it, drives Playwright + axe-core over each page, writes
|
|
1433
|
+
* a11y-report.json, and returns a process exit code (0 pass, 1 fail/error).
|
|
1434
|
+
*/
|
|
1435
|
+
async function runAudit(projectRoot, workspaceRoot, options = {}) {
|
|
1436
|
+
const threshold = options.threshold ?? "serious";
|
|
1437
|
+
const deps = await loadDeps();
|
|
1438
|
+
if (!deps.ok) {
|
|
1439
|
+
console.error("\x1B[31m[tessera a11y]\x1B[0m Tier 2 needs Playwright + axe-core, which aren't installed.\n Install them to run the runtime audit:\n pnpm add -D playwright @axe-core/playwright\n pnpm exec playwright install chromium");
|
|
1440
|
+
return 1;
|
|
1441
|
+
}
|
|
1442
|
+
const { chromium, AxeBuilder } = deps.deps;
|
|
1443
|
+
const read = readCourseConfig(projectRoot);
|
|
1444
|
+
const settings = normalizeA11y(read.ok ? read.config.a11y : void 0);
|
|
1445
|
+
const tags = axeTags(settings.standard);
|
|
1446
|
+
const disableRules = axeIgnoreRules(settings.ignore);
|
|
1447
|
+
const manifest = generateManifest(resolve(projectRoot, "pages"));
|
|
1448
|
+
const vite = await import("vite");
|
|
1449
|
+
const { resolveTesseraConfig } = await import("./inline-config-CroQ-_2Y.js");
|
|
1450
|
+
const auditBaseConfig = await resolveTesseraConfig(projectRoot, workspaceRoot, {
|
|
1451
|
+
command: "build",
|
|
1452
|
+
mode: "production"
|
|
1453
|
+
});
|
|
1454
|
+
const auditDist = resolve(projectRoot, "node_modules", ".tessera-a11y");
|
|
1455
|
+
const distHtml = resolve(auditDist, "index.html");
|
|
1456
|
+
const prevEnv = process.env[AUDIT_ENV_FLAG];
|
|
1457
|
+
process.env[AUDIT_ENV_FLAG] = "1";
|
|
1458
|
+
let server;
|
|
1459
|
+
try {
|
|
1460
|
+
if (options.rebuild || !existsSync(distHtml)) {
|
|
1461
|
+
console.log("[tessera a11y] Building course…");
|
|
1462
|
+
await vite.build(vite.mergeConfig(auditBaseConfig, {
|
|
1463
|
+
build: {
|
|
1464
|
+
outDir: auditDist,
|
|
1465
|
+
emptyOutDir: true
|
|
1466
|
+
},
|
|
1467
|
+
logLevel: "warn"
|
|
1468
|
+
}));
|
|
1469
|
+
}
|
|
1470
|
+
server = await vite.preview({
|
|
1471
|
+
root: projectRoot,
|
|
1472
|
+
base: auditBaseConfig.base,
|
|
1473
|
+
build: { outDir: auditDist },
|
|
1474
|
+
preview: {
|
|
1475
|
+
port: 0,
|
|
1476
|
+
host: "127.0.0.1"
|
|
1477
|
+
},
|
|
1478
|
+
logLevel: "warn"
|
|
1479
|
+
});
|
|
1480
|
+
const baseUrl = server.resolvedUrls?.local?.[0];
|
|
1481
|
+
if (!baseUrl) {
|
|
1482
|
+
console.error("[tessera a11y] Could not determine preview server URL.");
|
|
1483
|
+
return 1;
|
|
1484
|
+
}
|
|
1485
|
+
let browser;
|
|
1486
|
+
try {
|
|
1487
|
+
browser = await chromium.launch();
|
|
1488
|
+
} catch (err) {
|
|
1489
|
+
if (isMissingBrowserError(err instanceof Error ? err.message : String(err))) {
|
|
1490
|
+
console.error("\x1B[31m[tessera a11y]\x1B[0m Chromium isn't installed for Playwright.\n Install it once:\n pnpm exec playwright install chromium");
|
|
1491
|
+
return 1;
|
|
1492
|
+
}
|
|
1493
|
+
throw err;
|
|
1494
|
+
}
|
|
1495
|
+
const pages = [];
|
|
1496
|
+
try {
|
|
1497
|
+
const page = await (await browser.newContext()).newPage();
|
|
1498
|
+
const auditUrl = new URL(baseUrl);
|
|
1499
|
+
auditUrl.searchParams.set("__tessera_audit", "1");
|
|
1500
|
+
await page.goto(auditUrl.href, { waitUntil: "networkidle" });
|
|
1501
|
+
await page.waitForSelector("#tessera-app", { timeout: 2e4 });
|
|
1502
|
+
const scan = async () => {
|
|
1503
|
+
const builder = new AxeBuilder({ page }).withTags(tags);
|
|
1504
|
+
if (disableRules.length > 0) builder.disableRules(disableRules);
|
|
1505
|
+
return (await builder.analyze()).violations.map((v) => ({
|
|
1506
|
+
id: v.id,
|
|
1507
|
+
impact: v.impact ?? null,
|
|
1508
|
+
help: v.help,
|
|
1509
|
+
helpUrl: v.helpUrl,
|
|
1510
|
+
nodes: v.nodes.length
|
|
1511
|
+
}));
|
|
1512
|
+
};
|
|
1513
|
+
const navCount = await page.locator("button.tessera-nav-page").count();
|
|
1514
|
+
if (navCount === 0) pages.push({
|
|
1515
|
+
index: 0,
|
|
1516
|
+
title: manifest.pages[0]?.title ?? "(entry)",
|
|
1517
|
+
violations: await scan()
|
|
1518
|
+
});
|
|
1519
|
+
else for (let i = 0; i < navCount; i++) {
|
|
1520
|
+
const btn = page.locator("button.tessera-nav-page").nth(i);
|
|
1521
|
+
const title = (await btn.textContent())?.trim() || `Page ${i + 1}`;
|
|
1522
|
+
await btn.click();
|
|
1523
|
+
await page.waitForFunction((idx) => document.querySelectorAll("button.tessera-nav-page")[idx]?.getAttribute("aria-current") === "page", i, { timeout: 2e4 });
|
|
1524
|
+
await page.waitForLoadState("networkidle");
|
|
1525
|
+
pages.push({
|
|
1526
|
+
index: i,
|
|
1527
|
+
title,
|
|
1528
|
+
violations: await scan()
|
|
1529
|
+
});
|
|
1530
|
+
}
|
|
1531
|
+
} finally {
|
|
1532
|
+
await browser.close();
|
|
1533
|
+
}
|
|
1534
|
+
const thresholdRank = IMPACT_RANK[threshold];
|
|
1535
|
+
let totalViolations = 0;
|
|
1536
|
+
let failingViolations = 0;
|
|
1537
|
+
for (const p of pages) for (const v of p.violations) {
|
|
1538
|
+
totalViolations++;
|
|
1539
|
+
if (isFailing(v, thresholdRank)) failingViolations++;
|
|
1540
|
+
}
|
|
1541
|
+
const report = {
|
|
1542
|
+
standard: settings.standard,
|
|
1543
|
+
threshold,
|
|
1544
|
+
pages,
|
|
1545
|
+
totalViolations,
|
|
1546
|
+
failingViolations,
|
|
1547
|
+
passed: failingViolations === 0
|
|
1548
|
+
};
|
|
1549
|
+
const reportPath = resolve(projectRoot, "a11y-report.json");
|
|
1550
|
+
writeFileSync(reportPath, JSON.stringify(report, null, 2), "utf-8");
|
|
1551
|
+
printSummary(report, reportPath);
|
|
1552
|
+
return report.passed ? 0 : 1;
|
|
1553
|
+
} catch (err) {
|
|
1554
|
+
console.error(`\x1b[31m[tessera a11y]\x1b[0m Audit could not complete: ${err instanceof Error ? err.message : String(err)}`);
|
|
1555
|
+
return 1;
|
|
1556
|
+
} finally {
|
|
1557
|
+
server?.httpServer?.close?.();
|
|
1558
|
+
if (prevEnv === void 0) delete process.env[AUDIT_ENV_FLAG];
|
|
1559
|
+
else process.env[AUDIT_ENV_FLAG] = prevEnv;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
function printSummary(report, reportPath) {
|
|
1563
|
+
const thresholdRank = IMPACT_RANK[report.threshold];
|
|
1564
|
+
for (const p of report.pages) {
|
|
1565
|
+
if (p.violations.length === 0) {
|
|
1566
|
+
console.log(`\x1b[32m ✓\x1b[0m ${p.title}`);
|
|
1567
|
+
continue;
|
|
1568
|
+
}
|
|
1569
|
+
const mark = p.violations.some((v) => isFailing(v, thresholdRank)) ? "\x1B[31m ✗\x1B[0m" : "\x1B[33m ⚠\x1B[0m";
|
|
1570
|
+
console.log(`${mark} ${p.title}`);
|
|
1571
|
+
for (const v of p.violations) console.log(` [${v.impact ?? "n/a"}] ${v.id} — ${v.help} (${v.nodes} node${v.nodes === 1 ? "" : "s"})`);
|
|
1572
|
+
}
|
|
1573
|
+
console.log(`\n[tessera a11y] Report written to ${reportPath}`);
|
|
1574
|
+
if (report.passed) console.log(`\x1b[32m[tessera a11y] Passed\x1b[0m — ${report.totalViolations} total finding(s), none at/above "${report.threshold}".`);
|
|
1575
|
+
else console.log(`\x1b[31m[tessera a11y] Failed\x1b[0m — ${report.failingViolations} finding(s) at/above "${report.threshold}" (of ${report.totalViolations} total).`);
|
|
1576
|
+
}
|
|
1577
|
+
//#endregion
|
|
1578
|
+
export { isPlausibleLanguageTag as a, validateProject as c, isIgnored as i, generateManifest as l, runAudit as n, normalizeA11y as o, resolvePackageRoot as r, reportValidationIssues as s, AUDIT_ENV_FLAG as t, readCourseConfig as u };
|
|
1243
1579
|
|
|
1244
|
-
//# sourceMappingURL=
|
|
1580
|
+
//# sourceMappingURL=audit-BA5o0ick.js.map
|