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
93 changes: 75 additions & 18 deletions src/rdf/turtle.js
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,47 @@ function quadsToJsonLd(quads, baseUri, prefixes = {}) {
return nodes.map((node, i) => i === 0 ? { '@context': context, ...node } : node);
}

/**
* Read a JSON-LD node's identifier, accepting both the explicit
* `@id` form AND the unprefixed `id` alias that JSON-LD 1.1 treats
* as equivalent (and that Solid profiles in the wild use). Same
* fallback for `@type` / `type`.
*
* Without this aliasing, nested objects authored with `id`/`type`
* (e.g. a CID v1 verificationMethod entry) get silently dropped:
* - the predicate-→-IRI quad isn't emitted (valueToTerm sees
* no `@id` and returns null)
* - the BFS enqueue check (`v['@id']`) is false, so the nested
* object's own triples are never written either
* - net result: the entire `cid:verificationMethod` predicate
* and the `#nostr-key-1` resource block disappear from Turtle.
*
* #415.
*/
function getNodeId(n) {
if (!n || typeof n !== 'object') return undefined;
const v = n['@id'] !== undefined ? n['@id'] : n.id;
// Strict string-only — downstream resolveUri/`.startsWith` would
// throw on a number, null, or object. Malformed user content
// (a profile that authored `id: 42`) shouldn't crash conneg;
// treat non-string identifiers as absent.
return typeof v === 'string' ? v : undefined;
}
function getNodeType(n) {
if (!n || typeof n !== 'object') return undefined;
const v = n['@type'] !== undefined ? n['@type'] : n.type;
// Accept string OR array — expandUri/`.includes` would throw on
// anything else. For arrays, filter to string entries downstream
// (handled by Array.isArray + the per-entry expandUri call which
// assumes string; we filter here to be safe).
if (typeof v === 'string') return v;
if (Array.isArray(v)) {
const strs = v.filter(t => typeof t === 'string');
return strs.length > 0 ? strs : undefined;
}
return undefined;
}

/**
* Convert JSON-LD to N3.js quads
*/
Expand All @@ -181,8 +222,8 @@ function jsonLdToQuads(jsonLd, baseUri) {
if (doc['@context']) {
mergedContext = { ...mergedContext, ...doc['@context'] };
}
// Each document with @id is a node (no @graph needed)
if (doc['@id']) {
// Each document with @id (or `id` alias) is a node (no @graph needed)
if (getNodeId(doc) !== undefined) {
nodes.push(doc);
}
}
Expand All @@ -206,16 +247,18 @@ function jsonLdToQuads(jsonLd, baseUri) {
const queue = [...nodes];
for (let i = 0; i < queue.length; i++) {
const node = queue[i];
if (!node['@id']) continue;
const subjectUri = resolveUri(node['@id'], baseUri);
const nodeId = getNodeId(node);
if (nodeId === undefined) continue;
const subjectUri = resolveUri(nodeId, baseUri);

const subject = subjectUri.startsWith('_:')
? blankNode(subjectUri.slice(2))
: namedNode(subjectUri);

// Handle @type
if (node['@type']) {
const types = Array.isArray(node['@type']) ? node['@type'] : [node['@type']];
// Handle @type (or `type` alias).
const nodeType = getNodeType(node);
if (nodeType !== undefined) {
const types = Array.isArray(nodeType) ? nodeType : [nodeType];
for (const type of types) {
const typeUri = expandUri(type, context);
quads.push(quad(
Expand All @@ -226,9 +269,13 @@ function jsonLdToQuads(jsonLd, baseUri) {
}
}

// Handle other properties
// Handle other properties. Skip `@`-prefixed keys AND the `id`/
// `type` aliases (handled above as @id/@type) — emitting them as
// predicates would produce malformed triples like `<id>` and
// `<type>` since the names don't expand to URIs via context.
for (const [key, value] of Object.entries(node)) {
if (key.startsWith('@')) continue;
if (key === 'id' || key === 'type') continue;

const predicateUri = expandUri(key, context);
const predicate = namedNode(predicateUri);
Expand All @@ -243,15 +290,16 @@ function jsonLdToQuads(jsonLd, baseUri) {
if (object) {
quads.push(quad(subject, predicate, object));
}
// If v is a nested node (object with @id and at least one non-@value
// own property beyond @id), enqueue it so its triples are also
// emitted. Object-identity tracking (WeakSet) prevents the same
// nested object from being enqueued twice, which would otherwise
// loop for graphs that reuse an object reference (cycles).
// If v is a nested node (object with @id/id and at least one
// own property beyond the identifier), enqueue it so its
// triples are also emitted. Object-identity tracking
// (WeakSet) prevents the same nested object from being
// enqueued twice, which would otherwise loop for graphs
// that reuse an object reference (cycles).
if (v && typeof v === 'object' && !Array.isArray(v) &&
v['@id'] && v['@value'] === undefined &&
getNodeId(v) !== undefined && v['@value'] === undefined &&
!enqueuedNested.has(v)) {
const hasOwnClaims = Object.keys(v).some(k => k !== '@id');
const hasOwnClaims = Object.keys(v).some(k => k !== '@id' && k !== 'id');
if (hasOwnClaims) {
enqueuedNested.add(v);
queue.push(v);
Expand Down Expand Up @@ -348,9 +396,18 @@ function valueToTerm(value, baseUri, context, isIdType = false) {

// Object values
if (typeof value === 'object') {
// @id reference
if (value['@id']) {
const uri = resolveUri(value['@id'], baseUri);
// @id reference (or `id` alias — same JSON-LD 1.1 convention).
// This is what makes the predicate-→-IRI quad get emitted for
// nested objects authored with `id` instead of `@id`. Without
// it, an inline verificationMethod with `id`/`type` returned
// null here and the parent predicate triple was lost.
//
// String-only — a numeric or null `@id`/`id` would crash
// resolveUri's `.startsWith`. Treat as absent and fall through
// to the @value/@language branches below.
const rawObjId = value['@id'] !== undefined ? value['@id'] : value.id;
if (typeof rawObjId === 'string') {
const uri = resolveUri(rawObjId, baseUri);
return uri.startsWith('_:')
? blankNode(uri.slice(2))
: namedNode(uri);
Expand Down
68 changes: 68 additions & 0 deletions test/turtle.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,74 @@ describe('turtle converter — unit (#320 follow-ups)', () => {
`Turtle output must not contain object-stringification, got:\n${content}`);
});

it('nested object with `id`/`type` aliases survives the conversion (#415)', async () => {
// Solid profiles use the JSON-LD 1.1 `id`/`type` aliases for
// nested resources (no `@`). The converter must accept both
// forms — without this, a CID v1 verificationMethod object
// gets silently dropped:
// - the `cid:verificationMethod` predicate isn't emitted
// - the nested `#nostr-key-1` resource (Multikey, controller,
// publicKeyMultibase) isn't emitted either
// Net: third-party Turtle consumers see `cid:authentication
// <#nostr-key-1>` with no description of `#nostr-key-1`.
const doc = {
'@context': {
cid: 'https://www.w3.org/ns/cid/v1#',
verificationMethod: { '@id': 'cid:verificationMethod', '@container': '@set' },
authentication: { '@id': 'cid:authentication', '@type': '@id', '@container': '@set' },
controller: { '@id': 'cid:controller', '@type': '@id' },
publicKeyMultibase: { '@id': 'cid:publicKeyMultibase' },
},
'@id': 'https://example.test/profile/card.jsonld#me',
verificationMethod: [{
// Aliases — `id`/`type`, not `@id`/`@type`.
id: 'https://example.test/profile/card.jsonld#k',
type: 'Multikey',
controller: 'https://example.test/profile/card.jsonld#me',
publicKeyMultibase: 'fe70102de7ec',
}],
authentication: ['https://example.test/profile/card.jsonld#k'],
};
const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);

// The cid:verificationMethod predicate must connect #me to the VM.
assert.match(content, /cid:verificationMethod|<https:\/\/www\.w3\.org\/ns\/cid\/v1#verificationMethod>/,
`cid:verificationMethod predicate missing from Turtle:\n${content}`);
// The VM resource must be described — its type, controller, key.
assert.ok(content.includes('https://example.test/profile/card.jsonld#k'),
`VM #k must appear in Turtle:\n${content}`);
assert.match(content, /Multikey|<https:\/\/www\.w3\.org\/ns\/cid\/v1#Multikey>/,
`Multikey type missing from Turtle:\n${content}`);
assert.ok(content.includes('fe70102de7ec'),
`publicKeyMultibase value missing from Turtle:\n${content}`);
assert.match(content, /cid:controller|<https:\/\/www\.w3\.org\/ns\/cid\/v1#controller>/,
`cid:controller predicate missing on the VM:\n${content}`);
});

it('malformed `id`/`type` values are silently dropped, not crashed on (#415 review)', async () => {
// Profiles in the wild can have malformed user-authored content
// — e.g. `id: 42` or `type: null`. The converter must NOT throw
// (downstream `resolveUri.startsWith` and `expandUri.includes`
// assume strings); it should treat the malformed value as absent
// and skip the affected resource cleanly.
const doc = {
'@context': { 'cid': 'https://www.w3.org/ns/cid/v1#' },
'@id': 'https://example.test/s',
// Nested object with a non-string `id` — must not crash.
'cid:bad1': { id: 42, 'cid:foo': 'x' },
// Nested object with a null `type` — must not crash.
'cid:bad2': { id: 'https://example.test/n2', type: null, 'cid:foo': 'x' },
// Array `type` with mixed string/non-string entries — string
// entries should still emit.
'cid:mixed': { id: 'https://example.test/n3', type: ['Multikey', 42, null], 'cid:foo': 'x' },
};
const { content } = await fromJsonLd(doc, 'text/turtle', 'https://example.test/', true);
assert.ok(typeof content === 'string', 'must produce a string output, not throw');
// The valid string type entry should survive in the mixed-type case.
assert.ok(content.includes('https://example.test/n3'),
`node n3 should appear:\n${content}`);
});

it('cyclical nested node reference does not hang', async () => {
// Two nested nodes reference each other. BFS must not loop.
const a = { '@id': 'https://example.test/a', 'ex:knows': null };
Expand Down