@dwk/rdf 0.1.0-beta.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 +15 -0
- package/README.md +125 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/index.js.map +1 -0
- package/dist/jsonld.d.ts +44 -0
- package/dist/jsonld.d.ts.map +1 -0
- package/dist/jsonld.js +620 -0
- package/dist/jsonld.js.map +1 -0
- package/dist/media-types.d.ts +23 -0
- package/dist/media-types.d.ts.map +1 -0
- package/dist/media-types.js +33 -0
- package/dist/media-types.js.map +1 -0
- package/dist/store.d.ts +38 -0
- package/dist/store.d.ts.map +1 -0
- package/dist/store.js +55 -0
- package/dist/store.js.map +1 -0
- package/dist/turtle.d.ts +30 -0
- package/dist/turtle.d.ts.map +1 -0
- package/dist/turtle.js +20 -0
- package/dist/turtle.js.map +1 -0
- package/package.json +48 -0
- package/src/index.ts +98 -0
- package/src/jsonld.ts +813 -0
- package/src/media-types.ts +36 -0
- package/src/store.ts +106 -0
- package/src/turtle.ts +55 -0
package/src/jsonld.ts
ADDED
|
@@ -0,0 +1,813 @@
|
|
|
1
|
+
import { DataFactory } from "n3";
|
|
2
|
+
import type {
|
|
3
|
+
BlankNode,
|
|
4
|
+
Literal,
|
|
5
|
+
NamedNode,
|
|
6
|
+
Quad,
|
|
7
|
+
Quad_Graph,
|
|
8
|
+
Quad_Object,
|
|
9
|
+
Quad_Subject,
|
|
10
|
+
} from "n3";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* JSON-LD ⇄ RDF for the edge.
|
|
14
|
+
*
|
|
15
|
+
* N3.js does not handle JSON-LD, and `jsonld.js` is too large for the Worker
|
|
16
|
+
* script-size budget. This is a **dependency-free** JSON-LD ↔ quads converter
|
|
17
|
+
* covering the subset `@dwk/solid-pod` content negotiation needs. See
|
|
18
|
+
* `spec/open-questions.md` §4 and the README for the exact supported subset and
|
|
19
|
+
* its known limitations.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/** A parsed JSON value. */
|
|
23
|
+
export type JsonValue =
|
|
24
|
+
| string
|
|
25
|
+
| number
|
|
26
|
+
| boolean
|
|
27
|
+
| null
|
|
28
|
+
| JsonValue[]
|
|
29
|
+
| { [key: string]: JsonValue };
|
|
30
|
+
|
|
31
|
+
/** A JSON object. */
|
|
32
|
+
export type JsonObject = { [key: string]: JsonValue };
|
|
33
|
+
|
|
34
|
+
/** Error raised for malformed or unsupported JSON-LD input. */
|
|
35
|
+
export class JsonLdError extends Error {
|
|
36
|
+
constructor(message: string) {
|
|
37
|
+
super(`@dwk/rdf: ${message}`);
|
|
38
|
+
this.name = "JsonLdError";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const RDF = "http://www.w3.org/1999/02/22-rdf-syntax-ns#";
|
|
43
|
+
const XSD = "http://www.w3.org/2001/XMLSchema#";
|
|
44
|
+
const RDF_TYPE = `${RDF}type`;
|
|
45
|
+
const RDF_FIRST = `${RDF}first`;
|
|
46
|
+
const RDF_REST = `${RDF}rest`;
|
|
47
|
+
const RDF_NIL = `${RDF}nil`;
|
|
48
|
+
const RDF_LIST = `${RDF}List`;
|
|
49
|
+
const RDF_LANGSTRING = `${RDF}langString`;
|
|
50
|
+
const XSD_STRING = `${XSD}string`;
|
|
51
|
+
const XSD_BOOLEAN = `${XSD}boolean`;
|
|
52
|
+
const XSD_INTEGER = `${XSD}integer`;
|
|
53
|
+
const XSD_DOUBLE = `${XSD}double`;
|
|
54
|
+
|
|
55
|
+
// --- Active context -------------------------------------------------------
|
|
56
|
+
|
|
57
|
+
interface TermDefinition {
|
|
58
|
+
/** IRI mapping, or `null` when the term is explicitly disabled. */
|
|
59
|
+
id: string | null;
|
|
60
|
+
/** Type coercion: an IRI datatype, or the keywords `"@id"` / `"@vocab"`. */
|
|
61
|
+
type?: string;
|
|
62
|
+
/** Language coercion (`null` clears the default language). */
|
|
63
|
+
language?: string | null;
|
|
64
|
+
/** Container mapping (`"@list"`, `"@set"`, …). */
|
|
65
|
+
container?: string;
|
|
66
|
+
/** Whether the term is a reverse property. */
|
|
67
|
+
reverse?: boolean;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface ActiveContext {
|
|
71
|
+
base?: string;
|
|
72
|
+
vocab?: string;
|
|
73
|
+
language?: string;
|
|
74
|
+
terms: Map<string, TermDefinition>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function isObject(value: JsonValue | undefined): value is JsonObject {
|
|
78
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function arrayify(value: JsonValue | undefined): JsonValue[] {
|
|
82
|
+
if (value === undefined || value === null) return [];
|
|
83
|
+
return Array.isArray(value) ? value : [value];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function isKeyword(value: string): boolean {
|
|
87
|
+
return value.startsWith("@");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isIriType(type: string | undefined): type is string {
|
|
91
|
+
return type !== undefined && type !== "@id" && type !== "@vocab";
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// An absolute IRI begins with a scheme (`http:`, `urn:`, …). A value without a
|
|
95
|
+
// scheme is a relative reference; JSON-LD 1.0 drops it (rather than emitting a
|
|
96
|
+
// bogus RDF term) when no base resolves it to an absolute IRI.
|
|
97
|
+
const ABSOLUTE_IRI = /^[A-Za-z][A-Za-z0-9+.-]*:/;
|
|
98
|
+
function isAbsoluteIri(value: string): boolean {
|
|
99
|
+
return ABSOLUTE_IRI.test(value);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function resolveIri(base: string | undefined, value: string): string {
|
|
103
|
+
if (base === undefined) return value;
|
|
104
|
+
try {
|
|
105
|
+
return new URL(value, base).href;
|
|
106
|
+
} catch {
|
|
107
|
+
return value;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Expand a term, CURIE, or IRI against the active context. With `vocab`, terms
|
|
113
|
+
* and `@vocab` apply (property / `@type` position); otherwise relative IRIs are
|
|
114
|
+
* resolved against `@base` (`@id` position).
|
|
115
|
+
*/
|
|
116
|
+
function expandIri(
|
|
117
|
+
active: ActiveContext,
|
|
118
|
+
value: string,
|
|
119
|
+
opts: { vocab?: boolean } = {},
|
|
120
|
+
): string {
|
|
121
|
+
if (isKeyword(value)) return value;
|
|
122
|
+
|
|
123
|
+
const term = active.terms.get(value);
|
|
124
|
+
// A term whose definition is `null` is explicitly disabled: it expands to
|
|
125
|
+
// nothing (callers drop empty results) rather than to a bogus IRI.
|
|
126
|
+
if (opts.vocab && term) return term.id ?? "";
|
|
127
|
+
|
|
128
|
+
const colon = value.indexOf(":");
|
|
129
|
+
if (colon > 0) {
|
|
130
|
+
const prefix = value.slice(0, colon);
|
|
131
|
+
const suffix = value.slice(colon + 1);
|
|
132
|
+
if (prefix === "_" || suffix.startsWith("//")) return value;
|
|
133
|
+
const prefixDef = active.terms.get(prefix);
|
|
134
|
+
if (prefixDef?.id) return prefixDef.id + suffix;
|
|
135
|
+
return value;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (opts.vocab) {
|
|
139
|
+
return active.vocab !== undefined ? active.vocab + value : value;
|
|
140
|
+
}
|
|
141
|
+
return resolveIri(active.base, value);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function createTermDefinition(
|
|
145
|
+
active: ActiveContext,
|
|
146
|
+
term: string,
|
|
147
|
+
definition: JsonValue,
|
|
148
|
+
): TermDefinition {
|
|
149
|
+
if (definition === null) return { id: null };
|
|
150
|
+
|
|
151
|
+
if (typeof definition === "string") {
|
|
152
|
+
return { id: expandIri(active, definition, { vocab: true }) };
|
|
153
|
+
}
|
|
154
|
+
if (!isObject(definition)) {
|
|
155
|
+
throw new JsonLdError(`invalid term definition for "${term}"`);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const def: TermDefinition = { id: null };
|
|
159
|
+
if ("@reverse" in definition && definition["@reverse"] != null) {
|
|
160
|
+
def.reverse = true;
|
|
161
|
+
def.id = expandIri(active, String(definition["@reverse"]), { vocab: true });
|
|
162
|
+
} else if ("@id" in definition) {
|
|
163
|
+
const id = definition["@id"];
|
|
164
|
+
def.id = id == null ? null : expandIri(active, String(id), { vocab: true });
|
|
165
|
+
} else {
|
|
166
|
+
def.id = expandIri(active, term, { vocab: true });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if ("@type" in definition && definition["@type"] != null) {
|
|
170
|
+
const type = String(definition["@type"]);
|
|
171
|
+
def.type =
|
|
172
|
+
type === "@id" || type === "@vocab"
|
|
173
|
+
? type
|
|
174
|
+
: expandIri(active, type, { vocab: true });
|
|
175
|
+
}
|
|
176
|
+
if ("@language" in definition) {
|
|
177
|
+
const language = definition["@language"];
|
|
178
|
+
def.language = language == null ? null : String(language).toLowerCase();
|
|
179
|
+
}
|
|
180
|
+
if ("@container" in definition && definition["@container"] != null) {
|
|
181
|
+
const container = definition["@container"];
|
|
182
|
+
def.container = String(Array.isArray(container) ? container[0] : container);
|
|
183
|
+
}
|
|
184
|
+
return def;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function processContext(
|
|
188
|
+
active: ActiveContext,
|
|
189
|
+
local: JsonValue,
|
|
190
|
+
): ActiveContext {
|
|
191
|
+
const result: ActiveContext = { ...active, terms: new Map(active.terms) };
|
|
192
|
+
|
|
193
|
+
for (const entry of arrayify(local)) {
|
|
194
|
+
if (entry === null) {
|
|
195
|
+
result.terms = new Map();
|
|
196
|
+
delete result.vocab;
|
|
197
|
+
delete result.language;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (typeof entry === "string") {
|
|
201
|
+
throw new JsonLdError(
|
|
202
|
+
`remote @context (${JSON.stringify(entry)}) is not supported; inline the context`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (!isObject(entry)) {
|
|
206
|
+
throw new JsonLdError("invalid @context entry");
|
|
207
|
+
}
|
|
208
|
+
for (const [key, value] of Object.entries(entry)) {
|
|
209
|
+
switch (key) {
|
|
210
|
+
case "@base":
|
|
211
|
+
result.base = value == null ? undefined : String(value);
|
|
212
|
+
break;
|
|
213
|
+
case "@vocab":
|
|
214
|
+
result.vocab = value == null ? undefined : String(value);
|
|
215
|
+
break;
|
|
216
|
+
case "@language":
|
|
217
|
+
result.language =
|
|
218
|
+
value == null ? undefined : String(value).toLowerCase();
|
|
219
|
+
break;
|
|
220
|
+
case "@version":
|
|
221
|
+
case "@protected":
|
|
222
|
+
case "@import":
|
|
223
|
+
break;
|
|
224
|
+
default:
|
|
225
|
+
result.terms.set(key, createTermDefinition(result, key, value));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return result;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// --- Expansion to RDF -----------------------------------------------------
|
|
233
|
+
|
|
234
|
+
class RdfEmitter {
|
|
235
|
+
readonly quads: Quad[] = [];
|
|
236
|
+
private counter = 0;
|
|
237
|
+
private readonly blanks = new Map<string, BlankNode>();
|
|
238
|
+
|
|
239
|
+
private blankFor(label: string): BlankNode {
|
|
240
|
+
let blank = this.blanks.get(label);
|
|
241
|
+
if (!blank) {
|
|
242
|
+
blank = DataFactory.blankNode(`b${this.counter++}`);
|
|
243
|
+
this.blanks.set(label, blank);
|
|
244
|
+
}
|
|
245
|
+
return blank;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
private fresh(): BlankNode {
|
|
249
|
+
return DataFactory.blankNode(`b${this.counter++}`);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
private emit(
|
|
253
|
+
subject: Quad_Subject,
|
|
254
|
+
predicate: NamedNode,
|
|
255
|
+
object: Quad_Object,
|
|
256
|
+
graph: Quad_Graph,
|
|
257
|
+
): void {
|
|
258
|
+
this.quads.push(DataFactory.quad(subject, predicate, object, graph));
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Process a top-level document (node, array of nodes, or default-graph wrapper). */
|
|
262
|
+
toRdf(input: JsonValue, active: ActiveContext): void {
|
|
263
|
+
for (const item of arrayify(input)) {
|
|
264
|
+
if (!isObject(item)) continue;
|
|
265
|
+
const ctx =
|
|
266
|
+
"@context" in item ? processContext(active, item["@context"]) : active;
|
|
267
|
+
|
|
268
|
+
if ("@graph" in item && !("@id" in item)) {
|
|
269
|
+
for (const node of arrayify(item["@graph"])) {
|
|
270
|
+
if (isObject(node)) {
|
|
271
|
+
this.processNode(node, ctx, DataFactory.defaultGraph());
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
this.processNode(item, ctx, DataFactory.defaultGraph());
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Resolve an already-expanded `@id` to a subject/object term, or `null` when
|
|
282
|
+
* it is still a relative reference (JSON-LD 1.0 drops such terms rather than
|
|
283
|
+
* minting an invalid NamedNode).
|
|
284
|
+
*/
|
|
285
|
+
private idToTerm(id: string): NamedNode | BlankNode | null {
|
|
286
|
+
if (id.startsWith("_:")) return this.blankFor(id);
|
|
287
|
+
return isAbsoluteIri(id) ? DataFactory.namedNode(id) : null;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
private nodeSubject(
|
|
291
|
+
node: JsonObject,
|
|
292
|
+
active: ActiveContext,
|
|
293
|
+
): NamedNode | BlankNode | null {
|
|
294
|
+
if ("@id" in node && node["@id"] != null) {
|
|
295
|
+
return this.idToTerm(expandIri(active, String(node["@id"])));
|
|
296
|
+
}
|
|
297
|
+
return this.fresh();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private processNode(
|
|
301
|
+
node: JsonObject,
|
|
302
|
+
context: ActiveContext,
|
|
303
|
+
graph: Quad_Graph,
|
|
304
|
+
): NamedNode | BlankNode | null {
|
|
305
|
+
const active =
|
|
306
|
+
"@context" in node ? processContext(context, node["@context"]) : context;
|
|
307
|
+
const subject = this.nodeSubject(node, active);
|
|
308
|
+
// An explicit but unresolvable (relative) @id drops the whole node.
|
|
309
|
+
if (!subject) return null;
|
|
310
|
+
|
|
311
|
+
for (const type of arrayify(node["@type"])) {
|
|
312
|
+
const iri = expandIri(active, String(type), { vocab: true });
|
|
313
|
+
if (iri && !isKeyword(iri) && isAbsoluteIri(iri)) {
|
|
314
|
+
this.emit(
|
|
315
|
+
subject,
|
|
316
|
+
DataFactory.namedNode(RDF_TYPE),
|
|
317
|
+
DataFactory.namedNode(iri),
|
|
318
|
+
graph,
|
|
319
|
+
);
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
for (const [key, value] of Object.entries(node)) {
|
|
324
|
+
if (key === "@context" || key === "@id" || key === "@type") continue;
|
|
325
|
+
|
|
326
|
+
if (key === "@graph") {
|
|
327
|
+
for (const inner of arrayify(value)) {
|
|
328
|
+
if (isObject(inner)) this.processNode(inner, active, subject);
|
|
329
|
+
}
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
if (key === "@reverse") {
|
|
333
|
+
if (isObject(value)) this.processReverse(value, subject, active, graph);
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
if (isKeyword(key)) continue; // @index, @included, … unsupported — skip
|
|
337
|
+
|
|
338
|
+
const predicateIri = expandIri(active, key, { vocab: true });
|
|
339
|
+
if (
|
|
340
|
+
!predicateIri ||
|
|
341
|
+
isKeyword(predicateIri) ||
|
|
342
|
+
!isAbsoluteIri(predicateIri)
|
|
343
|
+
) {
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
const predicate = DataFactory.namedNode(predicateIri);
|
|
347
|
+
const def = active.terms.get(key);
|
|
348
|
+
|
|
349
|
+
if (def?.container === "@list") {
|
|
350
|
+
const head = this.buildList(arrayify(value), def, active, graph);
|
|
351
|
+
this.emit(subject, predicate, head, graph);
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
for (const item of arrayify(value)) {
|
|
356
|
+
if (isObject(item) && "@list" in item) {
|
|
357
|
+
const head = this.buildList(
|
|
358
|
+
arrayify(item["@list"]),
|
|
359
|
+
def,
|
|
360
|
+
active,
|
|
361
|
+
graph,
|
|
362
|
+
);
|
|
363
|
+
this.emit(subject, predicate, head, graph);
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
const object = this.valueToObject(item, def, active, graph);
|
|
367
|
+
if (!object) continue;
|
|
368
|
+
if (def?.reverse) {
|
|
369
|
+
// A literal cannot be the subject of a triple — drop reverse literals.
|
|
370
|
+
if (object.termType !== "Literal") {
|
|
371
|
+
this.emit(object as Quad_Subject, predicate, subject, graph);
|
|
372
|
+
}
|
|
373
|
+
} else {
|
|
374
|
+
this.emit(subject, predicate, object, graph);
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return subject;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
private processReverse(
|
|
382
|
+
reverse: JsonObject,
|
|
383
|
+
subject: NamedNode | BlankNode,
|
|
384
|
+
active: ActiveContext,
|
|
385
|
+
graph: Quad_Graph,
|
|
386
|
+
): void {
|
|
387
|
+
for (const [key, value] of Object.entries(reverse)) {
|
|
388
|
+
const predicateIri = expandIri(active, key, { vocab: true });
|
|
389
|
+
if (
|
|
390
|
+
!predicateIri ||
|
|
391
|
+
isKeyword(predicateIri) ||
|
|
392
|
+
!isAbsoluteIri(predicateIri)
|
|
393
|
+
) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
396
|
+
const predicate = DataFactory.namedNode(predicateIri);
|
|
397
|
+
const def = active.terms.get(key);
|
|
398
|
+
for (const item of arrayify(value)) {
|
|
399
|
+
const object = this.valueToObject(item, def, active, graph);
|
|
400
|
+
// A literal cannot be the subject of a triple — drop reverse literals.
|
|
401
|
+
if (object && object.termType !== "Literal") {
|
|
402
|
+
this.emit(object as Quad_Subject, predicate, subject, graph);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
private valueToObject(
|
|
409
|
+
value: JsonValue,
|
|
410
|
+
def: TermDefinition | undefined,
|
|
411
|
+
active: ActiveContext,
|
|
412
|
+
graph: Quad_Graph,
|
|
413
|
+
): Quad_Object | null {
|
|
414
|
+
if (value === null) return null;
|
|
415
|
+
|
|
416
|
+
if (typeof value === "string") {
|
|
417
|
+
if (def?.type === "@id") {
|
|
418
|
+
return this.idToTerm(expandIri(active, value));
|
|
419
|
+
}
|
|
420
|
+
if (def?.type === "@vocab") {
|
|
421
|
+
const id = expandIri(active, value, { vocab: true });
|
|
422
|
+
return isAbsoluteIri(id) ? DataFactory.namedNode(id) : null;
|
|
423
|
+
}
|
|
424
|
+
return this.literalFor(value, def, active);
|
|
425
|
+
}
|
|
426
|
+
if (typeof value === "number" || typeof value === "boolean") {
|
|
427
|
+
return this.literalFor(value, def, active);
|
|
428
|
+
}
|
|
429
|
+
if (Array.isArray(value)) return null; // nested arrays are not valid here
|
|
430
|
+
|
|
431
|
+
if ("@value" in value) return this.valueObjectToLiteral(value, active);
|
|
432
|
+
if ("@list" in value) {
|
|
433
|
+
return this.buildList(arrayify(value["@list"]), def, active, graph);
|
|
434
|
+
}
|
|
435
|
+
if ("@id" in value && Object.keys(value).length === 1) {
|
|
436
|
+
return this.idToTerm(expandIri(active, String(value["@id"])));
|
|
437
|
+
}
|
|
438
|
+
return this.processNode(value, active, graph);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private literalFor(
|
|
442
|
+
value: string | number | boolean,
|
|
443
|
+
def: TermDefinition | undefined,
|
|
444
|
+
active: ActiveContext,
|
|
445
|
+
): Literal {
|
|
446
|
+
if (typeof value === "boolean") {
|
|
447
|
+
const datatype = isIriType(def?.type) ? def.type : XSD_BOOLEAN;
|
|
448
|
+
return DataFactory.literal(
|
|
449
|
+
value ? "true" : "false",
|
|
450
|
+
DataFactory.namedNode(datatype),
|
|
451
|
+
);
|
|
452
|
+
}
|
|
453
|
+
if (typeof value === "number") {
|
|
454
|
+
const datatype = isIriType(def?.type)
|
|
455
|
+
? def.type
|
|
456
|
+
: isJsonLdDouble(value)
|
|
457
|
+
? XSD_DOUBLE
|
|
458
|
+
: XSD_INTEGER;
|
|
459
|
+
return DataFactory.literal(
|
|
460
|
+
numberToLexical(value, datatype),
|
|
461
|
+
DataFactory.namedNode(datatype),
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
if (isIriType(def?.type)) {
|
|
465
|
+
return DataFactory.literal(value, DataFactory.namedNode(def.type));
|
|
466
|
+
}
|
|
467
|
+
const language =
|
|
468
|
+
def?.language !== undefined ? def.language : active.language;
|
|
469
|
+
if (language) return DataFactory.literal(value, language);
|
|
470
|
+
return DataFactory.literal(value);
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private valueObjectToLiteral(
|
|
474
|
+
valueObject: JsonObject,
|
|
475
|
+
active: ActiveContext,
|
|
476
|
+
): Literal | null {
|
|
477
|
+
const raw = valueObject["@value"];
|
|
478
|
+
// JSON-LD 1.0: a value object whose @value is null (or absent) produces no
|
|
479
|
+
// triple — drop it rather than emit a bogus "null" literal.
|
|
480
|
+
if (raw === null || raw === undefined) return null;
|
|
481
|
+
|
|
482
|
+
// Resolve the explicit datatype first so a numeric @value coerced to
|
|
483
|
+
// xsd:double uses the canonical double lexical form.
|
|
484
|
+
const explicitType =
|
|
485
|
+
"@type" in valueObject && valueObject["@type"] != null
|
|
486
|
+
? expandIri(active, String(valueObject["@type"]), { vocab: true })
|
|
487
|
+
: undefined;
|
|
488
|
+
|
|
489
|
+
const lexical =
|
|
490
|
+
typeof raw === "boolean"
|
|
491
|
+
? raw
|
|
492
|
+
? "true"
|
|
493
|
+
: "false"
|
|
494
|
+
: typeof raw === "number"
|
|
495
|
+
? numberToLexical(
|
|
496
|
+
raw,
|
|
497
|
+
explicitType ?? (isJsonLdDouble(raw) ? XSD_DOUBLE : XSD_INTEGER),
|
|
498
|
+
)
|
|
499
|
+
: String(raw);
|
|
500
|
+
|
|
501
|
+
if (explicitType !== undefined) {
|
|
502
|
+
return DataFactory.literal(lexical, DataFactory.namedNode(explicitType));
|
|
503
|
+
}
|
|
504
|
+
if ("@language" in valueObject && valueObject["@language"] != null) {
|
|
505
|
+
return DataFactory.literal(
|
|
506
|
+
lexical,
|
|
507
|
+
String(valueObject["@language"]).toLowerCase(),
|
|
508
|
+
);
|
|
509
|
+
}
|
|
510
|
+
if (typeof raw === "boolean") {
|
|
511
|
+
return DataFactory.literal(lexical, DataFactory.namedNode(XSD_BOOLEAN));
|
|
512
|
+
}
|
|
513
|
+
if (typeof raw === "number") {
|
|
514
|
+
return DataFactory.literal(
|
|
515
|
+
lexical,
|
|
516
|
+
DataFactory.namedNode(isJsonLdDouble(raw) ? XSD_DOUBLE : XSD_INTEGER),
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
if (active.language) return DataFactory.literal(lexical, active.language);
|
|
520
|
+
return DataFactory.literal(lexical);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
private buildList(
|
|
524
|
+
items: JsonValue[],
|
|
525
|
+
def: TermDefinition | undefined,
|
|
526
|
+
active: ActiveContext,
|
|
527
|
+
graph: Quad_Graph,
|
|
528
|
+
): NamedNode | BlankNode {
|
|
529
|
+
// Strip the list container so list members aren't re-wrapped as lists.
|
|
530
|
+
const itemDef = def?.container ? { ...def, container: undefined } : def;
|
|
531
|
+
const objects = items
|
|
532
|
+
.map((item) => this.valueToObject(item, itemDef, active, graph))
|
|
533
|
+
.filter((object): object is Quad_Object => object !== null);
|
|
534
|
+
|
|
535
|
+
if (objects.length === 0) return DataFactory.namedNode(RDF_NIL);
|
|
536
|
+
|
|
537
|
+
let rest: NamedNode | BlankNode = DataFactory.namedNode(RDF_NIL);
|
|
538
|
+
for (let i = objects.length - 1; i >= 0; i--) {
|
|
539
|
+
const node = this.fresh();
|
|
540
|
+
this.emit(node, DataFactory.namedNode(RDF_FIRST), objects[i]!, graph);
|
|
541
|
+
this.emit(node, DataFactory.namedNode(RDF_REST), rest, graph);
|
|
542
|
+
rest = node;
|
|
543
|
+
}
|
|
544
|
+
return rest;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
548
|
+
/**
|
|
549
|
+
* Whether a JSON number maps to `xsd:double` rather than `xsd:integer`. Matches
|
|
550
|
+
* conformant processors: a number is a double when it has a fractional part or
|
|
551
|
+
* its magnitude is `>= 1e21` (the point past which decimal integer notation is
|
|
552
|
+
* no longer used); otherwise it is an integer.
|
|
553
|
+
*/
|
|
554
|
+
function isJsonLdDouble(value: number): boolean {
|
|
555
|
+
return !Number.isInteger(value) || Math.abs(value) >= 1e21;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function numberToLexical(value: number, datatype: string): string {
|
|
559
|
+
// xsd:double/float canonical forms for the non-finite values; reachable only
|
|
560
|
+
// via pre-parsed object input since JSON itself has no NaN/Infinity.
|
|
561
|
+
if (Number.isNaN(value)) return "NaN";
|
|
562
|
+
if (value === Infinity) return "INF";
|
|
563
|
+
if (value === -Infinity) return "-INF";
|
|
564
|
+
// Canonical xsd:double lexical form — a mantissa with a decimal point and no
|
|
565
|
+
// trailing zeros, an uppercase "E", and a signed exponent (100 → "1.0E2",
|
|
566
|
+
// 1e-7 → "1.0E-7"). Used when the datatype is xsd:double, the value has a
|
|
567
|
+
// fractional part, or (when not explicitly typed xsd:integer) its magnitude is
|
|
568
|
+
// >= 1e21. An explicit xsd:integer is kept in integer form so it never lands
|
|
569
|
+
// outside that datatype's lexical space.
|
|
570
|
+
if (
|
|
571
|
+
datatype === XSD_DOUBLE ||
|
|
572
|
+
!Number.isInteger(value) ||
|
|
573
|
+
(datatype !== XSD_INTEGER && Math.abs(value) >= 1e21)
|
|
574
|
+
) {
|
|
575
|
+
return value.toExponential(15).replace(/(\d)0*e\+?/, "$1E");
|
|
576
|
+
}
|
|
577
|
+
// Canonical xsd:integer form. `toFixed(0)` renders magnitudes >= 1e21 in
|
|
578
|
+
// exponential notation (invalid for xsd:integer), so fall back to BigInt for
|
|
579
|
+
// those — only reachable when a value is explicitly typed xsd:integer.
|
|
580
|
+
return Math.abs(value) < 1e21 ? value.toFixed(0) : BigInt(value).toString();
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/** Options for {@link parseJsonLd}. */
|
|
584
|
+
export interface ParseJsonLdOptions {
|
|
585
|
+
/** Base IRI used to resolve relative `@id` / `@base` references. */
|
|
586
|
+
readonly base?: string;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* Parse a JSON-LD document (string or already-parsed value) into quads.
|
|
591
|
+
*
|
|
592
|
+
* Supports the subset documented in the package README: inline contexts only —
|
|
593
|
+
* no remote (URL) contexts.
|
|
594
|
+
*/
|
|
595
|
+
export async function parseJsonLd(
|
|
596
|
+
input: string | JsonValue,
|
|
597
|
+
options?: ParseJsonLdOptions,
|
|
598
|
+
): Promise<Quad[]> {
|
|
599
|
+
let document: JsonValue;
|
|
600
|
+
try {
|
|
601
|
+
document =
|
|
602
|
+
typeof input === "string" ? (JSON.parse(input) as JsonValue) : input;
|
|
603
|
+
} catch (error) {
|
|
604
|
+
throw new JsonLdError(
|
|
605
|
+
`invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
|
|
606
|
+
);
|
|
607
|
+
}
|
|
608
|
+
const active: ActiveContext = { terms: new Map(), base: options?.base };
|
|
609
|
+
const emitter = new RdfEmitter();
|
|
610
|
+
emitter.toRdf(document, active);
|
|
611
|
+
return emitter.quads;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// --- Serialization from RDF -----------------------------------------------
|
|
615
|
+
|
|
616
|
+
function termId(term: { termType: string; value: string }): string {
|
|
617
|
+
return term.termType === "BlankNode" ? `_:${term.value}` : term.value;
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
function literalToValueObject(literal: Literal): JsonObject {
|
|
621
|
+
const datatype = literal.datatype.value;
|
|
622
|
+
if (datatype === RDF_LANGSTRING) {
|
|
623
|
+
return { "@value": literal.value, "@language": literal.language };
|
|
624
|
+
}
|
|
625
|
+
if (datatype === XSD_STRING) {
|
|
626
|
+
return { "@value": literal.value };
|
|
627
|
+
}
|
|
628
|
+
return { "@value": literal.value, "@type": datatype };
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function objectToValue(object: Quad_Object): JsonValue {
|
|
632
|
+
switch (object.termType) {
|
|
633
|
+
case "NamedNode":
|
|
634
|
+
return { "@id": object.value };
|
|
635
|
+
case "BlankNode":
|
|
636
|
+
return { "@id": `_:${object.value}` };
|
|
637
|
+
case "Literal":
|
|
638
|
+
return literalToValueObject(object);
|
|
639
|
+
default:
|
|
640
|
+
return { "@id": object.value };
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function pushValue(node: JsonObject, key: string, value: JsonValue): void {
|
|
645
|
+
const existing = node[key];
|
|
646
|
+
if (Array.isArray(existing)) existing.push(value);
|
|
647
|
+
else node[key] = [value];
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
/** Where a blank node is referenced as an object value within a graph. */
|
|
651
|
+
interface ListUsage {
|
|
652
|
+
node: JsonObject;
|
|
653
|
+
property: string;
|
|
654
|
+
value: JsonObject;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
function isBlankId(value: JsonValue | undefined): value is string {
|
|
658
|
+
return typeof value === "string" && value.startsWith("_:");
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* A node that is a well-formed list cell: exactly one `rdf:first` and one
|
|
663
|
+
* `rdf:rest`, and at most a `@type` of `rdf:List`.
|
|
664
|
+
*/
|
|
665
|
+
function isListNode(node: JsonObject): boolean {
|
|
666
|
+
const first = node[RDF_FIRST];
|
|
667
|
+
const rest = node[RDF_REST];
|
|
668
|
+
if (!Array.isArray(first) || first.length !== 1) return false;
|
|
669
|
+
if (!Array.isArray(rest) || rest.length !== 1) return false;
|
|
670
|
+
for (const key of Object.keys(node)) {
|
|
671
|
+
if (key === "@id" || key === RDF_FIRST || key === RDF_REST) continue;
|
|
672
|
+
if (key === "@type") {
|
|
673
|
+
const type = node["@type"];
|
|
674
|
+
if (Array.isArray(type) && type.length === 1 && type[0] === RDF_LIST) {
|
|
675
|
+
continue;
|
|
676
|
+
}
|
|
677
|
+
}
|
|
678
|
+
return false;
|
|
679
|
+
}
|
|
680
|
+
return true;
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Collapse well-formed `rdf:first`/`rdf:rest`/`rdf:nil` chains in a graph back
|
|
685
|
+
* into JSON-LD `@list` value objects (the fromRDF list-conversion step), so
|
|
686
|
+
* lists round-trip through their `@list` abstraction rather than as a raw cell
|
|
687
|
+
* chain. An empty list is `rdf:nil`, which — per the JSON-LD data model — is
|
|
688
|
+
* indistinguishable from a property whose value is literally `rdf:nil`, so it is
|
|
689
|
+
* left as a node reference.
|
|
690
|
+
*/
|
|
691
|
+
function convertLists(nodes: Map<string, JsonObject>): void {
|
|
692
|
+
// Record, for every blank-node object reference, the single place it is used
|
|
693
|
+
// (`false` once referenced more than once), plus every use of `rdf:nil`.
|
|
694
|
+
const referencedOnce = new Map<string, ListUsage | false>();
|
|
695
|
+
const nilUsages: ListUsage[] = [];
|
|
696
|
+
|
|
697
|
+
for (const node of nodes.values()) {
|
|
698
|
+
for (const [property, values] of Object.entries(node)) {
|
|
699
|
+
if (property === "@id" || !Array.isArray(values)) continue;
|
|
700
|
+
for (const value of values) {
|
|
701
|
+
if (!isObject(value)) continue;
|
|
702
|
+
const ref = value["@id"];
|
|
703
|
+
if (ref === RDF_NIL) {
|
|
704
|
+
nilUsages.push({ node, property, value });
|
|
705
|
+
} else if (isBlankId(ref)) {
|
|
706
|
+
referencedOnce.set(
|
|
707
|
+
ref,
|
|
708
|
+
referencedOnce.has(ref) ? false : { node, property, value },
|
|
709
|
+
);
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
// Walk each `rdf:nil` terminator back up the `rdf:rest` chain, gathering a
|
|
716
|
+
// list whose cells are each referenced exactly once.
|
|
717
|
+
for (const nilUsage of nilUsages) {
|
|
718
|
+
let { node, property, value: head } = nilUsage;
|
|
719
|
+
const list: JsonValue[] = [];
|
|
720
|
+
const listNodes: string[] = [];
|
|
721
|
+
|
|
722
|
+
while (property === RDF_REST) {
|
|
723
|
+
const id = node["@id"];
|
|
724
|
+
if (!isBlankId(id)) break;
|
|
725
|
+
const usage = referencedOnce.get(id);
|
|
726
|
+
if (!usage || !isListNode(node)) break;
|
|
727
|
+
list.push((node[RDF_FIRST] as JsonValue[])[0] as JsonValue);
|
|
728
|
+
listNodes.push(id);
|
|
729
|
+
({ node, property, value: head } = usage);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
if (listNodes.length === 0) continue;
|
|
733
|
+
// `head` is the reference that points at the list head; rewrite it in place.
|
|
734
|
+
delete head["@id"];
|
|
735
|
+
list.reverse();
|
|
736
|
+
head["@list"] = list;
|
|
737
|
+
for (const id of listNodes) nodes.delete(id);
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/**
|
|
742
|
+
* Serialize quads into JSON-LD in **expanded / flattened** form (node objects,
|
|
743
|
+
* no `@context`). Lists are re-emitted as `@list`. This form round-trips
|
|
744
|
+
* through {@link parseJsonLd} at the RDF (quad) level; note an empty list and a
|
|
745
|
+
* literal `rdf:nil` reference share one representation (see {@link convertLists}).
|
|
746
|
+
*/
|
|
747
|
+
function quadsToJsonLd(quads: Quad[]): JsonValue[] {
|
|
748
|
+
interface GraphBucket {
|
|
749
|
+
nodes: Map<string, JsonObject>;
|
|
750
|
+
}
|
|
751
|
+
const graphs = new Map<string, GraphBucket>();
|
|
752
|
+
const order: string[] = [];
|
|
753
|
+
|
|
754
|
+
for (const quad of quads) {
|
|
755
|
+
const graphKey =
|
|
756
|
+
quad.graph.termType === "DefaultGraph" ? "" : termId(quad.graph);
|
|
757
|
+
let bucket = graphs.get(graphKey);
|
|
758
|
+
if (!bucket) {
|
|
759
|
+
bucket = { nodes: new Map() };
|
|
760
|
+
graphs.set(graphKey, bucket);
|
|
761
|
+
order.push(graphKey);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const subjectKey = termId(quad.subject);
|
|
765
|
+
let node = bucket.nodes.get(subjectKey);
|
|
766
|
+
if (!node) {
|
|
767
|
+
node = { "@id": subjectKey };
|
|
768
|
+
bucket.nodes.set(subjectKey, node);
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
if (
|
|
772
|
+
quad.predicate.value === RDF_TYPE &&
|
|
773
|
+
quad.object.termType === "NamedNode"
|
|
774
|
+
) {
|
|
775
|
+
pushValue(node, "@type", quad.object.value);
|
|
776
|
+
} else {
|
|
777
|
+
pushValue(node, quad.predicate.value, objectToValue(quad.object));
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
for (const bucket of graphs.values()) convertLists(bucket.nodes);
|
|
782
|
+
|
|
783
|
+
const output: JsonValue[] = [];
|
|
784
|
+
const defaultBucket = graphs.get("");
|
|
785
|
+
if (defaultBucket) {
|
|
786
|
+
for (const node of defaultBucket.nodes.values()) output.push(node);
|
|
787
|
+
}
|
|
788
|
+
for (const graphKey of order) {
|
|
789
|
+
if (graphKey === "") continue;
|
|
790
|
+
const bucket = graphs.get(graphKey)!;
|
|
791
|
+
output.push({
|
|
792
|
+
"@id": graphKey,
|
|
793
|
+
"@graph": [...bucket.nodes.values()],
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
return output;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/** Options for {@link writeJsonLd}. */
|
|
800
|
+
export interface WriteJsonLdOptions {
|
|
801
|
+
/** `JSON.stringify` indentation width (default `2`). */
|
|
802
|
+
readonly space?: number;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* Serialize quads into a JSON-LD document string (expanded / flattened form).
|
|
807
|
+
*/
|
|
808
|
+
export async function writeJsonLd(
|
|
809
|
+
quads: Quad[],
|
|
810
|
+
options?: WriteJsonLdOptions,
|
|
811
|
+
): Promise<string> {
|
|
812
|
+
return JSON.stringify(quadsToJsonLd(quads), null, options?.space ?? 2);
|
|
813
|
+
}
|