@kgdiff/formats 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +68 -2
- package/dist/detect.d.ts +47 -0
- package/dist/detect.d.ts.map +1 -0
- package/dist/detect.js +425 -0
- package/dist/detect.js.map +1 -0
- package/dist/index.d.ts +203 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +114 -0
- package/dist/index.js.map +1 -0
- package/dist/iri.d.ts +13 -0
- package/dist/iri.d.ts.map +1 -0
- package/dist/iri.js +91 -0
- package/dist/iri.js.map +1 -0
- package/dist/json.d.ts +30 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +749 -0
- package/dist/json.js.map +1 -0
- package/dist/jsonld.d.ts +16 -0
- package/dist/jsonld.d.ts.map +1 -0
- package/dist/jsonld.js +1249 -0
- package/dist/jsonld.js.map +1 -0
- package/dist/obo.d.ts +25 -0
- package/dist/obo.d.ts.map +1 -0
- package/dist/obo.js +478 -0
- package/dist/obo.js.map +1 -0
- package/dist/prefixes.d.ts +49 -0
- package/dist/prefixes.d.ts.map +1 -0
- package/dist/prefixes.js +177 -0
- package/dist/prefixes.js.map +1 -0
- package/dist/rdf.d.ts +46 -0
- package/dist/rdf.d.ts.map +1 -0
- package/dist/rdf.js +1047 -0
- package/dist/rdf.js.map +1 -0
- package/dist/rdfxml.d.ts +8 -0
- package/dist/rdfxml.d.ts.map +1 -0
- package/dist/rdfxml.js +529 -0
- package/dist/rdfxml.js.map +1 -0
- package/dist/shared.d.ts +117 -0
- package/dist/shared.d.ts.map +1 -0
- package/dist/shared.js +355 -0
- package/dist/shared.js.map +1 -0
- package/dist/xml.d.ts +50 -0
- package/dist/xml.d.ts.map +1 -0
- package/dist/xml.js +508 -0
- package/dist/xml.js.map +1 -0
- package/package.json +55 -3
- package/src/detect.ts +435 -0
- package/src/index.ts +296 -0
- package/src/iri.ts +86 -0
- package/src/json.ts +835 -0
- package/src/jsonld.ts +1376 -0
- package/src/obo.ts +557 -0
- package/src/prefixes.ts +195 -0
- package/src/rdf.ts +1181 -0
- package/src/rdfxml.ts +633 -0
- package/src/shared.ts +408 -0
- package/src/xml.ts +550 -0
package/src/xml.ts
ADDED
|
@@ -0,0 +1,550 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, non-validating XML 1.0 parser with namespaces and SAX-style callbacks, for RDF/XML.
|
|
3
|
+
*
|
|
4
|
+
* Supports what real RDF/XML files contain: the XML declaration, comments, processing
|
|
5
|
+
* instructions, CDATA sections, character references, the predefined entities and general
|
|
6
|
+
* entities declared in the internal DTD subset (`<!ENTITY owl "…">`, as older Protégé / OWL API
|
|
7
|
+
* versions write). External DTDs and entities are never loaded (referencing an external
|
|
8
|
+
* entity is an error), and entity expansion is capped, so a "billion laughs" document fails
|
|
9
|
+
* fast instead of exhausting memory. Linear in the input size; no recursion, so nesting depth
|
|
10
|
+
* is unlimited.
|
|
11
|
+
*/
|
|
12
|
+
import { lineAndColumn } from './shared.js';
|
|
13
|
+
|
|
14
|
+
export const XML_NS = 'http://www.w3.org/XML/1998/namespace';
|
|
15
|
+
const XMLNS_NS = 'http://www.w3.org/2000/xmlns/';
|
|
16
|
+
|
|
17
|
+
/** A namespace-qualified name: `prefix` is `''` when unprefixed, `uri` `''` when in no namespace. */
|
|
18
|
+
export interface XmlName {
|
|
19
|
+
readonly prefix: string;
|
|
20
|
+
readonly local: string;
|
|
21
|
+
readonly uri: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface XmlAttribute extends XmlName {
|
|
25
|
+
/** Normalized value: references resolved, literal tabs and line breaks turned into spaces. */
|
|
26
|
+
readonly value: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface XmlElement extends XmlName {
|
|
30
|
+
/** Attributes other than namespace declarations, in document order. */
|
|
31
|
+
readonly attributes: readonly XmlAttribute[];
|
|
32
|
+
/** Namespace declarations made on this element (`''` is the default namespace). */
|
|
33
|
+
readonly namespaces: readonly (readonly [prefix: string, uri: string])[];
|
|
34
|
+
/** Offset of the element's `<`, for error positions. */
|
|
35
|
+
readonly offset: number;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface XmlHandler {
|
|
39
|
+
startElement(element: XmlElement): void;
|
|
40
|
+
endElement(offset: number): void;
|
|
41
|
+
/** Character data inside the root element; consecutive chunks may arrive separately. */
|
|
42
|
+
text(value: string, offset: number): void;
|
|
43
|
+
/** A processing instruction inside the root element. */
|
|
44
|
+
processingInstruction(target: string, data: string, offset: number): void;
|
|
45
|
+
/** A comment inside the root element. */
|
|
46
|
+
comment(value: string, offset: number): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A well-formedness (or, thrown by a handler, grammar) error at an offset of the parsed text.
|
|
51
|
+
* `parseXml` fills in the 1-based `line` before passing it on.
|
|
52
|
+
*/
|
|
53
|
+
export class XmlSyntaxError {
|
|
54
|
+
line = 0;
|
|
55
|
+
|
|
56
|
+
constructor(
|
|
57
|
+
readonly message: string,
|
|
58
|
+
readonly offset: number,
|
|
59
|
+
) {}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const NAME_START =
|
|
63
|
+
'A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C\\u200D' +
|
|
64
|
+
'\\u2070-\\u218F\\u2C00-\\u2FEF\\u3001-\\uD7FF\\uF900-\\uFDCF\\uFDF0-\\uFFFD\\u{10000}-\\u{EFFFF}';
|
|
65
|
+
const NAME_CHAR = `${NAME_START}\\-.0-9\\u00B7\\u0300-\\u036F\\u203F\\u2040`;
|
|
66
|
+
const NC_NAME_SOURCE = `[${NAME_START}][${NAME_CHAR}]*`;
|
|
67
|
+
|
|
68
|
+
/** XML namespaces `NCName` (a name without colons): rdf:ID and rdf:nodeID values. */
|
|
69
|
+
export const NC_NAME = new RegExp(`^${NC_NAME_SOURCE}$`, 'u');
|
|
70
|
+
/** A qualified name at a position (sticky): `local` or `prefix:local`. */
|
|
71
|
+
const QNAME_AT = new RegExp(`(?:${NC_NAME_SOURCE}:)?${NC_NAME_SOURCE}`, 'uy');
|
|
72
|
+
/** An XML `Name` at a position (DTD declarations may use colons freely). */
|
|
73
|
+
const NAME_AT = new RegExp(`[:${NAME_START}][:${NAME_CHAR}]*`, 'uy');
|
|
74
|
+
const ENTITY_NAME = new RegExp(`^[:${NAME_START}][:${NAME_CHAR}]*$`, 'u');
|
|
75
|
+
const NOT_SPACE = /[^ \t\n\r]/;
|
|
76
|
+
|
|
77
|
+
const GT = 0x3e;
|
|
78
|
+
const SLASH = 0x2f;
|
|
79
|
+
const BANG = 0x21;
|
|
80
|
+
const QUESTION = 0x3f;
|
|
81
|
+
|
|
82
|
+
/** Entity expansion budget: generous for real files, fatal for exponential blow-ups. */
|
|
83
|
+
const MIN_EXPANSION_BUDGET = 4 * 1024 * 1024;
|
|
84
|
+
const EXPANSION_FACTOR = 16;
|
|
85
|
+
|
|
86
|
+
interface Entity {
|
|
87
|
+
/** Replacement text with character references already resolved (XML 1.0 §4.5). */
|
|
88
|
+
readonly value: string;
|
|
89
|
+
readonly external: boolean;
|
|
90
|
+
expanded?: string;
|
|
91
|
+
expanding?: boolean;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
interface OpenElement {
|
|
95
|
+
readonly qname: string;
|
|
96
|
+
readonly offset: number;
|
|
97
|
+
/** Namespace bindings to restore when the element closes. */
|
|
98
|
+
readonly outer: ReadonlyMap<string, string>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const PREDEFINED: Readonly<Record<string, string>> = {
|
|
102
|
+
lt: '<',
|
|
103
|
+
gt: '>',
|
|
104
|
+
amp: '&',
|
|
105
|
+
apos: "'",
|
|
106
|
+
quot: '"',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parses an XML document, reporting elements, text, comments and processing instructions to
|
|
111
|
+
* `handler`. Line breaks are normalized to `\n` first (XML 1.0 §2.11). Throws `XmlSyntaxError`
|
|
112
|
+
* (with its line) on documents that are not namespace-well-formed, and passes on whatever
|
|
113
|
+
* the handler throws, filling in the line of handler-thrown `XmlSyntaxError`s too.
|
|
114
|
+
*/
|
|
115
|
+
export function parseXml(text: string, handler: XmlHandler): void {
|
|
116
|
+
const source = text.includes('\r') ? text.replace(/\r\n?/g, '\n') : text;
|
|
117
|
+
try {
|
|
118
|
+
new XmlParser(source, handler).parse();
|
|
119
|
+
} catch (error) {
|
|
120
|
+
if (error instanceof XmlSyntaxError) error.line = lineAndColumn(source, error.offset).line;
|
|
121
|
+
throw error;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const isXmlSpace = (value: string): boolean => !NOT_SPACE.test(value);
|
|
126
|
+
|
|
127
|
+
class XmlParser {
|
|
128
|
+
private i = 0;
|
|
129
|
+
private readonly stack: OpenElement[] = [];
|
|
130
|
+
private namespaces: ReadonlyMap<string, string> = new Map([['xml', XML_NS]]);
|
|
131
|
+
private readonly entities = new Map<string, Entity>();
|
|
132
|
+
private expansion = 0;
|
|
133
|
+
private readonly expansionBudget: number;
|
|
134
|
+
private sawRoot = false;
|
|
135
|
+
private sawDoctype = false;
|
|
136
|
+
|
|
137
|
+
constructor(
|
|
138
|
+
private readonly text: string,
|
|
139
|
+
private readonly handler: XmlHandler,
|
|
140
|
+
) {
|
|
141
|
+
this.expansionBudget = Math.max(MIN_EXPANSION_BUDGET, EXPANSION_FACTOR * text.length);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
parse(): void {
|
|
145
|
+
const { text } = this;
|
|
146
|
+
// Leading whitespace before the declaration is tolerated (strictly, it is an error).
|
|
147
|
+
const start = skipSpace(text, 0);
|
|
148
|
+
if (text.startsWith('<?xml', start) && /[ \t\n]/.test(text[start + 5] ?? '')) {
|
|
149
|
+
this.i = this.skipPast('?>', start, 'XML declaration');
|
|
150
|
+
}
|
|
151
|
+
while (this.i < text.length) {
|
|
152
|
+
const lt = text.indexOf('<', this.i);
|
|
153
|
+
const end = lt < 0 ? text.length : lt;
|
|
154
|
+
if (end > this.i) this.characters(this.i, end);
|
|
155
|
+
if (lt < 0) break;
|
|
156
|
+
this.i = lt;
|
|
157
|
+
const next = text.charCodeAt(lt + 1);
|
|
158
|
+
if (next === SLASH) this.endTag();
|
|
159
|
+
else if (next === BANG) this.markupDeclaration();
|
|
160
|
+
else if (next === QUESTION) this.processingInstruction();
|
|
161
|
+
else this.startTag();
|
|
162
|
+
}
|
|
163
|
+
const open = this.stack[this.stack.length - 1];
|
|
164
|
+
if (open) throw new XmlSyntaxError(`Element <${open.qname}> is never closed`, open.offset);
|
|
165
|
+
if (!this.sawRoot) throw new XmlSyntaxError('The document has no root element', text.length);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// ─── Markup ─────────────────────────────────────────────────────────────
|
|
169
|
+
|
|
170
|
+
private startTag(): void {
|
|
171
|
+
const { text } = this;
|
|
172
|
+
const offset = this.i;
|
|
173
|
+
if (this.sawRoot && this.stack.length === 0) {
|
|
174
|
+
throw new XmlSyntaxError('Only one root element is allowed', offset);
|
|
175
|
+
}
|
|
176
|
+
const qname = this.qnameAt(offset + 1, 'an element name');
|
|
177
|
+
let i = offset + 1 + qname.length;
|
|
178
|
+
const raw: [name: string, value: string, offset: number][] = [];
|
|
179
|
+
let selfClosing = false;
|
|
180
|
+
for (;;) {
|
|
181
|
+
const at = skipSpace(text, i);
|
|
182
|
+
const c = text.charCodeAt(at);
|
|
183
|
+
if (c === GT) {
|
|
184
|
+
i = at + 1;
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
if (c === SLASH && text.charCodeAt(at + 1) === GT) {
|
|
188
|
+
i = at + 2;
|
|
189
|
+
selfClosing = true;
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
if (at >= text.length) throw new XmlSyntaxError(`Unclosed tag <${qname}>`, offset);
|
|
193
|
+
if (at === i) throw new XmlSyntaxError(`Expected whitespace, ">" or "/>" in <${qname}>`, at);
|
|
194
|
+
const name = this.qnameAt(at, 'an attribute name');
|
|
195
|
+
let j = skipSpace(text, at + name.length);
|
|
196
|
+
if (text[j] !== '=') throw new XmlSyntaxError(`Expected "=" after attribute ${name}`, j);
|
|
197
|
+
j = skipSpace(text, j + 1);
|
|
198
|
+
const quote = text[j];
|
|
199
|
+
if (quote !== '"' && quote !== "'") {
|
|
200
|
+
throw new XmlSyntaxError(`Expected a quoted value for attribute ${name}`, j);
|
|
201
|
+
}
|
|
202
|
+
const close = text.indexOf(quote, j + 1);
|
|
203
|
+
if (close < 0) throw new XmlSyntaxError(`Unterminated value of attribute ${name}`, j);
|
|
204
|
+
const value = text.slice(j + 1, close);
|
|
205
|
+
if (value.includes('<')) {
|
|
206
|
+
throw new XmlSyntaxError(`"<" is not allowed in the value of attribute ${name}`, j);
|
|
207
|
+
}
|
|
208
|
+
raw.push([name, value, at]);
|
|
209
|
+
i = close + 1;
|
|
210
|
+
}
|
|
211
|
+
this.i = i;
|
|
212
|
+
this.openElement(qname, raw, offset, selfClosing);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
private openElement(
|
|
216
|
+
qname: string,
|
|
217
|
+
raw: readonly (readonly [string, string, number])[],
|
|
218
|
+
offset: number,
|
|
219
|
+
selfClosing: boolean,
|
|
220
|
+
): void {
|
|
221
|
+
const outer = this.namespaces;
|
|
222
|
+
let declarations: Map<string, string> | undefined;
|
|
223
|
+
const declared: [string, string][] = [];
|
|
224
|
+
for (const [name, value, at] of raw) {
|
|
225
|
+
if (name !== 'xmlns' && !name.startsWith('xmlns:')) continue;
|
|
226
|
+
const prefix = name === 'xmlns' ? '' : name.slice(6);
|
|
227
|
+
const uri = this.attributeValue(value, at);
|
|
228
|
+
checkNamespaceDeclaration(prefix, uri, at);
|
|
229
|
+
declarations ??= new Map(outer);
|
|
230
|
+
declarations.set(prefix, uri);
|
|
231
|
+
declared.push([prefix, uri]);
|
|
232
|
+
}
|
|
233
|
+
const scope = declarations ?? outer;
|
|
234
|
+
const element = splitName(qname, scope, offset, true);
|
|
235
|
+
const attributes: XmlAttribute[] = [];
|
|
236
|
+
const seen = raw.length > 1 ? new Set<string>() : undefined;
|
|
237
|
+
for (const [name, value, at] of raw) {
|
|
238
|
+
if (seen) {
|
|
239
|
+
if (seen.has(name)) throw new XmlSyntaxError(`Duplicate attribute ${name}`, at);
|
|
240
|
+
seen.add(name);
|
|
241
|
+
}
|
|
242
|
+
if (name === 'xmlns' || name.startsWith('xmlns:')) continue;
|
|
243
|
+
const attribute = {
|
|
244
|
+
...splitName(name, scope, at, false),
|
|
245
|
+
value: this.attributeValue(value, at),
|
|
246
|
+
};
|
|
247
|
+
// Two prefixes for one namespace still name the same attribute.
|
|
248
|
+
const key = `{${attribute.uri}}${attribute.local}`;
|
|
249
|
+
if (seen && attribute.prefix !== '') {
|
|
250
|
+
if (seen.has(key)) throw new XmlSyntaxError(`Duplicate attribute ${key}`, at);
|
|
251
|
+
seen.add(key);
|
|
252
|
+
}
|
|
253
|
+
attributes.push(attribute);
|
|
254
|
+
}
|
|
255
|
+
this.sawRoot = true;
|
|
256
|
+
this.handler.startElement({ ...element, attributes, namespaces: declared, offset });
|
|
257
|
+
if (selfClosing) this.handler.endElement(offset);
|
|
258
|
+
else {
|
|
259
|
+
this.stack.push({ qname, offset, outer });
|
|
260
|
+
this.namespaces = scope;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private endTag(): void {
|
|
265
|
+
const { text } = this;
|
|
266
|
+
const offset = this.i;
|
|
267
|
+
const qname = this.qnameAt(offset + 2, 'an element name');
|
|
268
|
+
const close = skipSpace(text, offset + 2 + qname.length);
|
|
269
|
+
if (text.charCodeAt(close) !== GT)
|
|
270
|
+
throw new XmlSyntaxError(`Expected ">" after </${qname}`, close);
|
|
271
|
+
const open = this.stack.pop();
|
|
272
|
+
if (!open) throw new XmlSyntaxError(`Unexpected end tag </${qname}>`, offset);
|
|
273
|
+
if (open.qname !== qname) {
|
|
274
|
+
throw new XmlSyntaxError(`Expected </${open.qname}>, found </${qname}>`, offset);
|
|
275
|
+
}
|
|
276
|
+
this.namespaces = open.outer;
|
|
277
|
+
this.i = close + 1;
|
|
278
|
+
this.handler.endElement(offset);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private markupDeclaration(): void {
|
|
282
|
+
const { text } = this;
|
|
283
|
+
const offset = this.i;
|
|
284
|
+
if (text.startsWith('<!--', offset)) {
|
|
285
|
+
this.i = this.skipPast('-->', offset + 4, 'comment');
|
|
286
|
+
if (this.stack.length > 0) this.handler.comment(text.slice(offset + 4, this.i - 3), offset);
|
|
287
|
+
} else if (text.startsWith('<![CDATA[', offset)) {
|
|
288
|
+
if (this.stack.length === 0) {
|
|
289
|
+
throw new XmlSyntaxError('CDATA sections are only allowed inside the root element', offset);
|
|
290
|
+
}
|
|
291
|
+
const end = text.indexOf(']]>', offset + 9);
|
|
292
|
+
if (end < 0) throw new XmlSyntaxError('Unterminated CDATA section', offset);
|
|
293
|
+
if (end > offset + 9) this.handler.text(text.slice(offset + 9, end), offset);
|
|
294
|
+
this.i = end + 3;
|
|
295
|
+
} else if (text.startsWith('<!DOCTYPE', offset)) {
|
|
296
|
+
if (this.sawRoot || this.sawDoctype) {
|
|
297
|
+
throw new XmlSyntaxError('A DOCTYPE is only allowed once, before the root element', offset);
|
|
298
|
+
}
|
|
299
|
+
this.sawDoctype = true;
|
|
300
|
+
this.i = this.doctype(offset + 9);
|
|
301
|
+
} else {
|
|
302
|
+
throw new XmlSyntaxError('Unexpected "<!"', offset);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private processingInstruction(): void {
|
|
307
|
+
const offset = this.i;
|
|
308
|
+
const target = this.nameAt(offset + 2, 'a processing instruction target');
|
|
309
|
+
if (target.toLowerCase() === 'xml') {
|
|
310
|
+
throw new XmlSyntaxError('The XML declaration is only allowed at the very start', offset);
|
|
311
|
+
}
|
|
312
|
+
const end = this.text.indexOf('?>', offset + 2 + target.length);
|
|
313
|
+
if (end < 0) throw new XmlSyntaxError('Unterminated processing instruction', offset);
|
|
314
|
+
if (this.stack.length > 0) {
|
|
315
|
+
const data = this.text.slice(offset + 2 + target.length, end).replace(/^[ \t\n]+/, '');
|
|
316
|
+
this.handler.processingInstruction(target, data, offset);
|
|
317
|
+
}
|
|
318
|
+
this.i = end + 2;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private characters(start: number, end: number): void {
|
|
322
|
+
const raw = this.text.slice(start, end);
|
|
323
|
+
if (this.stack.length === 0) {
|
|
324
|
+
if (!isXmlSpace(raw)) {
|
|
325
|
+
throw new XmlSyntaxError(
|
|
326
|
+
this.sawRoot ? 'Text after the root element' : 'Text before the root element',
|
|
327
|
+
start + raw.search(NOT_SPACE),
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
this.handler.text(raw.includes('&') ? this.resolve(raw, start, false) : raw, start);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ─── DTD ────────────────────────────────────────────────────────────────
|
|
336
|
+
|
|
337
|
+
/** Skips `<!DOCTYPE …>`, reading entity declarations from the internal subset. */
|
|
338
|
+
private doctype(start: number): number {
|
|
339
|
+
const { text } = this;
|
|
340
|
+
let i = start;
|
|
341
|
+
for (;;) {
|
|
342
|
+
const c = text[i];
|
|
343
|
+
if (c === undefined) throw new XmlSyntaxError('Unterminated DOCTYPE', start);
|
|
344
|
+
if (c === '"' || c === "'") i = this.skipPast(c, i + 1, 'DOCTYPE literal');
|
|
345
|
+
else if (c === '[') i = this.internalSubset(i + 1);
|
|
346
|
+
else if (c === '>') return i + 1;
|
|
347
|
+
else i++;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
private internalSubset(start: number): number {
|
|
352
|
+
const { text } = this;
|
|
353
|
+
let i = start;
|
|
354
|
+
for (;;) {
|
|
355
|
+
i = skipSpace(text, i);
|
|
356
|
+
if (i >= text.length) throw new XmlSyntaxError('Unterminated DTD internal subset', start);
|
|
357
|
+
if (text[i] === ']') return i + 1;
|
|
358
|
+
if (text.startsWith('<!--', i)) i = this.skipPast('-->', i + 4, 'comment');
|
|
359
|
+
else if (text.startsWith('<?', i)) i = this.skipPast('?>', i + 2, 'processing instruction');
|
|
360
|
+
else if (text.startsWith('<!ENTITY', i)) i = this.entityDeclaration(i);
|
|
361
|
+
else if (text.startsWith('<!', i)) i = this.skipDeclaration(i);
|
|
362
|
+
else if (text[i] === '%') i = this.skipPast(';', i + 1, 'parameter entity reference');
|
|
363
|
+
else throw new XmlSyntaxError('Unexpected content in the DTD internal subset', i);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** `<!ENTITY name "value">`, `<!ENTITY name SYSTEM "uri">`, `<!ENTITY % name …>`. */
|
|
368
|
+
private entityDeclaration(start: number): number {
|
|
369
|
+
const { text } = this;
|
|
370
|
+
let i = skipSpace(text, start + 8);
|
|
371
|
+
const parameter = text[i] === '%';
|
|
372
|
+
if (parameter) i = skipSpace(text, i + 1);
|
|
373
|
+
const name = this.nameAt(i, 'an entity name');
|
|
374
|
+
i = skipSpace(text, i + name.length);
|
|
375
|
+
const quote = text[i];
|
|
376
|
+
// Without a quoted value it is SYSTEM / PUBLIC: an external entity, never fetched.
|
|
377
|
+
let entity: Entity = { value: '', external: true };
|
|
378
|
+
if (quote === '"' || quote === "'") {
|
|
379
|
+
const close = text.indexOf(quote, i + 1);
|
|
380
|
+
if (close < 0) throw new XmlSyntaxError(`Unterminated value of entity ${name}`, i);
|
|
381
|
+
entity = { value: this.characterReferences(text.slice(i + 1, close), i), external: false };
|
|
382
|
+
}
|
|
383
|
+
// The first declaration of an entity is binding (XML 1.0 §4.2).
|
|
384
|
+
if (!parameter && !this.entities.has(name)) this.entities.set(name, entity);
|
|
385
|
+
return this.skipDeclaration(start);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/** Skips a markup declaration up to its closing `>`, honouring quoted literals. */
|
|
389
|
+
private skipDeclaration(start: number): number {
|
|
390
|
+
const { text } = this;
|
|
391
|
+
let i = start + 2;
|
|
392
|
+
for (;;) {
|
|
393
|
+
const c = text[i];
|
|
394
|
+
if (c === undefined) throw new XmlSyntaxError('Unterminated DTD declaration', start);
|
|
395
|
+
if (c === '"' || c === "'") i = this.skipPast(c, i + 1, 'DTD literal');
|
|
396
|
+
else if (c === '>') return i + 1;
|
|
397
|
+
else i++;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ─── References ─────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
/** Attribute value normalization (XML 1.0 §3.3.3, CDATA attributes). */
|
|
404
|
+
private attributeValue(raw: string, offset: number): string {
|
|
405
|
+
const spaced = /[\t\n]/.test(raw) ? raw.replace(/[\t\n]/g, ' ') : raw;
|
|
406
|
+
return spaced.includes('&') ? this.resolve(spaced, offset, true) : spaced;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** Resolves character and entity references in content or an attribute value. */
|
|
410
|
+
private resolve(raw: string, offset: number, inAttribute: boolean): string {
|
|
411
|
+
let out = '';
|
|
412
|
+
let last = 0;
|
|
413
|
+
for (let amp = raw.indexOf('&'); amp >= 0; amp = raw.indexOf('&', last)) {
|
|
414
|
+
const semicolon = raw.indexOf(';', amp + 1);
|
|
415
|
+
if (semicolon < 0) throw new XmlSyntaxError('Unterminated "&" reference', offset + amp);
|
|
416
|
+
const name = raw.slice(amp + 1, semicolon);
|
|
417
|
+
out += raw.slice(last, amp);
|
|
418
|
+
if (name.startsWith('#')) out += characterReference(name, offset + amp);
|
|
419
|
+
else {
|
|
420
|
+
const replacement = getPredefined(name) ?? this.entity(name, offset + amp, inAttribute);
|
|
421
|
+
out += replacement;
|
|
422
|
+
}
|
|
423
|
+
last = semicolon + 1;
|
|
424
|
+
}
|
|
425
|
+
return out + raw.slice(last);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
private entity(name: string, offset: number, inAttribute: boolean): string {
|
|
429
|
+
if (!ENTITY_NAME.test(name)) throw new XmlSyntaxError(`Invalid reference "&${name};"`, offset);
|
|
430
|
+
const entity = this.entities.get(name);
|
|
431
|
+
if (!entity) throw new XmlSyntaxError(`Undeclared entity "&${name};"`, offset);
|
|
432
|
+
if (entity.external) {
|
|
433
|
+
throw new XmlSyntaxError(
|
|
434
|
+
`External entity "&${name};" is not loaded (external resources are never fetched)`,
|
|
435
|
+
offset,
|
|
436
|
+
);
|
|
437
|
+
}
|
|
438
|
+
if (entity.expanded === undefined) {
|
|
439
|
+
if (entity.expanding) throw new XmlSyntaxError(`Entity "&${name};" refers to itself`, offset);
|
|
440
|
+
entity.expanding = true;
|
|
441
|
+
// Memoized without attribute normalization, which applies per use.
|
|
442
|
+
entity.expanded = entity.value.includes('&')
|
|
443
|
+
? this.resolve(entity.value, offset, false)
|
|
444
|
+
: entity.value;
|
|
445
|
+
entity.expanding = false;
|
|
446
|
+
}
|
|
447
|
+
const value = entity.expanded;
|
|
448
|
+
if (value.includes('<')) {
|
|
449
|
+
throw new XmlSyntaxError(
|
|
450
|
+
`Entity "&${name};" contains markup, which is not supported`,
|
|
451
|
+
offset,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
this.expansion += value.length;
|
|
455
|
+
if (this.expansion > this.expansionBudget) {
|
|
456
|
+
throw new XmlSyntaxError('Entity expansion exceeds the size limit', offset);
|
|
457
|
+
}
|
|
458
|
+
return inAttribute && /[\t\n]/.test(value) ? value.replace(/[\t\n]/g, ' ') : value;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Character references in an entity value are resolved when it is declared (§4.5). */
|
|
462
|
+
private characterReferences(value: string, offset: number): string {
|
|
463
|
+
return value.includes('&#')
|
|
464
|
+
? value.replace(/&(#[^;]*);/g, (_match, name: string) => characterReference(name, offset))
|
|
465
|
+
: value;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ─── Scanning helpers ───────────────────────────────────────────────────
|
|
469
|
+
|
|
470
|
+
private qnameAt(at: number, what: string): string {
|
|
471
|
+
QNAME_AT.lastIndex = at;
|
|
472
|
+
const match = QNAME_AT.exec(this.text);
|
|
473
|
+
if (!match) throw new XmlSyntaxError(`Expected ${what}`, at);
|
|
474
|
+
return match[0];
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
private nameAt(at: number, what: string): string {
|
|
478
|
+
NAME_AT.lastIndex = at;
|
|
479
|
+
const match = NAME_AT.exec(this.text);
|
|
480
|
+
if (!match) throw new XmlSyntaxError(`Expected ${what}`, at);
|
|
481
|
+
return match[0];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/** Index just past the next `terminator` at or after `from`. */
|
|
485
|
+
private skipPast(terminator: string, from: number, what: string): number {
|
|
486
|
+
const end = this.text.indexOf(terminator, from);
|
|
487
|
+
if (end < 0) throw new XmlSyntaxError(`Unterminated ${what}`, from);
|
|
488
|
+
return end + terminator.length;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function skipSpace(text: string, from: number): number {
|
|
493
|
+
let i = from;
|
|
494
|
+
for (;;) {
|
|
495
|
+
const c = text.charCodeAt(i);
|
|
496
|
+
if (c === 0x20 || c === 0x0a || c === 0x09 || c === 0x0d) i++;
|
|
497
|
+
else return i;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function getPredefined(name: string): string | undefined {
|
|
502
|
+
return Object.hasOwn(PREDEFINED, name) ? PREDEFINED[name] : undefined;
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
const isXmlChar = (c: number): boolean =>
|
|
506
|
+
c === 0x9 ||
|
|
507
|
+
c === 0xa ||
|
|
508
|
+
c === 0xd ||
|
|
509
|
+
(c >= 0x20 && c <= 0xd7ff) ||
|
|
510
|
+
(c >= 0xe000 && c <= 0xfffd) ||
|
|
511
|
+
(c >= 0x10000 && c <= 0x10ffff);
|
|
512
|
+
|
|
513
|
+
/** `#65` / `#x41` → `A`. */
|
|
514
|
+
function characterReference(name: string, offset: number): string {
|
|
515
|
+
const hex = name[1] === 'x';
|
|
516
|
+
const digits = name.slice(hex ? 2 : 1);
|
|
517
|
+
const code = (hex ? /^[0-9a-fA-F]+$/ : /^[0-9]+$/).test(digits)
|
|
518
|
+
? Number.parseInt(digits, hex ? 16 : 10)
|
|
519
|
+
: Number.NaN;
|
|
520
|
+
if (!isXmlChar(code)) {
|
|
521
|
+
throw new XmlSyntaxError(`Invalid character reference "&${name};"`, offset);
|
|
522
|
+
}
|
|
523
|
+
return String.fromCodePoint(code);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function checkNamespaceDeclaration(prefix: string, uri: string, offset: number): void {
|
|
527
|
+
const fail = (message: string) => new XmlSyntaxError(message, offset);
|
|
528
|
+
if (prefix === 'xmlns') throw fail('The xmlns prefix cannot be declared');
|
|
529
|
+
if (prefix === 'xml' && uri !== XML_NS) throw fail('The xml prefix cannot be rebound');
|
|
530
|
+
if (prefix !== 'xml' && uri === XML_NS)
|
|
531
|
+
throw fail(`Prefix "${prefix}" cannot be bound to the XML namespace`);
|
|
532
|
+
if (uri === XMLNS_NS) throw fail('The xmlns namespace cannot be bound');
|
|
533
|
+
if (prefix !== '' && uri === '') throw fail(`Prefix "${prefix}" cannot be undeclared`);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** Resolves a qualified name; unprefixed attributes are in no namespace. */
|
|
537
|
+
function splitName(
|
|
538
|
+
qname: string,
|
|
539
|
+
scope: ReadonlyMap<string, string>,
|
|
540
|
+
offset: number,
|
|
541
|
+
isElement: boolean,
|
|
542
|
+
): XmlName {
|
|
543
|
+
const colon = qname.indexOf(':');
|
|
544
|
+
if (colon < 0) return { prefix: '', local: qname, uri: isElement ? (scope.get('') ?? '') : '' };
|
|
545
|
+
const prefix = qname.slice(0, colon);
|
|
546
|
+
const uri = scope.get(prefix);
|
|
547
|
+
if (uri === undefined)
|
|
548
|
+
throw new XmlSyntaxError(`Undeclared namespace prefix "${prefix}"`, offset);
|
|
549
|
+
return { prefix, local: qname.slice(colon + 1), uri };
|
|
550
|
+
}
|