Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 50 additions & 14 deletions src/handlers/resource.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { emitChange } from '../notifications/events.js';
import { checkIfMatch, checkIfNoneMatchForGet, checkIfNoneMatchForWrite } from '../utils/conditional.js';
import { generateDatabrowserHtml, generateModuleDatabrowserHtml, shouldServeMashlib, DATA_ISLAND_MAX_BYTES } from '../mashlib/index.js';
import { turtleToJsonLd } from '../rdf/turtle.js';

/**
* Live reload script - injected into HTML when --live-reload is enabled
Expand Down Expand Up @@ -330,23 +331,58 @@ export async function handleGet(request, reply) {
// Check if we should serve Mashlib data browser
// Only for RDF resources when Accept: text/html is requested
if (shouldServeMashlib(request, request.mashlibEnabled, storedContentType)) {
// Phase 1 of #7: embed the resource's JSON-LD bytes as a data
// island when it's already JSON-LD (the JSS-native format). Other
// formats are out of Phase-1 scope; the wrapper still loads
// correctly and mashlib XHR-fetches as before.
// #7 / #344: embed the resource as a JSON-LD data island so
// non-mashlib consumers (search-engine rich-results, archival
// crawlers) get the data without a second request, and so the
// shape is uniform regardless of the URL extension.
//
// Cap-aware short-circuit: skip the read entirely when the file is
// already over the embed cap. The island would be dropped anyway,
// and large JSON-LD resources would otherwise load into memory on
// every HTML navigation.
// JSS stores all RDF as JSON-LD on disk (PUT converts Turtle/N3
// before write — see the conneg branch in handlePut), so for
// `.ttl` / `.n3` URLs the bytes on disk are usually already
// JSON-LD. Try JSON parse first; only fall back to a Turtle parse
// when that fails (covers files placed on the filesystem
// out-of-band in their native format).
//
// Cap-aware short-circuit: skip the read entirely when the file
// is already over the embed cap. The island would be dropped
// anyway, and large RDF resources would otherwise load into
// memory on every HTML navigation. Other formats (rdf+xml, etc.)
// are not handled — the wrapper still loads and mashlib
// XHR-fetches them as before.
const islandConvertible =
storedContentType === RDF_TYPES.JSON_LD ||
storedContentType === RDF_TYPES.TURTLE ||
storedContentType === RDF_TYPES.N3;
let embedJsonLd;
if (storedContentType === 'application/ld+json' &&
stats.size <= DATA_ISLAND_MAX_BYTES) {
// dataIsland() in mashlib/index.js coerces Buffer → string itself,
// so we hand it the Buffer directly instead of allocating a UTF-8
// string copy on every navigation.
if (islandConvertible && stats.size <= DATA_ISLAND_MAX_BYTES) {
const buf = await storage.read(storagePath);
if (buf) embedJsonLd = buf;
if (buf) {
if (storedContentType === RDF_TYPES.JSON_LD) {
// Pass the Buffer through. dataIsland() decodes once when
// it needs to; we don't pre-validate or pre-decode here.
embedJsonLd = buf;
} else {
// Turtle / N3 URL. JSS stores everything as JSON-LD on
// disk (PUT converts), so try JSON parse first and pass
// the *decoded text* through (avoids a second decode
// inside dataIsland's String() coercion). Fall back to a
// Turtle parse for files placed on the filesystem
// out-of-band in their native format.
const text = buf.toString('utf8');
try {
JSON.parse(text);
embedJsonLd = text;
} catch {
try {
const jsonLd = await turtleToJsonLd(text, resourceUrl);
embedJsonLd = JSON.stringify(jsonLd);
} catch {
// Both parses failed → drop the island. The wrapper
// still renders and mashlib XHR-fetches the original.
}
}
Comment on lines +372 to +383
}
}
}
const html = request.mashlibModule
? generateModuleDatabrowserHtml(request.mashlibModule, resourceUrl, { embedJsonLd })
Expand Down
84 changes: 84 additions & 0 deletions test/data-island.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

import { describe, it, before, after } from 'node:test';
import assert from 'node:assert';
import fs from 'node:fs/promises';
import path from 'node:path';
import {
startTestServer,
stopTestServer,
Expand Down Expand Up @@ -207,3 +209,85 @@ describe('mashlib data island — integration (#7)', () => {
assert.doesNotMatch(body, /<!doctype html>/i);
});
});

// #344: data island also covers Turtle and N3 stored resources, by
// parsing them server-side and re-emitting the body as JSON-LD inside
// the script tag. Embedded shape is uniform across stored formats.
describe('mashlib data island — Turtle/N3 translation (#344)', () => {
before(async () => {
await startTestServer({ mashlibCdn: true, conneg: true });
await createTestPod('turtleisland');
await request('/turtleisland/public/note.ttl', {
method: 'PUT',
headers: { 'Content-Type': 'text/turtle' },
body: '@prefix foaf: <http://xmlns.com/foaf/0.1/> .\n' +
'<#note> foaf:name "turtle island" .\n',
auth: 'turtleisland'
});
await request('/turtleisland/public/note.n3', {
method: 'PUT',
headers: { 'Content-Type': 'text/n3' },
body: '@prefix foaf: <http://xmlns.com/foaf/0.1/> .\n' +
'<#note> foaf:name "n3 island" .\n',
auth: 'turtleisland'
});
// The both-parses-fail branch in the handler is unreachable via
// HTTP — handlePut validates Turtle/N3 input and rejects malformed
// bodies with 400 before they ever reach storage. To exercise the
// defensive guard we plant a file directly on disk in the test
// data dir, mimicking the "out-of-band placement" case the
// production code handles.
const brokenPath = path.resolve('./data/turtleisland/public/broken.ttl');
await fs.writeFile(
brokenPath,
'@prefix foaf: <http://xmlns.com/foaf/0.1/>\n' +
'<#note> foaf:name "broken — missing dot above" .\n'
);
});

after(async () => { await stopTestServer(); });

it('a browser GET to a Turtle resource embeds parsed JSON-LD', async () => {
const res = await request('/turtleisland/public/note.ttl', {
headers: { Accept: 'text/html,application/xhtml+xml,*/*;q=0.8' }
});
assertStatus(res, 200);
assertHeaderContains(res, 'Content-Type', 'text/html');
const body = await res.text();
assert.match(body, /id="dataisland"/);
assert.match(body, /<script type="application\/ld\+json"/);
// The Turtle name literal must round-trip into the embedded JSON-LD.
assert.match(body, /"turtle island"/);
// No raw Turtle prefix syntax should leak into the script body.
assert.doesNotMatch(body, /id="dataisland"[^>]*>[^<]*@prefix/);
});

it('a browser GET to an N3 resource embeds parsed JSON-LD', async () => {
const res = await request('/turtleisland/public/note.n3', {
headers: { Accept: 'text/html,application/xhtml+xml,*/*;q=0.8' }
});
assertStatus(res, 200);
assertHeaderContains(res, 'Content-Type', 'text/html');
const body = await res.text();
assert.match(body, /id="dataisland"/);
assert.match(body, /"n3 island"/);
});

it('out-of-band malformed Turtle drops the island, wrapper still renders', async () => {
// File was planted on disk directly (in `before`), bypassing the
// PUT validator. The handler's two-stage parse (JSON, then Turtle)
// both fail; the island is dropped silently and the mashlib
// wrapper is still served so the browser can XHR-fetch the
// resource and surface the parse problem to the user.
const res = await request('/turtleisland/public/broken.ttl', {
headers: { Accept: 'text/html,application/xhtml+xml,*/*;q=0.8' }
});
assertStatus(res, 200);
assertHeaderContains(res, 'Content-Type', 'text/html');
const body = await res.text();
assert.doesNotMatch(body, /id="dataisland"/,
'island must drop when both JSON and Turtle parses fail');
assert.match(body, /<!doctype html>/i);
assert.match(body, /mashlib/);
});
});