@conform-ed/qti-xml 0.0.16
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/README.md +25 -0
- package/dist/example-inventory.d.ts +2 -0
- package/dist/index.d.ts +14 -0
- package/dist/index.js +5414 -0
- package/dist/normalize.d.ts +3 -0
- package/dist/parse-xml.d.ts +15 -0
- package/dist/root-detection.d.ts +2 -0
- package/dist/schema-selection.d.ts +12 -0
- package/dist/serialize-asi.d.ts +30 -0
- package/dist/serialize-document.d.ts +10 -0
- package/dist/serialize-manifest.d.ts +11 -0
- package/dist/serialize-pnp.d.ts +11 -0
- package/dist/serialize-result.d.ts +12 -0
- package/dist/serialize-usage-data.d.ts +8 -0
- package/dist/types.d.ts +53 -0
- package/dist/validate-package.d.ts +30 -0
- package/dist/validate.d.ts +10 -0
- package/dist/xinclude.d.ts +11 -0
- package/dist/xml-writer.d.ts +17 -0
- package/package.json +33 -0
- package/src/example-inventory.ts +194 -0
- package/src/index.ts +14 -0
- package/src/normalize.ts +2814 -0
- package/src/parse-xml.ts +147 -0
- package/src/root-detection.ts +214 -0
- package/src/schema-selection.ts +78 -0
- package/src/serialize-asi.ts +2030 -0
- package/src/serialize-document.ts +89 -0
- package/src/serialize-manifest.ts +215 -0
- package/src/serialize-pnp.ts +385 -0
- package/src/serialize-result.ts +215 -0
- package/src/serialize-usage-data.ts +85 -0
- package/src/types.ts +78 -0
- package/src/validate-package.ts +408 -0
- package/src/validate.ts +118 -0
- package/src/xinclude.ts +92 -0
- package/src/xml-writer.ts +68 -0
|
@@ -0,0 +1,408 @@
|
|
|
1
|
+
import { createReadStream } from "node:fs";
|
|
2
|
+
import { access, mkdir, mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
|
|
6
|
+
import { Unzip, UnzipInflate, unzipSync } from "fflate";
|
|
7
|
+
|
|
8
|
+
import type { QtiValidationIssue, QtiValidationResult } from "./types";
|
|
9
|
+
import { validateQtiXmlContent } from "./validate";
|
|
10
|
+
|
|
11
|
+
export interface QtiPackageValidationResult {
|
|
12
|
+
packagePath: string;
|
|
13
|
+
manifestPath?: string;
|
|
14
|
+
status: "invalid" | "unsupported" | "valid";
|
|
15
|
+
issues: QtiValidationIssue[];
|
|
16
|
+
manifestValidation?: QtiValidationResult;
|
|
17
|
+
referencedDocumentResults: QtiValidationResult[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** How the manifest and its referenced documents are read — from disk, or from a ZIP. */
|
|
21
|
+
interface PackageSource {
|
|
22
|
+
/** The path reported as `packagePath` in the result. */
|
|
23
|
+
readonly reportPath: string;
|
|
24
|
+
/** The imsmanifest.xml content + the sourcePath xi:includes resolve against, or null. */
|
|
25
|
+
readManifest(): Promise<{ xml: string; sourcePath: string } | null>;
|
|
26
|
+
/** A referenced document's content, or null when the package omits it. */
|
|
27
|
+
readReferenced(href: string): Promise<{ xml: string; sourcePath: string } | null>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function prependIssues(prefix: string, issues: QtiValidationIssue[]): QtiValidationIssue[] {
|
|
31
|
+
return issues.map((issue) => ({
|
|
32
|
+
path: `${prefix}${issue.path === "$" ? "" : issue.path.slice(1)}`,
|
|
33
|
+
message: issue.message,
|
|
34
|
+
}));
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function hasManifestShape(value: unknown): value is {
|
|
38
|
+
manifest: {
|
|
39
|
+
resources?: Array<{
|
|
40
|
+
href?: string;
|
|
41
|
+
files?: Array<{ href: string }>;
|
|
42
|
+
}>;
|
|
43
|
+
};
|
|
44
|
+
} {
|
|
45
|
+
return Boolean(
|
|
46
|
+
value &&
|
|
47
|
+
typeof value === "object" &&
|
|
48
|
+
"manifest" in value &&
|
|
49
|
+
(value as { manifest?: unknown }).manifest &&
|
|
50
|
+
typeof (value as { manifest?: unknown }).manifest === "object",
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function fileExists(filePath: string): Promise<boolean> {
|
|
55
|
+
try {
|
|
56
|
+
await access(filePath);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The shared manifest + referenced-document validation, independent of the source. */
|
|
64
|
+
async function validatePackage(source: PackageSource): Promise<QtiPackageValidationResult> {
|
|
65
|
+
const manifest = await source.readManifest();
|
|
66
|
+
if (!manifest) {
|
|
67
|
+
return {
|
|
68
|
+
packagePath: source.reportPath,
|
|
69
|
+
status: "unsupported",
|
|
70
|
+
issues: [{ path: "$", message: "Package does not contain imsmanifest.xml at its root." }],
|
|
71
|
+
referencedDocumentResults: [],
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const manifestValidation = await validateQtiXmlContent(manifest.xml, { sourcePath: manifest.sourcePath });
|
|
76
|
+
const issues = prependIssues("$.manifest", manifestValidation.issues);
|
|
77
|
+
|
|
78
|
+
if (manifestValidation.status !== "valid") {
|
|
79
|
+
return {
|
|
80
|
+
packagePath: source.reportPath,
|
|
81
|
+
manifestPath: manifest.sourcePath,
|
|
82
|
+
status: manifestValidation.status === "unsupported" ? "unsupported" : "invalid",
|
|
83
|
+
issues,
|
|
84
|
+
manifestValidation,
|
|
85
|
+
referencedDocumentResults: [],
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!hasManifestShape(manifestValidation.normalizedDocument)) {
|
|
90
|
+
return {
|
|
91
|
+
packagePath: source.reportPath,
|
|
92
|
+
manifestPath: manifest.sourcePath,
|
|
93
|
+
status: "unsupported",
|
|
94
|
+
issues: [{ path: "$.manifest", message: "Manifest normalized shape is not available for package validation." }],
|
|
95
|
+
manifestValidation,
|
|
96
|
+
referencedDocumentResults: [],
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const referencedXmlFiles = new Set<string>();
|
|
101
|
+
|
|
102
|
+
for (const resource of manifestValidation.normalizedDocument.manifest.resources ?? []) {
|
|
103
|
+
if (resource.href?.toLowerCase().endsWith(".xml")) {
|
|
104
|
+
referencedXmlFiles.add(resource.href);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
for (const file of resource.files ?? []) {
|
|
108
|
+
if (file.href.toLowerCase().endsWith(".xml")) {
|
|
109
|
+
referencedXmlFiles.add(file.href);
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const referencedDocumentResults: QtiValidationResult[] = [];
|
|
115
|
+
|
|
116
|
+
for (const relativeHref of [...referencedXmlFiles].sort()) {
|
|
117
|
+
const referenced = await source.readReferenced(relativeHref);
|
|
118
|
+
|
|
119
|
+
if (!referenced) {
|
|
120
|
+
issues.push({
|
|
121
|
+
path: "$.manifest.resources",
|
|
122
|
+
message: `Referenced file is missing from the package: ${relativeHref}`,
|
|
123
|
+
});
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const result = await validateQtiXmlContent(referenced.xml, { sourcePath: referenced.sourcePath });
|
|
128
|
+
referencedDocumentResults.push(result);
|
|
129
|
+
|
|
130
|
+
if (result.status !== "valid") {
|
|
131
|
+
issues.push(
|
|
132
|
+
...prependIssues(
|
|
133
|
+
`$.resources[${relativeHref}]`,
|
|
134
|
+
result.issues.length
|
|
135
|
+
? result.issues
|
|
136
|
+
: [{ path: "$", message: `Referenced XML validation status: ${result.status}` }],
|
|
137
|
+
),
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
packagePath: source.reportPath,
|
|
144
|
+
manifestPath: manifest.sourcePath,
|
|
145
|
+
status: issues.length === 0 ? "valid" : "invalid",
|
|
146
|
+
issues,
|
|
147
|
+
manifestValidation,
|
|
148
|
+
referencedDocumentResults,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function directorySource(directoryPath: string): PackageSource {
|
|
153
|
+
const manifestPath = path.join(directoryPath, "imsmanifest.xml");
|
|
154
|
+
return {
|
|
155
|
+
reportPath: directoryPath,
|
|
156
|
+
async readManifest() {
|
|
157
|
+
if (!(await fileExists(manifestPath))) {
|
|
158
|
+
return null;
|
|
159
|
+
}
|
|
160
|
+
return { xml: await readFile(manifestPath, "utf8"), sourcePath: manifestPath };
|
|
161
|
+
},
|
|
162
|
+
async readReferenced(href) {
|
|
163
|
+
const absoluteHref = path.resolve(directoryPath, href);
|
|
164
|
+
if (!(await fileExists(absoluteHref))) {
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
return { xml: await readFile(absoluteHref, "utf8"), sourcePath: absoluteHref };
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** The first two bytes of every ZIP local-file/empty/spanned record ("PK"). */
|
|
173
|
+
function isZipArchive(bytes: Uint8Array): boolean {
|
|
174
|
+
return bytes[0] === 0x50 && bytes[1] === 0x4b;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Normalize a manifest href to the forward-slash, no-"./" form ZIP entries use. */
|
|
178
|
+
function zipEntryKey(href: string): string {
|
|
179
|
+
return href.replace(/\\/gu, "/").replace(/^\.\//u, "");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Validation only ever needs the XML; media (the bulk of a package) is never inflated. */
|
|
183
|
+
function isXmlEntry(name: string): boolean {
|
|
184
|
+
return name.toLowerCase().endsWith(".xml");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function concatChunks(chunks: readonly Uint8Array[]): Uint8Array {
|
|
188
|
+
if (chunks.length === 1) {
|
|
189
|
+
return chunks[0]!;
|
|
190
|
+
}
|
|
191
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
192
|
+
const out = new Uint8Array(total);
|
|
193
|
+
let offset = 0;
|
|
194
|
+
for (const chunk of chunks) {
|
|
195
|
+
out.set(chunk, offset);
|
|
196
|
+
offset += chunk.length;
|
|
197
|
+
}
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Stream a ZIP from disk and write only its `.xml` entries to `destDir`, preserving
|
|
203
|
+
* relative structure. Memory stays bounded to roughly one entry plus stream buffers
|
|
204
|
+
* regardless of total package size — media entries are skipped without inflation.
|
|
205
|
+
* Guards against zip-slip (entry names escaping destDir).
|
|
206
|
+
*/
|
|
207
|
+
async function extractXmlEntriesToDir(zipPath: string, destDir: string): Promise<void> {
|
|
208
|
+
await new Promise<void>((resolve, reject) => {
|
|
209
|
+
const unzip = new Unzip();
|
|
210
|
+
unzip.register(UnzipInflate);
|
|
211
|
+
|
|
212
|
+
const writes: Array<Promise<void>> = [];
|
|
213
|
+
let failed = false;
|
|
214
|
+
const fail = (error: unknown): void => {
|
|
215
|
+
if (!failed) {
|
|
216
|
+
failed = true;
|
|
217
|
+
reject(error instanceof Error ? error : new Error(String(error)));
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
|
|
221
|
+
unzip.onfile = (file) => {
|
|
222
|
+
if (!isXmlEntry(file.name)) {
|
|
223
|
+
return; // not started → never decompressed
|
|
224
|
+
}
|
|
225
|
+
const chunks: Uint8Array[] = [];
|
|
226
|
+
file.ondata = (error, chunk, final) => {
|
|
227
|
+
if (error) {
|
|
228
|
+
fail(error);
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
if (chunk.length) {
|
|
232
|
+
chunks.push(chunk);
|
|
233
|
+
}
|
|
234
|
+
if (final) {
|
|
235
|
+
const target = path.join(destDir, file.name);
|
|
236
|
+
if (path.relative(destDir, target).startsWith("..")) {
|
|
237
|
+
fail(new Error(`Unsafe archive entry path: ${file.name}`));
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
writes.push(
|
|
241
|
+
mkdir(path.dirname(target), { recursive: true }).then(() => writeFile(target, concatChunks(chunks))),
|
|
242
|
+
);
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
file.start();
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const stream = createReadStream(zipPath);
|
|
249
|
+
stream.on("data", (chunk) => {
|
|
250
|
+
if (failed) {
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
try {
|
|
254
|
+
unzip.push(chunk as Uint8Array, false);
|
|
255
|
+
} catch (error) {
|
|
256
|
+
fail(error);
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
stream.on("error", fail);
|
|
260
|
+
stream.on("end", () => {
|
|
261
|
+
if (failed) {
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
try {
|
|
265
|
+
unzip.push(new Uint8Array(0), true);
|
|
266
|
+
} catch (error) {
|
|
267
|
+
fail(error);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
Promise.all(writes)
|
|
271
|
+
.then(() => resolve())
|
|
272
|
+
.catch(fail);
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function rebasePath(filePath: string | undefined, fromRoot: string, toRoot: string): string | undefined {
|
|
278
|
+
if (filePath === undefined) {
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
const relative = path.relative(fromRoot, filePath);
|
|
282
|
+
return relative.startsWith("..") ? filePath : path.join(toRoot, relative);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Re-anchor reported paths from the ephemeral temp tree to the package's real path. */
|
|
286
|
+
function rebaseResult(
|
|
287
|
+
result: QtiPackageValidationResult,
|
|
288
|
+
fromRoot: string,
|
|
289
|
+
toRoot: string,
|
|
290
|
+
): QtiPackageValidationResult {
|
|
291
|
+
const manifestPath = rebasePath(result.manifestPath, fromRoot, toRoot);
|
|
292
|
+
return {
|
|
293
|
+
...result,
|
|
294
|
+
packagePath: toRoot,
|
|
295
|
+
...(manifestPath !== undefined ? { manifestPath } : {}),
|
|
296
|
+
...(result.manifestValidation
|
|
297
|
+
? {
|
|
298
|
+
manifestValidation: {
|
|
299
|
+
...result.manifestValidation,
|
|
300
|
+
filePath:
|
|
301
|
+
rebasePath(result.manifestValidation.filePath, fromRoot, toRoot) ?? result.manifestValidation.filePath,
|
|
302
|
+
},
|
|
303
|
+
}
|
|
304
|
+
: {}),
|
|
305
|
+
referencedDocumentResults: result.referencedDocumentResults.map((document) => ({
|
|
306
|
+
...document,
|
|
307
|
+
filePath: rebasePath(document.filePath, fromRoot, toRoot) ?? document.filePath,
|
|
308
|
+
})),
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Validate a QTI 3 content package held entirely in memory as PIF (Package Interchange
|
|
314
|
+
* Format) ZIP bytes. Only `.xml` entries are decompressed (media is skipped), but the
|
|
315
|
+
* caller's full byte buffer is still resident — suitable for modest packages and
|
|
316
|
+
* callers that already hold the bytes (tests, small authored packages). For
|
|
317
|
+
* potentially large packages prefer `validateQtiPackagePath`, which streams from disk.
|
|
318
|
+
*
|
|
319
|
+
* xi:include across archive entries is not resolved on this in-memory path (the
|
|
320
|
+
* resolver reads from the filesystem); `validateQtiPackagePath` resolves them.
|
|
321
|
+
*/
|
|
322
|
+
export async function validateQtiPackageArchive(
|
|
323
|
+
zipBytes: Uint8Array,
|
|
324
|
+
options?: { readonly reportPath?: string },
|
|
325
|
+
): Promise<QtiPackageValidationResult> {
|
|
326
|
+
const reportPath = options?.reportPath ?? "qti-package.zip";
|
|
327
|
+
|
|
328
|
+
if (!isZipArchive(zipBytes)) {
|
|
329
|
+
return {
|
|
330
|
+
packagePath: reportPath,
|
|
331
|
+
status: "unsupported",
|
|
332
|
+
issues: [{ path: "$", message: "Package bytes are not a ZIP archive (missing the PK signature)." }],
|
|
333
|
+
referencedDocumentResults: [],
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
let entries: Record<string, Uint8Array>;
|
|
338
|
+
try {
|
|
339
|
+
entries = unzipSync(zipBytes, { filter: (file) => isXmlEntry(file.name) });
|
|
340
|
+
} catch (error) {
|
|
341
|
+
return {
|
|
342
|
+
packagePath: reportPath,
|
|
343
|
+
status: "unsupported",
|
|
344
|
+
issues: [
|
|
345
|
+
{
|
|
346
|
+
path: "$",
|
|
347
|
+
message: `Could not read the ZIP archive: ${error instanceof Error ? error.message : String(error)}`,
|
|
348
|
+
},
|
|
349
|
+
],
|
|
350
|
+
referencedDocumentResults: [],
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const decoder = new TextDecoder();
|
|
355
|
+
const source: PackageSource = {
|
|
356
|
+
reportPath,
|
|
357
|
+
async readManifest() {
|
|
358
|
+
const entry = entries["imsmanifest.xml"];
|
|
359
|
+
return entry ? { xml: decoder.decode(entry), sourcePath: path.join(reportPath, "imsmanifest.xml") } : null;
|
|
360
|
+
},
|
|
361
|
+
async readReferenced(href) {
|
|
362
|
+
const entry = entries[zipEntryKey(href)];
|
|
363
|
+
return entry ? { xml: decoder.decode(entry), sourcePath: path.join(reportPath, href) } : null;
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
return validatePackage(source);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Validate a QTI 3 content package at a filesystem path: an exploded directory, or a
|
|
372
|
+
* PIF ZIP file. The ZIP route streams from disk, materializing only the package's XML
|
|
373
|
+
* into a temporary directory (media is never inflated, so memory stays bounded for
|
|
374
|
+
* large packages), then validates the exploded tree — which also resolves xi:include
|
|
375
|
+
* across entries — and cleans the temp directory up afterwards.
|
|
376
|
+
*/
|
|
377
|
+
export async function validateQtiPackagePath(packagePath: string): Promise<QtiPackageValidationResult> {
|
|
378
|
+
const absolutePackagePath = path.resolve(packagePath);
|
|
379
|
+
const pathStat = await stat(absolutePackagePath);
|
|
380
|
+
|
|
381
|
+
if (pathStat.isDirectory()) {
|
|
382
|
+
return validatePackage(directorySource(absolutePackagePath));
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
const tempRoot = await mkdtemp(path.join(tmpdir(), "qti-pif-"));
|
|
386
|
+
try {
|
|
387
|
+
try {
|
|
388
|
+
await extractXmlEntriesToDir(absolutePackagePath, tempRoot);
|
|
389
|
+
} catch (error) {
|
|
390
|
+
return {
|
|
391
|
+
packagePath: absolutePackagePath,
|
|
392
|
+
status: "unsupported",
|
|
393
|
+
issues: [
|
|
394
|
+
{
|
|
395
|
+
path: "$",
|
|
396
|
+
message: `Could not read the package archive: ${error instanceof Error ? error.message : String(error)}`,
|
|
397
|
+
},
|
|
398
|
+
],
|
|
399
|
+
referencedDocumentResults: [],
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const result = await validatePackage(directorySource(tempRoot));
|
|
404
|
+
return rebaseResult(result, tempRoot, absolutePackagePath);
|
|
405
|
+
} finally {
|
|
406
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
407
|
+
}
|
|
408
|
+
}
|
package/src/validate.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
|
|
4
|
+
import { normalizeQtiDocument } from "./normalize";
|
|
5
|
+
import { parseXmlDocument } from "./parse-xml";
|
|
6
|
+
import { detectQtiRoot } from "./root-detection";
|
|
7
|
+
import { isNormalizationImplemented, selectQtiSchema } from "./schema-selection";
|
|
8
|
+
import type { QtiValidationIssue, QtiValidationResult } from "./types";
|
|
9
|
+
import { resolveXIncludes } from "./xinclude";
|
|
10
|
+
|
|
11
|
+
type SafeParseResult = {
|
|
12
|
+
success: boolean;
|
|
13
|
+
error?: { issues?: Array<{ path?: PropertyKey[]; message?: string }> };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function formatIssuePath(pathEntries: PropertyKey[] | undefined): string {
|
|
17
|
+
if (!pathEntries?.length) {
|
|
18
|
+
return "$";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
return `$${pathEntries.map((entry) => (typeof entry === "number" ? `[${entry}]` : `.${String(entry)}`)).join("")}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function flattenIssues(
|
|
25
|
+
error: { issues?: Array<{ path?: PropertyKey[]; message?: string }> } | undefined,
|
|
26
|
+
): QtiValidationIssue[] {
|
|
27
|
+
return (error?.issues ?? []).map((issue) => ({
|
|
28
|
+
path: formatIssuePath(issue.path),
|
|
29
|
+
message: issue.message ?? "Validation error.",
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function validateQtiXmlFile(filePath: string): Promise<QtiValidationResult> {
|
|
34
|
+
const absolutePath = path.resolve(filePath);
|
|
35
|
+
const xml = await readFile(absolutePath, "utf8");
|
|
36
|
+
|
|
37
|
+
return validateQtiXmlContent(xml, { sourcePath: absolutePath });
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Validate an in-memory XML instance (e.g. a serialized assessmentResult on its way
|
|
42
|
+
* out — the export-conformance round trip). `sourcePath` anchors relative xi:include
|
|
43
|
+
* hrefs and the reported `filePath`; in-memory instances default to the cwd.
|
|
44
|
+
*/
|
|
45
|
+
export async function validateQtiXmlContent(
|
|
46
|
+
xml: string,
|
|
47
|
+
options?: { readonly sourcePath?: string },
|
|
48
|
+
): Promise<QtiValidationResult> {
|
|
49
|
+
const absolutePath = path.resolve(options?.sourcePath ?? "qti-content.xml");
|
|
50
|
+
const rootDetection = detectQtiRoot(xml);
|
|
51
|
+
|
|
52
|
+
if (!rootDetection) {
|
|
53
|
+
return {
|
|
54
|
+
filePath: absolutePath,
|
|
55
|
+
status: "unsupported",
|
|
56
|
+
issues: [{ path: "$", message: "Could not detect an XML root element." }],
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const schemaSelection = selectQtiSchema(rootDetection);
|
|
61
|
+
if (!schemaSelection) {
|
|
62
|
+
return {
|
|
63
|
+
filePath: absolutePath,
|
|
64
|
+
rootDetection,
|
|
65
|
+
status: "unsupported",
|
|
66
|
+
issues: [{ path: "$", message: "No contracts schema is registered for this QTI root." }],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!isNormalizationImplemented(schemaSelection.version, schemaSelection.key)) {
|
|
71
|
+
return {
|
|
72
|
+
filePath: absolutePath,
|
|
73
|
+
rootDetection,
|
|
74
|
+
schemaSelectionKey: schemaSelection.key,
|
|
75
|
+
status: "unsupported",
|
|
76
|
+
issues: [{ path: "$", message: "XML normalization is not implemented for this QTI root yet." }],
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
let normalizedDocument: unknown;
|
|
81
|
+
try {
|
|
82
|
+
const documentRoot = parseXmlDocument(xml);
|
|
83
|
+
|
|
84
|
+
// Shared fragments (xi:include) splice in before normalization — this is the only
|
|
85
|
+
// layer that knows the file path the hrefs are relative to.
|
|
86
|
+
await resolveXIncludes(documentRoot, absolutePath);
|
|
87
|
+
normalizedDocument = normalizeQtiDocument(schemaSelection.version, schemaSelection.key, documentRoot);
|
|
88
|
+
} catch (error) {
|
|
89
|
+
return {
|
|
90
|
+
filePath: absolutePath,
|
|
91
|
+
rootDetection,
|
|
92
|
+
schemaSelectionKey: schemaSelection.key,
|
|
93
|
+
status: "parse-error",
|
|
94
|
+
issues: [{ path: "$", message: error instanceof Error ? error.message : String(error) }],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const parsed = schemaSelection.schema.safeParse(normalizedDocument) as SafeParseResult;
|
|
99
|
+
if (!parsed.success) {
|
|
100
|
+
return {
|
|
101
|
+
filePath: absolutePath,
|
|
102
|
+
rootDetection,
|
|
103
|
+
schemaSelectionKey: schemaSelection.key,
|
|
104
|
+
normalizedDocument,
|
|
105
|
+
status: "invalid",
|
|
106
|
+
issues: flattenIssues(parsed.error),
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
filePath: absolutePath,
|
|
112
|
+
rootDetection,
|
|
113
|
+
schemaSelectionKey: schemaSelection.key,
|
|
114
|
+
normalizedDocument,
|
|
115
|
+
status: "valid",
|
|
116
|
+
issues: [],
|
|
117
|
+
};
|
|
118
|
+
}
|
package/src/xinclude.ts
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* XInclude resolution: `xi:include` elements splice their target's root element (or
|
|
3
|
+
* text, for parse="text") into the including document before normalization, resolving
|
|
4
|
+
* hrefs relative to each including file. Recursion is cycle-guarded; a failed include
|
|
5
|
+
* uses its `xi:fallback` children when present and otherwise fails loudly — the
|
|
6
|
+
* corpus's shared-fragment items depend on this happening at the file boundary, the
|
|
7
|
+
* only layer that knows the path.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { readFile } from "node:fs/promises";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
import { parseXmlDocument, type QtiXmlElementNode, type QtiXmlNode } from "./parse-xml";
|
|
14
|
+
|
|
15
|
+
const xincludeNamespace = "http://www.w3.org/2001/XInclude";
|
|
16
|
+
|
|
17
|
+
function isXinclude(element: QtiXmlElementNode, localName: string): boolean {
|
|
18
|
+
return element.localName === localName && (element.namespaceUri === xincludeNamespace || element.prefix === "xi");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function resolveInclude(
|
|
22
|
+
include: QtiXmlElementNode,
|
|
23
|
+
baseFilePath: string,
|
|
24
|
+
stack: string[],
|
|
25
|
+
): Promise<QtiXmlNode[]> {
|
|
26
|
+
const href = include.attributes["href"];
|
|
27
|
+
|
|
28
|
+
if (href === undefined || href === "") {
|
|
29
|
+
throw new Error("<xi:include> requires an href.");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const targetPath = path.resolve(path.dirname(baseFilePath), href);
|
|
33
|
+
|
|
34
|
+
if (stack.includes(targetPath)) {
|
|
35
|
+
throw new Error(`XInclude cycle detected at ${href} (${[...stack, targetPath].join(" -> ")}).`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
let content: string;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
content = await readFile(targetPath, "utf8");
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const fallback = include.children.find(
|
|
44
|
+
(child): child is QtiXmlElementNode => child.type === "element" && isXinclude(child, "fallback"),
|
|
45
|
+
);
|
|
46
|
+
|
|
47
|
+
if (fallback) {
|
|
48
|
+
// Fallback children may themselves contain includes, relative to this file.
|
|
49
|
+
await resolveChildren(fallback, baseFilePath, stack);
|
|
50
|
+
|
|
51
|
+
return fallback.children;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw new Error(
|
|
55
|
+
`XInclude target "${href}" could not be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (include.attributes["parse"] === "text") {
|
|
60
|
+
return [{ type: "text", value: content }];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const fragmentRoot = parseXmlDocument(content);
|
|
64
|
+
|
|
65
|
+
await resolveChildren(fragmentRoot, targetPath, [...stack, targetPath]);
|
|
66
|
+
|
|
67
|
+
return [fragmentRoot];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function resolveChildren(element: QtiXmlElementNode, baseFilePath: string, stack: string[]): Promise<void> {
|
|
71
|
+
const resolved: QtiXmlNode[] = [];
|
|
72
|
+
|
|
73
|
+
for (const child of element.children) {
|
|
74
|
+
if (child.type === "element") {
|
|
75
|
+
if (isXinclude(child, "include")) {
|
|
76
|
+
resolved.push(...(await resolveInclude(child, baseFilePath, stack)));
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await resolveChildren(child, baseFilePath, stack);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
resolved.push(child);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
element.children = resolved;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Resolve every `xi:include` under `root` in place, relative to the document's file. */
|
|
90
|
+
export async function resolveXIncludes(root: QtiXmlElementNode, filePath: string): Promise<void> {
|
|
91
|
+
await resolveChildren(root, filePath, [path.resolve(filePath)]);
|
|
92
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny indenting XML writer shared by the binding serializers (results, usage
|
|
3
|
+
* data, AfA PNP). The XSDs' sequences dictate emit order; escaping covers text
|
|
4
|
+
* content and attribute values.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export function escapeText(value: string): string {
|
|
8
|
+
return value.replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function escapeAttribute(value: string): string {
|
|
12
|
+
return escapeText(value).replaceAll('"', """);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type AttributeValue = string | number | boolean | undefined;
|
|
16
|
+
|
|
17
|
+
function openTag(
|
|
18
|
+
name: string,
|
|
19
|
+
attributes: ReadonlyArray<readonly [string, AttributeValue]>,
|
|
20
|
+
selfClose: boolean,
|
|
21
|
+
): string {
|
|
22
|
+
const rendered = attributes
|
|
23
|
+
.filter((entry): entry is readonly [string, string | number | boolean] => entry[1] !== undefined)
|
|
24
|
+
.map(([attribute, value]) => ` ${attribute}="${escapeAttribute(String(value))}"`)
|
|
25
|
+
.join("");
|
|
26
|
+
|
|
27
|
+
return `<${name}${rendered}${selfClose ? "/" : ""}>`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export class XmlWriter {
|
|
31
|
+
private readonly lines: string[] = [];
|
|
32
|
+
private depth = 0;
|
|
33
|
+
|
|
34
|
+
line(text: string): void {
|
|
35
|
+
this.lines.push(`${" ".repeat(this.depth)}${text}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Emit an escaped text fragment of mixed content on its own indented line. */
|
|
39
|
+
text(value: string): void {
|
|
40
|
+
this.line(escapeText(value));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
element(
|
|
44
|
+
name: string,
|
|
45
|
+
attributes: ReadonlyArray<readonly [string, AttributeValue]>,
|
|
46
|
+
body?: (() => void) | string,
|
|
47
|
+
): void {
|
|
48
|
+
if (body === undefined) {
|
|
49
|
+
this.line(openTag(name, attributes, true));
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (typeof body === "string") {
|
|
54
|
+
this.line(`${openTag(name, attributes, false)}${escapeText(body)}</${name}>`);
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.line(openTag(name, attributes, false));
|
|
59
|
+
this.depth += 1;
|
|
60
|
+
body();
|
|
61
|
+
this.depth -= 1;
|
|
62
|
+
this.line(`</${name}>`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
toString(): string {
|
|
66
|
+
return `${this.lines.join("\n")}\n`;
|
|
67
|
+
}
|
|
68
|
+
}
|