@longsightgroup/qti3-core 0.3.0 → 0.5.1

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,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 { qtiScalarToString, qtiValueToString, qtiValueToStringList } from "./value-format.js";
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
- if (value === null) return true;
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(
@@ -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 searchOffset = 0;
42
+ let tagTokenIndex = 0;
31
43
 
32
- for (const event of parser) {
33
- if (event.type === XmlEventType.ERROR) {
34
- errors.push(event.error);
35
- continue;
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
- if (event.type === XmlEventType.START_ELEMENT) {
39
- const parent = stack.at(-1);
40
- const offset = findStartElementOffset(xml, event.name, searchOffset);
41
- searchOffset = offset >= 0 ? offset + 1 : searchOffset;
42
- const node: XmlNode = {
43
- name: event.name,
44
- localName: event.localName ?? event.name,
45
- prefix: event.prefix,
46
- uri: event.uri,
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
- if (event.type === XmlEventType.END_ELEMENT) {
66
- stack.pop();
67
- continue;
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
- if (event.type === XmlEventType.CHARACTERS || event.type === XmlEventType.CDATA) {
71
- const node = stack.at(-1);
72
- if (node) {
73
- node.text += event.value;
74
- node.content.push(event.value);
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
- function findStartElementOffset(xml: string, name: string, from: number): number {
103
- let offset = from;
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 start = xml.indexOf("<", offset);
106
- if (start === -1) return -1;
107
- const next = xml.charAt(start + 1);
108
- if (next === "/" || next === "!" || next === "?") {
109
- offset = start + 1;
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
- const afterName = start + 1 + name.length;
113
- if (
114
- xml.slice(start + 1, afterName) === name &&
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
- offset = start + 1;
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;