@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/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
- if (Array.isArray(value)) return value.map(String);
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 searchOffset = 0;
42
+ let tagTokenIndex = 0;
29
43
 
30
- for (const event of parser) {
31
- if (event.type === XmlEventType.ERROR) {
32
- errors.push(event.error);
33
- continue;
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
- if (event.type === XmlEventType.START_ELEMENT) {
37
- const parent = stack.at(-1);
38
- const offset = findStartElementOffset(xml, event.name, searchOffset);
39
- searchOffset = offset >= 0 ? offset + 1 : searchOffset;
40
- const node: XmlNode = {
41
- name: event.name,
42
- localName: event.localName ?? event.name,
43
- attributes: event.attributes,
44
- children: [],
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
- if (event.type === XmlEventType.END_ELEMENT) {
62
- stack.pop();
63
- continue;
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
- if (event.type === XmlEventType.CHARACTERS || event.type === XmlEventType.CDATA) {
67
- const node = stack.at(-1);
68
- if (node) {
69
- node.text += event.value;
70
- 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
+ }
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
- function findStartElementOffset(xml: string, name: string, from: number): number {
99
- 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
+
100
159
  while (offset < xml.length) {
101
- const start = xml.indexOf("<", offset);
102
- if (start === -1) return -1;
103
- const next = xml.charAt(start + 1);
104
- if (next === "/" || next === "!" || next === "?") {
105
- 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;
106
243
  continue;
107
244
  }
108
- const afterName = start + 1 + name.length;
109
- if (
110
- xml.slice(start + 1, afterName) === name &&
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
- offset = start + 1;
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;