@fedify/vocab-tools 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/src/class.ts CHANGED
@@ -12,6 +12,7 @@ import { emitOverride } from "./type.ts";
12
12
 
13
13
  const XSD_ANY_URI = "http://www.w3.org/2001/XMLSchema#anyURI";
14
14
  const FEDIFY_URL = "fedify:url";
15
+ const FEDIFY_ABSOLUTE_IRI = "fedify:absoluteIri";
15
16
  const INTERNAL_RUNTIME_IMPORTS = [
16
17
  "compactJsonLdCache",
17
18
  "createScopedContextLoader",
@@ -255,7 +256,8 @@ function canContainIriValue(
255
256
  types: Record<string, TypeSchema>,
256
257
  ): boolean {
257
258
  return property.range.some((typeUri) =>
258
- typeUri === XSD_ANY_URI || typeUri === FEDIFY_URL || types[typeUri]?.entity
259
+ typeUri === XSD_ANY_URI || typeUri === FEDIFY_URL ||
260
+ typeUri === FEDIFY_ABSOLUTE_IRI || types[typeUri]?.entity
259
261
  );
260
262
  }
261
263
 
@@ -334,7 +336,45 @@ function isValidLanguageTag(language: string): boolean {
334
336
  }
335
337
  }
336
338
  `;
337
- // Contexts are activated by expanded property IRIs left after compaction.
339
+ if (
340
+ Object.values(types).some((type) =>
341
+ type.properties.some((property) =>
342
+ property.range.includes(FEDIFY_ABSOLUTE_IRI)
343
+ )
344
+ )
345
+ ) {
346
+ // An absolute IRI may arrive as an IRI node or, when the publisher's
347
+ // context does not coerce the term, as a string or IRI-typed literal.
348
+ // It is never resolved against a base IRI.
349
+ yield `
350
+ function getAbsoluteIriValue(value: unknown): string | undefined {
351
+ if (value == null || typeof value !== "object") return undefined;
352
+ const node = value as Record<string, unknown>;
353
+ if (typeof node["@id"] === "string") {
354
+ return node["@id"].startsWith("_:") ? undefined : node["@id"];
355
+ }
356
+ // A language tag can come from a context's default language, so it does
357
+ // not make a literal any less of an IRI:
358
+ if (typeof node["@value"] !== "string") return undefined;
359
+ const type = node["@type"];
360
+ return type == null ||
361
+ type === "http://www.w3.org/2001/XMLSchema#string" ||
362
+ type === "http://www.w3.org/2001/XMLSchema#anyURI"
363
+ ? node["@value"]
364
+ : undefined;
365
+ }
366
+
367
+ function isAbsoluteIriValue(value: unknown): boolean {
368
+ const iri = getAbsoluteIriValue(value);
369
+ return iri != null && canDecodeIri(iri);
370
+ }
371
+ `;
372
+ }
373
+ // Contexts are activated by the expanded property IRIs of the values to be
374
+ // compacted, and by the contexts that already serialized children carry.
375
+ // They are detected before compaction, because a default context may
376
+ // define a prefix that would otherwise hide a property IRI behind a compact
377
+ // IRI such as `dc:license`.
338
378
  const extraContexts = Object.fromEntries(
339
379
  Object.values(types).flatMap((type) =>
340
380
  type.properties.flatMap((property) =>
@@ -349,6 +389,9 @@ function isValidLanguageTag(language: string): boolean {
349
389
  const extraPropertyContexts: Readonly<Record<string, string>> =
350
390
  ${JSON.stringify(extraContexts)};
351
391
 
392
+ const extraContextUrls: readonly string[] =
393
+ ${JSON.stringify([...new Set(Object.values(extraContexts))])};
394
+
352
395
  function getExtraContexts(document: unknown): string[] {
353
396
  const contexts = new Set<string>();
354
397
  const pending: unknown[] = [document];
@@ -361,7 +404,16 @@ function getExtraContexts(document: unknown): string[] {
361
404
  }
362
405
  if ("@value" in value) continue;
363
406
  for (const [key, child] of globalThis.Object.entries(value)) {
364
- if (key === "@context") continue;
407
+ if (key === "@context") {
408
+ for (const context of Array.isArray(child) ? child : [child]) {
409
+ if (
410
+ typeof context === "string" && extraContextUrls.includes(context)
411
+ ) {
412
+ contexts.add(context);
413
+ }
414
+ }
415
+ continue;
416
+ }
365
417
  if (globalThis.Object.hasOwn(extraPropertyContexts, key)) {
366
418
  contexts.add(extraPropertyContexts[key]);
367
419
  }
package/src/codec.test.ts CHANGED
@@ -59,7 +59,12 @@ async function withFixture(
59
59
  const terms: Record<string, unknown> = { ...context };
60
60
  for (const name of ["value", "first", "second"]) {
61
61
  const term: Record<string, unknown> = { "@id": `${NS}${name}` };
62
- if (property.range[0] === `${XSD}anyURI`) term["@type"] = "@id";
62
+ if (
63
+ property.range[0] === `${XSD}anyURI` ||
64
+ property.range[0] === "fedify:absoluteIri"
65
+ ) {
66
+ term["@type"] = "@id";
67
+ }
63
68
  if ("container" in property && property.container != null) {
64
69
  term["@container"] = `@${property.container}`;
65
70
  }
@@ -407,3 +412,68 @@ for (const functional of [false, true]) {
407
412
  }
408
413
  });
409
414
  }
415
+
416
+ test("absolute IRIs are read from nodes and plain literals without a base", async () => {
417
+ await withFixture({
418
+ range: ["fedify:absoluteIri"],
419
+ redundantPropertiesWrite: "canonical",
420
+ redundantProperties: [{ uri: `${NS}first` }],
421
+ }, async (Fixture, contextLoader, source) => {
422
+ match(source, /PORTABLE_IRI_KEYS[^\n]+https:\/\/example.com\/value/);
423
+ match(source, /PORTABLE_IRI_KEYS[^\n]+https:\/\/example.com\/first/);
424
+ const values = [
425
+ { "@id": "https://example.com/node" },
426
+ literal("urn:example:literal"),
427
+ { "@value": "https://example.com/any-uri", "@type": `${XSD}anyURI` },
428
+ { "@value": "https://example.com/string", "@type": `${XSD}string` },
429
+ // A context's default language tags uncoerced strings:
430
+ { "@value": "https://example.com/tagged", "@language": "und" },
431
+ // Relative IRIs are never resolved against the object's ID:
432
+ { "@id": "CC-BY-4.0" },
433
+ { "@id": "_:b0" },
434
+ literal("Creative Commons Attribution 4.0 International"),
435
+ { "@value": "https://example.com/date", "@type": `${XSD}date` },
436
+ ];
437
+ const expected = [
438
+ new URL("https://example.com/node"),
439
+ new URL("urn:example:literal"),
440
+ new URL("https://example.com/any-uri"),
441
+ new URL("https://example.com/string"),
442
+ new URL("https://example.com/tagged"),
443
+ ];
444
+ for (const key of ["value", "first"]) {
445
+ const parsed = await Fixture.fromJsonLd(
446
+ {
447
+ "@id": "https://example.com/objects/1",
448
+ ...document({ [`${NS}${key}`]: values }),
449
+ },
450
+ { contextLoader, baseUrl: new URL("https://example.com/base/") },
451
+ );
452
+ deepStrictEqual(parsed.values, expected);
453
+ }
454
+ // An invalid canonical set is not replaced by a synonym:
455
+ const invalid = await Fixture.fromJsonLd(
456
+ document({
457
+ [`${NS}value`]: [{ "@id": "CC-BY-4.0" }],
458
+ [`${NS}first`]: [{ "@id": "https://example.com/fallback" }],
459
+ }),
460
+ { contextLoader },
461
+ );
462
+ deepStrictEqual(invalid.values, []);
463
+ const instance = new Fixture({ values: expected });
464
+ const expanded = await instance.toJsonLd({
465
+ format: "expand",
466
+ contextLoader,
467
+ }) as unknown as Record<string, unknown>[];
468
+ deepStrictEqual(
469
+ expanded[0][`${NS}value`],
470
+ expected.map((url) => ({ "@id": url.href })),
471
+ );
472
+ ok(!(`${NS}first` in expanded[0]));
473
+ const compact = await instance.toJsonLd({
474
+ format: "compact",
475
+ contextLoader,
476
+ });
477
+ deepStrictEqual(compact.value, expected.map((url) => url.href));
478
+ });
479
+ });
package/src/codec.ts CHANGED
@@ -288,17 +288,33 @@ export async function* generateEncoder(
288
288
  }
289
289
  let docContext: Parameters<typeof jsonld.compact>[1] = options.context ??
290
290
  ${JSON.stringify(type.defaultContext)};
291
+ `;
292
+ const hasExtraContexts = Object.values(types).some((t) =>
293
+ t.properties.some((p) => p.extraContext != null)
294
+ );
295
+ if (hasExtraContexts) {
296
+ yield `
297
+ if (options.context == null) {
298
+ const currentContexts = Array.isArray(docContext) ? docContext : [docContext];
299
+ const additionalContexts = getExtraContexts(values).filter(
300
+ context => !currentContexts.includes(context)
301
+ );
302
+ if (additionalContexts.length > 0) {
303
+ docContext = [...currentContexts, ...additionalContexts];
304
+ }
305
+ }
306
+ `;
307
+ }
308
+ yield `
291
309
  let compacted = await jsonld.compact(
292
310
  values,
293
311
  docContext,
294
312
  { documentLoader: options.contextLoader },
295
313
  );
296
314
  `;
297
- if (
298
- Object.values(types).some((t) =>
299
- t.properties.some((p) => p.extraContext != null)
300
- )
301
- ) {
315
+ if (hasExtraContexts) {
316
+ // A cached child may map a property with its own inline context, which
317
+ // the scan above cannot recognize; compaction then leaves its full IRI:
302
318
  yield `
303
319
  if (options.context == null) {
304
320
  const currentContexts = Array.isArray(docContext) ? docContext : [docContext];
package/src/type.ts CHANGED
@@ -334,6 +334,25 @@ const scalarTypes: Record<string, ScalarType> = {
334
334
  },
335
335
  skipUnparsable: true,
336
336
  },
337
+ "fedify:absoluteIri": {
338
+ name: "URL",
339
+ typeGuard(v) {
340
+ return `${v} instanceof URL`;
341
+ },
342
+ encoder(v) {
343
+ return `{ "@id": formatIri(${v}) }`;
344
+ },
345
+ compactEncoder(v) {
346
+ return `formatIri(${v})`;
347
+ },
348
+ dataCheck(v) {
349
+ return `isAbsoluteIriValue(${v})`;
350
+ },
351
+ decoder(v) {
352
+ return `decodeIri(getAbsoluteIriValue(${v})!)`;
353
+ },
354
+ skipUnparsable: true,
355
+ },
337
356
  "fedify:gatewayUrl": {
338
357
  name: "URL",
339
358
  typeGuard(v) {