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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "javascript-solid-server",
3
- "version": "0.0.28",
3
+ "version": "0.0.31",
4
4
  "description": "A minimal, fast Solid server",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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 && isRdfContentType(storedContentType)) {
197
- try {
198
- // Parse stored content as JSON-LD
199
- const jsonLd = JSON.parse(content.toString());
200
-
201
- // Select output format based on Accept header
202
- const acceptHeader = request.headers.accept;
203
- const targetType = selectContentType(acceptHeader, connegEnabled);
204
-
205
- // Convert to requested format
206
- const { content: outputContent, contentType: outputType } = await fromJsonLd(
207
- jsonLd,
208
- targetType,
209
- resourceUrl,
210
- connegEnabled
211
- );
212
-
213
- const headers = getAllHeaders({
214
- isContainer: false,
215
- etag: stats.etag,
216
- contentType: outputType,
217
- origin,
218
- resourceUrl,
219
- connegEnabled
220
- });
221
- headers['Vary'] = getVaryHeader(connegEnabled, request.mashlibEnabled);
222
-
223
- Object.entries(headers).forEach(([k, v]) => reply.header(k, v));
224
- return reply.send(outputContent);
225
- } catch (e) {
226
- // If not valid JSON-LD, serve as-is
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
- // Parse existing document as JSON-LD
505
- try {
506
- document = JSON.parse(existingContent.toString());
507
- } catch (e) {
508
- return reply.code(409).send({
509
- error: 'Conflict',
510
- message: 'Resource is not valid JSON-LD and cannot be patched'
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
- const updatedContent = JSON.stringify(updatedDocument, null, 2);
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
- return { '@context': context, '@graph': nodes };
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 @graph or single object
172
- const nodes = jsonLd['@graph'] || [jsonLd];
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;
@@ -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
- '@graph': [
39
- {
40
- '@id': profileDoc,
41
- '@type': 'foaf:PersonalProfileDocument',
42
- 'foaf:maker': { '@id': webId },
43
- 'foaf:primaryTopic': { '@id': webId }
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; max-width: 600px; margin: 2rem auto; padding: 0 1rem; }
82
- h1 { color: #333; }
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="card">
91
- <h1>${escapeHtml(name)}</h1>
92
- <dl>
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
  }