@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,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
+ }
@@ -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
+ }
@@ -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("&", "&amp;").replaceAll("<", "&lt;").replaceAll(">", "&gt;");
9
+ }
10
+
11
+ export function escapeAttribute(value: string): string {
12
+ return escapeText(value).replaceAll('"', "&quot;");
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
+ }