javascript-solid-server 0.0.28 → 0.0.31
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/package.json +1 -1
- package/src/handlers/resource.js +189 -41
- package/src/rdf/turtle.js +21 -5
- package/src/webid/profile.js +51 -37
package/package.json
CHANGED
package/src/handlers/resource.js
CHANGED
|
@@ -192,38 +192,70 @@ export async function handleGet(request, reply) {
|
|
|
192
192
|
return reply.code(500).send({ error: 'Read error' });
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
-
// Content negotiation for RDF resources
|
|
196
|
-
if (connegEnabled
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
195
|
+
// Content negotiation for RDF resources (including HTML with JSON-LD data islands)
|
|
196
|
+
if (connegEnabled) {
|
|
197
|
+
const contentStr = content.toString();
|
|
198
|
+
const acceptHeader = request.headers.accept || '';
|
|
199
|
+
const wantsTurtle = acceptHeader.includes('text/turtle') ||
|
|
200
|
+
acceptHeader.includes('text/n3') ||
|
|
201
|
+
acceptHeader.includes('application/n-triples');
|
|
202
|
+
|
|
203
|
+
// Check if this is HTML with JSON-LD data island
|
|
204
|
+
const isHtmlWithDataIsland = contentStr.trimStart().startsWith('<!DOCTYPE') ||
|
|
205
|
+
contentStr.trimStart().startsWith('<html');
|
|
206
|
+
|
|
207
|
+
if (isHtmlWithDataIsland && wantsTurtle) {
|
|
208
|
+
// Extract JSON-LD from HTML data island and convert to Turtle
|
|
209
|
+
try {
|
|
210
|
+
const jsonLdMatch = contentStr.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
|
|
211
|
+
if (jsonLdMatch) {
|
|
212
|
+
const jsonLd = JSON.parse(jsonLdMatch[1]);
|
|
213
|
+
const { content: turtleContent } = await fromJsonLd(jsonLd, 'text/turtle', resourceUrl, true);
|
|
214
|
+
|
|
215
|
+
const headers = getAllHeaders({
|
|
216
|
+
isContainer: false,
|
|
217
|
+
etag: stats.etag,
|
|
218
|
+
contentType: 'text/turtle',
|
|
219
|
+
origin,
|
|
220
|
+
resourceUrl,
|
|
221
|
+
connegEnabled
|
|
222
|
+
});
|
|
223
|
+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
|
|
224
|
+
|
|
225
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
226
|
+
return reply.send(turtleContent);
|
|
227
|
+
}
|
|
228
|
+
} catch (err) {
|
|
229
|
+
// Fall through to serve HTML if conversion fails
|
|
230
|
+
console.error('Failed to convert HTML data island to Turtle:', err.message);
|
|
231
|
+
}
|
|
232
|
+
} else if (isRdfContentType(storedContentType)) {
|
|
233
|
+
// Plain JSON-LD file
|
|
234
|
+
try {
|
|
235
|
+
const jsonLd = JSON.parse(contentStr);
|
|
236
|
+
const targetType = selectContentType(acceptHeader, connegEnabled);
|
|
237
|
+
const { content: outputContent, contentType: outputType } = await fromJsonLd(
|
|
238
|
+
jsonLd,
|
|
239
|
+
targetType,
|
|
240
|
+
resourceUrl,
|
|
241
|
+
connegEnabled
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
const headers = getAllHeaders({
|
|
245
|
+
isContainer: false,
|
|
246
|
+
etag: stats.etag,
|
|
247
|
+
contentType: outputType,
|
|
248
|
+
origin,
|
|
249
|
+
resourceUrl,
|
|
250
|
+
connegEnabled
|
|
251
|
+
});
|
|
252
|
+
headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
|
|
253
|
+
|
|
254
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
255
|
+
return reply.send(outputContent);
|
|
256
|
+
} catch (e) {
|
|
257
|
+
// If not valid JSON-LD, serve as-is
|
|
258
|
+
}
|
|
227
259
|
}
|
|
228
260
|
}
|
|
229
261
|
|
|
@@ -495,20 +527,129 @@ export async function handlePatch(request, reply) {
|
|
|
495
527
|
|
|
496
528
|
// Read existing content or start with empty JSON-LD document
|
|
497
529
|
let document;
|
|
530
|
+
let htmlWrapper = null; // Track HTML wrapper for data island re-embedding
|
|
531
|
+
|
|
498
532
|
if (resourceExists) {
|
|
499
533
|
const existingContent = await storage.read(storagePath);
|
|
500
534
|
if (existingContent === null) {
|
|
501
535
|
return reply.code(500).send({ error: 'Read error' });
|
|
502
536
|
}
|
|
503
537
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
538
|
+
const contentStr = existingContent.toString();
|
|
539
|
+
|
|
540
|
+
// Check if this is HTML with embedded JSON-LD data island
|
|
541
|
+
if (contentStr.trimStart().startsWith('<!DOCTYPE') || contentStr.trimStart().startsWith('<html')) {
|
|
542
|
+
// Extract JSON-LD from <script type="application/ld+json"> tag
|
|
543
|
+
const jsonLdMatch = contentStr.match(/<script\s+type=["']application\/ld\+json["']\s*>([\s\S]*?)<\/script>/i);
|
|
544
|
+
|
|
545
|
+
if (!jsonLdMatch) {
|
|
546
|
+
return reply.code(409).send({
|
|
547
|
+
error: 'Conflict',
|
|
548
|
+
message: 'HTML document does not contain a JSON-LD data island'
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
try {
|
|
553
|
+
document = JSON.parse(jsonLdMatch[1]);
|
|
554
|
+
// Save the HTML parts for re-embedding after patch
|
|
555
|
+
const jsonLdStart = contentStr.indexOf(jsonLdMatch[0]) + jsonLdMatch[0].indexOf('>') + 1;
|
|
556
|
+
const jsonLdEnd = jsonLdStart + jsonLdMatch[1].length;
|
|
557
|
+
htmlWrapper = {
|
|
558
|
+
before: contentStr.substring(0, jsonLdStart),
|
|
559
|
+
after: contentStr.substring(jsonLdEnd)
|
|
560
|
+
};
|
|
561
|
+
} catch (e) {
|
|
562
|
+
return reply.code(409).send({
|
|
563
|
+
error: 'Conflict',
|
|
564
|
+
message: 'HTML data island contains invalid JSON-LD'
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
} else {
|
|
568
|
+
// Try to parse as JSON-LD first
|
|
569
|
+
try {
|
|
570
|
+
document = JSON.parse(contentStr);
|
|
571
|
+
} catch (e) {
|
|
572
|
+
// Not JSON - might be Turtle, handle with RDF store for SPARQL Update
|
|
573
|
+
if (isSparqlUpdate) {
|
|
574
|
+
// Parse Turtle and apply SPARQL Update directly
|
|
575
|
+
const { Parser, Writer } = await import('n3');
|
|
576
|
+
const parser = new Parser({ baseIRI: resourceUrl });
|
|
577
|
+
let quads;
|
|
578
|
+
try {
|
|
579
|
+
quads = parser.parse(contentStr);
|
|
580
|
+
} catch (parseErr) {
|
|
581
|
+
return reply.code(409).send({
|
|
582
|
+
error: 'Conflict',
|
|
583
|
+
message: 'Resource is not valid Turtle: ' + parseErr.message
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// Parse the SPARQL Update
|
|
588
|
+
const patchContent = Buffer.isBuffer(request.body) ? request.body.toString() : request.body;
|
|
589
|
+
let update;
|
|
590
|
+
try {
|
|
591
|
+
update = parseSparqlUpdate(patchContent, resourceUrl);
|
|
592
|
+
} catch (parseErr) {
|
|
593
|
+
return reply.code(400).send({
|
|
594
|
+
error: 'Bad Request',
|
|
595
|
+
message: 'Invalid SPARQL Update: ' + parseErr.message
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Apply deletes
|
|
600
|
+
for (const triple of update.deletes) {
|
|
601
|
+
quads = quads.filter(q => {
|
|
602
|
+
const matches = q.subject.value === triple.subject &&
|
|
603
|
+
q.predicate.value === triple.predicate &&
|
|
604
|
+
(q.object.value === (triple.object['@id'] || triple.object['@value'] || triple.object));
|
|
605
|
+
return !matches;
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Apply inserts
|
|
610
|
+
const { DataFactory } = await import('n3');
|
|
611
|
+
const { namedNode, literal } = DataFactory;
|
|
612
|
+
for (const triple of update.inserts) {
|
|
613
|
+
const subj = namedNode(triple.subject);
|
|
614
|
+
const pred = namedNode(triple.predicate);
|
|
615
|
+
let obj;
|
|
616
|
+
if (triple.object['@id']) {
|
|
617
|
+
obj = namedNode(triple.object['@id']);
|
|
618
|
+
} else if (typeof triple.object === 'string') {
|
|
619
|
+
obj = literal(triple.object);
|
|
620
|
+
} else {
|
|
621
|
+
obj = literal(triple.object['@value'] || triple.object);
|
|
622
|
+
}
|
|
623
|
+
quads.push(DataFactory.quad(subj, pred, obj));
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
// Serialize back to Turtle
|
|
627
|
+
const writer = new Writer({ prefixes: {} });
|
|
628
|
+
quads.forEach(q => writer.addQuad(q));
|
|
629
|
+
let turtleOutput;
|
|
630
|
+
writer.end((err, result) => { turtleOutput = result; });
|
|
631
|
+
|
|
632
|
+
const success = await storage.write(storagePath, Buffer.from(turtleOutput));
|
|
633
|
+
if (!success) {
|
|
634
|
+
return reply.code(500).send({ error: 'Write failed' });
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const origin = request.headers.origin;
|
|
638
|
+
const headers = getAllHeaders({ isContainer: false, origin, resourceUrl });
|
|
639
|
+
Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
|
|
640
|
+
|
|
641
|
+
if (request.notificationsEnabled) {
|
|
642
|
+
emitChange(resourceUrl);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
return reply.code(resourceExists ? 204 : 201).send();
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
return reply.code(409).send({
|
|
649
|
+
error: 'Conflict',
|
|
650
|
+
message: 'Resource is not valid JSON-LD and cannot be patched'
|
|
651
|
+
});
|
|
652
|
+
}
|
|
512
653
|
}
|
|
513
654
|
} else {
|
|
514
655
|
// Create empty JSON-LD document for new resource
|
|
@@ -568,7 +709,14 @@ export async function handlePatch(request, reply) {
|
|
|
568
709
|
}
|
|
569
710
|
|
|
570
711
|
// Write updated document
|
|
571
|
-
|
|
712
|
+
let updatedContent;
|
|
713
|
+
if (htmlWrapper) {
|
|
714
|
+
// Re-embed JSON-LD into HTML wrapper
|
|
715
|
+
const jsonLdStr = JSON.stringify(updatedDocument, null, 2);
|
|
716
|
+
updatedContent = htmlWrapper.before + '\n' + jsonLdStr + '\n ' + htmlWrapper.after;
|
|
717
|
+
} else {
|
|
718
|
+
updatedContent = JSON.stringify(updatedDocument, null, 2);
|
|
719
|
+
}
|
|
572
720
|
const success = await storage.write(storagePath, Buffer.from(updatedContent));
|
|
573
721
|
|
|
574
722
|
if (!success) {
|
package/src/rdf/turtle.js
CHANGED
|
@@ -151,14 +151,15 @@ function quadsToJsonLd(quads, baseUri, prefixes = {}) {
|
|
|
151
151
|
nodes.push(jsonNode);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
// Build result
|
|
154
|
+
// Build result - return array if multiple nodes, single object otherwise
|
|
155
155
|
const context = buildContext(prefixes);
|
|
156
156
|
|
|
157
157
|
if (nodes.length === 1) {
|
|
158
158
|
return { '@context': context, ...nodes[0] };
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
-
|
|
161
|
+
// Multiple nodes: return as array (no @graph)
|
|
162
|
+
return nodes.map((node, i) => i === 0 ? { '@context': context, ...node } : node);
|
|
162
163
|
}
|
|
163
164
|
|
|
164
165
|
/**
|
|
@@ -166,10 +167,25 @@ function quadsToJsonLd(quads, baseUri, prefixes = {}) {
|
|
|
166
167
|
*/
|
|
167
168
|
function jsonLdToQuads(jsonLd, baseUri) {
|
|
168
169
|
const quads = [];
|
|
169
|
-
const context = jsonLd['@context'] || {};
|
|
170
170
|
|
|
171
|
-
// Handle
|
|
172
|
-
const
|
|
171
|
+
// Handle array of JSON-LD objects (e.g., from multiple PATCH operations)
|
|
172
|
+
const documents = Array.isArray(jsonLd) ? jsonLd : [jsonLd];
|
|
173
|
+
|
|
174
|
+
// Merge all contexts and collect all nodes
|
|
175
|
+
let mergedContext = {};
|
|
176
|
+
let nodes = [];
|
|
177
|
+
|
|
178
|
+
for (const doc of documents) {
|
|
179
|
+
if (doc['@context']) {
|
|
180
|
+
mergedContext = { ...mergedContext, ...doc['@context'] };
|
|
181
|
+
}
|
|
182
|
+
// Each document with @id is a node (no @graph needed)
|
|
183
|
+
if (doc['@id']) {
|
|
184
|
+
nodes.push(doc);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const context = mergedContext;
|
|
173
189
|
|
|
174
190
|
for (const node of nodes) {
|
|
175
191
|
if (!node['@id']) continue;
|
package/src/webid/profile.js
CHANGED
|
@@ -33,40 +33,32 @@ export function generateProfileJsonLd({ webId, name, podUri, issuer }) {
|
|
|
33
33
|
'inbox': { '@id': 'ldp:inbox', '@type': '@id' },
|
|
34
34
|
'storage': { '@id': 'pim:storage', '@type': '@id' },
|
|
35
35
|
'oidcIssuer': { '@id': 'solid:oidcIssuer', '@type': '@id' },
|
|
36
|
-
'preferencesFile': { '@id': 'pim:preferencesFile', '@type': '@id' }
|
|
36
|
+
'preferencesFile': { '@id': 'pim:preferencesFile', '@type': '@id' },
|
|
37
|
+
'mainEntityOfPage': { '@id': 'schema:mainEntityOfPage', '@type': '@id' }
|
|
37
38
|
},
|
|
38
|
-
'@
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
'@id': webId,
|
|
47
|
-
'@type': ['foaf:Person', 'schema:Person'],
|
|
48
|
-
'foaf:name': name,
|
|
49
|
-
'inbox': `${pod}inbox/`,
|
|
50
|
-
'storage': pod,
|
|
51
|
-
'oidcIssuer': issuer,
|
|
52
|
-
'preferencesFile': `${pod}settings/prefs`
|
|
53
|
-
}
|
|
54
|
-
]
|
|
39
|
+
'@id': webId,
|
|
40
|
+
'@type': ['foaf:Person', 'schema:Person'],
|
|
41
|
+
'foaf:name': name,
|
|
42
|
+
'mainEntityOfPage': profileDoc,
|
|
43
|
+
'inbox': `${pod}inbox/`,
|
|
44
|
+
'storage': pod,
|
|
45
|
+
'oidcIssuer': issuer,
|
|
46
|
+
'preferencesFile': `${pod}settings/prefs`
|
|
55
47
|
};
|
|
56
48
|
}
|
|
57
49
|
|
|
58
50
|
/**
|
|
59
|
-
* Generate HTML profile with embedded JSON-LD
|
|
51
|
+
* Generate HTML profile with embedded JSON-LD data island
|
|
52
|
+
* The page uses mashlib + solidos-lite to render the profile from the data island
|
|
60
53
|
* @param {object} options
|
|
61
54
|
* @param {string} options.webId - Full WebID URI
|
|
62
55
|
* @param {string} options.name - Display name
|
|
63
56
|
* @param {string} options.podUri - Pod root URI
|
|
64
57
|
* @param {string} options.issuer - OIDC issuer URI
|
|
65
|
-
* @returns {string} HTML document with JSON-LD
|
|
58
|
+
* @returns {string} HTML document with JSON-LD data island
|
|
66
59
|
*/
|
|
67
60
|
export function generateProfile({ webId, name, podUri, issuer }) {
|
|
68
61
|
const jsonLd = generateProfileJsonLd({ webId, name, podUri, issuer });
|
|
69
|
-
const pod = podUri.endsWith('/') ? podUri : podUri + '/';
|
|
70
62
|
|
|
71
63
|
return `<!DOCTYPE html>
|
|
72
64
|
<html lang="en">
|
|
@@ -74,30 +66,52 @@ export function generateProfile({ webId, name, podUri, issuer }) {
|
|
|
74
66
|
<meta charset="utf-8">
|
|
75
67
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
76
68
|
<title>${escapeHtml(name)}'s Profile</title>
|
|
69
|
+
<link rel="stylesheet" href="https://javascriptsolidserver.github.io/mashlib-jss/dist/mash.css">
|
|
77
70
|
<script type="application/ld+json">
|
|
78
71
|
${JSON.stringify(jsonLd, null, 2)}
|
|
79
72
|
</script>
|
|
80
73
|
<style>
|
|
81
|
-
body { font-family: system-ui, sans-serif;
|
|
82
|
-
|
|
83
|
-
.card { background: #f5f5f5; padding: 1.5rem; border-radius: 8px; }
|
|
84
|
-
dt { font-weight: bold; margin-top: 1rem; }
|
|
85
|
-
dd { margin-left: 0; color: #666; }
|
|
86
|
-
a { color: #7c4dff; }
|
|
74
|
+
body { margin: 0; font-family: system-ui, sans-serif; }
|
|
75
|
+
.loading { padding: 2rem; text-align: center; color: #666; }
|
|
87
76
|
</style>
|
|
88
77
|
</head>
|
|
89
78
|
<body>
|
|
90
|
-
<div class="
|
|
91
|
-
<
|
|
92
|
-
<
|
|
93
|
-
<dt>WebID</dt>
|
|
94
|
-
<dd><a href="${escapeHtml(webId)}">${escapeHtml(webId)}</a></dd>
|
|
95
|
-
<dt>Storage</dt>
|
|
96
|
-
<dd><a href="${escapeHtml(pod)}">${escapeHtml(pod)}</a></dd>
|
|
97
|
-
<dt>Inbox</dt>
|
|
98
|
-
<dd><a href="${escapeHtml(pod)}inbox/">${escapeHtml(pod)}inbox/</a></dd>
|
|
99
|
-
</dl>
|
|
79
|
+
<div class="TabulatorOutline" id="DummyUUID" role="main">
|
|
80
|
+
<table id="outline"></table>
|
|
81
|
+
<div id="GlobalDashboard"></div>
|
|
100
82
|
</div>
|
|
83
|
+
<div class="loading" id="loading">Loading profile...</div>
|
|
84
|
+
|
|
85
|
+
<script src="https://javascriptsolidserver.github.io/mashlib-jss/dist/mashlib.min.js"></script>
|
|
86
|
+
<script src="https://cdn.jsdelivr.net/npm/solidos-lite/solidos-lite.js"></script>
|
|
87
|
+
<script>
|
|
88
|
+
document.addEventListener('DOMContentLoaded', function() {
|
|
89
|
+
const loadingEl = document.getElementById('loading');
|
|
90
|
+
|
|
91
|
+
// Initialize solidos-lite to handle data islands
|
|
92
|
+
const success = SolidOSLite.init({ verbose: false });
|
|
93
|
+
if (!success) {
|
|
94
|
+
loadingEl.textContent = 'Failed to initialize. Please try refreshing.';
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Parse data islands into the RDF store
|
|
99
|
+
SolidOSLite.parseAllIslands();
|
|
100
|
+
|
|
101
|
+
// Mark this document as already fetched
|
|
102
|
+
const pageBase = window.location.href.split('?')[0].split('#')[0];
|
|
103
|
+
const fetcher = SolidLogic.store.fetcher;
|
|
104
|
+
fetcher.requested[pageBase] = 'done';
|
|
105
|
+
fetcher.requested[pageBase.replace(/\\/$/, '')] = 'done';
|
|
106
|
+
|
|
107
|
+
// Navigate to #me
|
|
108
|
+
const subject = $rdf.sym(pageBase + '#me');
|
|
109
|
+
const outliner = panes.getOutliner(document);
|
|
110
|
+
outliner.GotoSubject(subject, true, undefined, true, undefined);
|
|
111
|
+
|
|
112
|
+
loadingEl.style.display = 'none';
|
|
113
|
+
});
|
|
114
|
+
</script>
|
|
101
115
|
</body>
|
|
102
116
|
</html>`;
|
|
103
117
|
}
|