@longsightgroup/qti3-core 0.3.0 → 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 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +3 -0
- 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 +2 -26
- package/dist/session.js.map +1 -1
- package/dist/value-format.d.ts +5 -1
- package/dist/value-format.d.ts.map +1 -1
- package/dist/value-format.js +61 -0
- package/dist/value-format.js.map +1 -1
- package/dist/xml.d.ts +10 -0
- package/dist/xml.d.ts.map +1 -1
- package/dist/xml.js +200 -52
- package/dist/xml.js.map +1 -1
- package/package.json +1 -1
- package/src/delivery-security.ts +283 -0
- package/src/index.ts +18 -0
- package/src/parser.ts +4 -0
- package/src/server-scoring.ts +244 -0
- package/src/session.ts +8 -22
- package/src/value-format.ts +65 -1
- package/src/xml.ts +222 -54
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
import { createItemSession } from "./session.js";
|
|
2
|
+
import { parseQtiXml } from "./parser.js";
|
|
3
|
+
import type {
|
|
4
|
+
QtiAssessmentItem,
|
|
5
|
+
QtiAttemptStateV1,
|
|
6
|
+
QtiAttemptStatus,
|
|
7
|
+
QtiDiagnostic,
|
|
8
|
+
QtiPortableCustomStateValue,
|
|
9
|
+
QtiScoreResult,
|
|
10
|
+
QtiValue,
|
|
11
|
+
} from "./types.js";
|
|
12
|
+
import { isQtiPortableCustomStateValue, readQtiJsonValue } from "./value-format.js";
|
|
13
|
+
|
|
14
|
+
export interface QtiServerScoringResponseInput {
|
|
15
|
+
identifier: string;
|
|
16
|
+
value: unknown;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface QtiServerScoringInput {
|
|
20
|
+
itemXml: string;
|
|
21
|
+
/**
|
|
22
|
+
* Server-trusted response values. This API validates JSON/QTI value shape and declared
|
|
23
|
+
* identifiers, but it does not run candidate response-validation policy such as required
|
|
24
|
+
* interactions or min/max response counts.
|
|
25
|
+
*/
|
|
26
|
+
trustedResponses?: Record<string, unknown> | readonly QtiServerScoringResponseInput[] | undefined;
|
|
27
|
+
trustedInteractionStates?: Record<string, QtiPortableCustomStateValue> | undefined;
|
|
28
|
+
status?: QtiAttemptStatus | undefined;
|
|
29
|
+
allowedUndeclaredResponseIdentifiers?: readonly string[] | undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface QtiServerScoringResult {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
diagnostics: QtiDiagnostic[];
|
|
35
|
+
state: QtiAttemptStateV1 | null;
|
|
36
|
+
responses: Record<string, QtiValue>;
|
|
37
|
+
outcomes: Record<string, QtiValue>;
|
|
38
|
+
score: number | null;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function scoreQtiItemServerSide(input: QtiServerScoringInput): QtiServerScoringResult {
|
|
42
|
+
let parsed: ReturnType<typeof parseQtiXml>;
|
|
43
|
+
try {
|
|
44
|
+
parsed = parseQtiXml(input.itemXml);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
return failed([
|
|
47
|
+
{
|
|
48
|
+
code: "xml.parse",
|
|
49
|
+
severity: "error",
|
|
50
|
+
message: error instanceof Error ? error.message : String(error),
|
|
51
|
+
},
|
|
52
|
+
]);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const allowedUndeclaredResponseIdentifiers = new Set(
|
|
56
|
+
input.allowedUndeclaredResponseIdentifiers ?? [],
|
|
57
|
+
);
|
|
58
|
+
const parseDiagnostics = parsed.diagnostics.filter(
|
|
59
|
+
(diagnostic) =>
|
|
60
|
+
!isAllowedUndeclaredVariableReference(diagnostic, allowedUndeclaredResponseIdentifiers),
|
|
61
|
+
);
|
|
62
|
+
if (!parsed.document || parseDiagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
63
|
+
return failed(parseDiagnostics);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const diagnostics = [...parseDiagnostics];
|
|
67
|
+
const session = createItemSession(parsed.document);
|
|
68
|
+
if (input.status) session.setStatus(input.status);
|
|
69
|
+
|
|
70
|
+
const responseIdentifiers = new Set(
|
|
71
|
+
parsed.document.item.responseDeclarations.map((declaration) => declaration.identifier),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
for (const response of normalizeResponseInputs(input.trustedResponses)) {
|
|
75
|
+
const identifier = response.identifier.trim();
|
|
76
|
+
if (!identifier) {
|
|
77
|
+
diagnostics.push({
|
|
78
|
+
code: "serverScoring.response.identifier",
|
|
79
|
+
severity: "error",
|
|
80
|
+
message: "Trusted response identifiers must be non-empty strings.",
|
|
81
|
+
});
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (
|
|
86
|
+
!responseIdentifiers.has(identifier) &&
|
|
87
|
+
!allowedUndeclaredResponseIdentifiers.has(identifier)
|
|
88
|
+
) {
|
|
89
|
+
diagnostics.push({
|
|
90
|
+
code: "serverScoring.response.ignored",
|
|
91
|
+
severity: "warning",
|
|
92
|
+
message: `Trusted response ${identifier} was ignored because it is not declared by the item.`,
|
|
93
|
+
});
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const value = readQtiJsonValue(response.value);
|
|
98
|
+
if (value === undefined) {
|
|
99
|
+
diagnostics.push({
|
|
100
|
+
code: "serverScoring.response.value",
|
|
101
|
+
severity: "error",
|
|
102
|
+
message: `Trusted response ${identifier} is not a supported QTI value.`,
|
|
103
|
+
});
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
session.respond(identifier, value);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
for (const [identifier, state] of Object.entries(input.trustedInteractionStates ?? {})) {
|
|
110
|
+
if (!isQtiPortableCustomStateValue(state)) {
|
|
111
|
+
diagnostics.push({
|
|
112
|
+
code: "serverScoring.interactionState.value",
|
|
113
|
+
severity: "error",
|
|
114
|
+
message: `Trusted interaction state ${identifier} is not a supported portable custom state value.`,
|
|
115
|
+
});
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
try {
|
|
120
|
+
session.setInteractionState(identifier, state);
|
|
121
|
+
} catch (error) {
|
|
122
|
+
diagnostics.push({
|
|
123
|
+
code: "serverScoring.interactionState.identifier",
|
|
124
|
+
severity: "error",
|
|
125
|
+
message: error instanceof Error ? error.message : String(error),
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
131
|
+
return failed(diagnostics);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let scored: QtiScoreResult;
|
|
135
|
+
try {
|
|
136
|
+
scored = session.score();
|
|
137
|
+
} catch (error) {
|
|
138
|
+
return failed([
|
|
139
|
+
...diagnostics,
|
|
140
|
+
{
|
|
141
|
+
code: "serverScoring.score.exception",
|
|
142
|
+
severity: "error",
|
|
143
|
+
message: error instanceof Error ? error.message : String(error),
|
|
144
|
+
},
|
|
145
|
+
]);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const scoredDiagnostics = [...diagnostics, ...scored.diagnostics];
|
|
149
|
+
const score = readNumericScore(scored.outcomes.SCORE);
|
|
150
|
+
const scoredState = stripUndeclaredResponses(scored.state, responseIdentifiers);
|
|
151
|
+
if (scored.diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
|
|
152
|
+
return failed(scoredDiagnostics, scoredState, scored.outcomes, score);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (shouldRequireNumericScore(parsed.document.item) && score === null) {
|
|
156
|
+
return failed(
|
|
157
|
+
[
|
|
158
|
+
...scoredDiagnostics,
|
|
159
|
+
{
|
|
160
|
+
code: "serverScoring.score.missing",
|
|
161
|
+
severity: "error",
|
|
162
|
+
message: "Server-side scoring did not produce a numeric SCORE outcome.",
|
|
163
|
+
},
|
|
164
|
+
],
|
|
165
|
+
scoredState,
|
|
166
|
+
scored.outcomes,
|
|
167
|
+
score,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
ok: true,
|
|
173
|
+
diagnostics: scoredDiagnostics,
|
|
174
|
+
state: scoredState,
|
|
175
|
+
responses: scoredState.responses,
|
|
176
|
+
outcomes: scored.outcomes,
|
|
177
|
+
score,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function normalizeResponseInputs(
|
|
182
|
+
responses: QtiServerScoringInput["trustedResponses"],
|
|
183
|
+
): QtiServerScoringResponseInput[] {
|
|
184
|
+
if (!responses) return [];
|
|
185
|
+
if (Array.isArray(responses)) return [...responses];
|
|
186
|
+
return Object.entries(responses).map(([identifier, value]) => ({ identifier, value }));
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function shouldRequireNumericScore(item: QtiAssessmentItem): boolean {
|
|
190
|
+
return (
|
|
191
|
+
Boolean(item.responseProcessing) ||
|
|
192
|
+
item.outcomeDeclarations.some((declaration) => declaration.identifier === "SCORE")
|
|
193
|
+
);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function readNumericScore(value: QtiValue | undefined): number | null {
|
|
197
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
198
|
+
if (typeof value === "string") {
|
|
199
|
+
const score = Number(value.trim());
|
|
200
|
+
return Number.isFinite(score) ? score : null;
|
|
201
|
+
}
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function isAllowedUndeclaredVariableReference(
|
|
206
|
+
diagnostic: QtiDiagnostic,
|
|
207
|
+
allowedUndeclaredResponseIdentifiers: Set<string>,
|
|
208
|
+
): boolean {
|
|
209
|
+
if (diagnostic.code !== "processing.variable.reference") return false;
|
|
210
|
+
const identifier = diagnostic.message.match(
|
|
211
|
+
/^Processing expression references missing variable (.+)\.$/,
|
|
212
|
+
)?.[1];
|
|
213
|
+
return Boolean(identifier && allowedUndeclaredResponseIdentifiers.has(identifier));
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function failed(
|
|
217
|
+
diagnostics: QtiDiagnostic[],
|
|
218
|
+
state: QtiAttemptStateV1 | null = null,
|
|
219
|
+
outcomes: Record<string, QtiValue> = {},
|
|
220
|
+
score: number | null = null,
|
|
221
|
+
): QtiServerScoringResult {
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
diagnostics,
|
|
225
|
+
state,
|
|
226
|
+
responses: state?.responses ?? {},
|
|
227
|
+
outcomes,
|
|
228
|
+
score,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function stripUndeclaredResponses(
|
|
233
|
+
state: QtiAttemptStateV1,
|
|
234
|
+
declaredResponseIdentifiers: Set<string>,
|
|
235
|
+
): QtiAttemptStateV1 {
|
|
236
|
+
return {
|
|
237
|
+
...state,
|
|
238
|
+
responses: Object.fromEntries(
|
|
239
|
+
Object.entries(state.responses).filter(([identifier]) =>
|
|
240
|
+
declaredResponseIdentifiers.has(identifier),
|
|
241
|
+
),
|
|
242
|
+
),
|
|
243
|
+
};
|
|
244
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -17,7 +17,13 @@ import type {
|
|
|
17
17
|
QtiValue,
|
|
18
18
|
QtiVariableDeclaration,
|
|
19
19
|
} from "./types.js";
|
|
20
|
-
import {
|
|
20
|
+
import {
|
|
21
|
+
isQtiPortableCustomStateValue,
|
|
22
|
+
isQtiValue,
|
|
23
|
+
qtiScalarToString,
|
|
24
|
+
qtiValueToString,
|
|
25
|
+
qtiValueToStringList,
|
|
26
|
+
} from "./value-format.js";
|
|
21
27
|
|
|
22
28
|
export interface QtiCustomOperatorContext {
|
|
23
29
|
definition?: string | undefined;
|
|
@@ -1995,33 +2001,13 @@ function isRecordValue(value: QtiValue): value is QtiRecordValue {
|
|
|
1995
2001
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1996
2002
|
}
|
|
1997
2003
|
|
|
1998
|
-
function isQtiValue(value: unknown): value is QtiValue {
|
|
1999
|
-
if (value === null) return true;
|
|
2000
|
-
if (isQtiScalarValue(value)) return true;
|
|
2001
|
-
if (Array.isArray(value)) return value.every(isQtiScalarValue);
|
|
2002
|
-
return isQtiValueRecord(value);
|
|
2003
|
-
}
|
|
2004
|
-
|
|
2005
|
-
function isQtiScalarValue(value: unknown): value is QtiScalarValue {
|
|
2006
|
-
return (
|
|
2007
|
-
typeof value === "string" ||
|
|
2008
|
-
typeof value === "boolean" ||
|
|
2009
|
-
(typeof value === "number" && Number.isFinite(value))
|
|
2010
|
-
);
|
|
2011
|
-
}
|
|
2012
|
-
|
|
2013
2004
|
function isQtiValueRecord(value: unknown): value is Record<string, QtiValue> {
|
|
2014
2005
|
if (!isRecord(value)) return false;
|
|
2015
2006
|
return Object.values(value).every(isQtiValue);
|
|
2016
2007
|
}
|
|
2017
2008
|
|
|
2018
2009
|
function isPortableCustomState(value: unknown): value is QtiPortableCustomStateValue {
|
|
2019
|
-
|
|
2020
|
-
if (typeof value === "string" || typeof value === "boolean") return true;
|
|
2021
|
-
if (typeof value === "number") return Number.isFinite(value);
|
|
2022
|
-
if (Array.isArray(value)) return value.every(isPortableCustomState);
|
|
2023
|
-
if (isRecord(value)) return Object.values(value).every(isPortableCustomState);
|
|
2024
|
-
return false;
|
|
2010
|
+
return isQtiPortableCustomStateValue(value);
|
|
2025
2011
|
}
|
|
2026
2012
|
|
|
2027
2013
|
function isPortableCustomStateObject(
|
package/src/value-format.ts
CHANGED
|
@@ -1,4 +1,54 @@
|
|
|
1
|
-
import type { QtiScalarValue, QtiValue } from "./types.js";
|
|
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
|
+
}
|
|
2
52
|
|
|
3
53
|
export function qtiScalarToString(value: QtiScalarValue): string {
|
|
4
54
|
return String(value);
|
|
@@ -37,3 +87,17 @@ export function unknownToDisplayString(value: unknown): string {
|
|
|
37
87
|
if (typeof value === "object") return JSON.stringify(value);
|
|
38
88
|
return JSON.stringify(value);
|
|
39
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
|
@@ -10,6 +10,8 @@ export interface XmlNode {
|
|
|
10
10
|
content: Array<string | XmlNode>;
|
|
11
11
|
text: string;
|
|
12
12
|
source: XmlSourceLocation;
|
|
13
|
+
endSource?: XmlSourceLocation | undefined;
|
|
14
|
+
sourceRange: XmlSourceRange;
|
|
13
15
|
parent?: XmlNode;
|
|
14
16
|
}
|
|
15
17
|
|
|
@@ -20,60 +22,102 @@ export interface XmlSourceLocation {
|
|
|
20
22
|
path: string;
|
|
21
23
|
}
|
|
22
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
|
+
|
|
23
34
|
export function parseXmlTree(xml: string): { root: XmlNode | undefined; errors: Error[] } {
|
|
24
35
|
const parser = new StaxXmlParserSync(xml, {
|
|
25
36
|
autoDecodeEntities: true,
|
|
26
37
|
});
|
|
38
|
+
const tagTokens = scanXmlTagTokens(xml);
|
|
27
39
|
const stack: XmlNode[] = [];
|
|
28
40
|
const errors: Error[] = [];
|
|
29
41
|
let root: XmlNode | undefined;
|
|
30
|
-
let
|
|
42
|
+
let tagTokenIndex = 0;
|
|
31
43
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
+
};
|
|
37
78
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
attributes: event.attributes,
|
|
48
|
-
children: [],
|
|
49
|
-
content: [],
|
|
50
|
-
text: "",
|
|
51
|
-
source: sourceLocation(xml, offset, nodePath(parent, event.localName ?? event.name)),
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
if (parent) {
|
|
55
|
-
node.parent = parent;
|
|
56
|
-
parent.children.push(node);
|
|
57
|
-
parent.content.push(node);
|
|
58
|
-
} else {
|
|
59
|
-
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;
|
|
60
88
|
}
|
|
61
|
-
stack.push(node);
|
|
62
|
-
continue;
|
|
63
|
-
}
|
|
64
89
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
+
}
|
|
69
106
|
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
+
}
|
|
75
113
|
}
|
|
76
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}>.`));
|
|
77
121
|
}
|
|
78
122
|
|
|
79
123
|
return { root, errors };
|
|
@@ -99,28 +143,152 @@ export function textContent(node: XmlNode): string {
|
|
|
99
143
|
return parts.join(" ").replace(/\s+/g, " ").trim();
|
|
100
144
|
}
|
|
101
145
|
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
|
|
104
159
|
while (offset < xml.length) {
|
|
105
|
-
const
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
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;
|
|
110
243
|
continue;
|
|
111
244
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
(afterName >= xml.length || /[\s/>]/.test(xml.charAt(afterName)))
|
|
116
|
-
) {
|
|
117
|
-
return start;
|
|
245
|
+
if (char === "]" && internalSubsetDepth > 0) {
|
|
246
|
+
internalSubsetDepth -= 1;
|
|
247
|
+
continue;
|
|
118
248
|
}
|
|
119
|
-
|
|
249
|
+
if (char === ">" && internalSubsetDepth === 0) return index;
|
|
120
250
|
}
|
|
121
251
|
return -1;
|
|
122
252
|
}
|
|
123
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
|
+
|
|
124
292
|
function sourceLocation(xml: string, offset: number, path: string): XmlSourceLocation {
|
|
125
293
|
if (offset < 0) return { line: 1, column: 1, offset: 0, path };
|
|
126
294
|
let line = 1;
|