@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.
@@ -0,0 +1,3 @@
1
+ import type { QtiXmlElementNode } from "./parse-xml";
2
+ import type { QtiSchemaSelectionKey, QtiVersion } from "./types";
3
+ export declare function normalizeQtiDocument(version: QtiVersion, schemaSelectionKey: QtiSchemaSelectionKey, root: QtiXmlElementNode): unknown;
@@ -0,0 +1,15 @@
1
+ export interface QtiXmlTextNode {
2
+ type: "text";
3
+ value: string;
4
+ }
5
+ export interface QtiXmlElementNode {
6
+ type: "element";
7
+ name: string;
8
+ localName: string;
9
+ prefix?: string;
10
+ namespaceUri?: string;
11
+ attributes: Record<string, string>;
12
+ children: QtiXmlNode[];
13
+ }
14
+ export type QtiXmlNode = QtiXmlElementNode | QtiXmlTextNode;
15
+ export declare function parseXmlDocument(xml: string): QtiXmlElementNode;
@@ -0,0 +1,2 @@
1
+ import type { QtiRootDetection } from "./types";
2
+ export declare function detectQtiRoot(xml: string): QtiRootDetection | undefined;
@@ -0,0 +1,12 @@
1
+ import type { QtiRootDetection, QtiSchemaSelectionKey, QtiVersion } from "./types";
2
+ type SafeParseSchema = {
3
+ safeParse(input: unknown): unknown;
4
+ };
5
+ export interface QtiSchemaSelection {
6
+ version: QtiVersion;
7
+ key: QtiSchemaSelectionKey;
8
+ schema: SafeParseSchema;
9
+ }
10
+ export declare function isNormalizationImplemented(version: QtiVersion, key: QtiSchemaSelectionKey): boolean;
11
+ export declare function selectQtiSchema(rootDetection: QtiRootDetection): QtiSchemaSelection | undefined;
12
+ export {};
@@ -0,0 +1,30 @@
1
+ /**
2
+ * QTI 3 ASI (Assessment, Section & Item) XML serialization — the authoring-system
3
+ * EXPORT direction. Takes the normalized/contracts document shape and emits an
4
+ * instance against the official ASI binding (namespace imsqtiasi_v3p0), the exact
5
+ * inverse of the normalizer in normalize.ts. The export-conformance gate is the model
6
+ * round trip (serialize → parse → normalize → strict contracts schema → deep-equal),
7
+ * proven across the whole vendored corpus in serialize-asi-corpus.local.test.ts.
8
+ *
9
+ * Element/attribute spellings mirror the normalizer's reads exactly (kebab-case in the
10
+ * XSD, e.g. `qti-hottext` not `qti-hot-text`, `base-type`, `response-identifier`). The
11
+ * normalizer is lossy in known ways — element @id, comments, the optional
12
+ * <qti-content-body> wrapper, the item-ref/section-ref distinction for bare refs — and
13
+ * those losses are exactly what this writer need not reproduce: re-normalization drops
14
+ * the same nothing, so model idempotence holds.
15
+ */
16
+ export declare const asiNamespace = "http://www.imsglobal.org/xsd/imsqtiasi_v3p0";
17
+ /** Serialize a qti-assessment-item document against the ASI binding. */
18
+ export declare function serializeQtiAssessmentItem(document: unknown): string;
19
+ /** Serialize a qti-assessment-stimulus document against the ASI binding. */
20
+ export declare function serializeQtiAssessmentStimulus(document: unknown): string;
21
+ /** Serialize a qti-assessment-test document against the ASI binding. */
22
+ export declare function serializeQtiAssessmentTest(document: unknown): string;
23
+ /** Serialize a standalone qti-assessment-section document. */
24
+ export declare function serializeQtiAssessmentSection(document: unknown): string;
25
+ /** Serialize a standalone qti-response-processing document (best-practice templates). */
26
+ export declare function serializeQtiResponseProcessingDocument(document: unknown): string;
27
+ /** Serialize a standalone qti-outcome-declaration document. */
28
+ export declare function serializeQtiOutcomeDeclarationDocument(document: unknown): string;
29
+ /** Serialize a standalone qti-outcome-processing document. */
30
+ export declare function serializeQtiOutcomeProcessingDocument(document: unknown): string;
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Universal QTI serialization dispatch: given a (version, schema-selection-key) and a
3
+ * normalized document, emit the spec-valid XML instance against the matching binding.
4
+ * The inverse of normalizeQtiDocument — delegating to the per-binding serializers
5
+ * (ASI, manifest, results, usage data, PNP), each gated by its own export round trip.
6
+ */
7
+ import type { QtiSchemaSelectionKey, QtiVersion } from "./types";
8
+ /** Roots with a serializer registered (the export-conformant direction). */
9
+ export declare function isSerializationImplemented(version: QtiVersion, key: QtiSchemaSelectionKey): boolean;
10
+ export declare function serializeQtiDocument(version: QtiVersion, key: QtiSchemaSelectionKey, document: unknown): string;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * QTI 3 content-package manifest + qtiMetadata XML serialization — the export
3
+ * direction of the packaging binding (imscp_v1p1) and the metadata binding
4
+ * (imsqti_metadata_v3p0). Inverse of normalizeQti301Manifest / mapV3QtiMetadata;
5
+ * IEEE LOM rides through as a structurally preserved foreign-XML node. Gated by the
6
+ * corpus model round trip.
7
+ */
8
+ /** Serialize a standalone qtiMetadata document. */
9
+ export declare function serializeQtiMetadata(document: unknown): string;
10
+ /** Serialize a QTI 3 content-package manifest. */
11
+ export declare function serializeQtiManifest(document: unknown): string;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * AfA PNP (QTI 3.0 profile) XML serialization (imsqtiv3p0_afa3p0pnp_v1p0) — the
3
+ * export direction of the candidate-preferences binding, the exact inverse of the
4
+ * import normalizer; gated in tests by the round trip through our own parser,
5
+ * normalizer, and strict contracts schema.
6
+ */
7
+ import type { QtiAccessForAllPnpDocument, QtiAccessForAllPnpRecordsDocument } from "@conform-ed/contracts/qti/v3_0_1";
8
+ /** Serialize a candidate-preferences document against the official QTI 3 profile binding. */
9
+ export declare function serializeQtiAccessForAllPnp(document: QtiAccessForAllPnpDocument): string;
10
+ /** Serialize a person-keyed PNP records document. */
11
+ export declare function serializeQtiAccessForAllPnpRecords(document: QtiAccessForAllPnpRecordsDocument): string;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * QTI Results Reporting XML serialization — conform-ed's first XML writer. Takes the
3
+ * normalized/contracts document shape and emits an instance against the official
4
+ * binding (namespace imsqti_result_v3p0), in the XSD's element order. The export
5
+ * conformance gate: "The system MUST create an instance with all of the REQUIRED
6
+ * properties and values; … The XML instance MUST be valid with respect to the
7
+ * official XSD" — guarded in tests by round-tripping the output through our own
8
+ * parser, normalizer, and strict contracts schema.
9
+ */
10
+ import type { QtiAssessmentResultDocument } from "@conform-ed/contracts/qti/v3_0_1";
11
+ /** Serialize a results document against the official 3.0 binding. */
12
+ export declare function serializeQtiAssessmentResult(document: QtiAssessmentResultDocument): string;
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Usage Data & Item Statistics XML serialization (imsqti_usagedata_v3p0) — the
3
+ * EXPORT direction of the Usage Data specification, gated in tests by the round
4
+ * trip through our own parser, normalizer, and strict contracts schema.
5
+ */
6
+ import type { QtiUsageDataDocument } from "@conform-ed/contracts/qti/v3_0_1";
7
+ /** Serialize a usage data document against the official 3.0 binding. */
8
+ export declare function serializeQtiUsageData(document: QtiUsageDataDocument): string;
@@ -0,0 +1,53 @@
1
+ export type QtiFileKind = "html" | "other" | "xml" | "zip";
2
+ export type QtiXmlStatus = "malformed" | "not-xml" | "well-formed";
3
+ export type QtiSupportStatus = "known-broken-example" | "not-xml" | "supported" | "unsupported-normalization" | "unsupported-root" | "zip-package";
4
+ export type QtiValidationStatus = "invalid" | "parse-error" | "unsupported" | "valid";
5
+ export type QtiVersion = "2.2" | "3.0.1";
6
+ export type QtiSchemaSelectionKey = "qtiAccessForAllPnpDocument" | "qtiAccessForAllPnpRecordsDocument" | "qtiAssessmentItemDocument" | "qtiAssessmentResultDocument" | "qtiOutcomeDeclarationDocument" | "qtiOutcomeProcessingDocument" | "qtiResponseProcessingDocument" | "qtiUsageDataDocument" | "qtiAssessmentSectionDocument" | "qtiAssessmentStimulusDocument" | "qtiAssessmentTestDocument" | "qtiManifestDocument" | "qtiMetadataDocument";
7
+ export interface QtiRootDetection {
8
+ rootName: string;
9
+ localName: string;
10
+ prefix?: string;
11
+ namespaceUri?: string;
12
+ inferredVersion?: QtiVersion;
13
+ schemaSelectionKey?: QtiSchemaSelectionKey;
14
+ }
15
+ export interface QtiExampleInventoryEntry {
16
+ absolutePath: string;
17
+ relativePath: string;
18
+ sourceGroup: string;
19
+ fileKind: QtiFileKind;
20
+ xmlStatus: QtiXmlStatus;
21
+ supportStatus: QtiSupportStatus;
22
+ rootName?: string;
23
+ localName?: string;
24
+ namespaceUri?: string;
25
+ inferredVersion?: QtiVersion;
26
+ schemaSelectionKey?: QtiSchemaSelectionKey;
27
+ contentHash: string;
28
+ note?: string;
29
+ }
30
+ export interface QtiExampleInventorySummary {
31
+ totalFiles: number;
32
+ byFileKind: Record<QtiFileKind, number>;
33
+ bySupportStatus: Record<QtiSupportStatus, number>;
34
+ byVersion: Record<string, number>;
35
+ }
36
+ export interface QtiExampleInventoryReport {
37
+ rootPath: string;
38
+ generatedAt: string;
39
+ entries: QtiExampleInventoryEntry[];
40
+ summary: QtiExampleInventorySummary;
41
+ }
42
+ export interface QtiValidationIssue {
43
+ path: string;
44
+ message: string;
45
+ }
46
+ export interface QtiValidationResult {
47
+ filePath: string;
48
+ rootDetection?: QtiRootDetection;
49
+ status: QtiValidationStatus;
50
+ schemaSelectionKey?: QtiSchemaSelectionKey;
51
+ normalizedDocument?: unknown;
52
+ issues: QtiValidationIssue[];
53
+ }
@@ -0,0 +1,30 @@
1
+ import type { QtiValidationIssue, QtiValidationResult } from "./types";
2
+ export interface QtiPackageValidationResult {
3
+ packagePath: string;
4
+ manifestPath?: string;
5
+ status: "invalid" | "unsupported" | "valid";
6
+ issues: QtiValidationIssue[];
7
+ manifestValidation?: QtiValidationResult;
8
+ referencedDocumentResults: QtiValidationResult[];
9
+ }
10
+ /**
11
+ * Validate a QTI 3 content package held entirely in memory as PIF (Package Interchange
12
+ * Format) ZIP bytes. Only `.xml` entries are decompressed (media is skipped), but the
13
+ * caller's full byte buffer is still resident — suitable for modest packages and
14
+ * callers that already hold the bytes (tests, small authored packages). For
15
+ * potentially large packages prefer `validateQtiPackagePath`, which streams from disk.
16
+ *
17
+ * xi:include across archive entries is not resolved on this in-memory path (the
18
+ * resolver reads from the filesystem); `validateQtiPackagePath` resolves them.
19
+ */
20
+ export declare function validateQtiPackageArchive(zipBytes: Uint8Array, options?: {
21
+ readonly reportPath?: string;
22
+ }): Promise<QtiPackageValidationResult>;
23
+ /**
24
+ * Validate a QTI 3 content package at a filesystem path: an exploded directory, or a
25
+ * PIF ZIP file. The ZIP route streams from disk, materializing only the package's XML
26
+ * into a temporary directory (media is never inflated, so memory stays bounded for
27
+ * large packages), then validates the exploded tree — which also resolves xi:include
28
+ * across entries — and cleans the temp directory up afterwards.
29
+ */
30
+ export declare function validateQtiPackagePath(packagePath: string): Promise<QtiPackageValidationResult>;
@@ -0,0 +1,10 @@
1
+ import type { QtiValidationResult } from "./types";
2
+ export declare function validateQtiXmlFile(filePath: string): Promise<QtiValidationResult>;
3
+ /**
4
+ * Validate an in-memory XML instance (e.g. a serialized assessmentResult on its way
5
+ * out — the export-conformance round trip). `sourcePath` anchors relative xi:include
6
+ * hrefs and the reported `filePath`; in-memory instances default to the cwd.
7
+ */
8
+ export declare function validateQtiXmlContent(xml: string, options?: {
9
+ readonly sourcePath?: string;
10
+ }): Promise<QtiValidationResult>;
@@ -0,0 +1,11 @@
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
+ import { type QtiXmlElementNode } from "./parse-xml";
10
+ /** Resolve every `xi:include` under `root` in place, relative to the document's file. */
11
+ export declare function resolveXIncludes(root: QtiXmlElementNode, filePath: string): Promise<void>;
@@ -0,0 +1,17 @@
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
+ export declare function escapeText(value: string): string;
7
+ export declare function escapeAttribute(value: string): string;
8
+ export type AttributeValue = string | number | boolean | undefined;
9
+ export declare class XmlWriter {
10
+ private readonly lines;
11
+ private depth;
12
+ line(text: string): void;
13
+ /** Emit an escaped text fragment of mixed content on its own indented line. */
14
+ text(value: string): void;
15
+ element(name: string, attributes: ReadonlyArray<readonly [string, AttributeValue]>, body?: (() => void) | string): void;
16
+ toString(): string;
17
+ }
package/package.json ADDED
@@ -0,0 +1,33 @@
1
+ {
2
+ "name": "@conform-ed/qti-xml",
3
+ "version": "0.0.16",
4
+ "files": [
5
+ "src",
6
+ "dist"
7
+ ],
8
+ "type": "module",
9
+ "module": "src/index.ts",
10
+ "exports": {
11
+ ".": {
12
+ "development": "./src/index.ts",
13
+ "types": "./dist/index.d.ts",
14
+ "default": "./dist/index.js"
15
+ }
16
+ },
17
+ "scripts": {
18
+ "build": "bun build ./src/index.ts --outdir dist --format esm --target node --external @conform-ed/contracts --external fast-xml-parser --external fast-xml-validator --external fflate && tsgo -p tsconfig.build.json",
19
+ "format": "oxfmt --config ../../.oxfmtrc.jsonc --check .",
20
+ "lint": "oxlint --config ../../.oxlintrc.jsonc .",
21
+ "test": "bun test",
22
+ "typecheck": "tsgo --noEmit"
23
+ },
24
+ "dependencies": {
25
+ "@conform-ed/contracts": "0.0.16",
26
+ "fast-xml-parser": "^5.3.1",
27
+ "fast-xml-validator": "^1.1.1",
28
+ "fflate": "^0.8.3"
29
+ },
30
+ "devDependencies": {
31
+ "xmllint-wasm": "^5.2.0"
32
+ }
33
+ }
@@ -0,0 +1,194 @@
1
+ import { createHash } from "node:crypto";
2
+ import { readdir, readFile } from "node:fs/promises";
3
+ import path from "node:path";
4
+
5
+ import { SyntaxValidator } from "fast-xml-validator";
6
+
7
+ import { detectQtiRoot } from "./root-detection";
8
+ import { isNormalizationImplemented, selectQtiSchema } from "./schema-selection";
9
+ import type {
10
+ QtiExampleInventoryEntry,
11
+ QtiExampleInventoryReport,
12
+ QtiFileKind,
13
+ QtiSupportStatus,
14
+ QtiXmlStatus,
15
+ } from "./types";
16
+
17
+ const knownBrokenExampleSuffixes = ["qtiv3-examples/CAT/test.xml", "qtiv3-examples/results/full-example.xml"] as const;
18
+
19
+ function createEmptyCountRecord<T extends string>(keys: readonly T[]): Record<T, number> {
20
+ return Object.fromEntries(keys.map((key) => [key, 0])) as Record<T, number>;
21
+ }
22
+
23
+ async function walkFiles(rootPath: string): Promise<string[]> {
24
+ const entries = await readdir(rootPath, { withFileTypes: true });
25
+ const files: string[] = [];
26
+
27
+ for (const entry of entries) {
28
+ if (entry.name.startsWith(".")) {
29
+ continue;
30
+ }
31
+
32
+ const absolutePath = path.join(rootPath, entry.name);
33
+ if (entry.isDirectory()) {
34
+ files.push(...(await walkFiles(absolutePath)));
35
+ continue;
36
+ }
37
+
38
+ files.push(absolutePath);
39
+ }
40
+
41
+ return files;
42
+ }
43
+
44
+ function normalizeRelativePath(rootPath: string, absolutePath: string): string {
45
+ return path.relative(rootPath, absolutePath).split(path.sep).join("/");
46
+ }
47
+
48
+ function fileKindForPath(filePath: string): QtiFileKind {
49
+ const extension = path.extname(filePath).toLowerCase();
50
+
51
+ switch (extension) {
52
+ case ".xml":
53
+ return "xml";
54
+ case ".zip":
55
+ return "zip";
56
+ case ".html":
57
+ case ".htm":
58
+ return "html";
59
+ default:
60
+ return "other";
61
+ }
62
+ }
63
+
64
+ function isKnownBrokenExample(relativePath: string): boolean {
65
+ return knownBrokenExampleSuffixes.some((suffix) => relativePath === suffix || relativePath.endsWith(`/${suffix}`));
66
+ }
67
+
68
+ function xmlStatusForContent(fileKind: QtiFileKind, content: string): QtiXmlStatus {
69
+ if (fileKind !== "xml") {
70
+ return "not-xml";
71
+ }
72
+
73
+ return SyntaxValidator.validate(content) === true ? "well-formed" : "malformed";
74
+ }
75
+
76
+ function supportStatusForEntry(entry: {
77
+ fileKind: QtiFileKind;
78
+ xmlStatus: QtiXmlStatus;
79
+ relativePath: string;
80
+ hasSchema: boolean;
81
+ hasNormalizer: boolean;
82
+ }): { status: QtiSupportStatus; note?: string } {
83
+ if (entry.fileKind === "zip") {
84
+ return { status: "zip-package" };
85
+ }
86
+
87
+ if (entry.fileKind !== "xml") {
88
+ return { status: "not-xml" };
89
+ }
90
+
91
+ if (isKnownBrokenExample(entry.relativePath)) {
92
+ return {
93
+ status: "known-broken-example",
94
+ note:
95
+ entry.xmlStatus === "malformed"
96
+ ? "Known malformed official example."
97
+ : "Known official example path that does not currently contain a usable QTI XML document.",
98
+ };
99
+ }
100
+
101
+ if (entry.xmlStatus === "malformed") {
102
+ return { status: "unsupported-root", note: "XML is not well formed." };
103
+ }
104
+
105
+ if (!entry.hasSchema) {
106
+ return { status: "unsupported-root", note: "No contracts schema is registered for this root." };
107
+ }
108
+
109
+ if (!entry.hasNormalizer) {
110
+ return {
111
+ status: "unsupported-normalization",
112
+ note: "A contracts schema exists, but XML normalization is not implemented yet.",
113
+ };
114
+ }
115
+
116
+ return { status: "supported" };
117
+ }
118
+
119
+ function hashContent(content: string): string {
120
+ return createHash("sha256").update(content, "utf8").digest("hex");
121
+ }
122
+
123
+ export async function buildQtiExampleInventory(rootPath: string): Promise<QtiExampleInventoryReport> {
124
+ const absoluteRootPath = path.resolve(rootPath);
125
+ const filePaths = (await walkFiles(absoluteRootPath)).sort();
126
+ const entries: QtiExampleInventoryEntry[] = [];
127
+
128
+ for (const absolutePath of filePaths) {
129
+ const relativePath = normalizeRelativePath(absoluteRootPath, absolutePath);
130
+ const fileKind = fileKindForPath(absolutePath);
131
+ const sourceGroup = relativePath.split("/", 1)[0] ?? ".";
132
+ const content = fileKind === "zip" ? "" : await readFile(absolutePath, "utf8");
133
+ const contentHash = hashContent(content);
134
+ const xmlStatus = xmlStatusForContent(fileKind, content);
135
+ const rootDetection = fileKind === "xml" ? detectQtiRoot(content) : undefined;
136
+ const schemaSelection = rootDetection ? selectQtiSchema(rootDetection) : undefined;
137
+ const support = supportStatusForEntry({
138
+ fileKind,
139
+ xmlStatus,
140
+ relativePath,
141
+ hasSchema: Boolean(schemaSelection),
142
+ hasNormalizer:
143
+ schemaSelection !== undefined && isNormalizationImplemented(schemaSelection.version, schemaSelection.key),
144
+ });
145
+
146
+ entries.push({
147
+ absolutePath,
148
+ relativePath,
149
+ sourceGroup,
150
+ fileKind,
151
+ xmlStatus,
152
+ supportStatus: support.status,
153
+ contentHash,
154
+ ...(rootDetection?.rootName ? { rootName: rootDetection.rootName } : {}),
155
+ ...(rootDetection?.localName ? { localName: rootDetection.localName } : {}),
156
+ ...(rootDetection?.namespaceUri ? { namespaceUri: rootDetection.namespaceUri } : {}),
157
+ ...(rootDetection?.inferredVersion ? { inferredVersion: rootDetection.inferredVersion } : {}),
158
+ ...(rootDetection?.schemaSelectionKey ? { schemaSelectionKey: rootDetection.schemaSelectionKey } : {}),
159
+ ...(support.note ? { note: support.note } : {}),
160
+ });
161
+ }
162
+
163
+ const byFileKind = createEmptyCountRecord(["html", "other", "xml", "zip"] as const);
164
+ const bySupportStatus = createEmptyCountRecord([
165
+ "known-broken-example",
166
+ "not-xml",
167
+ "supported",
168
+ "unsupported-normalization",
169
+ "unsupported-root",
170
+ "zip-package",
171
+ ] as const);
172
+ const byVersion: Record<string, number> = {};
173
+
174
+ for (const entry of entries) {
175
+ byFileKind[entry.fileKind] += 1;
176
+ bySupportStatus[entry.supportStatus] += 1;
177
+
178
+ if (entry.inferredVersion) {
179
+ byVersion[entry.inferredVersion] = (byVersion[entry.inferredVersion] ?? 0) + 1;
180
+ }
181
+ }
182
+
183
+ return {
184
+ rootPath: absoluteRootPath,
185
+ generatedAt: new Date().toISOString(),
186
+ entries,
187
+ summary: {
188
+ totalFiles: entries.length,
189
+ byFileKind,
190
+ bySupportStatus,
191
+ byVersion,
192
+ },
193
+ };
194
+ }
package/src/index.ts ADDED
@@ -0,0 +1,14 @@
1
+ export * from "./example-inventory";
2
+ export * from "./normalize";
3
+ export * from "./parse-xml";
4
+ export * from "./root-detection";
5
+ export * from "./schema-selection";
6
+ export * from "./types";
7
+ export * from "./validate";
8
+ export * from "./validate-package";
9
+ export * from "./serialize-result";
10
+ export * from "./serialize-pnp";
11
+ export * from "./serialize-usage-data";
12
+ export * from "./serialize-asi";
13
+ export * from "./serialize-manifest";
14
+ export * from "./serialize-document";