@fedify/vocab 2.5.0-dev.2339 → 2.5.0-dev.2358
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/deno.json +1 -1
- package/dist/mod.cjs +851 -2
- package/dist/mod.d.cts +227 -0
- package/dist/mod.d.ts +227 -0
- package/dist/mod.js +851 -2
- package/dist-tests/{actor-qViYned_.mjs → actor-Bph7CW-x.mjs} +2 -2
- package/dist-tests/actor.test.mjs +3 -3
- package/dist-tests/application.yaml +15 -0
- package/dist-tests/{esm-Cncdk-Cm.mjs → esm-rg6f9oLR.mjs} +1 -1
- package/dist-tests/gateway.test.mjs +3 -3
- package/dist-tests/group.yaml +15 -0
- package/dist-tests/license.test.mjs +538 -0
- package/dist-tests/link.yaml +25 -0
- package/dist-tests/{lookup-DHfL6oZO.mjs → lookup-BJrVO5_F.mjs} +2 -2
- package/dist-tests/lookup.test.mjs +3 -3
- package/dist-tests/object.yaml +30 -0
- package/dist-tests/organization.yaml +15 -0
- package/dist-tests/person.yaml +15 -0
- package/dist-tests/portable-lookup.test.mjs +4 -4
- package/dist-tests/service.yaml +15 -0
- package/dist-tests/signed-representation.test.mjs +1 -1
- package/dist-tests/src-7iJrdKrK.mjs +9 -0
- package/dist-tests/translation.test.mjs +3 -6
- package/dist-tests/type.test.mjs +1 -1
- package/dist-tests/{vocab-EFWWufqL.mjs → vocab-Cg2PG-xH.mjs} +851 -2
- package/dist-tests/vocab.test.mjs +9 -3
- package/package.json +4 -4
- package/src/__snapshots__/vocab.test.ts.snap +480 -33
- package/src/application.yaml +15 -0
- package/src/group.yaml +15 -0
- package/src/license.test.ts +588 -0
- package/src/link.yaml +25 -0
- package/src/object.yaml +30 -0
- package/src/organization.yaml +15 -0
- package/src/person.yaml +15 -0
- package/src/service.yaml +15 -0
- package/src/vocab.test.ts +18 -6
- package/LICENSE +0 -20
package/src/application.yaml
CHANGED
|
@@ -351,3 +351,18 @@ properties:
|
|
|
351
351
|
attached in some clients.
|
|
352
352
|
range:
|
|
353
353
|
- "http://www.w3.org/2001/XMLSchema#boolean"
|
|
354
|
+
|
|
355
|
+
- singularName: preferredLicense
|
|
356
|
+
functional: true
|
|
357
|
+
compactName: preferredLicense
|
|
358
|
+
uri: "https://w3id.org/fep/6757#preferredLicense"
|
|
359
|
+
extraContext: "https://w3id.org/fep/6757"
|
|
360
|
+
description: |
|
|
361
|
+
The URI of the license this actor prefers for the new objects it creates,
|
|
362
|
+
as defined by [FEP-6757](https://w3id.org/fep/6757). A client can use it
|
|
363
|
+
as the default `license` of a new object and let the user choose another.
|
|
364
|
+
|
|
365
|
+
This is not a fallback license: an object without its own `license` is not
|
|
366
|
+
licensed under its author's preferred license.
|
|
367
|
+
range:
|
|
368
|
+
- "fedify:absoluteIri"
|
package/src/group.yaml
CHANGED
|
@@ -351,3 +351,18 @@ properties:
|
|
|
351
351
|
attached in some clients.
|
|
352
352
|
range:
|
|
353
353
|
- "http://www.w3.org/2001/XMLSchema#boolean"
|
|
354
|
+
|
|
355
|
+
- singularName: preferredLicense
|
|
356
|
+
functional: true
|
|
357
|
+
compactName: preferredLicense
|
|
358
|
+
uri: "https://w3id.org/fep/6757#preferredLicense"
|
|
359
|
+
extraContext: "https://w3id.org/fep/6757"
|
|
360
|
+
description: |
|
|
361
|
+
The URI of the license this actor prefers for the new objects it creates,
|
|
362
|
+
as defined by [FEP-6757](https://w3id.org/fep/6757). A client can use it
|
|
363
|
+
as the default `license` of a new object and let the user choose another.
|
|
364
|
+
|
|
365
|
+
This is not a fallback license: an object without its own `license` is not
|
|
366
|
+
licensed under its author's preferred license.
|
|
367
|
+
range:
|
|
368
|
+
- "fedify:absoluteIri"
|
|
@@ -0,0 +1,588 @@
|
|
|
1
|
+
import { test } from "@fedify/fixture";
|
|
2
|
+
import {
|
|
3
|
+
type DocumentLoader,
|
|
4
|
+
getDocumentLoader,
|
|
5
|
+
preloadedContexts,
|
|
6
|
+
} from "@fedify/vocab-runtime";
|
|
7
|
+
import jsonld from "@fedify/vocab-runtime/jsonld";
|
|
8
|
+
import { deepStrictEqual, ok } from "node:assert/strict";
|
|
9
|
+
import {
|
|
10
|
+
Application,
|
|
11
|
+
Collection,
|
|
12
|
+
Create,
|
|
13
|
+
Group,
|
|
14
|
+
Image,
|
|
15
|
+
Link,
|
|
16
|
+
Note,
|
|
17
|
+
Organization,
|
|
18
|
+
Person,
|
|
19
|
+
Question,
|
|
20
|
+
Service,
|
|
21
|
+
Video,
|
|
22
|
+
} from "./mod.ts";
|
|
23
|
+
|
|
24
|
+
// FEP-6757 draft and examples, pinned to:
|
|
25
|
+
// https://codeberg.org/fediverse/fep/src/commit/e735111d2d28a3d7b9798b7d5d436ec2d1f3bd04/fep/6757/fep-6757.md
|
|
26
|
+
const AS = "https://www.w3.org/ns/activitystreams";
|
|
27
|
+
const FEP = "https://w3id.org/fep/6757";
|
|
28
|
+
const DC_LICENSE = "http://purl.org/dc/terms/license";
|
|
29
|
+
const PREFERRED_LICENSE = `${FEP}#preferredLicense`;
|
|
30
|
+
const SCHEMA_LICENSE = "https://schema.org/license";
|
|
31
|
+
const HTTP_SCHEMA_LICENSE = "http://schema.org/license";
|
|
32
|
+
const CC_LICENSE = "http://creativecommons.org/ns#license";
|
|
33
|
+
const BY = "https://creativecommons.org/licenses/by/4.0/";
|
|
34
|
+
const BY_NC = "https://creativecommons.org/licenses/by-nc/4.0/";
|
|
35
|
+
const BY_NC_ND = "https://creativecommons.org/licenses/by-nc-nd/4.0/";
|
|
36
|
+
const CC0 = "https://creativecommons.org/publicdomain/zero/1.0/";
|
|
37
|
+
const PD_MARK = "https://creativecommons.org/publicdomain/mark/1.0/";
|
|
38
|
+
|
|
39
|
+
const noteExample = {
|
|
40
|
+
"@context": [AS, FEP, { schema: "https://schema.org/" }],
|
|
41
|
+
id: "https://social.example/note/857",
|
|
42
|
+
type: "Note",
|
|
43
|
+
content: "Hello, world!",
|
|
44
|
+
license: BY,
|
|
45
|
+
"schema:license": BY,
|
|
46
|
+
attachment: {
|
|
47
|
+
type: "Link",
|
|
48
|
+
href: "https://media.social.example/uploads/file339.jpg",
|
|
49
|
+
mediaType: "image/jpeg",
|
|
50
|
+
license: PD_MARK,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const personExample = {
|
|
55
|
+
"@context": [AS, FEP],
|
|
56
|
+
id: "https://social.example/user/1753",
|
|
57
|
+
type: "Person",
|
|
58
|
+
name: "Example User",
|
|
59
|
+
inbox: "https://social.example/collection/9257",
|
|
60
|
+
outbox: "https://social.example/collection/10772",
|
|
61
|
+
summary: "An example user who is very illustrative of important features",
|
|
62
|
+
license: BY_NC_ND,
|
|
63
|
+
icon: {
|
|
64
|
+
type: "Image",
|
|
65
|
+
license: BY_NC,
|
|
66
|
+
url: {
|
|
67
|
+
type: "Link",
|
|
68
|
+
mediaType: "image/jpeg",
|
|
69
|
+
href: "https://media.social.example/upload/7272.jpg",
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
preferredLicense: BY_NC,
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
function record(value: unknown): Record<string, unknown> {
|
|
76
|
+
ok(value != null && typeof value === "object" && !Array.isArray(value));
|
|
77
|
+
return value as Record<string, unknown>;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const noFetch: DocumentLoader = (url) => {
|
|
81
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A context loader that serves only preloaded contexts and records them.
|
|
86
|
+
*/
|
|
87
|
+
function offlineContextLoader(): DocumentLoader & { loaded: string[] } {
|
|
88
|
+
const loaded: string[] = [];
|
|
89
|
+
const loader = getDocumentLoader();
|
|
90
|
+
const contextLoader: DocumentLoader = (url, options) => {
|
|
91
|
+
if (!(url in preloadedContexts)) {
|
|
92
|
+
throw new Error(`Unexpected context fetch: ${url}`);
|
|
93
|
+
}
|
|
94
|
+
loaded.push(url);
|
|
95
|
+
return loader(url, options);
|
|
96
|
+
};
|
|
97
|
+
return Object.assign(contextLoader, { loaded });
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
async function expand(json: unknown): Promise<Record<string, unknown>> {
|
|
101
|
+
const expanded = await jsonld.expand(json, {
|
|
102
|
+
documentLoader: getDocumentLoader(),
|
|
103
|
+
});
|
|
104
|
+
return record(expanded[0]);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function contexts(json: unknown): unknown[] {
|
|
108
|
+
const context = record(json)["@context"];
|
|
109
|
+
return Array.isArray(context) ? context : [context];
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function note(licenses: URL[] = [new URL(BY)]): Note {
|
|
113
|
+
return new Note({
|
|
114
|
+
id: new URL("https://social.example/note/857"),
|
|
115
|
+
content: "Hello, world!",
|
|
116
|
+
licenses,
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
test("FEP-6757 context is preloaded", async () => {
|
|
121
|
+
ok(FEP in preloadedContexts);
|
|
122
|
+
const loaded = await getDocumentLoader()(FEP);
|
|
123
|
+
deepStrictEqual(record(loaded.document)["@context"], {
|
|
124
|
+
license: { "@id": DC_LICENSE, "@type": "@id" },
|
|
125
|
+
preferredLicense: { "@id": PREFERRED_LICENSE, "@type": "@id" },
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("FEP-6757 note example is parsed offline", async () => {
|
|
130
|
+
const contextLoader = offlineContextLoader();
|
|
131
|
+
const parsed = await Note.fromJsonLd(noteExample, {
|
|
132
|
+
documentLoader: noFetch,
|
|
133
|
+
contextLoader,
|
|
134
|
+
});
|
|
135
|
+
deepStrictEqual(parsed.licenses, [new URL(BY)]);
|
|
136
|
+
const attachments = await Array.fromAsync(
|
|
137
|
+
parsed.getAttachments({ documentLoader: noFetch, contextLoader }),
|
|
138
|
+
);
|
|
139
|
+
deepStrictEqual(attachments.length, 1);
|
|
140
|
+
ok(attachments[0] instanceof Link);
|
|
141
|
+
deepStrictEqual(attachments[0].licenses, [new URL(PD_MARK)]);
|
|
142
|
+
ok(contextLoader.loaded.includes(FEP));
|
|
143
|
+
// An unchanged parsed object keeps its input, including the synonym:
|
|
144
|
+
deepStrictEqual(await parsed.toJsonLd(), noteExample);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("FEP-6757 person example is parsed offline", async () => {
|
|
148
|
+
const contextLoader = offlineContextLoader();
|
|
149
|
+
const person = await Person.fromJsonLd(personExample, {
|
|
150
|
+
documentLoader: noFetch,
|
|
151
|
+
contextLoader,
|
|
152
|
+
});
|
|
153
|
+
deepStrictEqual(person.licenses, [new URL(BY_NC_ND)]);
|
|
154
|
+
deepStrictEqual(person.preferredLicense, new URL(BY_NC));
|
|
155
|
+
const icon = await person.getIcon({ documentLoader: noFetch, contextLoader });
|
|
156
|
+
ok(icon instanceof Image);
|
|
157
|
+
deepStrictEqual(icon.licenses, [new URL(BY_NC)]);
|
|
158
|
+
const url = icon.url;
|
|
159
|
+
ok(url instanceof Link);
|
|
160
|
+
deepStrictEqual(url.licenses, []);
|
|
161
|
+
deepStrictEqual(await person.toJsonLd(), personExample);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("FEP-6757 license is written only as the canonical IRI", async () => {
|
|
165
|
+
for (const format of [undefined, "compact"] as const) {
|
|
166
|
+
const json = record(await note().toJsonLd({ format }));
|
|
167
|
+
deepStrictEqual(contexts(json).at(-1), FEP);
|
|
168
|
+
deepStrictEqual(json.license, BY);
|
|
169
|
+
const expanded = await expand(json);
|
|
170
|
+
deepStrictEqual(expanded[DC_LICENSE], [{ "@id": BY }]);
|
|
171
|
+
for (const synonym of [SCHEMA_LICENSE, HTTP_SCHEMA_LICENSE, CC_LICENSE]) {
|
|
172
|
+
ok(!(synonym in expanded));
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const expanded = await note().toJsonLd({ format: "expand" });
|
|
176
|
+
deepStrictEqual(
|
|
177
|
+
record((expanded as unknown[])[0])[DC_LICENSE],
|
|
178
|
+
[{ "@id": BY }],
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test("FEP-6757 multiple licenses are alternatives in observed order", async () => {
|
|
183
|
+
const licenses = [new URL(BY), new URL(CC0)];
|
|
184
|
+
const json = record(await note(licenses).toJsonLd());
|
|
185
|
+
deepStrictEqual(json.license, [BY, CC0]);
|
|
186
|
+
const parsed = await Note.fromJsonLd(json, { documentLoader: noFetch });
|
|
187
|
+
deepStrictEqual(parsed.licenses, licenses);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("FEP-6757 objects without license metadata serialize as before", async () => {
|
|
191
|
+
const fields = {
|
|
192
|
+
id: new URL("https://social.example/note/1"),
|
|
193
|
+
content: "No license",
|
|
194
|
+
};
|
|
195
|
+
for (const format of [undefined, "compact"] as const) {
|
|
196
|
+
const json = record(await new Note(fields).toJsonLd({ format }));
|
|
197
|
+
ok(!JSON.stringify(json["@context"]).includes(FEP));
|
|
198
|
+
ok(!("license" in json));
|
|
199
|
+
const person = record(
|
|
200
|
+
await new Person({ id: new URL("https://social.example/user/1") })
|
|
201
|
+
.toJsonLd({ format }),
|
|
202
|
+
);
|
|
203
|
+
ok(!JSON.stringify(person["@context"]).includes(FEP));
|
|
204
|
+
ok(!("preferredLicense" in person));
|
|
205
|
+
}
|
|
206
|
+
deepStrictEqual(await new Note(fields).toJsonLd(), {
|
|
207
|
+
"@context": [
|
|
208
|
+
AS,
|
|
209
|
+
"https://w3id.org/security/data-integrity/v1",
|
|
210
|
+
"https://gotosocial.org/ns",
|
|
211
|
+
{
|
|
212
|
+
toot: "http://joinmastodon.org/ns#",
|
|
213
|
+
misskey: "https://misskey-hub.net/ns#",
|
|
214
|
+
fedibird: "http://fedibird.com/ns#",
|
|
215
|
+
sensitive: "as:sensitive",
|
|
216
|
+
Emoji: "toot:Emoji",
|
|
217
|
+
Hashtag: "as:Hashtag",
|
|
218
|
+
quote: { "@id": "https://w3id.org/fep/044f#quote", "@type": "@id" },
|
|
219
|
+
quoteUrl: "as:quoteUrl",
|
|
220
|
+
_misskey_quote: "misskey:_misskey_quote",
|
|
221
|
+
quoteUri: "fedibird:quoteUri",
|
|
222
|
+
QuoteAuthorization: "https://w3id.org/fep/044f#QuoteAuthorization",
|
|
223
|
+
quoteAuthorization: {
|
|
224
|
+
"@id": "https://w3id.org/fep/044f#quoteAuthorization",
|
|
225
|
+
"@type": "@id",
|
|
226
|
+
},
|
|
227
|
+
emojiReactions: { "@id": "fedibird:emojiReactions", "@type": "@id" },
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
id: "https://social.example/note/1",
|
|
231
|
+
type: "Note",
|
|
232
|
+
content: "No license",
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("FEP-6757 preferredLicense on every actor type", async () => {
|
|
237
|
+
for (const Actor of [Application, Group, Organization, Person, Service]) {
|
|
238
|
+
const actor = new Actor({
|
|
239
|
+
id: new URL("https://social.example/actor"),
|
|
240
|
+
preferredLicense: new URL(BY_NC),
|
|
241
|
+
});
|
|
242
|
+
for (const format of [undefined, "compact"] as const) {
|
|
243
|
+
const json = record(await actor.toJsonLd({ format }));
|
|
244
|
+
deepStrictEqual(contexts(json).at(-1), FEP);
|
|
245
|
+
deepStrictEqual(json.preferredLicense, BY_NC);
|
|
246
|
+
ok(!("license" in json));
|
|
247
|
+
const expanded = await expand(json);
|
|
248
|
+
deepStrictEqual(expanded[PREFERRED_LICENSE], [{ "@id": BY_NC }]);
|
|
249
|
+
const parsed = await Actor.fromJsonLd(json, { documentLoader: noFetch });
|
|
250
|
+
deepStrictEqual(parsed.preferredLicense, new URL(BY_NC));
|
|
251
|
+
// A preferred license is not a license of the actor itself:
|
|
252
|
+
deepStrictEqual(parsed.licenses, []);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("FEP-6757 licenses in activities compact to the license term", async () => {
|
|
258
|
+
// Activity contexts define a dc: prefix, which must not hide the license
|
|
259
|
+
// term behind a compact IRI such as dc:license:
|
|
260
|
+
const create = new Create({
|
|
261
|
+
id: new URL("https://social.example/create/1"),
|
|
262
|
+
actor: new URL("https://social.example/user/1753"),
|
|
263
|
+
object: note(),
|
|
264
|
+
});
|
|
265
|
+
for (const format of [undefined, "compact"] as const) {
|
|
266
|
+
const json = record(await create.toJsonLd({ format }));
|
|
267
|
+
deepStrictEqual(contexts(json).at(-1), FEP);
|
|
268
|
+
deepStrictEqual(record(json.object).license, BY);
|
|
269
|
+
ok(!("license" in json));
|
|
270
|
+
ok(!JSON.stringify(json).includes("dc:license"));
|
|
271
|
+
}
|
|
272
|
+
const parsedNote = await Note.fromJsonLd(noteExample, {
|
|
273
|
+
documentLoader: noFetch,
|
|
274
|
+
});
|
|
275
|
+
const forwarded = record(
|
|
276
|
+
await new Create({
|
|
277
|
+
actor: new URL("https://social.example/user/1753"),
|
|
278
|
+
object: parsedNote,
|
|
279
|
+
}).toJsonLd(),
|
|
280
|
+
);
|
|
281
|
+
deepStrictEqual(contexts(forwarded).at(-1), FEP);
|
|
282
|
+
deepStrictEqual(record(forwarded.object).license, BY);
|
|
283
|
+
const question = record(
|
|
284
|
+
await new Question({
|
|
285
|
+
id: new URL("https://social.example/question/1"),
|
|
286
|
+
licenses: [new URL(BY)],
|
|
287
|
+
}).toJsonLd(),
|
|
288
|
+
);
|
|
289
|
+
deepStrictEqual(question.license, BY);
|
|
290
|
+
ok(!JSON.stringify(question).includes("dc:license"));
|
|
291
|
+
// A cached child may define the license term inline:
|
|
292
|
+
const inline = await Note.fromJsonLd({
|
|
293
|
+
"@context": [AS, {
|
|
294
|
+
license: { "@id": DC_LICENSE, "@type": "@id" },
|
|
295
|
+
}],
|
|
296
|
+
id: "https://social.example/note/inline",
|
|
297
|
+
type: "Note",
|
|
298
|
+
license: BY,
|
|
299
|
+
}, { documentLoader: noFetch });
|
|
300
|
+
const collection = record(
|
|
301
|
+
await new Collection({
|
|
302
|
+
id: new URL("https://social.example/collection/1"),
|
|
303
|
+
items: [inline],
|
|
304
|
+
summary: "Licensed items",
|
|
305
|
+
}).toJsonLd(),
|
|
306
|
+
);
|
|
307
|
+
deepStrictEqual(contexts(collection).at(-1), FEP);
|
|
308
|
+
const items = collection.items as Record<string, unknown>;
|
|
309
|
+
deepStrictEqual(items.license, BY);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("FEP-6757 licenses of nested objects only", async () => {
|
|
313
|
+
// Neither the note nor the actor has a license of its own:
|
|
314
|
+
const unlicensed = new Note({
|
|
315
|
+
id: new URL("https://social.example/note/2"),
|
|
316
|
+
attachments: [
|
|
317
|
+
new Link({ href: new URL(noteExample.attachment.href), licenses: [] }),
|
|
318
|
+
new Link({
|
|
319
|
+
href: new URL(noteExample.attachment.href),
|
|
320
|
+
licenses: [new URL(PD_MARK)],
|
|
321
|
+
}),
|
|
322
|
+
],
|
|
323
|
+
});
|
|
324
|
+
const json = record(await unlicensed.toJsonLd());
|
|
325
|
+
ok(!("license" in json));
|
|
326
|
+
const attachments = json.attachment as Record<string, unknown>[];
|
|
327
|
+
ok(!("license" in attachments[0]));
|
|
328
|
+
deepStrictEqual(attachments[1].license, PD_MARK);
|
|
329
|
+
const expanded = await expand(json);
|
|
330
|
+
const links =
|
|
331
|
+
expanded["https://www.w3.org/ns/activitystreams#attachment"] as Record<
|
|
332
|
+
string,
|
|
333
|
+
unknown
|
|
334
|
+
>[];
|
|
335
|
+
deepStrictEqual(links[1][DC_LICENSE], [{ "@id": PD_MARK }]);
|
|
336
|
+
const person = record(
|
|
337
|
+
await new Person({
|
|
338
|
+
id: new URL("https://social.example/user/1"),
|
|
339
|
+
icon: new Image({ licenses: [new URL(BY_NC)] }),
|
|
340
|
+
}).toJsonLd(),
|
|
341
|
+
);
|
|
342
|
+
ok(!("license" in person));
|
|
343
|
+
deepStrictEqual(record(person.icon).license, BY_NC);
|
|
344
|
+
// A collection's license does not apply to its items:
|
|
345
|
+
const collection = await Collection.fromJsonLd({
|
|
346
|
+
"@context": [AS, FEP],
|
|
347
|
+
type: "Collection",
|
|
348
|
+
license: CC0,
|
|
349
|
+
items: [{ type: "Note", content: "Unlicensed item" }],
|
|
350
|
+
}, { documentLoader: noFetch });
|
|
351
|
+
deepStrictEqual(collection.licenses, [new URL(CC0)]);
|
|
352
|
+
const items = await Array.fromAsync(
|
|
353
|
+
collection.getItems({ documentLoader: noFetch }),
|
|
354
|
+
);
|
|
355
|
+
ok(items[0] instanceof Note);
|
|
356
|
+
deepStrictEqual(items[0].licenses, []);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("FEP-6757 modified clones write the canonical property", async () => {
|
|
360
|
+
const parsed = await Note.fromJsonLd({
|
|
361
|
+
"@context": [AS, { schema: "https://schema.org/" }],
|
|
362
|
+
id: "https://social.example/note/3",
|
|
363
|
+
type: "Note",
|
|
364
|
+
"schema:license": BY,
|
|
365
|
+
}, { documentLoader: noFetch });
|
|
366
|
+
deepStrictEqual(parsed.licenses, [new URL(BY)]);
|
|
367
|
+
for (
|
|
368
|
+
const clone of [
|
|
369
|
+
parsed.clone({ content: "Edited" }),
|
|
370
|
+
parsed.clone({ licenses: [new URL(CC0)] }),
|
|
371
|
+
]
|
|
372
|
+
) {
|
|
373
|
+
const json = record(await clone.toJsonLd());
|
|
374
|
+
ok(!("schema:license" in json));
|
|
375
|
+
const expanded = await expand(json);
|
|
376
|
+
ok(!(SCHEMA_LICENSE in expanded));
|
|
377
|
+
deepStrictEqual(expanded[DC_LICENSE], [{ "@id": clone.licenses[0].href }]);
|
|
378
|
+
}
|
|
379
|
+
// Explicit compaction bypasses the cached input:
|
|
380
|
+
const compact = await expand(await parsed.toJsonLd({ format: "compact" }));
|
|
381
|
+
deepStrictEqual(compact[DC_LICENSE], [{ "@id": BY }]);
|
|
382
|
+
ok(!(SCHEMA_LICENSE in compact));
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
test("FEP-6757 caller-provided contexts", async () => {
|
|
386
|
+
for (
|
|
387
|
+
const context of [
|
|
388
|
+
AS,
|
|
389
|
+
[AS, FEP],
|
|
390
|
+
[AS, "http://schema.org/"],
|
|
391
|
+
[AS, FEP, "http://schema.org/"],
|
|
392
|
+
[AS, "http://schema.org/", FEP],
|
|
393
|
+
]
|
|
394
|
+
) {
|
|
395
|
+
const json = await note().toJsonLd({ format: "compact", context });
|
|
396
|
+
// The context is used as is; no extension context is appended:
|
|
397
|
+
deepStrictEqual(record(json)["@context"], context);
|
|
398
|
+
const expanded = await expand(json);
|
|
399
|
+
deepStrictEqual(expanded[DC_LICENSE], [{ "@id": BY }]);
|
|
400
|
+
const parsed = await Note.fromJsonLd(json, { documentLoader: noFetch });
|
|
401
|
+
deepStrictEqual(parsed.licenses, [new URL(BY)]);
|
|
402
|
+
}
|
|
403
|
+
});
|
|
404
|
+
|
|
405
|
+
test("FEP-6757 synonym precedence", async () => {
|
|
406
|
+
const id = "https://social.example/note/4";
|
|
407
|
+
const cases: [Record<string, unknown>, string[]][] = [
|
|
408
|
+
[{ [DC_LICENSE]: [{ "@id": BY }], [SCHEMA_LICENSE]: [{ "@id": CC0 }] }, [
|
|
409
|
+
BY,
|
|
410
|
+
]],
|
|
411
|
+
[{ [SCHEMA_LICENSE]: [{ "@id": CC0 }], [CC_LICENSE]: [{ "@id": BY }] }, [
|
|
412
|
+
CC0,
|
|
413
|
+
]],
|
|
414
|
+
// http://schema.org/license is not a synonym; see the PeerTube test:
|
|
415
|
+
[{ [HTTP_SCHEMA_LICENSE]: [{ "@id": BY }] }, []],
|
|
416
|
+
[{ [CC_LICENSE]: [{ "@id": CC0 }, { "@value": BY }] }, [CC0, BY]],
|
|
417
|
+
// An explicitly empty canonical set falls through to the synonyms:
|
|
418
|
+
[{ [DC_LICENSE]: [], [SCHEMA_LICENSE]: [{ "@value": BY }] }, [BY]],
|
|
419
|
+
// An invalid canonical set is not replaced by a synonym:
|
|
420
|
+
[{
|
|
421
|
+
[DC_LICENSE]: [{ "@id": "CC-BY-4.0" }],
|
|
422
|
+
[SCHEMA_LICENSE]: [{
|
|
423
|
+
"@id": BY,
|
|
424
|
+
}],
|
|
425
|
+
}, []],
|
|
426
|
+
[{}, []],
|
|
427
|
+
];
|
|
428
|
+
for (const [properties, expected] of cases) {
|
|
429
|
+
const parsed = await Note.fromJsonLd({
|
|
430
|
+
"@id": id,
|
|
431
|
+
"@type": [`${AS}#Note`],
|
|
432
|
+
...properties,
|
|
433
|
+
}, { documentLoader: noFetch });
|
|
434
|
+
deepStrictEqual(parsed.licenses, expected.map((l) => new URL(l)));
|
|
435
|
+
}
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("FEP-6757 schema.org context order", async () => {
|
|
439
|
+
// The bundled Schema.org context also defines a license term, which expands
|
|
440
|
+
// to http://schema.org/license instead. The later context wins:
|
|
441
|
+
for (
|
|
442
|
+
const [context, predicate, expected] of [
|
|
443
|
+
[[AS, FEP, "http://schema.org/"], HTTP_SCHEMA_LICENSE, []],
|
|
444
|
+
[[AS, "http://schema.org/", FEP], DC_LICENSE, [new URL(BY)]],
|
|
445
|
+
] as const
|
|
446
|
+
) {
|
|
447
|
+
const json = {
|
|
448
|
+
"@context": context,
|
|
449
|
+
id: "https://social.example/note/5",
|
|
450
|
+
type: "Note",
|
|
451
|
+
license: BY,
|
|
452
|
+
};
|
|
453
|
+
deepStrictEqual(Object.keys(await expand(json)).includes(predicate), true);
|
|
454
|
+
const parsed = await Note.fromJsonLd(json, {
|
|
455
|
+
documentLoader: noFetch,
|
|
456
|
+
contextLoader: offlineContextLoader(),
|
|
457
|
+
});
|
|
458
|
+
deepStrictEqual(parsed.licenses, expected);
|
|
459
|
+
}
|
|
460
|
+
// The FEP example's own schema prefix yields a string literal, and
|
|
461
|
+
// a context's default language tags it:
|
|
462
|
+
for (const extra of [{}, { "@language": "und" }]) {
|
|
463
|
+
const literal = await Note.fromJsonLd({
|
|
464
|
+
"@context": [AS, { schema: "https://schema.org/", ...extra }],
|
|
465
|
+
type: "Note",
|
|
466
|
+
"schema:license": BY,
|
|
467
|
+
}, { documentLoader: noFetch });
|
|
468
|
+
deepStrictEqual(literal.licenses, [new URL(BY)]);
|
|
469
|
+
}
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
test("FEP-6757 PeerTube licences round-trip unchanged", async () => {
|
|
473
|
+
// PeerTube maps its licence to http://schema.org/license with a value
|
|
474
|
+
// that is not a URI, which must not cost the object its cached input:
|
|
475
|
+
const video = {
|
|
476
|
+
"@context": [AS, {
|
|
477
|
+
sc: "http://schema.org/",
|
|
478
|
+
licence: "sc:license",
|
|
479
|
+
uuid: "sc:identifier",
|
|
480
|
+
}],
|
|
481
|
+
type: "Video",
|
|
482
|
+
id: "https://peertube.example/videos/watch/1",
|
|
483
|
+
name: "A video",
|
|
484
|
+
uuid: "e5a2d4b0-0000-4000-8000-000000000000",
|
|
485
|
+
licence: { identifier: "1", name: "Attribution" },
|
|
486
|
+
};
|
|
487
|
+
const parsed = await Video.fromJsonLd(video, { documentLoader: noFetch });
|
|
488
|
+
deepStrictEqual(parsed.licenses, []);
|
|
489
|
+
deepStrictEqual(await parsed.toJsonLd(), video);
|
|
490
|
+
const create = {
|
|
491
|
+
"@context": video["@context"],
|
|
492
|
+
type: "Create",
|
|
493
|
+
id: "https://peertube.example/videos/watch/1/activity",
|
|
494
|
+
actor: "https://peertube.example/accounts/alice",
|
|
495
|
+
object: { ...video, "@context": undefined },
|
|
496
|
+
};
|
|
497
|
+
delete create.object["@context"];
|
|
498
|
+
const parsedCreate = await Create.fromJsonLd(create, {
|
|
499
|
+
documentLoader: noFetch,
|
|
500
|
+
});
|
|
501
|
+
deepStrictEqual(await parsedCreate.toJsonLd(), create);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("FEP-6757 rejects license names and relative identifiers", async () => {
|
|
505
|
+
const base = {
|
|
506
|
+
"@context": [AS, FEP],
|
|
507
|
+
id: "https://social.example/note/6",
|
|
508
|
+
type: "Note",
|
|
509
|
+
};
|
|
510
|
+
for (
|
|
511
|
+
const license of [
|
|
512
|
+
"CC-BY-4.0",
|
|
513
|
+
"Creative Commons Attribution 4.0 International",
|
|
514
|
+
"licenses/by/4.0/",
|
|
515
|
+
"_:license",
|
|
516
|
+
]
|
|
517
|
+
) {
|
|
518
|
+
const parsed = await Note.fromJsonLd({ ...base, license }, {
|
|
519
|
+
documentLoader: noFetch,
|
|
520
|
+
});
|
|
521
|
+
deepStrictEqual(parsed.licenses, []);
|
|
522
|
+
ok(!("license" in record(await parsed.toJsonLd())));
|
|
523
|
+
}
|
|
524
|
+
const mixed = await Note.fromJsonLd({
|
|
525
|
+
...base,
|
|
526
|
+
license: ["CC-BY-4.0", BY, "urn:example:license"],
|
|
527
|
+
}, { documentLoader: noFetch });
|
|
528
|
+
deepStrictEqual(mixed.licenses, [
|
|
529
|
+
new URL(BY),
|
|
530
|
+
new URL("urn:example:license"),
|
|
531
|
+
]);
|
|
532
|
+
// The input is no longer cached because a value was dropped:
|
|
533
|
+
deepStrictEqual(
|
|
534
|
+
record(await mixed.toJsonLd()).license,
|
|
535
|
+
[BY, "urn:example:license"],
|
|
536
|
+
);
|
|
537
|
+
// A relative identifier made absolute by JSON-LD @base is accepted:
|
|
538
|
+
const withBase = await Note.fromJsonLd({
|
|
539
|
+
...base,
|
|
540
|
+
"@context": [AS, FEP, { "@base": "https://licenses.example/" }],
|
|
541
|
+
license: "by/4.0/",
|
|
542
|
+
}, { documentLoader: noFetch });
|
|
543
|
+
deepStrictEqual(withBase.licenses, [
|
|
544
|
+
new URL("https://licenses.example/by/4.0/"),
|
|
545
|
+
]);
|
|
546
|
+
for (
|
|
547
|
+
const value of [
|
|
548
|
+
{ "@value": BY, "@type": "http://www.w3.org/2001/XMLSchema#date" },
|
|
549
|
+
{ "@value": "CC-BY-4.0", "@language": "en" },
|
|
550
|
+
]
|
|
551
|
+
) {
|
|
552
|
+
const parsed = await Note.fromJsonLd({
|
|
553
|
+
"@type": [`${AS}#Note`],
|
|
554
|
+
[DC_LICENSE]: [value],
|
|
555
|
+
}, { documentLoader: noFetch });
|
|
556
|
+
deepStrictEqual(parsed.licenses, []);
|
|
557
|
+
}
|
|
558
|
+
const typed = await Note.fromJsonLd({
|
|
559
|
+
"@type": [`${AS}#Note`],
|
|
560
|
+
[DC_LICENSE]: [{
|
|
561
|
+
"@value": BY,
|
|
562
|
+
"@type": "http://www.w3.org/2001/XMLSchema#anyURI",
|
|
563
|
+
}],
|
|
564
|
+
}, { documentLoader: noFetch });
|
|
565
|
+
deepStrictEqual(typed.licenses, [new URL(BY)]);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("FEP-6757 license IRIs are never dereferenced", async () => {
|
|
569
|
+
const requested: string[] = [];
|
|
570
|
+
const documentLoader: DocumentLoader = (url) => {
|
|
571
|
+
requested.push(url);
|
|
572
|
+
throw new Error(`Unexpected fetch: ${url}`);
|
|
573
|
+
};
|
|
574
|
+
const contextLoader = offlineContextLoader();
|
|
575
|
+
const parsed = await Note.fromJsonLd(noteExample, {
|
|
576
|
+
documentLoader,
|
|
577
|
+
contextLoader,
|
|
578
|
+
});
|
|
579
|
+
deepStrictEqual(parsed.licenses, [new URL(BY)]);
|
|
580
|
+
await parsed.clone({ content: "Edited" }).toJsonLd({ contextLoader });
|
|
581
|
+
const person = await Person.fromJsonLd(personExample, {
|
|
582
|
+
documentLoader,
|
|
583
|
+
contextLoader,
|
|
584
|
+
});
|
|
585
|
+
deepStrictEqual(person.preferredLicense, new URL(BY_NC));
|
|
586
|
+
deepStrictEqual(requested, []);
|
|
587
|
+
for (const url of contextLoader.loaded) ok(url in preloadedContexts);
|
|
588
|
+
});
|
package/src/link.yaml
CHANGED
|
@@ -112,3 +112,28 @@ properties:
|
|
|
112
112
|
range:
|
|
113
113
|
- "https://www.w3.org/ns/activitystreams#Link"
|
|
114
114
|
- "https://www.w3.org/ns/activitystreams#Object"
|
|
115
|
+
|
|
116
|
+
- pluralName: licenses
|
|
117
|
+
singularName: license
|
|
118
|
+
compactName: license
|
|
119
|
+
uri: "http://purl.org/dc/terms/license"
|
|
120
|
+
extraContext: "https://w3id.org/fep/6757"
|
|
121
|
+
redundantProperties:
|
|
122
|
+
- uri: "https://schema.org/license"
|
|
123
|
+
- uri: "http://creativecommons.org/ns#license"
|
|
124
|
+
redundantPropertiesWrite: canonical
|
|
125
|
+
description: |
|
|
126
|
+
The copyright licenses for the linked resource, as defined by
|
|
127
|
+
[FEP-6757](https://w3id.org/fep/6757). Each license is identified by its
|
|
128
|
+
URI, such as <https://creativecommons.org/publicdomain/mark/1.0/>; license
|
|
129
|
+
names and SPDX short identifiers are not license URIs. Multiple values are
|
|
130
|
+
alternatives that a consumer may choose from; their order has no meaning.
|
|
131
|
+
|
|
132
|
+
An empty list means the link carries no license metadata. Fedify does not
|
|
133
|
+
synthesize a default license for it.
|
|
134
|
+
|
|
135
|
+
The `https://schema.org/license` and
|
|
136
|
+
`http://creativecommons.org/ns#license` properties are read as synonyms
|
|
137
|
+
when this property is absent, but only this property is written.
|
|
138
|
+
range:
|
|
139
|
+
- "fedify:absoluteIri"
|
package/src/object.yaml
CHANGED
|
@@ -484,3 +484,33 @@ properties:
|
|
|
484
484
|
no authority to update the object.
|
|
485
485
|
range:
|
|
486
486
|
- "https://w3id.org/fep/22cd#Translation"
|
|
487
|
+
|
|
488
|
+
- pluralName: licenses
|
|
489
|
+
singularName: license
|
|
490
|
+
compactName: license
|
|
491
|
+
uri: "http://purl.org/dc/terms/license"
|
|
492
|
+
extraContext: "https://w3id.org/fep/6757"
|
|
493
|
+
redundantProperties:
|
|
494
|
+
- uri: "https://schema.org/license"
|
|
495
|
+
- uri: "http://creativecommons.org/ns#license"
|
|
496
|
+
redundantPropertiesWrite: canonical
|
|
497
|
+
description: |
|
|
498
|
+
The copyright licenses for this object, as defined by
|
|
499
|
+
[FEP-6757](https://w3id.org/fep/6757). Each license is identified by its
|
|
500
|
+
URI, such as <https://creativecommons.org/licenses/by/4.0/>; license names
|
|
501
|
+
and SPDX short identifiers are not license URIs. Multiple values are
|
|
502
|
+
alternatives that a consumer may choose from; their order has no meaning.
|
|
503
|
+
|
|
504
|
+
An empty list means the object carries no license metadata. Fedify does
|
|
505
|
+
not synthesize a default license for it; FEP-6757 suggests that consumers
|
|
506
|
+
treat such content as “all rights reserved,” but that is a consumer policy,
|
|
507
|
+
not a value of this property. A license on an actor, activity, or
|
|
508
|
+
collection covers that object itself, not the objects the actor authored,
|
|
509
|
+
the activity's object, or the collection's items. An actor's
|
|
510
|
+
`preferredLicense` is not a fallback for this property either.
|
|
511
|
+
|
|
512
|
+
The `https://schema.org/license` and
|
|
513
|
+
`http://creativecommons.org/ns#license` properties are read as synonyms
|
|
514
|
+
when this property is absent, but only this property is written.
|
|
515
|
+
range:
|
|
516
|
+
- "fedify:absoluteIri"
|