@longsightgroup/qti3-core 0.2.1 → 0.4.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/README.md +85 -8
- package/dist/delivery-security.d.ts +26 -0
- package/dist/delivery-security.d.ts.map +1 -0
- package/dist/delivery-security.js +213 -0
- package/dist/delivery-security.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +21 -3
- package/dist/parser.js.map +1 -1
- package/dist/server-scoring.d.ts +27 -0
- package/dist/server-scoring.d.ts.map +1 -0
- package/dist/server-scoring.js +162 -0
- package/dist/server-scoring.js.map +1 -0
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +192 -105
- package/dist/session.js.map +1 -1
- package/dist/types.d.ts +3 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts.map +1 -1
- package/dist/validation.js +136 -3
- package/dist/validation.js.map +1 -1
- package/dist/value-format.d.ts +11 -0
- package/dist/value-format.d.ts.map +1 -0
- package/dist/value-format.js +107 -0
- package/dist/value-format.js.map +1 -0
- package/dist/xml.d.ts +12 -0
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +200 -50
- package/dist/xml.js.map +1 -1
- package/package.json +2 -2
- package/src/delivery-security.ts +283 -0
- package/src/index.ts +25 -0
- package/src/parser.ts +23 -3
- package/src/server-scoring.ts +244 -0
- package/src/session.ts +336 -291
- package/src/types.ts +3 -0
- package/src/validation.ts +151 -2
- package/src/value-format.ts +103 -0
- package/src/xml.ts +224 -52
package/src/types.ts
CHANGED
|
@@ -416,7 +416,10 @@ export interface QtiAssessmentItem {
|
|
|
416
416
|
title?: string | undefined;
|
|
417
417
|
language?: string | undefined;
|
|
418
418
|
adaptive: boolean;
|
|
419
|
+
timeDependent?: boolean | undefined;
|
|
420
|
+
attributes: Record<string, string>;
|
|
419
421
|
prompt?: string | undefined;
|
|
422
|
+
itemBodySource?: QtiSourceLocation | undefined;
|
|
420
423
|
responseDeclarations: QtiResponseDeclaration[];
|
|
421
424
|
outcomeDeclarations: QtiOutcomeDeclaration[];
|
|
422
425
|
templateDeclarations: QtiTemplateDeclaration[];
|
package/src/validation.ts
CHANGED
|
@@ -21,6 +21,7 @@ import type {
|
|
|
21
21
|
QtiValidationResult,
|
|
22
22
|
} from "./types.js";
|
|
23
23
|
import { validateQtiDataSsmlMetadata } from "./tts.js";
|
|
24
|
+
import { qtiValueToStringList } from "./value-format.js";
|
|
24
25
|
|
|
25
26
|
const BUILT_IN_COMPLETION_STATUS = "completionStatus";
|
|
26
27
|
|
|
@@ -29,6 +30,8 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
|
|
|
29
30
|
const item = document.item;
|
|
30
31
|
|
|
31
32
|
requireIdentifier("qti-assessment-item", item.identifier, diagnostics, item.source);
|
|
33
|
+
validateAssessmentItemRoot(item, diagnostics);
|
|
34
|
+
validateItemBody(item, diagnostics);
|
|
32
35
|
validateDeclarationIdentifiers(item, diagnostics);
|
|
33
36
|
validateOutcomeLookupTables(item, diagnostics);
|
|
34
37
|
validateInteractions(item, diagnostics);
|
|
@@ -36,6 +39,7 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
|
|
|
36
39
|
validateCatalogInfo(item, diagnostics);
|
|
37
40
|
validateStylesheets(item, diagnostics);
|
|
38
41
|
diagnostics.push(...validateQtiDataSsmlMetadata(item));
|
|
42
|
+
validateResponseProcessingTemplate(item, diagnostics);
|
|
39
43
|
validateProcessingReferences(item, diagnostics);
|
|
40
44
|
|
|
41
45
|
return {
|
|
@@ -44,6 +48,152 @@ export function validateAssessmentItem(document: QtiDocument): QtiValidationResu
|
|
|
44
48
|
};
|
|
45
49
|
}
|
|
46
50
|
|
|
51
|
+
function validateResponseProcessingTemplate(
|
|
52
|
+
item: QtiAssessmentItem,
|
|
53
|
+
diagnostics: QtiDiagnostic[],
|
|
54
|
+
): void {
|
|
55
|
+
const template = item.responseProcessing?.template;
|
|
56
|
+
if (!template) return;
|
|
57
|
+
const templateKind = responseProcessingTemplateKind(template);
|
|
58
|
+
if (templateKind) {
|
|
59
|
+
validateBuiltInResponseProcessingTemplate(item, templateKind, diagnostics);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
diagnostics.push({
|
|
63
|
+
code: "processing.template.unsupported",
|
|
64
|
+
severity: "error",
|
|
65
|
+
message: `qti-response-processing template ${template} is not currently supported.`,
|
|
66
|
+
path: item.source?.path,
|
|
67
|
+
source: item.source,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function responseProcessingTemplateKind(
|
|
72
|
+
template: string,
|
|
73
|
+
): "matchCorrect" | "mapResponse" | "mapResponsePoint" | undefined {
|
|
74
|
+
const path = template.split(/[?#]/, 1)[0] ?? "";
|
|
75
|
+
const name = path.slice(path.lastIndexOf("/") + 1).replace(/\.xml$/i, "");
|
|
76
|
+
if (name === "match_correct") return "matchCorrect";
|
|
77
|
+
if (name === "map_response") return "mapResponse";
|
|
78
|
+
if (name === "map_response_point") return "mapResponsePoint";
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function validateAssessmentItemRoot(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
|
|
83
|
+
if (!item.attributes.title?.trim()) {
|
|
84
|
+
diagnostics.push({
|
|
85
|
+
code: "assessmentItem.title.required",
|
|
86
|
+
severity: "error",
|
|
87
|
+
message: "qti-assessment-item requires a non-empty title attribute.",
|
|
88
|
+
path: item.source?.path,
|
|
89
|
+
source: item.source,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
const timeDependent = item.attributes["time-dependent"];
|
|
93
|
+
if (timeDependent === undefined || timeDependent.trim() === "") {
|
|
94
|
+
diagnostics.push({
|
|
95
|
+
code: "assessmentItem.timeDependent.required",
|
|
96
|
+
severity: "error",
|
|
97
|
+
message: "qti-assessment-item requires a time-dependent attribute.",
|
|
98
|
+
path: item.source?.path,
|
|
99
|
+
source: item.source,
|
|
100
|
+
});
|
|
101
|
+
} else if (!isXmlBoolean(timeDependent)) {
|
|
102
|
+
diagnostics.push({
|
|
103
|
+
code: "assessmentItem.timeDependent.boolean",
|
|
104
|
+
severity: "error",
|
|
105
|
+
message: `qti-assessment-item time-dependent must be an XML boolean, found ${timeDependent}.`,
|
|
106
|
+
path: item.source?.path,
|
|
107
|
+
source: item.source,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function validateBuiltInResponseProcessingTemplate(
|
|
113
|
+
item: QtiAssessmentItem,
|
|
114
|
+
templateKind: "matchCorrect" | "mapResponse" | "mapResponsePoint",
|
|
115
|
+
diagnostics: QtiDiagnostic[],
|
|
116
|
+
): void {
|
|
117
|
+
const response = item.responseDeclarations.find(
|
|
118
|
+
(declaration) => declaration.identifier === "RESPONSE",
|
|
119
|
+
);
|
|
120
|
+
const score = item.outcomeDeclarations.find((declaration) => declaration.identifier === "SCORE");
|
|
121
|
+
if (!response) {
|
|
122
|
+
diagnostics.push({
|
|
123
|
+
code: "processing.template.responseIdentifier",
|
|
124
|
+
severity: "error",
|
|
125
|
+
message:
|
|
126
|
+
"Built-in response-processing templates require a response declaration named RESPONSE.",
|
|
127
|
+
path: item.source?.path,
|
|
128
|
+
source: item.source,
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
if (!score) {
|
|
132
|
+
diagnostics.push({
|
|
133
|
+
code: "processing.template.scoreIdentifier",
|
|
134
|
+
severity: "error",
|
|
135
|
+
message: "Built-in response-processing templates require an outcome declaration named SCORE.",
|
|
136
|
+
path: item.source?.path,
|
|
137
|
+
source: item.source,
|
|
138
|
+
});
|
|
139
|
+
} else if (score.cardinality !== "single" || score.baseType !== "float") {
|
|
140
|
+
diagnostics.push({
|
|
141
|
+
code: "processing.template.scoreDeclaration",
|
|
142
|
+
severity: "error",
|
|
143
|
+
message:
|
|
144
|
+
"Built-in response-processing templates require SCORE to be single cardinality with base-type float.",
|
|
145
|
+
path: score.source?.path,
|
|
146
|
+
source: score.source,
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
const responseInteractions = item.interactions.filter(
|
|
150
|
+
(interaction) => interaction.responseIdentifier === "RESPONSE",
|
|
151
|
+
);
|
|
152
|
+
if (responseInteractions.length !== 1 || item.interactions.length !== 1) {
|
|
153
|
+
diagnostics.push({
|
|
154
|
+
code: "processing.template.singleInteraction",
|
|
155
|
+
severity: "error",
|
|
156
|
+
message:
|
|
157
|
+
"Built-in response-processing templates require a single interaction bound to RESPONSE.",
|
|
158
|
+
path: item.source?.path,
|
|
159
|
+
source: item.source,
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (templateKind === "mapResponse" && response && !response.mapping) {
|
|
163
|
+
diagnostics.push({
|
|
164
|
+
code: "processing.template.mapping",
|
|
165
|
+
severity: "error",
|
|
166
|
+
message: "The map_response template requires RESPONSE to define qti-mapping.",
|
|
167
|
+
path: response.source?.path,
|
|
168
|
+
source: response.source,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (templateKind === "mapResponsePoint" && response && !response.areaMapping) {
|
|
172
|
+
diagnostics.push({
|
|
173
|
+
code: "processing.template.areaMapping",
|
|
174
|
+
severity: "error",
|
|
175
|
+
message: "The map_response_point template requires RESPONSE to define qti-area-mapping.",
|
|
176
|
+
path: response.source?.path,
|
|
177
|
+
source: response.source,
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function isXmlBoolean(value: string): boolean {
|
|
183
|
+
return value === "true" || value === "false" || value === "1" || value === "0";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function validateItemBody(item: QtiAssessmentItem, diagnostics: QtiDiagnostic[]): void {
|
|
187
|
+
if (item.itemBodySource) return;
|
|
188
|
+
diagnostics.push({
|
|
189
|
+
code: "itemBody.required",
|
|
190
|
+
severity: "error",
|
|
191
|
+
message: "qti-assessment-item requires a qti-item-body.",
|
|
192
|
+
path: item.source?.path,
|
|
193
|
+
source: item.source,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
47
197
|
function requireIdentifier(
|
|
48
198
|
elementName: string,
|
|
49
199
|
identifier: string | undefined,
|
|
@@ -1599,8 +1749,7 @@ function validateCorrectResponseReferences(
|
|
|
1599
1749
|
}
|
|
1600
1750
|
|
|
1601
1751
|
function responseValues(value: QtiResponseDeclaration["correctResponse"]): string[] {
|
|
1602
|
-
|
|
1603
|
-
return [String(value)];
|
|
1752
|
+
return qtiValueToStringList(value);
|
|
1604
1753
|
}
|
|
1605
1754
|
|
|
1606
1755
|
function invalidCorrectResponseReference(
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { QtiPortableCustomStateValue, QtiScalarValue, QtiValue } from "./types.js";
|
|
2
|
+
|
|
3
|
+
export function isQtiValue(value: unknown): value is QtiValue {
|
|
4
|
+
return readQtiJsonValue(value) !== undefined;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function readQtiJsonValue(value: unknown): QtiValue | undefined {
|
|
8
|
+
if (value === null || isQtiScalarValue(value)) return value;
|
|
9
|
+
if (Array.isArray(value)) return value.every(isQtiScalarValue) ? value : undefined;
|
|
10
|
+
if (!isPlainRecord(value)) return undefined;
|
|
11
|
+
|
|
12
|
+
const record: Record<string, QtiValue> = {};
|
|
13
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
14
|
+
const converted = readQtiJsonValue(entry);
|
|
15
|
+
if (converted === undefined) return undefined;
|
|
16
|
+
record[key] = converted;
|
|
17
|
+
}
|
|
18
|
+
return record;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function isQtiPortableCustomStateValue(
|
|
22
|
+
value: unknown,
|
|
23
|
+
): value is QtiPortableCustomStateValue {
|
|
24
|
+
return readQtiPortableCustomStateValue(value) !== undefined;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function readQtiPortableCustomStateValue(
|
|
28
|
+
value: unknown,
|
|
29
|
+
): QtiPortableCustomStateValue | undefined {
|
|
30
|
+
if (value === null) return value;
|
|
31
|
+
if (typeof value === "string" || typeof value === "boolean") return value;
|
|
32
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : undefined;
|
|
33
|
+
if (Array.isArray(value)) {
|
|
34
|
+
const values: QtiPortableCustomStateValue[] = [];
|
|
35
|
+
for (const entry of value) {
|
|
36
|
+
const converted = readQtiPortableCustomStateValue(entry);
|
|
37
|
+
if (converted === undefined) return undefined;
|
|
38
|
+
values.push(converted);
|
|
39
|
+
}
|
|
40
|
+
return values;
|
|
41
|
+
}
|
|
42
|
+
if (!isPlainRecord(value)) return undefined;
|
|
43
|
+
|
|
44
|
+
const record: Record<string, QtiPortableCustomStateValue> = {};
|
|
45
|
+
for (const [key, entry] of Object.entries(value)) {
|
|
46
|
+
const converted = readQtiPortableCustomStateValue(entry);
|
|
47
|
+
if (converted === undefined) return undefined;
|
|
48
|
+
record[key] = converted;
|
|
49
|
+
}
|
|
50
|
+
return record;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function qtiScalarToString(value: QtiScalarValue): string {
|
|
54
|
+
return String(value);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function qtiValueToString(value: QtiValue): string {
|
|
58
|
+
if (value === null) return "";
|
|
59
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
60
|
+
return qtiScalarToString(value);
|
|
61
|
+
}
|
|
62
|
+
if (Array.isArray(value)) return value.map(qtiScalarToString).join(" ");
|
|
63
|
+
return JSON.stringify(value);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function qtiValueToStringList(value: QtiValue): string[] {
|
|
67
|
+
if (value === null) return [""];
|
|
68
|
+
if (Array.isArray(value)) return value.map(qtiScalarToString);
|
|
69
|
+
if (typeof value === "object") return [JSON.stringify(value)];
|
|
70
|
+
return [qtiScalarToString(value)];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function qtiValueToIdentifierList(value: QtiValue): string[] {
|
|
74
|
+
if (value === null) return [];
|
|
75
|
+
if (Array.isArray(value)) return value.map(qtiScalarToString);
|
|
76
|
+
if (typeof value === "object") return [JSON.stringify(value)];
|
|
77
|
+
return [qtiScalarToString(value)];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function unknownToDisplayString(value: unknown): string {
|
|
81
|
+
if (value === undefined || value === null) return "";
|
|
82
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") {
|
|
83
|
+
return String(value);
|
|
84
|
+
}
|
|
85
|
+
if (typeof value === "bigint" || typeof value === "symbol") return String(value);
|
|
86
|
+
if (Array.isArray(value)) return value.map(unknownToDisplayString).join(", ");
|
|
87
|
+
if (typeof value === "object") return JSON.stringify(value);
|
|
88
|
+
return JSON.stringify(value);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function isQtiScalarValue(value: unknown): value is QtiScalarValue {
|
|
92
|
+
return (
|
|
93
|
+
typeof value === "string" ||
|
|
94
|
+
typeof value === "boolean" ||
|
|
95
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function isPlainRecord(value: unknown): value is Record<string, unknown> {
|
|
100
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
|
|
101
|
+
const prototype = Object.getPrototypeOf(value);
|
|
102
|
+
return prototype === Object.prototype || prototype === null;
|
|
103
|
+
}
|
package/src/xml.ts
CHANGED
|
@@ -3,11 +3,15 @@ import { StaxXmlParserSync, XmlEventType } from "stax-xml";
|
|
|
3
3
|
export interface XmlNode {
|
|
4
4
|
name: string;
|
|
5
5
|
localName: string;
|
|
6
|
+
prefix?: string | undefined;
|
|
7
|
+
uri?: string | undefined;
|
|
6
8
|
attributes: Record<string, string>;
|
|
7
9
|
children: XmlNode[];
|
|
8
10
|
content: Array<string | XmlNode>;
|
|
9
11
|
text: string;
|
|
10
12
|
source: XmlSourceLocation;
|
|
13
|
+
endSource?: XmlSourceLocation | undefined;
|
|
14
|
+
sourceRange: XmlSourceRange;
|
|
11
15
|
parent?: XmlNode;
|
|
12
16
|
}
|
|
13
17
|
|
|
@@ -18,58 +22,102 @@ export interface XmlSourceLocation {
|
|
|
18
22
|
path: string;
|
|
19
23
|
}
|
|
20
24
|
|
|
25
|
+
export interface XmlSourceRange {
|
|
26
|
+
/** String offset of `<` for this element's start tag in the source XML string. */
|
|
27
|
+
startOffset: number;
|
|
28
|
+
/** String offset of `>` closing the start tag (or `/>` for self-closing tags). */
|
|
29
|
+
startTagEndOffset: number;
|
|
30
|
+
/** String offset one past the element's closing `>` (or self-closing `/>`). */
|
|
31
|
+
endOffset?: number | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
21
34
|
export function parseXmlTree(xml: string): { root: XmlNode | undefined; errors: Error[] } {
|
|
22
35
|
const parser = new StaxXmlParserSync(xml, {
|
|
23
36
|
autoDecodeEntities: true,
|
|
24
37
|
});
|
|
38
|
+
const tagTokens = scanXmlTagTokens(xml);
|
|
25
39
|
const stack: XmlNode[] = [];
|
|
26
40
|
const errors: Error[] = [];
|
|
27
41
|
let root: XmlNode | undefined;
|
|
28
|
-
let
|
|
42
|
+
let tagTokenIndex = 0;
|
|
29
43
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
44
|
+
try {
|
|
45
|
+
for (const event of parser) {
|
|
46
|
+
if (event.type === XmlEventType.ERROR) {
|
|
47
|
+
errors.push(event.error);
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (event.type === XmlEventType.START_ELEMENT) {
|
|
52
|
+
const parent = stack.at(-1);
|
|
53
|
+
const path = nodePath(parent, event.localName ?? event.name);
|
|
54
|
+
const sourceRange: XmlSourceRange = { startOffset: -1, startTagEndOffset: -1 };
|
|
55
|
+
const token = tagTokens[tagTokenIndex];
|
|
56
|
+
if (token?.kind === "start" && token.name === event.name) {
|
|
57
|
+
tagTokenIndex += 1;
|
|
58
|
+
sourceRange.startOffset = token.startOffset;
|
|
59
|
+
sourceRange.startTagEndOffset = token.startTagEndOffset;
|
|
60
|
+
if (token.endOffset !== undefined) {
|
|
61
|
+
sourceRange.endOffset = token.endOffset;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
errors.push(new Error(`XML source range alignment failed for <${event.name}>.`));
|
|
65
|
+
}
|
|
66
|
+
const node: XmlNode = {
|
|
67
|
+
name: event.name,
|
|
68
|
+
localName: event.localName ?? event.name,
|
|
69
|
+
prefix: event.prefix,
|
|
70
|
+
uri: event.uri,
|
|
71
|
+
attributes: event.attributes,
|
|
72
|
+
children: [],
|
|
73
|
+
content: [],
|
|
74
|
+
text: "",
|
|
75
|
+
source: sourceLocation(xml, sourceRange.startOffset, path),
|
|
76
|
+
sourceRange,
|
|
77
|
+
};
|
|
35
78
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
content: [],
|
|
46
|
-
text: "",
|
|
47
|
-
source: sourceLocation(xml, offset, nodePath(parent, event.localName ?? event.name)),
|
|
48
|
-
};
|
|
49
|
-
|
|
50
|
-
if (parent) {
|
|
51
|
-
node.parent = parent;
|
|
52
|
-
parent.children.push(node);
|
|
53
|
-
parent.content.push(node);
|
|
54
|
-
} else {
|
|
55
|
-
root = node;
|
|
79
|
+
if (parent) {
|
|
80
|
+
node.parent = parent;
|
|
81
|
+
parent.children.push(node);
|
|
82
|
+
parent.content.push(node);
|
|
83
|
+
} else {
|
|
84
|
+
root = node;
|
|
85
|
+
}
|
|
86
|
+
stack.push(node);
|
|
87
|
+
continue;
|
|
56
88
|
}
|
|
57
|
-
stack.push(node);
|
|
58
|
-
continue;
|
|
59
|
-
}
|
|
60
89
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
90
|
+
if (event.type === XmlEventType.END_ELEMENT) {
|
|
91
|
+
const node = stack.pop();
|
|
92
|
+
if (node) {
|
|
93
|
+
if (node.sourceRange.endOffset === undefined) {
|
|
94
|
+
const token = tagTokens[tagTokenIndex];
|
|
95
|
+
if (token?.kind === "end" && token.name === event.name) {
|
|
96
|
+
tagTokenIndex += 1;
|
|
97
|
+
node.sourceRange.endOffset = token.endOffset;
|
|
98
|
+
node.endSource = sourceLocation(xml, token.startOffset, node.source.path);
|
|
99
|
+
} else {
|
|
100
|
+
errors.push(new Error(`XML source range alignment failed for </${event.name}>.`));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
65
106
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
107
|
+
if (event.type === XmlEventType.CHARACTERS || event.type === XmlEventType.CDATA) {
|
|
108
|
+
const node = stack.at(-1);
|
|
109
|
+
if (node) {
|
|
110
|
+
node.text += event.value;
|
|
111
|
+
node.content.push(event.value);
|
|
112
|
+
}
|
|
71
113
|
}
|
|
72
114
|
}
|
|
115
|
+
} catch (error) {
|
|
116
|
+
errors.push(error instanceof Error ? error : new Error(String(error)));
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (const node of [...stack].reverse()) {
|
|
120
|
+
errors.push(new Error(`Unexpected end of document. Missing closing tag for <${node.name}>.`));
|
|
73
121
|
}
|
|
74
122
|
|
|
75
123
|
return { root, errors };
|
|
@@ -95,28 +143,152 @@ export function textContent(node: XmlNode): string {
|
|
|
95
143
|
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
96
144
|
}
|
|
97
145
|
|
|
98
|
-
|
|
99
|
-
|
|
146
|
+
interface XmlTagToken {
|
|
147
|
+
kind: "start" | "end";
|
|
148
|
+
name: string;
|
|
149
|
+
startOffset: number;
|
|
150
|
+
startTagEndOffset: number;
|
|
151
|
+
endOffset?: number | undefined;
|
|
152
|
+
selfClosing: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function scanXmlTagTokens(xml: string): XmlTagToken[] {
|
|
156
|
+
const tokens: XmlTagToken[] = [];
|
|
157
|
+
let offset = 0;
|
|
158
|
+
|
|
100
159
|
while (offset < xml.length) {
|
|
101
|
-
const
|
|
102
|
-
if (
|
|
103
|
-
|
|
104
|
-
if (
|
|
105
|
-
offset =
|
|
160
|
+
const startOffset = xml.indexOf("<", offset);
|
|
161
|
+
if (startOffset === -1 || startOffset + 1 >= xml.length) return tokens;
|
|
162
|
+
|
|
163
|
+
if (xml.startsWith("<!--", startOffset)) {
|
|
164
|
+
offset = skipPastSequence(xml, "-->", startOffset + 4);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (xml.startsWith("<![CDATA[", startOffset)) {
|
|
169
|
+
offset = skipPastSequence(xml, "]]>", startOffset + 9);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const next = xml.charAt(startOffset + 1);
|
|
174
|
+
if (next === "?") {
|
|
175
|
+
offset = skipPastSequence(xml, "?>", startOffset + 2);
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (next === "!") {
|
|
180
|
+
const declarationEndOffset = findMarkupDeclarationEndOffset(xml, startOffset + 2);
|
|
181
|
+
offset = declarationEndOffset >= 0 ? declarationEndOffset + 1 : xml.length;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (next === "/") {
|
|
186
|
+
const tagEndOffset = findTagEndOffset(xml, startOffset + 2);
|
|
187
|
+
if (tagEndOffset < 0) return tokens;
|
|
188
|
+
const name = readTagName(xml, startOffset + 2, tagEndOffset);
|
|
189
|
+
if (name) {
|
|
190
|
+
tokens.push({
|
|
191
|
+
kind: "end",
|
|
192
|
+
name,
|
|
193
|
+
startOffset,
|
|
194
|
+
startTagEndOffset: tagEndOffset,
|
|
195
|
+
endOffset: tagEndOffset + 1,
|
|
196
|
+
selfClosing: false,
|
|
197
|
+
});
|
|
198
|
+
}
|
|
199
|
+
offset = tagEndOffset + 1;
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const tagEndOffset = findTagEndOffset(xml, startOffset + 1);
|
|
204
|
+
if (tagEndOffset < 0) return tokens;
|
|
205
|
+
const name = readTagName(xml, startOffset + 1, tagEndOffset);
|
|
206
|
+
if (name) {
|
|
207
|
+
const selfClosing = isSelfClosingStartTag(xml, tagEndOffset);
|
|
208
|
+
tokens.push({
|
|
209
|
+
kind: "start",
|
|
210
|
+
name,
|
|
211
|
+
startOffset,
|
|
212
|
+
startTagEndOffset: tagEndOffset,
|
|
213
|
+
endOffset: selfClosing ? tagEndOffset + 1 : undefined,
|
|
214
|
+
selfClosing,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
offset = tagEndOffset + 1;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return tokens;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function skipPastSequence(xml: string, sequence: string, from: number): number {
|
|
224
|
+
const endOffset = xml.indexOf(sequence, from);
|
|
225
|
+
return endOffset >= 0 ? endOffset + sequence.length : xml.length;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function findMarkupDeclarationEndOffset(xml: string, from: number): number {
|
|
229
|
+
let quote: string | null = null;
|
|
230
|
+
let internalSubsetDepth = 0;
|
|
231
|
+
for (let index = from; index < xml.length; index += 1) {
|
|
232
|
+
const char = xml.charAt(index);
|
|
233
|
+
if (quote) {
|
|
234
|
+
if (char === quote) quote = null;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (char === '"' || char === "'") {
|
|
238
|
+
quote = char;
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if (char === "[") {
|
|
242
|
+
internalSubsetDepth += 1;
|
|
106
243
|
continue;
|
|
107
244
|
}
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
(afterName >= xml.length || /[\s/>]/.test(xml.charAt(afterName)))
|
|
112
|
-
) {
|
|
113
|
-
return start;
|
|
245
|
+
if (char === "]" && internalSubsetDepth > 0) {
|
|
246
|
+
internalSubsetDepth -= 1;
|
|
247
|
+
continue;
|
|
114
248
|
}
|
|
115
|
-
|
|
249
|
+
if (char === ">" && internalSubsetDepth === 0) return index;
|
|
116
250
|
}
|
|
117
251
|
return -1;
|
|
118
252
|
}
|
|
119
253
|
|
|
254
|
+
function readTagName(xml: string, from: number, to: number): string {
|
|
255
|
+
let start = from;
|
|
256
|
+
while (start < to && /\s/.test(xml.charAt(start))) start += 1;
|
|
257
|
+
let end = start;
|
|
258
|
+
while (end < to) {
|
|
259
|
+
const char = xml.charAt(end);
|
|
260
|
+
if (/\s/.test(char) || char === "/" || char === ">") break;
|
|
261
|
+
end += 1;
|
|
262
|
+
}
|
|
263
|
+
return xml.slice(start, end);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function findTagEndOffset(xml: string, from: number): number {
|
|
267
|
+
let quote: string | null = null;
|
|
268
|
+
for (let index = from; index < xml.length; index += 1) {
|
|
269
|
+
const char = xml.charAt(index);
|
|
270
|
+
if (quote) {
|
|
271
|
+
if (char === quote) quote = null;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (char === '"' || char === "'") {
|
|
275
|
+
quote = char;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
if (char === ">") return index;
|
|
279
|
+
}
|
|
280
|
+
return -1;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isSelfClosingStartTag(xml: string, tagEndOffset: number): boolean {
|
|
284
|
+
for (let index = tagEndOffset - 1; index >= 0; index -= 1) {
|
|
285
|
+
const char = xml.charAt(index);
|
|
286
|
+
if (/\s/.test(char)) continue;
|
|
287
|
+
return char === "/";
|
|
288
|
+
}
|
|
289
|
+
return false;
|
|
290
|
+
}
|
|
291
|
+
|
|
120
292
|
function sourceLocation(xml: string, offset: number, path: string): XmlSourceLocation {
|
|
121
293
|
if (offset < 0) return { line: 1, column: 1, offset: 0, path };
|
|
122
294
|
let line = 1;
|