@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.
- 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 +31 -0
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +787 -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 +889 -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/jsonld.ts
ADDED
|
@@ -0,0 +1,1376 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* JSON-LD 1.1 → RDF quads, synchronously: context processing, expansion and deserialization to
|
|
3
|
+
* RDF (JSON-LD 1.1 Processing Algorithms and API §4, §5, §8). The quads go through the same
|
|
4
|
+
* mapping as Turtle (`rdf.ts`), so a graph diffs identically in either syntax.
|
|
5
|
+
*
|
|
6
|
+
* - Contexts are inline, or come from the caller's synchronous `documentLoader` (remote
|
|
7
|
+
* `@context` URLs, `@import`). Nothing is ever fetched: results never depend on the network.
|
|
8
|
+
* - Without a base IRI, relative IRIs stay relative (as in Turtle without `@base`) instead of
|
|
9
|
+
* being dropped; IRIs with characters RDF forbids are dropped, as the spec says.
|
|
10
|
+
* - Keys that expand to no IRI (no term, no `@vocab`) are dropped and reported together.
|
|
11
|
+
* - `@direction` is not represented (`rdfDirection: null`); named graphs are merged like
|
|
12
|
+
* N-Quads graphs; `@json` literals use JCS canonical JSON.
|
|
13
|
+
* - Errors carry the JSON-LD error code (`invalid IRI mapping`, `protected term redefinition`…).
|
|
14
|
+
*/
|
|
15
|
+
import { BlankNode, DataFactory, type Literal, NamedNode, type Quad } from 'n3';
|
|
16
|
+
import type { JsonLdOptions } from './index.js';
|
|
17
|
+
import { isAbsoluteIri, resolveIri } from './iri.js';
|
|
18
|
+
import { RDF_NS, XSD_NS } from './prefixes.js';
|
|
19
|
+
import type { RdfDocument } from './rdf.js';
|
|
20
|
+
import { compareStrings, invalidInput, plural, setOwn, type Warnings } from './shared.js';
|
|
21
|
+
|
|
22
|
+
type JsonValue = null | boolean | number | string | JsonValue[] | JsonMap;
|
|
23
|
+
type JsonMap = { [key: string]: JsonValue };
|
|
24
|
+
|
|
25
|
+
type Direction = 'ltr' | 'rtl';
|
|
26
|
+
|
|
27
|
+
interface TermDefinition {
|
|
28
|
+
/** IRI mapping: an IRI, a blank node id, a keyword, or null for a term that maps to nothing. */
|
|
29
|
+
iri: string | null;
|
|
30
|
+
prefix: boolean;
|
|
31
|
+
protected: boolean;
|
|
32
|
+
reverse: boolean;
|
|
33
|
+
type?: string;
|
|
34
|
+
language?: string | null;
|
|
35
|
+
direction?: Direction | null;
|
|
36
|
+
/** Container mapping, sorted. */
|
|
37
|
+
container: readonly string[];
|
|
38
|
+
index?: string;
|
|
39
|
+
nest?: string;
|
|
40
|
+
/** Scoped context and the base URL it was defined with. */
|
|
41
|
+
context?: JsonValue;
|
|
42
|
+
baseUrl?: string | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Context {
|
|
46
|
+
readonly terms: ReadonlyMap<string, TermDefinition>;
|
|
47
|
+
readonly base: string | undefined;
|
|
48
|
+
readonly originalBase: string | undefined;
|
|
49
|
+
readonly vocab: string | undefined;
|
|
50
|
+
readonly language: string | undefined;
|
|
51
|
+
readonly direction: Direction | undefined;
|
|
52
|
+
/** The context to return to on new node objects, set by non-propagated contexts. */
|
|
53
|
+
readonly previous: Context | undefined;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
type MutableContext = { -readonly [K in keyof Context]: Context[K] } & {
|
|
57
|
+
terms: Map<string, TermDefinition>;
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Where term definitions are being created: the local context and its processing state. */
|
|
61
|
+
interface Definitions {
|
|
62
|
+
readonly local: JsonMap;
|
|
63
|
+
readonly defined: Map<string, boolean>;
|
|
64
|
+
readonly baseUrl: string | undefined;
|
|
65
|
+
readonly overrideProtected: boolean;
|
|
66
|
+
readonly remote: readonly string[];
|
|
67
|
+
readonly validateScoped: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A JSON-LD error (code from the spec, plus what it concerns); never escapes this module. */
|
|
71
|
+
class JsonLdError {
|
|
72
|
+
constructor(
|
|
73
|
+
readonly code: string,
|
|
74
|
+
readonly detail: string,
|
|
75
|
+
) {}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Offending JSON values are quoted in messages up to this length. */
|
|
79
|
+
const DETAIL_LENGTH = 120;
|
|
80
|
+
|
|
81
|
+
/** `detail` is an explanation, or a JSON value quoted (and cut) as found. */
|
|
82
|
+
const fail = (code: string, detail: unknown): JsonLdError => {
|
|
83
|
+
if (typeof detail === 'string') return new JsonLdError(code, detail);
|
|
84
|
+
const text = JSON.stringify(detail) ?? String(detail);
|
|
85
|
+
return new JsonLdError(
|
|
86
|
+
code,
|
|
87
|
+
text.length > DETAIL_LENGTH ? `${text.slice(0, DETAIL_LENGTH)}…` : text,
|
|
88
|
+
);
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const KEYWORDS: ReadonlySet<string> = new Set([
|
|
92
|
+
'@base',
|
|
93
|
+
'@container',
|
|
94
|
+
'@context',
|
|
95
|
+
'@direction',
|
|
96
|
+
'@graph',
|
|
97
|
+
'@id',
|
|
98
|
+
'@import',
|
|
99
|
+
'@included',
|
|
100
|
+
'@index',
|
|
101
|
+
'@json',
|
|
102
|
+
'@language',
|
|
103
|
+
'@list',
|
|
104
|
+
'@nest',
|
|
105
|
+
'@none',
|
|
106
|
+
'@prefix',
|
|
107
|
+
'@propagate',
|
|
108
|
+
'@protected',
|
|
109
|
+
'@reverse',
|
|
110
|
+
'@set',
|
|
111
|
+
'@type',
|
|
112
|
+
'@value',
|
|
113
|
+
'@version',
|
|
114
|
+
'@vocab',
|
|
115
|
+
]);
|
|
116
|
+
|
|
117
|
+
/** Context entries that are not term definitions. */
|
|
118
|
+
const CONTEXT_KEYWORDS: ReadonlySet<string> = new Set([
|
|
119
|
+
'@base',
|
|
120
|
+
'@direction',
|
|
121
|
+
'@import',
|
|
122
|
+
'@language',
|
|
123
|
+
'@propagate',
|
|
124
|
+
'@protected',
|
|
125
|
+
'@version',
|
|
126
|
+
'@vocab',
|
|
127
|
+
]);
|
|
128
|
+
|
|
129
|
+
const TERM_DEFINITION_KEYS: ReadonlySet<string> = new Set([
|
|
130
|
+
'@id',
|
|
131
|
+
'@reverse',
|
|
132
|
+
'@container',
|
|
133
|
+
'@context',
|
|
134
|
+
'@direction',
|
|
135
|
+
'@index',
|
|
136
|
+
'@language',
|
|
137
|
+
'@nest',
|
|
138
|
+
'@prefix',
|
|
139
|
+
'@protected',
|
|
140
|
+
'@type',
|
|
141
|
+
]);
|
|
142
|
+
|
|
143
|
+
const CONTAINERS: ReadonlySet<string> = new Set([
|
|
144
|
+
'@graph',
|
|
145
|
+
'@id',
|
|
146
|
+
'@index',
|
|
147
|
+
'@language',
|
|
148
|
+
'@list',
|
|
149
|
+
'@set',
|
|
150
|
+
'@type',
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
const VALUE_KEYS: ReadonlySet<string> = new Set([
|
|
154
|
+
'@direction',
|
|
155
|
+
'@index',
|
|
156
|
+
'@language',
|
|
157
|
+
'@type',
|
|
158
|
+
'@value',
|
|
159
|
+
]);
|
|
160
|
+
|
|
161
|
+
/** Reserved for future keywords: ignored, never expanded (JSON-LD 1.1 §3.3). */
|
|
162
|
+
const KEYWORD_FORM = /^@[a-zA-Z]+$/;
|
|
163
|
+
/** An IRI ending like this can serve as a prefix (gen-delims, RFC 3986). */
|
|
164
|
+
const GEN_DELIM_END = /[:/?#[\]@]$/;
|
|
165
|
+
/** Characters an RDF IRI cannot contain besides controls and the space (RFC 3987). */
|
|
166
|
+
const ILLEGAL_IRI_CHARACTERS = '<>"{}|\\^`';
|
|
167
|
+
const LANGUAGE_TAG = /^[a-zA-Z]{1,8}(?:-[a-zA-Z0-9]{1,8})*$/;
|
|
168
|
+
/** More remote contexts than this in one chain is a `context overflow`. */
|
|
169
|
+
const MAX_REMOTE_CONTEXTS = 32;
|
|
170
|
+
/**
|
|
171
|
+
* Deepest nesting accepted. Expansion recurses (about three stack frames per level), so this
|
|
172
|
+
* stays far below what small engine stacks allow; real documents nest a few levels.
|
|
173
|
+
*/
|
|
174
|
+
export const MAX_JSON_LD_NESTING = 200;
|
|
175
|
+
|
|
176
|
+
const RDF_TYPE = `${RDF_NS}type`;
|
|
177
|
+
const RDF_FIRST = `${RDF_NS}first`;
|
|
178
|
+
const RDF_REST = `${RDF_NS}rest`;
|
|
179
|
+
const RDF_NIL = `${RDF_NS}nil`;
|
|
180
|
+
const RDF_JSON = `${RDF_NS}JSON`;
|
|
181
|
+
const RDF_LANG_STRING = `${RDF_NS}langString`;
|
|
182
|
+
const XSD_BOOLEAN = `${XSD_NS}boolean`;
|
|
183
|
+
const XSD_DOUBLE = `${XSD_NS}double`;
|
|
184
|
+
const XSD_INTEGER = `${XSD_NS}integer`;
|
|
185
|
+
|
|
186
|
+
const isMap = (value: JsonValue | undefined): value is JsonMap =>
|
|
187
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
188
|
+
const has = (map: JsonMap, key: string): boolean => Object.hasOwn(map, key);
|
|
189
|
+
const arrayify = (value: JsonValue): JsonValue[] => (Array.isArray(value) ? value : [value]);
|
|
190
|
+
const isScalar = (value: JsonValue): value is string | number | boolean =>
|
|
191
|
+
typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean';
|
|
192
|
+
const isValueObject = (value: JsonValue): boolean => isMap(value) && has(value, '@value');
|
|
193
|
+
const isListObject = (value: JsonValue): boolean => isMap(value) && has(value, '@list');
|
|
194
|
+
const isGraphObject = (value: JsonValue): boolean =>
|
|
195
|
+
isMap(value) &&
|
|
196
|
+
has(value, '@graph') &&
|
|
197
|
+
Object.keys(value).every((key) => key === '@graph' || key === '@id' || key === '@index');
|
|
198
|
+
const isNodeObject = (value: JsonValue): boolean =>
|
|
199
|
+
isMap(value) && !has(value, '@value') && !has(value, '@list') && !has(value, '@set');
|
|
200
|
+
|
|
201
|
+
/** Appends values to `map[key]`, which always holds an array. */
|
|
202
|
+
function addValues(map: JsonMap, key: string, values: readonly JsonValue[]): void {
|
|
203
|
+
const existing = map[key];
|
|
204
|
+
if (Array.isArray(existing)) for (const value of values) existing.push(value);
|
|
205
|
+
else setOwn(map, key, existing === undefined ? [...values] : [existing, ...values]);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Container mappings JSON-LD 1.1 allows (§4.2.2, step 21.1). */
|
|
209
|
+
function validContainer(container: readonly string[]): boolean {
|
|
210
|
+
const set = new Set(container);
|
|
211
|
+
if (set.size !== container.length || !container.every((value) => CONTAINERS.has(value))) {
|
|
212
|
+
return false;
|
|
213
|
+
}
|
|
214
|
+
if (container.length === 1) return true;
|
|
215
|
+
if (set.has('@list')) return false;
|
|
216
|
+
if (set.has('@graph') && (set.has('@id') || set.has('@index'))) {
|
|
217
|
+
return (
|
|
218
|
+
!(set.has('@id') && set.has('@index')) &&
|
|
219
|
+
container.every((value) => value !== '@type' && value !== '@language')
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
return set.has('@set') && container.length === 2;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const isThenable = (value: unknown): boolean =>
|
|
226
|
+
typeof value === 'object' &&
|
|
227
|
+
value !== null &&
|
|
228
|
+
typeof (value as { then?: unknown }).then === 'function';
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Reads a parsed JSON-LD document into quads. Throws `KgdiffError('invalid-input')` with the
|
|
232
|
+
* JSON-LD error code for invalid documents and for remote contexts no loader provides.
|
|
233
|
+
*/
|
|
234
|
+
export function readJsonLd(
|
|
235
|
+
document: unknown,
|
|
236
|
+
options: JsonLdOptions & { readonly baseIRI?: string | undefined },
|
|
237
|
+
warnings: Warnings,
|
|
238
|
+
): RdfDocument {
|
|
239
|
+
const reader = new JsonLdReader(options.baseIRI, options.documentLoader);
|
|
240
|
+
try {
|
|
241
|
+
reader.read(document as JsonValue);
|
|
242
|
+
} catch (error) {
|
|
243
|
+
if (!(error instanceof JsonLdError)) throw error;
|
|
244
|
+
throw invalidInput(`Invalid JSON-LD: ${error.code}: ${error.detail}.`, {
|
|
245
|
+
format: 'jsonld',
|
|
246
|
+
code: error.code,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
if (reader.unmapped.size > 0) {
|
|
250
|
+
const keys = [...reader.unmapped].sort(compareStrings);
|
|
251
|
+
const shown = keys.slice(0, 5).map((key) => JSON.stringify(key));
|
|
252
|
+
warnings.add(
|
|
253
|
+
'jsonld-unmapped-terms',
|
|
254
|
+
`Dropped ${plural(keys.length, 'JSON-LD key')} that map to no IRI (no @context term or @vocab): ${shown.join(', ')}${keys.length > shown.length ? ', …' : ''}.`,
|
|
255
|
+
);
|
|
256
|
+
}
|
|
257
|
+
return { quads: reader.quads, prefixes: reader.prefixes };
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
class JsonLdReader {
|
|
261
|
+
readonly quads: Quad[] = [];
|
|
262
|
+
readonly prefixes: Record<string, string> = {};
|
|
263
|
+
/** Keys dropped because they expand to no IRI. */
|
|
264
|
+
readonly unmapped = new Set<string>();
|
|
265
|
+
/** `@context` of every remote document, by URL (loaded once per parse). */
|
|
266
|
+
private readonly loaded = new Map<string, JsonValue>();
|
|
267
|
+
private readonly scoped = new WeakMap<Context, Map<JsonValue, Map<string, Context>>>();
|
|
268
|
+
private readonly iris = new Map<string, NamedNode>();
|
|
269
|
+
private readonly defaultGraph = DataFactory.defaultGraph();
|
|
270
|
+
private blanks = 0;
|
|
271
|
+
|
|
272
|
+
constructor(
|
|
273
|
+
private readonly baseIRI: string | undefined,
|
|
274
|
+
private readonly documentLoader: ((url: string) => unknown) | undefined,
|
|
275
|
+
) {}
|
|
276
|
+
|
|
277
|
+
read(document: JsonValue): void {
|
|
278
|
+
const initial: Context = {
|
|
279
|
+
terms: new Map(),
|
|
280
|
+
base: this.baseIRI,
|
|
281
|
+
originalBase: this.baseIRI,
|
|
282
|
+
vocab: undefined,
|
|
283
|
+
language: undefined,
|
|
284
|
+
direction: undefined,
|
|
285
|
+
previous: undefined,
|
|
286
|
+
};
|
|
287
|
+
let expanded = this.expand(initial, null, document, this.baseIRI, false, 0);
|
|
288
|
+
if (isMap(expanded) && Object.keys(expanded).length === 1 && has(expanded, '@graph')) {
|
|
289
|
+
expanded = expanded['@graph'] ?? null;
|
|
290
|
+
}
|
|
291
|
+
for (const node of arrayify(expanded)) {
|
|
292
|
+
if (isMap(node)) this.emitNode(node, this.defaultGraph);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ─── Context processing (§4.1) ──────────────────────────────────────────
|
|
297
|
+
|
|
298
|
+
private processContext(
|
|
299
|
+
active: Context,
|
|
300
|
+
localContext: JsonValue,
|
|
301
|
+
baseUrl: string | undefined,
|
|
302
|
+
remote: readonly string[],
|
|
303
|
+
overrideProtected: boolean,
|
|
304
|
+
propagateDefault: boolean,
|
|
305
|
+
validateScoped: boolean,
|
|
306
|
+
): MutableContext {
|
|
307
|
+
let result: MutableContext = { ...active, terms: new Map(active.terms) };
|
|
308
|
+
let propagate = propagateDefault;
|
|
309
|
+
if (isMap(localContext) && has(localContext, '@propagate')) {
|
|
310
|
+
const value = localContext['@propagate'];
|
|
311
|
+
if (typeof value !== 'boolean') throw fail('invalid @propagate value', value ?? null);
|
|
312
|
+
propagate = value;
|
|
313
|
+
}
|
|
314
|
+
if (!propagate && result.previous === undefined) result.previous = active;
|
|
315
|
+
for (const context of arrayify(localContext)) {
|
|
316
|
+
if (context === null) {
|
|
317
|
+
if (!overrideProtected && [...result.terms.values()].some((term) => term.protected)) {
|
|
318
|
+
throw fail('invalid context nullification', 'a context with protected terms');
|
|
319
|
+
}
|
|
320
|
+
const previous = result;
|
|
321
|
+
result = {
|
|
322
|
+
terms: new Map(),
|
|
323
|
+
base: active.originalBase,
|
|
324
|
+
originalBase: active.originalBase,
|
|
325
|
+
vocab: undefined,
|
|
326
|
+
language: undefined,
|
|
327
|
+
direction: undefined,
|
|
328
|
+
previous: propagate ? undefined : previous,
|
|
329
|
+
};
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (typeof context === 'string') {
|
|
333
|
+
const url = resolveIri(context, baseUrl);
|
|
334
|
+
if (!validateScoped && remote.includes(url)) continue;
|
|
335
|
+
if (remote.length >= MAX_REMOTE_CONTEXTS) throw fail('context overflow', url);
|
|
336
|
+
const loaded = this.remoteContext(url);
|
|
337
|
+
result = this.processContext(
|
|
338
|
+
result,
|
|
339
|
+
loaded,
|
|
340
|
+
url,
|
|
341
|
+
[...remote, url],
|
|
342
|
+
false,
|
|
343
|
+
true,
|
|
344
|
+
validateScoped,
|
|
345
|
+
);
|
|
346
|
+
continue;
|
|
347
|
+
}
|
|
348
|
+
if (!isMap(context)) throw fail('invalid local context', context);
|
|
349
|
+
let map = context;
|
|
350
|
+
if (has(map, '@version') && map['@version'] !== 1.1) {
|
|
351
|
+
throw fail('invalid @version value', map['@version'] ?? null);
|
|
352
|
+
}
|
|
353
|
+
if (has(map, '@import')) {
|
|
354
|
+
const value = map['@import'];
|
|
355
|
+
if (typeof value !== 'string') throw fail('invalid @import value', value ?? null);
|
|
356
|
+
const imported = this.remoteContext(resolveIri(value, baseUrl));
|
|
357
|
+
if (!isMap(imported)) throw fail('invalid remote context', value);
|
|
358
|
+
if (has(imported, '@import')) throw fail('invalid context entry', `${value} has @import`);
|
|
359
|
+
map = { ...imported, ...map };
|
|
360
|
+
}
|
|
361
|
+
if (has(map, '@base') && remote.length === 0) {
|
|
362
|
+
const value = map['@base'];
|
|
363
|
+
if (value === null) result.base = undefined;
|
|
364
|
+
else if (typeof value === 'string') {
|
|
365
|
+
// Lenient where the spec errs: a relative @base without a base stays relative.
|
|
366
|
+
result.base =
|
|
367
|
+
isAbsoluteIri(value) || result.base === undefined
|
|
368
|
+
? value
|
|
369
|
+
: resolveIri(value, result.base);
|
|
370
|
+
} else throw fail('invalid base IRI', value ?? null);
|
|
371
|
+
}
|
|
372
|
+
if (has(map, '@vocab')) {
|
|
373
|
+
const value = map['@vocab'];
|
|
374
|
+
if (value === null) result.vocab = undefined;
|
|
375
|
+
else if (typeof value === 'string') {
|
|
376
|
+
const vocab = this.expandIri(result, value, { vocab: true, documentRelative: true });
|
|
377
|
+
if (vocab === null || KEYWORDS.has(vocab)) throw fail('invalid vocab mapping', value);
|
|
378
|
+
result.vocab = vocab;
|
|
379
|
+
} else throw fail('invalid vocab mapping', value ?? null);
|
|
380
|
+
}
|
|
381
|
+
if (has(map, '@language')) {
|
|
382
|
+
const value = map['@language'];
|
|
383
|
+
if (value === null) result.language = undefined;
|
|
384
|
+
else if (typeof value === 'string') result.language = value.toLowerCase();
|
|
385
|
+
else throw fail('invalid default language', value ?? null);
|
|
386
|
+
}
|
|
387
|
+
if (has(map, '@direction')) {
|
|
388
|
+
const value = map['@direction'];
|
|
389
|
+
if (value === null) result.direction = undefined;
|
|
390
|
+
else if (value === 'ltr' || value === 'rtl') result.direction = value;
|
|
391
|
+
else throw fail('invalid base direction', value ?? null);
|
|
392
|
+
}
|
|
393
|
+
if (has(map, '@protected') && typeof map['@protected'] !== 'boolean') {
|
|
394
|
+
throw fail('invalid @protected value', map['@protected'] ?? null);
|
|
395
|
+
}
|
|
396
|
+
const definitions: Definitions = {
|
|
397
|
+
local: map,
|
|
398
|
+
defined: new Map(),
|
|
399
|
+
baseUrl,
|
|
400
|
+
overrideProtected,
|
|
401
|
+
remote,
|
|
402
|
+
validateScoped,
|
|
403
|
+
};
|
|
404
|
+
for (const term of Object.keys(map)) {
|
|
405
|
+
if (!CONTEXT_KEYWORDS.has(term)) this.createTermDefinition(result, term, definitions);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return result;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/** Context processing for property-, type- and map-scoped contexts, memoized per context. */
|
|
412
|
+
private scopedContext(
|
|
413
|
+
active: Context,
|
|
414
|
+
local: JsonValue,
|
|
415
|
+
baseUrl: string | undefined,
|
|
416
|
+
overrideProtected: boolean,
|
|
417
|
+
propagate: boolean,
|
|
418
|
+
): Context {
|
|
419
|
+
let byLocal = this.scoped.get(active);
|
|
420
|
+
if (!byLocal) {
|
|
421
|
+
byLocal = new Map();
|
|
422
|
+
this.scoped.set(active, byLocal);
|
|
423
|
+
}
|
|
424
|
+
let byFlags = byLocal.get(local);
|
|
425
|
+
if (!byFlags) {
|
|
426
|
+
byFlags = new Map();
|
|
427
|
+
byLocal.set(local, byFlags);
|
|
428
|
+
}
|
|
429
|
+
const key = `${overrideProtected}|${propagate}|${baseUrl ?? ''}`;
|
|
430
|
+
let context = byFlags.get(key);
|
|
431
|
+
if (!context) {
|
|
432
|
+
context = this.processContext(active, local, baseUrl, [], overrideProtected, propagate, true);
|
|
433
|
+
byFlags.set(key, context);
|
|
434
|
+
}
|
|
435
|
+
return context;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** The `@context` of a remote document, from the caller's loader (never the network). */
|
|
439
|
+
private remoteContext(url: string): JsonValue {
|
|
440
|
+
const cached = this.loaded.get(url);
|
|
441
|
+
if (cached !== undefined) return cached;
|
|
442
|
+
if (!this.documentLoader) {
|
|
443
|
+
throw fail(
|
|
444
|
+
'loading remote context failed',
|
|
445
|
+
`"${url}" was not loaded, as kgdiff never fetches documents; inline the context, or pass ParseOptions.jsonld.documentLoader`,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
let document: unknown;
|
|
449
|
+
try {
|
|
450
|
+
document = this.documentLoader(url);
|
|
451
|
+
} catch (error) {
|
|
452
|
+
throw fail(
|
|
453
|
+
'loading remote context failed',
|
|
454
|
+
`"${url}": ${error instanceof Error ? error.message : String(error)}`,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
if (isThenable(document)) {
|
|
458
|
+
throw fail(
|
|
459
|
+
'loading remote context failed',
|
|
460
|
+
`documentLoader returned a Promise for "${url}"; it must return the document itself (load contexts beforehand)`,
|
|
461
|
+
);
|
|
462
|
+
}
|
|
463
|
+
if (typeof document === 'string') {
|
|
464
|
+
try {
|
|
465
|
+
document = JSON.parse(document);
|
|
466
|
+
} catch {
|
|
467
|
+
throw fail('loading remote context failed', `"${url}" is not JSON`);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (document === undefined || document === null) {
|
|
471
|
+
throw fail('loading remote context failed', `documentLoader has no document for "${url}"`);
|
|
472
|
+
}
|
|
473
|
+
if (!isMap(document as JsonValue) || !has(document as JsonMap, '@context')) {
|
|
474
|
+
throw fail('invalid remote context', url);
|
|
475
|
+
}
|
|
476
|
+
const context = (document as JsonMap)['@context'] ?? null;
|
|
477
|
+
this.loaded.set(url, context);
|
|
478
|
+
return context;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// ─── Term definitions (§4.2) ────────────────────────────────────────────
|
|
482
|
+
|
|
483
|
+
private createTermDefinition(active: MutableContext, term: string, scope: Definitions): void {
|
|
484
|
+
const { local, defined } = scope;
|
|
485
|
+
const state = defined.get(term);
|
|
486
|
+
if (state === true) return;
|
|
487
|
+
if (state === false) throw fail('cyclic IRI mapping', term);
|
|
488
|
+
if (term === '') throw fail('invalid term definition', 'an empty term');
|
|
489
|
+
defined.set(term, false);
|
|
490
|
+
const raw = local[term] ?? null;
|
|
491
|
+
if (term === '@type' && isMap(raw)) {
|
|
492
|
+
const keys = Object.keys(raw);
|
|
493
|
+
const valid =
|
|
494
|
+
keys.length > 0 && keys.every((key) => key === '@container' || key === '@protected');
|
|
495
|
+
if (!valid || (has(raw, '@container') && raw['@container'] !== '@set')) {
|
|
496
|
+
throw fail('keyword redefinition', term);
|
|
497
|
+
}
|
|
498
|
+
} else if (KEYWORDS.has(term)) throw fail('keyword redefinition', term);
|
|
499
|
+
else if (KEYWORD_FORM.test(term)) {
|
|
500
|
+
defined.set(term, true);
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
const previous = active.terms.get(term);
|
|
504
|
+
active.terms.delete(term);
|
|
505
|
+
let value: JsonMap;
|
|
506
|
+
let simpleTerm = false;
|
|
507
|
+
if (raw === null) value = { '@id': null };
|
|
508
|
+
else if (typeof raw === 'string') {
|
|
509
|
+
value = { '@id': raw };
|
|
510
|
+
simpleTerm = true;
|
|
511
|
+
} else if (isMap(raw)) value = raw;
|
|
512
|
+
else throw fail('invalid term definition', term);
|
|
513
|
+
|
|
514
|
+
const protectedDefault = local['@protected'] === true;
|
|
515
|
+
const definition: TermDefinition = {
|
|
516
|
+
iri: null,
|
|
517
|
+
prefix: false,
|
|
518
|
+
protected: protectedDefault,
|
|
519
|
+
reverse: false,
|
|
520
|
+
container: [],
|
|
521
|
+
};
|
|
522
|
+
if (has(value, '@protected')) {
|
|
523
|
+
const flag = value['@protected'];
|
|
524
|
+
if (typeof flag !== 'boolean') throw fail('invalid @protected value', term);
|
|
525
|
+
definition.protected = flag;
|
|
526
|
+
}
|
|
527
|
+
if (has(value, '@type')) {
|
|
528
|
+
const type = value['@type'];
|
|
529
|
+
if (typeof type !== 'string') throw fail('invalid type mapping', term);
|
|
530
|
+
const expanded = this.expandIri(active, type, { vocab: true, definitions: scope });
|
|
531
|
+
const keywordType =
|
|
532
|
+
expanded === '@id' || expanded === '@json' || expanded === '@none' || expanded === '@vocab';
|
|
533
|
+
if (
|
|
534
|
+
expanded === null ||
|
|
535
|
+
(!keywordType && (KEYWORDS.has(expanded) || !isAbsoluteIri(expanded)))
|
|
536
|
+
) {
|
|
537
|
+
throw fail('invalid type mapping', term);
|
|
538
|
+
}
|
|
539
|
+
definition.type = expanded;
|
|
540
|
+
}
|
|
541
|
+
if (has(value, '@reverse')) {
|
|
542
|
+
if (has(value, '@id') || has(value, '@nest')) throw fail('invalid reverse property', term);
|
|
543
|
+
const reverse = value['@reverse'];
|
|
544
|
+
if (typeof reverse !== 'string') throw fail('invalid IRI mapping', term);
|
|
545
|
+
if (KEYWORD_FORM.test(reverse) && !KEYWORDS.has(reverse)) {
|
|
546
|
+
defined.set(term, true);
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
const iri = this.expandIri(active, reverse, { vocab: true, definitions: scope });
|
|
550
|
+
if (iri === null || !iri.includes(':')) throw fail('invalid IRI mapping', term);
|
|
551
|
+
definition.iri = iri;
|
|
552
|
+
if (has(value, '@container')) {
|
|
553
|
+
const container = value['@container'];
|
|
554
|
+
if (container !== null && container !== '@set' && container !== '@index') {
|
|
555
|
+
throw fail('invalid reverse property', term);
|
|
556
|
+
}
|
|
557
|
+
definition.container = container === null ? [] : [container];
|
|
558
|
+
}
|
|
559
|
+
definition.reverse = true;
|
|
560
|
+
active.terms.set(term, definition);
|
|
561
|
+
defined.set(term, true);
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (has(value, '@id') && value['@id'] !== term) {
|
|
565
|
+
const id = value['@id'];
|
|
566
|
+
if (id !== null) {
|
|
567
|
+
if (typeof id !== 'string') throw fail('invalid IRI mapping', term);
|
|
568
|
+
if (KEYWORD_FORM.test(id) && !KEYWORDS.has(id)) {
|
|
569
|
+
defined.set(term, true);
|
|
570
|
+
return;
|
|
571
|
+
}
|
|
572
|
+
const iri = this.expandIri(active, id, { vocab: true, definitions: scope });
|
|
573
|
+
if (iri === null || (!KEYWORDS.has(iri) && !iri.includes(':'))) {
|
|
574
|
+
throw fail('invalid IRI mapping', term);
|
|
575
|
+
}
|
|
576
|
+
if (iri === '@context') throw fail('invalid keyword alias', term);
|
|
577
|
+
definition.iri = iri;
|
|
578
|
+
const colon = term.indexOf(':', 1);
|
|
579
|
+
if ((colon > 0 && colon < term.length - 1) || term.includes('/')) {
|
|
580
|
+
defined.set(term, true);
|
|
581
|
+
const expandedTerm = this.expandIri(active, term, { vocab: true, definitions: scope });
|
|
582
|
+
if (expandedTerm !== iri)
|
|
583
|
+
throw fail('invalid IRI mapping', `${term} does not expand to its @id`);
|
|
584
|
+
}
|
|
585
|
+
if (
|
|
586
|
+
!term.includes(':') &&
|
|
587
|
+
!term.includes('/') &&
|
|
588
|
+
simpleTerm &&
|
|
589
|
+
(GEN_DELIM_END.test(iri) || iri.startsWith('_:'))
|
|
590
|
+
) {
|
|
591
|
+
definition.prefix = true;
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
} else if (term.indexOf(':', 1) > 0) {
|
|
595
|
+
const colon = term.indexOf(':', 1);
|
|
596
|
+
const prefix = term.slice(0, colon);
|
|
597
|
+
if (has(local, prefix)) this.createTermDefinition(active, prefix, scope);
|
|
598
|
+
const prefixIri = active.terms.get(prefix)?.iri;
|
|
599
|
+
definition.iri =
|
|
600
|
+
prefixIri !== undefined && prefixIri !== null ? prefixIri + term.slice(colon + 1) : term;
|
|
601
|
+
} else if (term.includes('/')) {
|
|
602
|
+
const iri = this.expandIri(active, term, { vocab: true });
|
|
603
|
+
if (iri === null || !isAbsoluteIri(iri)) throw fail('invalid IRI mapping', term);
|
|
604
|
+
definition.iri = iri;
|
|
605
|
+
} else if (term === '@type') {
|
|
606
|
+
definition.iri = '@type';
|
|
607
|
+
} else if (active.vocab !== undefined) {
|
|
608
|
+
definition.iri = active.vocab + term;
|
|
609
|
+
} else {
|
|
610
|
+
throw fail('invalid IRI mapping', `${term} (no @id and no @vocab)`);
|
|
611
|
+
}
|
|
612
|
+
if (has(value, '@container')) {
|
|
613
|
+
const raw = value['@container'];
|
|
614
|
+
const container = Array.isArray(raw) ? raw : [raw];
|
|
615
|
+
if (
|
|
616
|
+
!container.every((item): item is string => typeof item === 'string') ||
|
|
617
|
+
!validContainer(container)
|
|
618
|
+
) {
|
|
619
|
+
throw fail('invalid container mapping', term);
|
|
620
|
+
}
|
|
621
|
+
definition.container = [...container].sort(compareStrings);
|
|
622
|
+
if (container.includes('@type')) {
|
|
623
|
+
if (definition.type === undefined) definition.type = '@id';
|
|
624
|
+
else if (definition.type !== '@id' && definition.type !== '@vocab') {
|
|
625
|
+
throw fail('invalid type mapping', term);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
if (has(value, '@index')) {
|
|
630
|
+
const index = value['@index'];
|
|
631
|
+
if (!definition.container.includes('@index') || typeof index !== 'string') {
|
|
632
|
+
throw fail('invalid term definition', term);
|
|
633
|
+
}
|
|
634
|
+
const expanded = this.expandIri(active, index, { vocab: true });
|
|
635
|
+
if (expanded === null || KEYWORDS.has(expanded) || !isAbsoluteIri(expanded)) {
|
|
636
|
+
throw fail('invalid term definition', term);
|
|
637
|
+
}
|
|
638
|
+
definition.index = index;
|
|
639
|
+
}
|
|
640
|
+
if (has(value, '@context')) {
|
|
641
|
+
const context = value['@context'] ?? null;
|
|
642
|
+
try {
|
|
643
|
+
this.processContext(active, context, scope.baseUrl, scope.remote, true, true, false);
|
|
644
|
+
} catch (error) {
|
|
645
|
+
if (error instanceof JsonLdError) throw fail('invalid scoped context', term);
|
|
646
|
+
throw error;
|
|
647
|
+
}
|
|
648
|
+
definition.context = context;
|
|
649
|
+
definition.baseUrl = scope.baseUrl;
|
|
650
|
+
}
|
|
651
|
+
if (has(value, '@language') && !has(value, '@type')) {
|
|
652
|
+
const language = value['@language'];
|
|
653
|
+
if (language !== null && typeof language !== 'string') {
|
|
654
|
+
throw fail('invalid language mapping', term);
|
|
655
|
+
}
|
|
656
|
+
definition.language = language === null ? null : language.toLowerCase();
|
|
657
|
+
}
|
|
658
|
+
if (has(value, '@direction') && !has(value, '@type')) {
|
|
659
|
+
const direction = value['@direction'];
|
|
660
|
+
if (direction !== null && direction !== 'ltr' && direction !== 'rtl') {
|
|
661
|
+
throw fail('invalid base direction', term);
|
|
662
|
+
}
|
|
663
|
+
definition.direction = direction;
|
|
664
|
+
}
|
|
665
|
+
if (has(value, '@nest')) {
|
|
666
|
+
const nest = value['@nest'];
|
|
667
|
+
if (typeof nest !== 'string' || (KEYWORDS.has(nest) && nest !== '@nest')) {
|
|
668
|
+
throw fail('invalid @nest value', term);
|
|
669
|
+
}
|
|
670
|
+
definition.nest = nest;
|
|
671
|
+
}
|
|
672
|
+
if (has(value, '@prefix')) {
|
|
673
|
+
const prefix = value['@prefix'];
|
|
674
|
+
if (term.includes(':') || term.includes('/')) throw fail('invalid term definition', term);
|
|
675
|
+
if (typeof prefix !== 'boolean') throw fail('invalid @prefix value', term);
|
|
676
|
+
if (prefix && definition.iri !== null && KEYWORDS.has(definition.iri)) {
|
|
677
|
+
throw fail('invalid term definition', term);
|
|
678
|
+
}
|
|
679
|
+
definition.prefix = prefix;
|
|
680
|
+
}
|
|
681
|
+
for (const key of Object.keys(value)) {
|
|
682
|
+
if (!TERM_DEFINITION_KEYS.has(key)) throw fail('invalid term definition', `${term}: ${key}`);
|
|
683
|
+
}
|
|
684
|
+
if (!scope.overrideProtected && previous?.protected) {
|
|
685
|
+
if (!sameDefinition(previous, definition)) throw fail('protected term redefinition', term);
|
|
686
|
+
active.terms.set(term, previous);
|
|
687
|
+
} else active.terms.set(term, definition);
|
|
688
|
+
defined.set(term, true);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ─── IRI expansion (§5.2) ───────────────────────────────────────────────
|
|
692
|
+
|
|
693
|
+
private expandIri(
|
|
694
|
+
active: Context,
|
|
695
|
+
value: string,
|
|
696
|
+
options: {
|
|
697
|
+
readonly vocab?: boolean;
|
|
698
|
+
readonly documentRelative?: boolean;
|
|
699
|
+
readonly definitions?: Definitions;
|
|
700
|
+
},
|
|
701
|
+
): string | null {
|
|
702
|
+
if (KEYWORDS.has(value)) return value;
|
|
703
|
+
if (KEYWORD_FORM.test(value)) return null;
|
|
704
|
+
const scope = options.definitions;
|
|
705
|
+
if (scope && has(scope.local, value) && scope.defined.get(value) !== true) {
|
|
706
|
+
this.createTermDefinition(active as MutableContext, value, scope);
|
|
707
|
+
}
|
|
708
|
+
const definition = active.terms.get(value);
|
|
709
|
+
if (definition?.iri != null && KEYWORDS.has(definition.iri)) return definition.iri;
|
|
710
|
+
if (options.vocab && definition !== undefined) return definition.iri;
|
|
711
|
+
const colon = value.indexOf(':', 1);
|
|
712
|
+
if (colon > 0) {
|
|
713
|
+
const prefix = value.slice(0, colon);
|
|
714
|
+
const suffix = value.slice(colon + 1);
|
|
715
|
+
if (prefix === '_' || suffix.startsWith('//')) return value;
|
|
716
|
+
if (scope && has(scope.local, prefix) && scope.defined.get(prefix) !== true) {
|
|
717
|
+
this.createTermDefinition(active as MutableContext, prefix, scope);
|
|
718
|
+
}
|
|
719
|
+
const prefixDefinition = active.terms.get(prefix);
|
|
720
|
+
if (prefixDefinition?.iri != null && prefixDefinition.prefix) {
|
|
721
|
+
return prefixDefinition.iri + suffix;
|
|
722
|
+
}
|
|
723
|
+
if (isAbsoluteIri(value)) return value;
|
|
724
|
+
}
|
|
725
|
+
if (options.vocab && active.vocab !== undefined) return active.vocab + value;
|
|
726
|
+
if (options.documentRelative) return resolveIri(value, active.base);
|
|
727
|
+
return value;
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
// ─── Expansion (§5.1) ───────────────────────────────────────────────────
|
|
731
|
+
|
|
732
|
+
private expand(
|
|
733
|
+
active: Context,
|
|
734
|
+
activeProperty: string | null,
|
|
735
|
+
element: JsonValue,
|
|
736
|
+
baseUrl: string | undefined,
|
|
737
|
+
fromMap: boolean,
|
|
738
|
+
depth: number,
|
|
739
|
+
): JsonValue {
|
|
740
|
+
if (depth > MAX_JSON_LD_NESTING) {
|
|
741
|
+
throw invalidInput(
|
|
742
|
+
`JSON-LD nested deeper than ${MAX_JSON_LD_NESTING} levels is not supported.`,
|
|
743
|
+
{
|
|
744
|
+
format: 'jsonld',
|
|
745
|
+
},
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
if (element === null) return null;
|
|
749
|
+
const definition = activeProperty === null ? undefined : active.terms.get(activeProperty);
|
|
750
|
+
if (isScalar(element)) {
|
|
751
|
+
if (activeProperty === null || activeProperty === '@graph') return null;
|
|
752
|
+
const context =
|
|
753
|
+
definition?.context !== undefined
|
|
754
|
+
? this.scopedContext(active, definition.context, definition.baseUrl, false, true)
|
|
755
|
+
: active;
|
|
756
|
+
return this.expandValue(context, activeProperty, element);
|
|
757
|
+
}
|
|
758
|
+
if (Array.isArray(element)) {
|
|
759
|
+
const result: JsonValue[] = [];
|
|
760
|
+
const list = definition?.container.includes('@list') ?? false;
|
|
761
|
+
for (const item of element) {
|
|
762
|
+
let expanded = this.expand(active, activeProperty, item, baseUrl, fromMap, depth + 1);
|
|
763
|
+
if (list && Array.isArray(expanded)) expanded = { '@list': expanded };
|
|
764
|
+
if (Array.isArray(expanded)) for (const value of expanded) result.push(value);
|
|
765
|
+
else if (expanded !== null) result.push(expanded);
|
|
766
|
+
}
|
|
767
|
+
return result;
|
|
768
|
+
}
|
|
769
|
+
return this.expandObject(active, activeProperty, definition, element, baseUrl, fromMap, depth);
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
private expandObject(
|
|
773
|
+
active: Context,
|
|
774
|
+
activeProperty: string | null,
|
|
775
|
+
definition: TermDefinition | undefined,
|
|
776
|
+
element: JsonMap,
|
|
777
|
+
baseUrl: string | undefined,
|
|
778
|
+
fromMap: boolean,
|
|
779
|
+
depth: number,
|
|
780
|
+
): JsonValue {
|
|
781
|
+
let context = active;
|
|
782
|
+
const keys = Object.keys(element);
|
|
783
|
+
if (context.previous !== undefined && !fromMap) {
|
|
784
|
+
const keeps = keys.some((key) => {
|
|
785
|
+
const expanded = this.expandIri(context, key, { vocab: true });
|
|
786
|
+
return expanded === '@value' || (expanded === '@id' && keys.length === 1);
|
|
787
|
+
});
|
|
788
|
+
if (!keeps) context = context.previous;
|
|
789
|
+
}
|
|
790
|
+
if (definition?.context !== undefined) {
|
|
791
|
+
context = this.scopedContext(context, definition.context, definition.baseUrl, true, true);
|
|
792
|
+
}
|
|
793
|
+
if (has(element, '@context')) {
|
|
794
|
+
context = this.processContext(
|
|
795
|
+
context,
|
|
796
|
+
element['@context'] ?? null,
|
|
797
|
+
baseUrl,
|
|
798
|
+
[],
|
|
799
|
+
false,
|
|
800
|
+
true,
|
|
801
|
+
true,
|
|
802
|
+
);
|
|
803
|
+
if (activeProperty === null) this.collectPrefixes(context);
|
|
804
|
+
}
|
|
805
|
+
const typeScoped = context;
|
|
806
|
+
let inputType: string | undefined;
|
|
807
|
+
for (const key of [...keys].sort(compareStrings)) {
|
|
808
|
+
if (this.expandIri(context, key, { vocab: true }) !== '@type') continue;
|
|
809
|
+
const value = element[key] ?? null;
|
|
810
|
+
const types = arrayify(value)
|
|
811
|
+
.filter((type): type is string => typeof type === 'string')
|
|
812
|
+
.sort(compareStrings);
|
|
813
|
+
for (const type of types) {
|
|
814
|
+
const typeDefinition = typeScoped.terms.get(type);
|
|
815
|
+
if (typeDefinition?.context !== undefined) {
|
|
816
|
+
context = this.scopedContext(
|
|
817
|
+
context,
|
|
818
|
+
typeDefinition.context,
|
|
819
|
+
typeDefinition.baseUrl,
|
|
820
|
+
false,
|
|
821
|
+
false,
|
|
822
|
+
);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
if (inputType === undefined) {
|
|
826
|
+
const last = arrayify(value).at(-1);
|
|
827
|
+
if (typeof last === 'string')
|
|
828
|
+
inputType = this.expandIri(context, last, { vocab: true }) ?? undefined;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
const result: JsonMap = {};
|
|
832
|
+
this.expandEntries(
|
|
833
|
+
context,
|
|
834
|
+
typeScoped,
|
|
835
|
+
activeProperty,
|
|
836
|
+
element,
|
|
837
|
+
baseUrl,
|
|
838
|
+
result,
|
|
839
|
+
inputType,
|
|
840
|
+
depth,
|
|
841
|
+
);
|
|
842
|
+
|
|
843
|
+
let output: JsonValue = result;
|
|
844
|
+
if (has(result, '@value')) {
|
|
845
|
+
const resultKeys = Object.keys(result);
|
|
846
|
+
if (
|
|
847
|
+
resultKeys.some((key) => !VALUE_KEYS.has(key)) ||
|
|
848
|
+
(has(result, '@type') && (has(result, '@language') || has(result, '@direction')))
|
|
849
|
+
) {
|
|
850
|
+
throw fail('invalid value object', resultKeys);
|
|
851
|
+
}
|
|
852
|
+
const type = result['@type'];
|
|
853
|
+
const value = result['@value'];
|
|
854
|
+
if (type === '@json') {
|
|
855
|
+
// Any JSON value.
|
|
856
|
+
} else if (value === null) return null;
|
|
857
|
+
else if (typeof value !== 'string' && has(result, '@language')) {
|
|
858
|
+
throw fail('invalid language-tagged value', value ?? null);
|
|
859
|
+
} else if (
|
|
860
|
+
has(result, '@type') &&
|
|
861
|
+
(typeof type !== 'string' || !isAbsoluteIri(type) || !wellFormedIri(type))
|
|
862
|
+
) {
|
|
863
|
+
throw fail('invalid typed value', type ?? null);
|
|
864
|
+
}
|
|
865
|
+
} else if (has(result, '@type') && !Array.isArray(result['@type'])) {
|
|
866
|
+
result['@type'] = [result['@type'] ?? null];
|
|
867
|
+
} else if (has(result, '@set') || has(result, '@list')) {
|
|
868
|
+
const resultKeys = Object.keys(result);
|
|
869
|
+
if (resultKeys.length > 2 || (resultKeys.length === 2 && !has(result, '@index'))) {
|
|
870
|
+
throw fail('invalid set or list object', resultKeys);
|
|
871
|
+
}
|
|
872
|
+
if (has(result, '@set')) output = result['@set'] ?? null;
|
|
873
|
+
}
|
|
874
|
+
if (isMap(output) && Object.keys(output).length === 1 && has(output, '@language')) return null;
|
|
875
|
+
if ((activeProperty === null || activeProperty === '@graph') && isMap(output)) {
|
|
876
|
+
const outputKeys = Object.keys(output);
|
|
877
|
+
if (outputKeys.length === 0 || has(output, '@value') || has(output, '@list')) return null;
|
|
878
|
+
if (outputKeys.length === 1 && has(output, '@id')) return null;
|
|
879
|
+
}
|
|
880
|
+
return output;
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
/** Step 13 (and 14, nested properties) of the expansion algorithm. */
|
|
884
|
+
private expandEntries(
|
|
885
|
+
context: Context,
|
|
886
|
+
typeScoped: Context,
|
|
887
|
+
activeProperty: string | null,
|
|
888
|
+
element: JsonMap,
|
|
889
|
+
baseUrl: string | undefined,
|
|
890
|
+
result: JsonMap,
|
|
891
|
+
inputType: string | undefined,
|
|
892
|
+
depth: number,
|
|
893
|
+
): void {
|
|
894
|
+
const nests: string[] = [];
|
|
895
|
+
// Keys in code unit order, as jsonld.js does: the result never depends on key order.
|
|
896
|
+
for (const key of Object.keys(element).sort(compareStrings)) {
|
|
897
|
+
if (key === '@context') continue;
|
|
898
|
+
const value = element[key] ?? null;
|
|
899
|
+
const property = this.expandIri(context, key, { vocab: true });
|
|
900
|
+
if (property === null || (!property.includes(':') && !KEYWORDS.has(property))) {
|
|
901
|
+
this.unmapped.add(key);
|
|
902
|
+
continue;
|
|
903
|
+
}
|
|
904
|
+
if (KEYWORDS.has(property)) {
|
|
905
|
+
if (activeProperty === '@reverse') throw fail('invalid reverse property map', key);
|
|
906
|
+
if (has(result, property) && property !== '@included' && property !== '@type') {
|
|
907
|
+
throw fail('colliding keywords', property);
|
|
908
|
+
}
|
|
909
|
+
let expanded: JsonValue;
|
|
910
|
+
switch (property) {
|
|
911
|
+
case '@id':
|
|
912
|
+
if (typeof value !== 'string') throw fail('invalid @id value', value);
|
|
913
|
+
expanded = this.expandIri(context, value, { documentRelative: true });
|
|
914
|
+
break;
|
|
915
|
+
case '@type': {
|
|
916
|
+
const types = arrayify(value);
|
|
917
|
+
if (!types.every((type): type is string => typeof type === 'string')) {
|
|
918
|
+
throw fail('invalid type value', value);
|
|
919
|
+
}
|
|
920
|
+
const iris: JsonValue[] = [];
|
|
921
|
+
for (const type of types) {
|
|
922
|
+
const iri = this.expandIri(typeScoped, type, { vocab: true, documentRelative: true });
|
|
923
|
+
if (iri !== null) iris.push(iri);
|
|
924
|
+
}
|
|
925
|
+
expanded = has(result, '@type')
|
|
926
|
+
? [...arrayify(result['@type'] ?? null), ...iris]
|
|
927
|
+
: iris;
|
|
928
|
+
if (!Array.isArray(value) && !has(result, '@type') && iris.length === 1) {
|
|
929
|
+
expanded = iris[0] ?? null;
|
|
930
|
+
}
|
|
931
|
+
break;
|
|
932
|
+
}
|
|
933
|
+
case '@graph':
|
|
934
|
+
expanded = arrayify(this.expand(context, '@graph', value, baseUrl, false, depth + 1));
|
|
935
|
+
break;
|
|
936
|
+
case '@included': {
|
|
937
|
+
const included = arrayify(this.expand(context, null, value, baseUrl, false, depth + 1));
|
|
938
|
+
if (!included.every(isNodeObject)) throw fail('invalid @included value', value);
|
|
939
|
+
expanded = has(result, '@included')
|
|
940
|
+
? [...arrayify(result['@included'] ?? null), ...included]
|
|
941
|
+
: included;
|
|
942
|
+
break;
|
|
943
|
+
}
|
|
944
|
+
case '@value':
|
|
945
|
+
if (inputType !== '@json' && value !== null && !isScalar(value)) {
|
|
946
|
+
throw fail('invalid value object value', value);
|
|
947
|
+
}
|
|
948
|
+
expanded = value;
|
|
949
|
+
break;
|
|
950
|
+
case '@language':
|
|
951
|
+
if (typeof value !== 'string') throw fail('invalid language-tagged string', value);
|
|
952
|
+
expanded = value.toLowerCase();
|
|
953
|
+
break;
|
|
954
|
+
case '@direction':
|
|
955
|
+
if (value !== 'ltr' && value !== 'rtl') throw fail('invalid base direction', value);
|
|
956
|
+
expanded = value;
|
|
957
|
+
break;
|
|
958
|
+
case '@index':
|
|
959
|
+
if (typeof value !== 'string') throw fail('invalid @index value', value);
|
|
960
|
+
expanded = value;
|
|
961
|
+
break;
|
|
962
|
+
case '@list':
|
|
963
|
+
if (activeProperty === null || activeProperty === '@graph') continue;
|
|
964
|
+
expanded = arrayify(
|
|
965
|
+
this.expand(context, activeProperty, value, baseUrl, false, depth + 1),
|
|
966
|
+
);
|
|
967
|
+
break;
|
|
968
|
+
case '@set':
|
|
969
|
+
expanded = this.expand(context, activeProperty, value, baseUrl, false, depth + 1);
|
|
970
|
+
break;
|
|
971
|
+
case '@reverse':
|
|
972
|
+
this.expandReverse(context, value, baseUrl, result, depth);
|
|
973
|
+
continue;
|
|
974
|
+
case '@nest':
|
|
975
|
+
nests.push(key);
|
|
976
|
+
continue;
|
|
977
|
+
default:
|
|
978
|
+
continue;
|
|
979
|
+
}
|
|
980
|
+
// A null @value is kept: whether @type means a datatype depends on it.
|
|
981
|
+
setOwn(result, property, expanded);
|
|
982
|
+
continue;
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
const definition = context.terms.get(key);
|
|
986
|
+
const container = definition?.container ?? [];
|
|
987
|
+
let expanded: JsonValue;
|
|
988
|
+
if (definition?.type === '@json') expanded = { '@value': value, '@type': '@json' };
|
|
989
|
+
else if (container.includes('@language') && isMap(value)) {
|
|
990
|
+
expanded = this.expandLanguageMap(context, definition, value);
|
|
991
|
+
} else if (
|
|
992
|
+
definition !== undefined &&
|
|
993
|
+
(container.includes('@index') ||
|
|
994
|
+
container.includes('@type') ||
|
|
995
|
+
container.includes('@id')) &&
|
|
996
|
+
isMap(value)
|
|
997
|
+
) {
|
|
998
|
+
expanded = this.expandIndexMap(context, key, definition, value, baseUrl, depth);
|
|
999
|
+
} else {
|
|
1000
|
+
expanded = this.expand(context, key, value, baseUrl, false, depth + 1);
|
|
1001
|
+
}
|
|
1002
|
+
if (expanded === null) continue;
|
|
1003
|
+
if (container.includes('@list') && !isListObject(expanded)) {
|
|
1004
|
+
expanded = { '@list': arrayify(expanded) };
|
|
1005
|
+
}
|
|
1006
|
+
if (
|
|
1007
|
+
container.includes('@graph') &&
|
|
1008
|
+
!container.includes('@id') &&
|
|
1009
|
+
!container.includes('@index')
|
|
1010
|
+
) {
|
|
1011
|
+
expanded = arrayify(expanded).map((item): JsonValue => ({ '@graph': arrayify(item) }));
|
|
1012
|
+
}
|
|
1013
|
+
if (definition?.reverse) {
|
|
1014
|
+
const reverseMap = this.reverseMapOf(result);
|
|
1015
|
+
for (const item of arrayify(expanded)) {
|
|
1016
|
+
if (isValueObject(item) || isListObject(item)) {
|
|
1017
|
+
throw fail('invalid reverse property value', key);
|
|
1018
|
+
}
|
|
1019
|
+
addValues(reverseMap, property, [item]);
|
|
1020
|
+
}
|
|
1021
|
+
} else addValues(result, property, arrayify(expanded));
|
|
1022
|
+
}
|
|
1023
|
+
// Nested properties belong to the node, read with the nesting term's scoped context.
|
|
1024
|
+
for (const nest of nests.sort(compareStrings)) {
|
|
1025
|
+
const nestDefinition = context.terms.get(nest);
|
|
1026
|
+
const nestContext =
|
|
1027
|
+
nestDefinition?.context !== undefined
|
|
1028
|
+
? this.scopedContext(context, nestDefinition.context, nestDefinition.baseUrl, true, true)
|
|
1029
|
+
: context;
|
|
1030
|
+
for (const nested of arrayify(element[nest] ?? null)) {
|
|
1031
|
+
if (
|
|
1032
|
+
!isMap(nested) ||
|
|
1033
|
+
Object.keys(nested).some(
|
|
1034
|
+
(key) => this.expandIri(nestContext, key, { vocab: true }) === '@value',
|
|
1035
|
+
)
|
|
1036
|
+
) {
|
|
1037
|
+
throw fail('invalid @nest value', nested);
|
|
1038
|
+
}
|
|
1039
|
+
this.expandEntries(
|
|
1040
|
+
nestContext,
|
|
1041
|
+
typeScoped,
|
|
1042
|
+
nest,
|
|
1043
|
+
nested,
|
|
1044
|
+
baseUrl,
|
|
1045
|
+
result,
|
|
1046
|
+
inputType,
|
|
1047
|
+
depth + 1,
|
|
1048
|
+
);
|
|
1049
|
+
}
|
|
1050
|
+
}
|
|
1051
|
+
}
|
|
1052
|
+
|
|
1053
|
+
private expandReverse(
|
|
1054
|
+
context: Context,
|
|
1055
|
+
value: JsonValue,
|
|
1056
|
+
baseUrl: string | undefined,
|
|
1057
|
+
result: JsonMap,
|
|
1058
|
+
depth: number,
|
|
1059
|
+
): void {
|
|
1060
|
+
if (!isMap(value)) throw fail('invalid @reverse value', value);
|
|
1061
|
+
const expanded = this.expand(context, '@reverse', value, baseUrl, false, depth + 1);
|
|
1062
|
+
if (!isMap(expanded)) return;
|
|
1063
|
+
const doubled = expanded['@reverse'];
|
|
1064
|
+
if (isMap(doubled)) {
|
|
1065
|
+
for (const property of Object.keys(doubled)) {
|
|
1066
|
+
addValues(result, property, arrayify(doubled[property] ?? null));
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
for (const property of Object.keys(expanded)) {
|
|
1070
|
+
if (property === '@reverse') continue;
|
|
1071
|
+
const reverseMap = this.reverseMapOf(result);
|
|
1072
|
+
for (const item of arrayify(expanded[property] ?? null)) {
|
|
1073
|
+
if (isValueObject(item) || isListObject(item)) {
|
|
1074
|
+
throw fail('invalid reverse property value', property);
|
|
1075
|
+
}
|
|
1076
|
+
addValues(reverseMap, property, [item]);
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
private reverseMapOf(result: JsonMap): JsonMap {
|
|
1082
|
+
const existing = result['@reverse'];
|
|
1083
|
+
if (isMap(existing)) return existing;
|
|
1084
|
+
const map: JsonMap = {};
|
|
1085
|
+
result['@reverse'] = map;
|
|
1086
|
+
return map;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
private expandLanguageMap(
|
|
1090
|
+
context: Context,
|
|
1091
|
+
definition: TermDefinition | undefined,
|
|
1092
|
+
value: JsonMap,
|
|
1093
|
+
): JsonValue[] {
|
|
1094
|
+
const out: JsonValue[] = [];
|
|
1095
|
+
const direction =
|
|
1096
|
+
definition?.direction !== undefined ? definition.direction : context.direction;
|
|
1097
|
+
for (const language of Object.keys(value).sort(compareStrings)) {
|
|
1098
|
+
const none = this.expandIri(context, language, { vocab: true }) === '@none';
|
|
1099
|
+
for (const item of arrayify(value[language] ?? null)) {
|
|
1100
|
+
if (item === null) continue;
|
|
1101
|
+
if (typeof item !== 'string') throw fail('invalid language map value', item);
|
|
1102
|
+
const expanded: JsonMap = { '@value': item };
|
|
1103
|
+
if (!none) expanded['@language'] = language.toLowerCase();
|
|
1104
|
+
if (direction) expanded['@direction'] = direction;
|
|
1105
|
+
out.push(expanded);
|
|
1106
|
+
}
|
|
1107
|
+
}
|
|
1108
|
+
return out;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
/** Index, id and type maps (§5.1.2 step 13.8). */
|
|
1112
|
+
private expandIndexMap(
|
|
1113
|
+
context: Context,
|
|
1114
|
+
key: string,
|
|
1115
|
+
definition: TermDefinition,
|
|
1116
|
+
value: JsonMap,
|
|
1117
|
+
baseUrl: string | undefined,
|
|
1118
|
+
depth: number,
|
|
1119
|
+
): JsonValue[] {
|
|
1120
|
+
const { container } = definition;
|
|
1121
|
+
const out: JsonValue[] = [];
|
|
1122
|
+
const indexKey = definition.index ?? '@index';
|
|
1123
|
+
const byIdOrType = container.includes('@id') || container.includes('@type');
|
|
1124
|
+
for (const index of Object.keys(value).sort(compareStrings)) {
|
|
1125
|
+
let mapContext = byIdOrType ? (context.previous ?? context) : context;
|
|
1126
|
+
const indexDefinition = mapContext.terms.get(index);
|
|
1127
|
+
if (container.includes('@type') && indexDefinition?.context !== undefined) {
|
|
1128
|
+
mapContext = this.scopedContext(
|
|
1129
|
+
mapContext,
|
|
1130
|
+
indexDefinition.context,
|
|
1131
|
+
indexDefinition.baseUrl,
|
|
1132
|
+
false,
|
|
1133
|
+
true,
|
|
1134
|
+
);
|
|
1135
|
+
}
|
|
1136
|
+
const expandedIndex = this.expandIri(context, index, { vocab: true });
|
|
1137
|
+
const items = arrayify(
|
|
1138
|
+
this.expand(mapContext, key, arrayify(value[index] ?? null), baseUrl, true, depth + 1),
|
|
1139
|
+
);
|
|
1140
|
+
for (let item of items) {
|
|
1141
|
+
if (container.includes('@graph') && !isGraphObject(item))
|
|
1142
|
+
item = { '@graph': arrayify(item) };
|
|
1143
|
+
if (!isMap(item)) continue;
|
|
1144
|
+
if (container.includes('@index') && indexKey !== '@index' && expandedIndex !== '@none') {
|
|
1145
|
+
if (isValueObject(item)) throw fail('invalid value object', item);
|
|
1146
|
+
const reExpanded = this.expandValue(context, indexKey, index);
|
|
1147
|
+
const indexProperty = this.expandIri(context, indexKey, { vocab: true });
|
|
1148
|
+
if (indexProperty !== null) {
|
|
1149
|
+
setOwn(item, indexProperty, [reExpanded, ...arrayify(item[indexProperty] ?? [])]);
|
|
1150
|
+
}
|
|
1151
|
+
} else if (
|
|
1152
|
+
container.includes('@index') &&
|
|
1153
|
+
!has(item, '@index') &&
|
|
1154
|
+
expandedIndex !== '@none'
|
|
1155
|
+
) {
|
|
1156
|
+
item['@index'] = index;
|
|
1157
|
+
} else if (container.includes('@id') && !has(item, '@id') && expandedIndex !== '@none') {
|
|
1158
|
+
item['@id'] = this.expandIri(context, index, { documentRelative: true });
|
|
1159
|
+
} else if (container.includes('@type') && expandedIndex !== '@none') {
|
|
1160
|
+
item['@type'] = [expandedIndex, ...arrayify(item['@type'] ?? [])];
|
|
1161
|
+
}
|
|
1162
|
+
out.push(item);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
return out;
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
/** Value expansion (§5.3). */
|
|
1169
|
+
private expandValue(
|
|
1170
|
+
context: Context,
|
|
1171
|
+
activeProperty: string,
|
|
1172
|
+
value: string | number | boolean,
|
|
1173
|
+
): JsonValue {
|
|
1174
|
+
const definition = context.terms.get(activeProperty);
|
|
1175
|
+
const type = definition?.type;
|
|
1176
|
+
if (type === '@id' && typeof value === 'string') {
|
|
1177
|
+
return { '@id': this.expandIri(context, value, { documentRelative: true }) };
|
|
1178
|
+
}
|
|
1179
|
+
if (type === '@vocab' && typeof value === 'string') {
|
|
1180
|
+
return { '@id': this.expandIri(context, value, { vocab: true, documentRelative: true }) };
|
|
1181
|
+
}
|
|
1182
|
+
const result: JsonMap = { '@value': value };
|
|
1183
|
+
if (type !== undefined && type !== '@id' && type !== '@vocab' && type !== '@none') {
|
|
1184
|
+
result['@type'] = type;
|
|
1185
|
+
} else if (typeof value === 'string') {
|
|
1186
|
+
const language = definition?.language !== undefined ? definition.language : context.language;
|
|
1187
|
+
const direction =
|
|
1188
|
+
definition?.direction !== undefined ? definition.direction : context.direction;
|
|
1189
|
+
if (language !== null && language !== undefined) result['@language'] = language;
|
|
1190
|
+
if (direction !== null && direction !== undefined) result['@direction'] = direction;
|
|
1191
|
+
}
|
|
1192
|
+
return result;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
/** Prefix-capable terms of a top-level context, reported for display like `@prefix`. */
|
|
1196
|
+
private collectPrefixes(context: Context): void {
|
|
1197
|
+
for (const [term, definition] of context.terms) {
|
|
1198
|
+
const iri = definition.iri;
|
|
1199
|
+
if (!definition.prefix || iri === null || KEYWORDS.has(iri) || iri.startsWith('_:')) continue;
|
|
1200
|
+
if (!Object.hasOwn(this.prefixes, term)) setOwn(this.prefixes, term, iri);
|
|
1201
|
+
}
|
|
1202
|
+
if (context.vocab !== undefined && !Object.hasOwn(this.prefixes, '')) {
|
|
1203
|
+
setOwn(this.prefixes, '', context.vocab);
|
|
1204
|
+
}
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
// ─── Deserialization to RDF (§8) ────────────────────────────────────────
|
|
1208
|
+
|
|
1209
|
+
/**
|
|
1210
|
+
* Emits the triples of a node object (and, recursively, of every node it embeds) and returns
|
|
1211
|
+
* its subject, or `undefined` when its id is not a usable IRI.
|
|
1212
|
+
*/
|
|
1213
|
+
private emitNode(node: JsonMap, graph: Graph): Resource | undefined {
|
|
1214
|
+
const subject = this.resourceTerm(has(node, '@id') ? (node['@id'] ?? null) : undefined);
|
|
1215
|
+
for (const type of arrayify(node['@type'] ?? [])) {
|
|
1216
|
+
const object = typeof type === 'string' ? this.resourceTerm(type) : undefined;
|
|
1217
|
+
if (subject && object) this.emit(subject, this.iri(RDF_TYPE), object, graph);
|
|
1218
|
+
}
|
|
1219
|
+
for (const property of Object.keys(node)) {
|
|
1220
|
+
if (KEYWORDS.has(property)) continue;
|
|
1221
|
+
const predicate = this.predicateTerm(property);
|
|
1222
|
+
for (const item of arrayify(node[property] ?? null)) {
|
|
1223
|
+
const object = this.objectTerm(item, graph);
|
|
1224
|
+
if (subject && predicate && object) this.emit(subject, predicate, object, graph);
|
|
1225
|
+
}
|
|
1226
|
+
}
|
|
1227
|
+
const reverse = node['@reverse'];
|
|
1228
|
+
if (isMap(reverse)) {
|
|
1229
|
+
for (const property of Object.keys(reverse)) {
|
|
1230
|
+
const predicate = this.predicateTerm(property);
|
|
1231
|
+
for (const item of arrayify(reverse[property] ?? null)) {
|
|
1232
|
+
const object = isMap(item) ? this.emitNode(item, graph) : undefined;
|
|
1233
|
+
if (subject && predicate && object) this.emit(object, predicate, subject, graph);
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
// A graph whose name RDF cannot represent is dropped with its triples, as the spec says.
|
|
1238
|
+
if (has(node, '@graph') && subject) {
|
|
1239
|
+
for (const child of arrayify(node['@graph'] ?? null)) {
|
|
1240
|
+
if (isMap(child)) this.emitNode(child, subject);
|
|
1241
|
+
}
|
|
1242
|
+
}
|
|
1243
|
+
for (const child of arrayify(node['@included'] ?? [])) {
|
|
1244
|
+
if (isMap(child)) this.emitNode(child, graph);
|
|
1245
|
+
}
|
|
1246
|
+
return subject;
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
/** Object to RDF (§8.2): a resource, a literal, or a list head. */
|
|
1250
|
+
private objectTerm(item: JsonValue, graph: Graph): Value | undefined {
|
|
1251
|
+
if (!isMap(item)) return undefined;
|
|
1252
|
+
if (has(item, '@value')) return this.literalTerm(item);
|
|
1253
|
+
if (has(item, '@list')) return this.listTerm(arrayify(item['@list'] ?? null), graph);
|
|
1254
|
+
return this.emitNode(item, graph);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/** List to RDF (§8.3): cells are created even for items that produce no object. */
|
|
1258
|
+
private listTerm(items: readonly JsonValue[], graph: Graph): Resource {
|
|
1259
|
+
if (items.length === 0) return this.iri(RDF_NIL);
|
|
1260
|
+
const cells = items.map(() => this.blank());
|
|
1261
|
+
items.forEach((item, i) => {
|
|
1262
|
+
const cell = cells[i] as BlankNode;
|
|
1263
|
+
const object = this.objectTerm(item, graph);
|
|
1264
|
+
if (object) this.emit(cell, this.iri(RDF_FIRST), object, graph);
|
|
1265
|
+
this.emit(cell, this.iri(RDF_REST), cells[i + 1] ?? this.iri(RDF_NIL), graph);
|
|
1266
|
+
});
|
|
1267
|
+
return cells[0] as BlankNode;
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
private literalTerm(item: JsonMap): Literal | undefined {
|
|
1271
|
+
const value = item['@value'] ?? null;
|
|
1272
|
+
let datatype = typeof item['@type'] === 'string' ? item['@type'] : undefined;
|
|
1273
|
+
const language = typeof item['@language'] === 'string' ? item['@language'] : undefined;
|
|
1274
|
+
if (datatype !== undefined && datatype !== '@json' && !wellFormedIri(datatype))
|
|
1275
|
+
return undefined;
|
|
1276
|
+
if (language !== undefined && !LANGUAGE_TAG.test(language)) return undefined;
|
|
1277
|
+
let lexical: string;
|
|
1278
|
+
if (datatype === '@json') {
|
|
1279
|
+
lexical = canonicalJson(value);
|
|
1280
|
+
datatype = RDF_JSON;
|
|
1281
|
+
} else if (typeof value === 'boolean') {
|
|
1282
|
+
lexical = String(value);
|
|
1283
|
+
datatype ??= XSD_BOOLEAN;
|
|
1284
|
+
} else if (
|
|
1285
|
+
typeof value === 'number' &&
|
|
1286
|
+
(!Number.isInteger(value) || Math.abs(value) >= 1e21 || datatype === XSD_DOUBLE)
|
|
1287
|
+
) {
|
|
1288
|
+
lexical = canonicalDouble(value);
|
|
1289
|
+
datatype ??= XSD_DOUBLE;
|
|
1290
|
+
} else if (typeof value === 'number') {
|
|
1291
|
+
lexical = value.toFixed(0);
|
|
1292
|
+
datatype ??= XSD_INTEGER;
|
|
1293
|
+
} else if (typeof value === 'string') {
|
|
1294
|
+
lexical = value;
|
|
1295
|
+
} else return undefined;
|
|
1296
|
+
if (language !== undefined && (datatype === undefined || datatype === RDF_LANG_STRING)) {
|
|
1297
|
+
return DataFactory.literal(lexical, language);
|
|
1298
|
+
}
|
|
1299
|
+
return datatype === undefined
|
|
1300
|
+
? DataFactory.literal(lexical)
|
|
1301
|
+
: DataFactory.literal(lexical, this.iri(datatype));
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
/** A node id or type: blank node id, IRI, or nothing when RDF cannot represent it. */
|
|
1305
|
+
private resourceTerm(id: JsonValue | undefined): Resource | undefined {
|
|
1306
|
+
if (id === undefined) return this.blank();
|
|
1307
|
+
if (typeof id !== 'string') return undefined;
|
|
1308
|
+
if (id.startsWith('_:')) return new BlankNode(`j${id.slice(2)}`);
|
|
1309
|
+
return wellFormedIri(id) ? this.iri(id) : undefined;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/** Blank node predicates would be generalized RDF, which is not produced. */
|
|
1313
|
+
private predicateTerm(property: string): NamedNode | undefined {
|
|
1314
|
+
return !property.startsWith('_:') && wellFormedIri(property) ? this.iri(property) : undefined;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
private blank(): BlankNode {
|
|
1318
|
+
return new BlankNode(`g${this.blanks++}`);
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
private iri(value: string): NamedNode {
|
|
1322
|
+
let node = this.iris.get(value);
|
|
1323
|
+
if (!node) {
|
|
1324
|
+
node = new NamedNode(value);
|
|
1325
|
+
this.iris.set(value, node);
|
|
1326
|
+
}
|
|
1327
|
+
return node;
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
private emit(subject: Resource, predicate: NamedNode, object: Value, graph: Graph): void {
|
|
1331
|
+
this.quads.push(DataFactory.quad(subject, predicate, object, graph));
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
type Resource = NamedNode | BlankNode;
|
|
1336
|
+
type Value = Resource | Literal;
|
|
1337
|
+
type Graph = Resource | ReturnType<typeof DataFactory.defaultGraph>;
|
|
1338
|
+
|
|
1339
|
+
/** No characters RDF forbids, and at most one `#` (a fragment cannot contain another). */
|
|
1340
|
+
function wellFormedIri(iri: string): boolean {
|
|
1341
|
+
if (iri === '' || iri.indexOf('#') !== iri.lastIndexOf('#')) return false;
|
|
1342
|
+
for (let i = 0; i < iri.length; i++) {
|
|
1343
|
+
if (iri.charCodeAt(i) <= 0x20 || ILLEGAL_IRI_CHARACTERS.includes(iri.charAt(i))) return false;
|
|
1344
|
+
}
|
|
1345
|
+
return true;
|
|
1346
|
+
}
|
|
1347
|
+
|
|
1348
|
+
/** Term definitions are "the same" for `@protected` purposes when all but `protected` match. */
|
|
1349
|
+
function sameDefinition(a: TermDefinition, b: TermDefinition): boolean {
|
|
1350
|
+
const comparable = ({ protected: _protected, baseUrl: _baseUrl, ...rest }: TermDefinition) =>
|
|
1351
|
+
rest;
|
|
1352
|
+
return canonicalJson(comparable(a)) === canonicalJson(comparable(b));
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* JSON Canonicalization Scheme (RFC 8785): sorted keys, ECMAScript number and string forms.
|
|
1357
|
+
* Absent (`undefined`) entries are left out, as `JSON.stringify` does.
|
|
1358
|
+
*/
|
|
1359
|
+
function canonicalJson(value: unknown): string {
|
|
1360
|
+
if (value === undefined) return 'null';
|
|
1361
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
1362
|
+
if (typeof value === 'object' && value !== null) {
|
|
1363
|
+
const record = value as Readonly<Record<string, unknown>>;
|
|
1364
|
+
const keys = Object.keys(record)
|
|
1365
|
+
.filter((key) => record[key] !== undefined)
|
|
1366
|
+
.sort(compareStrings);
|
|
1367
|
+
return `{${keys.map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
|
|
1368
|
+
}
|
|
1369
|
+
return JSON.stringify(value);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/** Canonical xsd:double: `1.1E0`, `1.0E-4`, `-5.0E21` (shortest digits that round-trip). */
|
|
1373
|
+
function canonicalDouble(value: number): string {
|
|
1374
|
+
const [mantissa = '0', exponent = '0'] = value.toExponential().split('e');
|
|
1375
|
+
return `${mantissa.includes('.') ? mantissa : `${mantissa}.0`}E${Number(exponent)}`;
|
|
1376
|
+
}
|