@kgdiff/formats 0.0.0-stage → 0.2.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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +68 -2
  3. package/dist/detect.d.ts +47 -0
  4. package/dist/detect.d.ts.map +1 -0
  5. package/dist/detect.js +425 -0
  6. package/dist/detect.js.map +1 -0
  7. package/dist/index.d.ts +203 -0
  8. package/dist/index.d.ts.map +1 -0
  9. package/dist/index.js +114 -0
  10. package/dist/index.js.map +1 -0
  11. package/dist/iri.d.ts +13 -0
  12. package/dist/iri.d.ts.map +1 -0
  13. package/dist/iri.js +91 -0
  14. package/dist/iri.js.map +1 -0
  15. package/dist/json.d.ts +31 -0
  16. package/dist/json.d.ts.map +1 -0
  17. package/dist/json.js +787 -0
  18. package/dist/json.js.map +1 -0
  19. package/dist/jsonld.d.ts +16 -0
  20. package/dist/jsonld.d.ts.map +1 -0
  21. package/dist/jsonld.js +1249 -0
  22. package/dist/jsonld.js.map +1 -0
  23. package/dist/obo.d.ts +25 -0
  24. package/dist/obo.d.ts.map +1 -0
  25. package/dist/obo.js +478 -0
  26. package/dist/obo.js.map +1 -0
  27. package/dist/prefixes.d.ts +49 -0
  28. package/dist/prefixes.d.ts.map +1 -0
  29. package/dist/prefixes.js +177 -0
  30. package/dist/prefixes.js.map +1 -0
  31. package/dist/rdf.d.ts +46 -0
  32. package/dist/rdf.d.ts.map +1 -0
  33. package/dist/rdf.js +1047 -0
  34. package/dist/rdf.js.map +1 -0
  35. package/dist/rdfxml.d.ts +8 -0
  36. package/dist/rdfxml.d.ts.map +1 -0
  37. package/dist/rdfxml.js +529 -0
  38. package/dist/rdfxml.js.map +1 -0
  39. package/dist/shared.d.ts +117 -0
  40. package/dist/shared.d.ts.map +1 -0
  41. package/dist/shared.js +355 -0
  42. package/dist/shared.js.map +1 -0
  43. package/dist/xml.d.ts +50 -0
  44. package/dist/xml.d.ts.map +1 -0
  45. package/dist/xml.js +508 -0
  46. package/dist/xml.js.map +1 -0
  47. package/package.json +55 -3
  48. package/src/detect.ts +435 -0
  49. package/src/index.ts +296 -0
  50. package/src/iri.ts +86 -0
  51. package/src/json.ts +889 -0
  52. package/src/jsonld.ts +1376 -0
  53. package/src/obo.ts +557 -0
  54. package/src/prefixes.ts +195 -0
  55. package/src/rdf.ts +1181 -0
  56. package/src/rdfxml.ts +633 -0
  57. package/src/shared.ts +408 -0
  58. package/src/xml.ts +550 -0
package/src/detect.ts ADDED
@@ -0,0 +1,435 @@
1
+ /**
2
+ * Format detection: file extension first, then content sniffing.
3
+ */
4
+ import type { KgdiffError } from '@kgdiff/core';
5
+ import type { FormatId } from './index.js';
6
+ import { RDF_NS } from './prefixes.js';
7
+ import { invalidInput, stripBom } from './shared.js';
8
+
9
+ /** File extensions understood by `detectFormat` (matched case-insensitively, longest first). */
10
+ export const FORMAT_EXTENSIONS: Readonly<Record<FormatId, readonly string[]>> = {
11
+ json: ['.json', '.kgdiff.json'],
12
+ jsonld: ['.jsonld'],
13
+ turtle: ['.ttl', '.turtle'],
14
+ ntriples: ['.nt'],
15
+ nquads: ['.nq'],
16
+ trig: ['.trig'],
17
+ rdfxml: ['.rdf', '.owl'],
18
+ obo: ['.obo'],
19
+ };
20
+
21
+ /**
22
+ * Extensions several syntaxes share: Protégé saves Turtle, OWL/XML or Functional Syntax as
23
+ * `.owl` too. For these the content decides; the extension's format is only the fallback.
24
+ */
25
+ const SHARED_EXTENSIONS: readonly string[] = ['.owl'];
26
+
27
+ const FORMATS = Object.keys(FORMAT_EXTENSIONS) as FormatId[];
28
+
29
+ const cleanName = (filename: string): string => filename.replace(/[?#].*$/, '').toLowerCase();
30
+
31
+ /** Format implied by a file name or URL, or `undefined` for unknown extensions. */
32
+ export function formatFromFilename(filename: string): FormatId | undefined {
33
+ const name = cleanName(filename);
34
+ let best: { format: FormatId; length: number } | undefined;
35
+ for (const format of FORMATS) {
36
+ for (const extension of FORMAT_EXTENSIONS[format]) {
37
+ if (name.endsWith(extension) && (!best || extension.length > best.length)) {
38
+ best = { format, length: extension.length };
39
+ }
40
+ }
41
+ }
42
+ return best?.format;
43
+ }
44
+
45
+ /** See `detectFormat` in index.ts. */
46
+ export function detect(text: string, filename: string | undefined): FormatId {
47
+ const named = filename === undefined ? undefined : formatFromFilename(filename);
48
+ if (named === undefined) return sniffFormat(text, filename);
49
+ const name = cleanName(filename ?? '');
50
+ if (SHARED_EXTENSIONS.some((extension) => name.endsWith(extension))) {
51
+ return sniffFormat(text, filename, named);
52
+ }
53
+ // `.json` holds every JSON dialect, JSON-LD included.
54
+ if (named === 'json' && isJsonLdHead(stripBom(text.slice(0, SNIFF_WINDOW)))) return 'jsonld';
55
+ return named;
56
+ }
57
+
58
+ /** How many leading characters content sniffing looks at (line-based checks). */
59
+ const SNIFF_WINDOW = 64 * 1024;
60
+ /** How many statement lines decide between N-Triples / N-Quads and Turtle. */
61
+ const SNIFF_LINES = 32;
62
+
63
+ /**
64
+ * Sniffs the format from content alone. Throws for recognisably unsupported formats; returns
65
+ * `fallback` (when given) for empty and unrecognisable text instead of guessing or failing.
66
+ */
67
+ export function sniffFormat(text: string, filename?: string, fallback?: FormatId): FormatId {
68
+ const head = stripBom(text.slice(0, SNIFF_WINDOW));
69
+ const start = skipWhitespaceAndComments(head);
70
+ // Empty (or comment-only): an empty RDF graph.
71
+ if (start >= head.length) return fallback ?? 'turtle';
72
+ const first = head[start];
73
+
74
+ if (/^format-version:/m.test(head) || /^\[(?:Term|Typedef|Instance)\][ \t]*\r?$/m.test(head)) {
75
+ return 'obo';
76
+ }
77
+ if (first === '{' || (first === '[' && looksLikeJsonArray(head, start))) {
78
+ return isJsonLdHead(head) ? 'jsonld' : 'json';
79
+ }
80
+ if (first === '<' && looksLikeXml(head, start)) {
81
+ const root = sniffXmlRoot(head, start);
82
+ if (root !== undefined) {
83
+ const kind = classifyXmlRoot(root);
84
+ if (kind === 'rdfxml') return 'rdfxml';
85
+ throw unsupportedFormat(kind, filename, root.qname);
86
+ }
87
+ if (head.includes(RDF_NS)) return 'rdfxml';
88
+ }
89
+ if (/^[ \t]*(?:Prefix|Ontology)\(/m.test(head)) {
90
+ throw unsupportedFormat('OWL Functional Syntax', filename);
91
+ }
92
+ if (/^[ \t]*(?:Prefix|Ontology):[ \t]/m.test(head)) {
93
+ throw unsupportedFormat('OWL Manchester Syntax', filename);
94
+ }
95
+
96
+ const hasDirectives = /^[ \t]*(?:@prefix|@base|PREFIX[ \t]|BASE[ \t])/im.test(head);
97
+ if (!hasDirectives) {
98
+ // A truncated window may end mid-line: leave that line out of the sample.
99
+ const complete = text.length > SNIFF_WINDOW ? head.slice(0, head.lastIndexOf('\n') + 1) : head;
100
+ const lineFormat = sniffLineFormat(complete);
101
+ if (lineFormat) return lineFormat;
102
+ }
103
+ if (hasDirectives || looksLikeTurtle(head.slice(start))) {
104
+ return hasGraphBlock(stripBom(text)) ? 'trig' : 'turtle';
105
+ }
106
+ if (fallback !== undefined) return fallback;
107
+ throw invalidInput(
108
+ `Could not detect the format of ${filename ? `"${filename}"` : 'the input'}. ` +
109
+ `Pass \`format\` explicitly (${FORMATS.join(', ')}).`,
110
+ filename ? { filename } : {},
111
+ );
112
+ }
113
+
114
+ // ─── Unsupported formats ────────────────────────────────────────────────────
115
+
116
+ const TO_RDF =
117
+ 'Convert it to RDF/XML or Turtle first (e.g. `robot convert --input onto.owx --output onto.owl`).';
118
+ const TO_JSON = 'Export it as JSON first (e.g. NetworkX node-link data or Cytoscape.js JSON).';
119
+
120
+ /** Formats that are recognised but not read, with how to convert them. */
121
+ const CONVERSION_HINTS = {
122
+ 'OWL/XML': TO_RDF,
123
+ 'OWL Functional Syntax': TO_RDF,
124
+ 'OWL Manchester Syntax': TO_RDF,
125
+ GraphML: TO_JSON,
126
+ GEXF: TO_JSON,
127
+ XML: 'kgdiff reads RDF/XML (an rdf:RDF root element, or a node element with rdf:about); other XML vocabularies are not graph formats it knows.',
128
+ } as const;
129
+
130
+ export type UnsupportedFormat = keyof typeof CONVERSION_HINTS;
131
+
132
+ /** `KgdiffError('invalid-input')` naming a recognised but unsupported format and a way out. */
133
+ export function unsupportedFormat(
134
+ kind: UnsupportedFormat,
135
+ filename?: string,
136
+ root?: string,
137
+ ): KgdiffError {
138
+ const what = kind === 'XML' ? `XML with root element <${root ?? '?'}>` : kind;
139
+ return invalidInput(
140
+ `${what} is not supported${filename ? ` ("${filename}")` : ''}. ${CONVERSION_HINTS[kind]}`,
141
+ filename ? { filename } : {},
142
+ );
143
+ }
144
+
145
+ /** What format detection needs to know about a root element. */
146
+ export interface XmlRootInfo {
147
+ readonly local: string;
148
+ /** Namespace URI, when known. */
149
+ readonly uri: string | undefined;
150
+ /** Whether it has attributes in the RDF namespace (`rdf:about`, …). */
151
+ readonly rdfAttributes: boolean;
152
+ /** Whether it declares the RDF namespace (OWL/XML often does, without using it). */
153
+ readonly rdfNamespace: boolean;
154
+ }
155
+
156
+ /**
157
+ * RDF/XML (an `rdf:RDF` root, or a node element using the RDF namespace) or the unsupported
158
+ * XML format a root element belongs to (`XML` when it is none of the known ones).
159
+ */
160
+ export function classifyXmlRoot(root: XmlRootInfo): 'rdfxml' | UnsupportedFormat {
161
+ const local = root.local;
162
+ if (local === 'RDF') return 'rdfxml';
163
+ // OWL/XML: <Ontology xmlns="http://www.w3.org/2002/07/owl#" ontologyIRI="…">; an RDF/XML
164
+ // document rooted at an owl:Ontology node element uses rdf:about.
165
+ if (local === 'Ontology' && !root.rdfAttributes) return 'OWL/XML';
166
+ if (local.toLowerCase() === 'graphml') return 'GraphML';
167
+ if (local.toLowerCase() === 'gexf') return 'GEXF';
168
+ return root.rdfAttributes || root.rdfNamespace ? 'rdfxml' : 'XML';
169
+ }
170
+
171
+ /**
172
+ * Markup rather than a Turtle IRI: a declaration, comment or DOCTYPE, or a start tag with
173
+ * attributes, self-closing or closed later (`<a> <b> <c> .` stays Turtle).
174
+ */
175
+ function looksLikeXml(head: string, start: number): boolean {
176
+ const tag =
177
+ /^<(?:([?!])|([A-Za-z_][\w.-]*(?::[A-Za-z_][\w.-]*)?)(\s+[A-Za-z_][\w.:-]*\s*=|\s*\/?>))/.exec(
178
+ head.slice(start, start + 1024),
179
+ );
180
+ if (!tag) return false;
181
+ if (tag[1] !== undefined) return true;
182
+ const [, , name = '', after = ''] = tag;
183
+ return after.includes('=') || after.endsWith('/>') || head.includes(`</${name}`);
184
+ }
185
+
186
+ /** The root element of an XML head: prolog skipped, its own namespace declarations read. */
187
+ function sniffXmlRoot(
188
+ head: string,
189
+ start: number,
190
+ ): (XmlRootInfo & { readonly qname: string }) | undefined {
191
+ let i = start;
192
+ for (;;) {
193
+ i = skipXmlSpace(head, i);
194
+ let end: number;
195
+ if (head.startsWith('<?', i)) end = afterOrMinus(head, '?>', i);
196
+ else if (head.startsWith('<!--', i)) end = afterOrMinus(head, '-->', i);
197
+ else if (head.startsWith('<!DOCTYPE', i)) end = afterDoctype(head, i);
198
+ else break;
199
+ if (end < 0) return undefined;
200
+ i = end;
201
+ }
202
+ const tag = /^<([^\s/>]+)((?:[^>"']|"[^"]*"|'[^']*')*)/.exec(head.slice(i));
203
+ if (!tag) return undefined;
204
+ const qname = tag[1] ?? '';
205
+ const namespaces = new Map<string, string>();
206
+ const prefixes: string[] = [];
207
+ for (const [, name = '', double, single] of (tag[2] ?? '').matchAll(
208
+ /([^\s=]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g,
209
+ )) {
210
+ const value = double ?? single ?? '';
211
+ if (name === 'xmlns') namespaces.set('', value);
212
+ else if (name.startsWith('xmlns:')) namespaces.set(name.slice(6), value);
213
+ else if (name.includes(':')) prefixes.push(name.slice(0, name.indexOf(':')));
214
+ }
215
+ const colon = qname.indexOf(':');
216
+ const local = colon < 0 ? qname : qname.slice(colon + 1);
217
+ const uri = namespaces.get(colon < 0 ? '' : qname.slice(0, colon));
218
+ const rdfAttributes = prefixes.some(
219
+ (prefix) => namespaces.get(prefix) === RDF_NS || prefix === 'rdf',
220
+ );
221
+ const rdfNamespace = [...namespaces.values()].includes(RDF_NS);
222
+ return { qname, local, uri, rdfAttributes, rdfNamespace };
223
+ }
224
+
225
+ function skipXmlSpace(text: string, from: number): number {
226
+ let i = from;
227
+ while (i < text.length && ' \t\n\r'.includes(text[i] ?? '')) i++;
228
+ return i;
229
+ }
230
+
231
+ function afterOrMinus(text: string, terminator: string, from: number): number {
232
+ const end = text.indexOf(terminator, from);
233
+ return end < 0 ? -1 : end + terminator.length;
234
+ }
235
+
236
+ /** End of `<!DOCTYPE … [ … ]>`, or -1 when it does not end inside `text`. */
237
+ function afterDoctype(text: string, from: number): number {
238
+ let inSubset = false;
239
+ for (let i = from + 9; i < text.length; i++) {
240
+ const c = text[i];
241
+ if (c === '"' || c === "'") {
242
+ const close = text.indexOf(c, i + 1);
243
+ if (close < 0) return -1;
244
+ i = close;
245
+ } else if (c === '[') inSubset = true;
246
+ else if (c === ']') inSubset = false;
247
+ else if (c === '>' && !inSubset) return i + 1;
248
+ }
249
+ return -1;
250
+ }
251
+
252
+ // ─── JSON-LD ────────────────────────────────────────────────────────────────
253
+
254
+ /** Top-level keys that make a JSON document JSON-LD. */
255
+ const JSON_LD_KEYS: ReadonlySet<string> = new Set([
256
+ '@context',
257
+ '@graph',
258
+ '@id',
259
+ '@type',
260
+ '@reverse',
261
+ '@included',
262
+ '@set',
263
+ '@list',
264
+ '@value',
265
+ ]);
266
+
267
+ /** Whether a parsed JSON value is a JSON-LD document (by its top-level keys). */
268
+ export function isJsonLd(data: unknown): boolean {
269
+ const object = Array.isArray(data) ? data[0] : data;
270
+ return (
271
+ typeof object === 'object' &&
272
+ object !== null &&
273
+ !Array.isArray(object) &&
274
+ Object.keys(object).some((key) => JSON_LD_KEYS.has(key))
275
+ );
276
+ }
277
+
278
+ /**
279
+ * `isJsonLd` on the head of a document: the keys of the top-level object (or of the first
280
+ * object of a top-level array) that start inside the sniff window.
281
+ */
282
+ function isJsonLdHead(head: string): boolean {
283
+ let i = skipXmlSpace(head, 0);
284
+ if (head[i] === '[') i = skipXmlSpace(head, i + 1);
285
+ if (head[i] !== '{') return false;
286
+ i++;
287
+ for (;;) {
288
+ i = skipXmlSpace(head, i);
289
+ if (head[i] !== '"') return false;
290
+ const keyEnd = endOfJsonString(head, i);
291
+ if (keyEnd < 0) return false;
292
+ const key = head.slice(i + 1, keyEnd - 1);
293
+ if (JSON_LD_KEYS.has(key)) return true;
294
+ i = skipXmlSpace(head, keyEnd);
295
+ if (head[i] !== ':') return false;
296
+ i = endOfJsonValue(head, skipXmlSpace(head, i + 1));
297
+ if (i < 0) return false;
298
+ i = skipXmlSpace(head, i);
299
+ if (head[i] !== ',') return false;
300
+ i++;
301
+ }
302
+ }
303
+
304
+ /** Index after the JSON string starting at `start`, or -1. */
305
+ function endOfJsonString(text: string, start: number): number {
306
+ for (let i = start + 1; i < text.length; i++) {
307
+ const c = text[i];
308
+ if (c === '\\') i++;
309
+ else if (c === '"') return i + 1;
310
+ }
311
+ return -1;
312
+ }
313
+
314
+ /** Index after the JSON value starting at `start` (nested values skipped), or -1. */
315
+ function endOfJsonValue(text: string, start: number): number {
316
+ let depth = 0;
317
+ for (let i = start; i < text.length; i++) {
318
+ const c = text[i];
319
+ if (c === '"') {
320
+ i = endOfJsonString(text, i) - 1;
321
+ if (i < 0) return -1;
322
+ if (depth === 0) return i + 1;
323
+ } else if (c === '{' || c === '[') depth++;
324
+ else if (c === '}' || c === ']') {
325
+ depth--;
326
+ if (depth === 0) return i + 1;
327
+ if (depth < 0) return -1;
328
+ } else if (depth === 0 && (c === ',' || c === ' ' || c === '\n' || c === '\r' || c === '\t')) {
329
+ return i;
330
+ }
331
+ }
332
+ return -1;
333
+ }
334
+
335
+ // ─── Text formats ───────────────────────────────────────────────────────────
336
+
337
+ function skipWhitespaceAndComments(text: string): number {
338
+ let i = 0;
339
+ while (i < text.length) {
340
+ const c = text[i];
341
+ if (c === ' ' || c === '\t' || c === '\n' || c === '\r') i++;
342
+ else if (c === '#') {
343
+ const end = text.indexOf('\n', i);
344
+ i = end < 0 ? text.length : end + 1;
345
+ } else break;
346
+ }
347
+ return i;
348
+ }
349
+
350
+ /** `[` opens JSON when followed by a JSON value; in Turtle it opens a blank node. */
351
+ function looksLikeJsonArray(text: string, start: number): boolean {
352
+ const rest = text.slice(start + 1).trimStart();
353
+ const next = rest[0];
354
+ if (next === undefined) return true;
355
+ if (next === ']') return rest.slice(1).trim() === '' || /^\]\s*[,\]]/.test(rest);
356
+ return /[{["\d-]/.test(next);
357
+ }
358
+
359
+ /** Subject-like first token: an IRI, a blank node, a collection or a prefixed name. */
360
+ function looksLikeTurtle(text: string): boolean {
361
+ return /^(?:<[^\s<>]*>|_:|\[|\(|[A-Za-z][\w.-]*:|:)/.test(text);
362
+ }
363
+
364
+ const NT_TERM =
365
+ /[ \t]*(?:<[^<>"\s]*>|_:[^\s.<>"]+(?:\.[^\s.<>"]+)*|"(?:[^"\\\n]|\\.)*"(?:@[A-Za-z]+(?:-[A-Za-z0-9]+)*(?:--[a-z]+)?|\^\^<[^<>"\s]*>)?)/y;
366
+
367
+ /**
368
+ * N-Triples / N-Quads are line-based: every statement is 3 or 4 terms and a dot. Returns
369
+ * `nquads` when any sampled line has a graph term, `ntriples` when all have 3, and `undefined`
370
+ * when a sampled line is not in line format (so it is Turtle or TriG).
371
+ */
372
+ function sniffLineFormat(text: string): 'ntriples' | 'nquads' | undefined {
373
+ let sampled = 0;
374
+ let quads = false;
375
+ for (const rawLine of text.split('\n')) {
376
+ const line = rawLine.trim();
377
+ if (line === '' || line.startsWith('#')) continue;
378
+ const terms = countLineTerms(line);
379
+ if (terms !== 3 && terms !== 4) return undefined;
380
+ if (terms === 4) quads = true;
381
+ if (++sampled >= SNIFF_LINES) break;
382
+ }
383
+ if (sampled === 0) return undefined;
384
+ return quads ? 'nquads' : 'ntriples';
385
+ }
386
+
387
+ function countLineTerms(line: string): number {
388
+ let position = 0;
389
+ let terms = 0;
390
+ for (;;) {
391
+ NT_TERM.lastIndex = position;
392
+ const match = NT_TERM.exec(line);
393
+ if (!match) break;
394
+ position = NT_TERM.lastIndex;
395
+ terms++;
396
+ }
397
+ const rest = line.slice(position).trim();
398
+ return /^\.\s*(?:#.*)?$/.test(rest) ? terms : -1;
399
+ }
400
+
401
+ /** TriG: a `{` outside IRIs, strings and comments (`{|` is RDF 1.2 annotation syntax). */
402
+ function hasGraphBlock(text: string): boolean {
403
+ const n = text.length;
404
+ let i = 0;
405
+ while (i < n) {
406
+ const c = text[i];
407
+ if (c === '#') {
408
+ const end = text.indexOf('\n', i);
409
+ i = end < 0 ? n : end + 1;
410
+ } else if (c === '<' && text[i + 1] !== '<') {
411
+ const end = text.indexOf('>', i + 1);
412
+ i = end < 0 ? n : end + 1;
413
+ } else if (c === '"' || c === "'") {
414
+ i = skipString(text, i, c);
415
+ } else if (c === '{') {
416
+ if (text[i + 1] !== '|') return true;
417
+ i += 2;
418
+ } else i++;
419
+ }
420
+ return false;
421
+ }
422
+
423
+ function skipString(text: string, start: number, quote: string): number {
424
+ const long = text.startsWith(quote.repeat(3), start);
425
+ const close = long ? quote.repeat(3) : quote;
426
+ let i = start + close.length;
427
+ while (i < text.length) {
428
+ const c = text[i];
429
+ if (c === '\\') i += 2;
430
+ else if (text.startsWith(close, i)) return i + close.length;
431
+ else if (!long && c === '\n') return i + 1;
432
+ else i++;
433
+ }
434
+ return text.length;
435
+ }