@fedify/vocab-runtime 2.3.11-dev.2280 → 2.3.11

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.
Files changed (45) hide show
  1. package/deno.json +2 -2
  2. package/dist/mod.cjs +91 -4
  3. package/dist/mod.d.cts +30 -2
  4. package/dist/mod.d.ts +30 -2
  5. package/dist/mod.js +91 -5
  6. package/dist/tests/{body-Bkseh1QO.mjs → body-Cfk4XwBC.mjs} +1 -1
  7. package/dist/tests/{body-DuDpNk3l.cjs → body-kujxx7yw.cjs} +1 -1
  8. package/dist/tests/body.test.cjs +1 -1
  9. package/dist/tests/body.test.mjs +1 -1
  10. package/dist/tests/decimal.test.cjs +3 -2
  11. package/dist/tests/decimal.test.mjs +3 -2
  12. package/dist/tests/{docloader-BHCG9eDn.mjs → docloader-BeuDl5a0.mjs} +43 -3
  13. package/dist/tests/{docloader-56AWfLMh.cjs → docloader-Bx_LR9xG.cjs} +43 -3
  14. package/dist/tests/docloader.test.cjs +168 -43
  15. package/dist/tests/docloader.test.mjs +158 -34
  16. package/dist/tests/{langstr-DDLc4833.mjs → langstr-ABdAOKlF.mjs} +5 -2
  17. package/dist/tests/{langstr-BOQHfJDr.cjs → langstr-BsqFJupv.cjs} +5 -2
  18. package/dist/tests/langstr.test.cjs +8 -1
  19. package/dist/tests/langstr.test.mjs +9 -2
  20. package/dist/tests/langtag-DgySESmk.cjs +50 -0
  21. package/dist/tests/langtag-LDBqlxHv.mjs +45 -0
  22. package/dist/tests/langtag.test.cjs +46 -0
  23. package/dist/tests/langtag.test.d.cts +1 -0
  24. package/dist/tests/langtag.test.d.mts +1 -0
  25. package/dist/tests/langtag.test.mjs +46 -0
  26. package/dist/tests/multibase/multibase.test.cjs +7 -7
  27. package/dist/tests/multibase/multibase.test.mjs +5 -5
  28. package/dist/tests/{request-DWyJcJ_e.mjs → request-D6bjY2cF.mjs} +1 -1
  29. package/dist/tests/{request-CVg2A6dZ.cjs → request-DPh7-Q1i.cjs} +1 -1
  30. package/dist/tests/request.test.cjs +1 -1
  31. package/dist/tests/request.test.mjs +1 -1
  32. package/package.json +6 -3
  33. package/scripts/test-bun.mjs +18 -0
  34. package/src/contexts/fep-22cd.json +14 -0
  35. package/src/contexts/fep-6757.json +12 -0
  36. package/src/contexts/joinmastodon.json +1 -1
  37. package/src/contexts.ts +16 -0
  38. package/src/docloader.test.ts +222 -67
  39. package/src/docloader.ts +2 -0
  40. package/src/langstr.test.ts +18 -1
  41. package/src/langstr.ts +6 -2
  42. package/src/langtag.test.ts +53 -0
  43. package/src/langtag.ts +76 -0
  44. package/src/mod.ts +1 -0
  45. package/src/multibase/multibase.test.ts +5 -5
@@ -2,12 +2,13 @@ import { configure, type LogRecord, reset } from "@logtape/logtape";
2
2
  import fetchMock from "fetch-mock";
3
3
  import { deepStrictEqual, ok, rejects } from "node:assert";
4
4
  import dns from "node:dns/promises";
5
- import { test } from "node:test";
5
+ import { test } from "@fedify/fixture";
6
6
  import { createServer } from "node:http";
7
7
  import { gzipSync } from "node:zlib";
8
8
  import preloadedContexts from "./contexts.ts";
9
9
  import cidV1Context from "./contexts/cid-v1.json" with { type: "json" };
10
10
  import { getDocumentLoader, getRemoteDocument } from "./docloader.ts";
11
+ import jsonld from "./jsonld.ts";
11
12
  import { FetchError } from "./request.ts";
12
13
  import { UrlError } from "./url.ts";
13
14
 
@@ -44,7 +45,7 @@ test("getDocumentLoader()", async (t) => {
44
45
  },
45
46
  });
46
47
 
47
- await t.test("ok", async () => {
48
+ await t.step("ok", async () => {
48
49
  deepStrictEqual(await fetchDocumentLoader("https://example.com/object"), {
49
50
  contextUrl: null,
50
51
  documentUrl: "https://example.com/object",
@@ -101,7 +102,7 @@ test("getDocumentLoader()", async (t) => {
101
102
  },
102
103
  });
103
104
 
104
- await t.test("Link header", async () => {
105
+ await t.step("Link header", async () => {
105
106
  deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
106
107
  contextUrl: "https://www.w3.org/ns/activitystreams",
107
108
  documentUrl: "https://example.com/link-ctx",
@@ -124,7 +125,7 @@ test("getDocumentLoader()", async (t) => {
124
125
  });
125
126
  });
126
127
 
127
- await t.test("Link header relative url", async () => {
128
+ await t.step("Link header relative url", async () => {
128
129
  deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
129
130
  contextUrl: "https://www.w3.org/ns/activitystreams",
130
131
  documentUrl: "https://example.com/link-ctx",
@@ -150,7 +151,7 @@ test("getDocumentLoader()", async (t) => {
150
151
  );
151
152
  });
152
153
 
153
- await t.test("wrong Link header syntax", async () => {
154
+ await t.step("wrong Link header syntax", async () => {
154
155
  deepStrictEqual(
155
156
  await fetchDocumentLoader("https://example.com/obj-w-wrong-link"),
156
157
  {
@@ -179,7 +180,7 @@ test("getDocumentLoader()", async (t) => {
179
180
  headers: { "Content-Type": "text/html; charset=utf-8" },
180
181
  });
181
182
 
182
- await t.test("HTML <link>", async () => {
183
+ await t.step("HTML <link>", async () => {
183
184
  deepStrictEqual(
184
185
  await fetchDocumentLoader("https://example.com/html-link"),
185
186
  {
@@ -208,7 +209,7 @@ test("getDocumentLoader()", async (t) => {
208
209
  headers: { "Content-Type": "application/xhtml+xml; charset=utf-8" },
209
210
  });
210
211
 
211
- await t.test("XHTML <link>", async () => {
212
+ await t.step("XHTML <link>", async () => {
212
213
  deepStrictEqual(
213
214
  await fetchDocumentLoader("https://example.com/xhtml-link"),
214
215
  {
@@ -239,7 +240,7 @@ test("getDocumentLoader()", async (t) => {
239
240
  headers: { "Content-Type": "text/html; charset=utf-8" },
240
241
  });
241
242
 
242
- await t.test("HTML <a>", async () => {
243
+ await t.step("HTML <a>", async () => {
243
244
  deepStrictEqual(await fetchDocumentLoader("https://example.com/html-a"), {
244
245
  contextUrl: null,
245
246
  documentUrl: "https://example.com/object",
@@ -262,7 +263,7 @@ test("getDocumentLoader()", async (t) => {
262
263
  headers: { "Content-Type": "text/html; charset=utf-8" },
263
264
  });
264
265
 
265
- await t.test("Wrong Content-Type", async () => {
266
+ await t.step("Wrong Content-Type", async () => {
266
267
  deepStrictEqual(
267
268
  await fetchDocumentLoader("https://example.com/wrong-content-type"),
268
269
  {
@@ -280,7 +281,7 @@ test("getDocumentLoader()", async (t) => {
280
281
 
281
282
  fetchMock.get("https://example.com/404", { status: 404 });
282
283
 
283
- await t.test("not ok", async () => {
284
+ await t.step("not ok", async () => {
284
285
  await rejects(
285
286
  () => fetchDocumentLoader("https://example.com/404"),
286
287
  FetchError,
@@ -288,7 +289,7 @@ test("getDocumentLoader()", async (t) => {
288
289
  );
289
290
  });
290
291
 
291
- await t.test("preloaded contexts", async () => {
292
+ await t.step("preloaded contexts", async () => {
292
293
  for (const [url, document] of Object.entries(preloadedContexts)) {
293
294
  deepStrictEqual(await fetchDocumentLoader(url), {
294
295
  contextUrl: null,
@@ -298,13 +299,37 @@ test("getDocumentLoader()", async (t) => {
298
299
  }
299
300
  });
300
301
 
302
+ await t.step("preloaded Mastodon attribution domains", async () => {
303
+ const context = [
304
+ "https://www.w3.org/ns/activitystreams",
305
+ "http://joinmastodon.org/ns",
306
+ ];
307
+ const expanded = await jsonld.expand({
308
+ "@context": context,
309
+ id: "https://social.example/users/alice",
310
+ type: "Person",
311
+ attributionDomains: ["blog.example"],
312
+ }, { documentLoader: fetchDocumentLoader });
313
+ deepStrictEqual(expanded, [{
314
+ "@id": "https://social.example/users/alice",
315
+ "@type": ["https://www.w3.org/ns/activitystreams#Person"],
316
+ "http://joinmastodon.org/ns#attributionDomains": [
317
+ { "@value": "blog.example" },
318
+ ],
319
+ }]);
320
+ const compacted = await jsonld.compact(expanded, context, {
321
+ documentLoader: fetchDocumentLoader,
322
+ });
323
+ deepStrictEqual(compacted.attributionDomains, ["blog.example"]);
324
+ });
325
+
301
326
  // Controlled Identifiers v1.0 requires JSON-LD processors to treat this
302
327
  // context URL as already resolved. A temporary W3C outage must not prevent
303
328
  // an otherwise valid document from being processed.
304
329
  // See: https://www.w3.org/TR/cid-1.0/#json-ld-context
305
330
  // https://github.com/fedify-dev/fedify/issues/932
306
331
  fetchMock.get("https://www.w3.org/ns/cid/v1", { status: 503 });
307
- await t.test("preloaded CID v1 context", async () => {
332
+ await t.step("preloaded CID v1 context", async () => {
308
333
  const url = "https://www.w3.org/ns/cid/v1";
309
334
  deepStrictEqual(await fetchDocumentLoader(url), {
310
335
  contextUrl: null,
@@ -318,7 +343,7 @@ test("getDocumentLoader()", async (t) => {
318
343
  // document referencing it fails before application handlers run. It has to
319
344
  // be resolved from the built-in copy rather than over the network.
320
345
  // See: https://github.com/fedify-dev/fedify/issues/982
321
- await t.test("preloaded FEP-ef61 context", async () => {
346
+ await t.step("preloaded FEP-ef61 context", async () => {
322
347
  const url = "https://w3id.org/fep/ef61";
323
348
  ok(url in preloadedContexts);
324
349
  deepStrictEqual(await fetchDocumentLoader(url), {
@@ -341,7 +366,7 @@ test("getDocumentLoader()", async (t) => {
341
366
  // A Codeberg Pages outage must not prevent loading the FEP-7aa9 context.
342
367
  // See: https://github.com/fedify-dev/fedify/issues/1078
343
368
  fetchMock.get("https://w3id.org/fep/7aa9", { status: 502 });
344
- await t.test("preloaded FEP-7aa9 context", async () => {
369
+ await t.step("preloaded FEP-7aa9 context", async () => {
345
370
  const url = "https://w3id.org/fep/7aa9";
346
371
  deepStrictEqual(await fetchDocumentLoader(url), {
347
372
  contextUrl: null,
@@ -375,7 +400,130 @@ test("getDocumentLoader()", async (t) => {
375
400
  deepStrictEqual(fetchMock.callHistory.calls(url).length, 0);
376
401
  });
377
402
 
378
- await t.test("deny non-HTTP/HTTPS", async () => {
403
+ // A Codeberg Pages outage must not prevent loading or expanding FEP-6757.
404
+ // See: https://github.com/fedify-dev/fedify/issues/1211
405
+ fetchMock.get("https://w3id.org/fep/6757", { status: 502 });
406
+ await t.step("preloaded FEP-6757 context", async () => {
407
+ const url = "https://w3id.org/fep/6757";
408
+ deepStrictEqual(await fetchDocumentLoader(url), {
409
+ contextUrl: null,
410
+ documentUrl: url,
411
+ document: {
412
+ "@context": {
413
+ license: {
414
+ "@id": "http://purl.org/dc/terms/license",
415
+ "@type": "@id",
416
+ },
417
+ preferredLicense: {
418
+ "@id": "https://w3id.org/fep/6757#preferredLicense",
419
+ "@type": "@id",
420
+ },
421
+ },
422
+ },
423
+ });
424
+ deepStrictEqual(
425
+ await jsonld.expand({
426
+ "@context": ["https://www.w3.org/ns/activitystreams", url],
427
+ type: "Person",
428
+ license: "https://creativecommons.org/licenses/by/4.0/",
429
+ preferredLicense: "https://creativecommons.org/licenses/by-nc/4.0/",
430
+ }, { documentLoader: fetchDocumentLoader }),
431
+ [{
432
+ "@type": ["https://www.w3.org/ns/activitystreams#Person"],
433
+ "http://purl.org/dc/terms/license": [{
434
+ "@id": "https://creativecommons.org/licenses/by/4.0/",
435
+ }],
436
+ "https://w3id.org/fep/6757#preferredLicense": [{
437
+ "@id": "https://creativecommons.org/licenses/by-nc/4.0/",
438
+ }],
439
+ }],
440
+ );
441
+ deepStrictEqual(fetchMock.callHistory.calls(url).length, 0);
442
+ });
443
+
444
+ // A Codeberg Pages outage must not prevent expanding translation metadata.
445
+ // See: https://github.com/fedify-dev/fedify/issues/1214
446
+ fetchMock.get("https://w3id.org/fep/22cd", { status: 502 });
447
+ await t.step("preloaded FEP-22cd context", async () => {
448
+ const url = "https://w3id.org/fep/22cd";
449
+ const articleId = "https://example.com/articles/1";
450
+ const translatorId = "https://example.com/users/alice";
451
+ const revisionId = "https://example.com/articles/1/revisions/1";
452
+ deepStrictEqual(await fetchDocumentLoader(url), {
453
+ contextUrl: null,
454
+ documentUrl: url,
455
+ document: {
456
+ "@context": {
457
+ "fep-22cd": "https://w3id.org/fep/22cd#",
458
+ schema: "https://schema.org/",
459
+ xsd: "http://www.w3.org/2001/XMLSchema#",
460
+ translations: {
461
+ "@id": "fep-22cd:translations",
462
+ "@container": "@set",
463
+ },
464
+ Translation: "fep-22cd:Translation",
465
+ sourceUpdated: {
466
+ "@id": "fep-22cd:sourceUpdated",
467
+ "@type": "xsd:dateTime",
468
+ },
469
+ translator: {
470
+ "@id": "schema:translator",
471
+ "@type": "@id",
472
+ "@container": "@set",
473
+ },
474
+ inLanguage: "schema:inLanguage",
475
+ translationOfWork: {
476
+ "@id": "schema:translationOfWork",
477
+ "@type": "@id",
478
+ },
479
+ isBasedOn: { "@id": "schema:isBasedOn", "@type": "@id" },
480
+ },
481
+ },
482
+ });
483
+ deepStrictEqual(
484
+ await jsonld.expand({
485
+ "@context": ["https://www.w3.org/ns/activitystreams", url],
486
+ id: articleId,
487
+ type: "Article",
488
+ contentMap: { en: "Original", ko: "Translation" },
489
+ translations: [{
490
+ type: "Translation",
491
+ inLanguage: "ko",
492
+ translator: [translatorId],
493
+ translationOfWork: articleId,
494
+ sourceUpdated: "2026-09-01T00:00:00Z",
495
+ isBasedOn: revisionId,
496
+ }],
497
+ }, { documentLoader: fetchDocumentLoader }),
498
+ [{
499
+ "@id": articleId,
500
+ "@type": ["https://www.w3.org/ns/activitystreams#Article"],
501
+ "https://www.w3.org/ns/activitystreams#content": [
502
+ { "@language": "en", "@value": "Original" },
503
+ { "@language": "ko", "@value": "Translation" },
504
+ ],
505
+ "https://w3id.org/fep/22cd#translations": [{
506
+ "@type": ["https://w3id.org/fep/22cd#Translation"],
507
+ "https://schema.org/inLanguage": [{ "@value": "ko" }],
508
+ "https://schema.org/translator": [{ "@id": translatorId }],
509
+ "https://schema.org/translationOfWork": [{ "@id": articleId }],
510
+ "https://w3id.org/fep/22cd#sourceUpdated": [{
511
+ "@type": "http://www.w3.org/2001/XMLSchema#dateTime",
512
+ "@value": "2026-09-01T00:00:00Z",
513
+ }],
514
+ "https://schema.org/isBasedOn": [{ "@id": revisionId }],
515
+ }],
516
+ }],
517
+ );
518
+ deepStrictEqual(fetchMock.callHistory.calls(url).length, 0);
519
+ deepStrictEqual(
520
+ fetchMock.callHistory.calls("https://www.w3.org/ns/activitystreams")
521
+ .length,
522
+ 0,
523
+ );
524
+ });
525
+
526
+ await t.step("deny non-HTTP/HTTPS", async () => {
379
527
  await rejects(
380
528
  () => fetchDocumentLoader("ftp://localhost"),
381
529
  UrlError,
@@ -409,7 +557,7 @@ test("getDocumentLoader()", async (t) => {
409
557
  },
410
558
  });
411
559
 
412
- await t.test("allowPrivateAddress: false", async () => {
560
+ await t.step("allowPrivateAddress: false", async () => {
413
561
  await rejects(
414
562
  () => fetchDocumentLoader("https://localhost/object"),
415
563
  UrlError,
@@ -426,7 +574,7 @@ test("getDocumentLoader()", async (t) => {
426
574
 
427
575
  const fetchDocumentLoader2 = getDocumentLoader({ allowPrivateAddress: true });
428
576
 
429
- await t.test("allowPrivateAddress: true", async () => {
577
+ await t.step("allowPrivateAddress: true", async () => {
430
578
  const expected = {
431
579
  contextUrl: null,
432
580
  documentUrl: "https://localhost/object",
@@ -463,7 +611,7 @@ test("getDocumentLoader()", async (t) => {
463
611
  };
464
612
  });
465
613
 
466
- await t.test("too many redirects", async () => {
614
+ await t.step("too many redirects", async () => {
467
615
  redirectAttempts = 0;
468
616
  await rejects(
469
617
  () => fetchDocumentLoader("https://example.com/too-many-redirects/0"),
@@ -473,7 +621,7 @@ test("getDocumentLoader()", async (t) => {
473
621
  deepStrictEqual(redirectAttempts, 21);
474
622
  });
475
623
 
476
- await t.test("custom max redirection", async () => {
624
+ await t.step("custom max redirection", async () => {
477
625
  redirectAttempts = 0;
478
626
  const loader = getDocumentLoader({ maxRedirection: 1 });
479
627
  await rejects(
@@ -500,7 +648,7 @@ test("getDocumentLoader()", async (t) => {
500
648
  };
501
649
  });
502
650
 
503
- await t.test("redirect loop", async () => {
651
+ await t.step("redirect loop", async () => {
504
652
  loopAttempts = 0;
505
653
  await rejects(
506
654
  () => fetchDocumentLoader("https://example.com/redirect-loop-a"),
@@ -519,7 +667,7 @@ test("getDocumentLoader()", async (t) => {
519
667
  };
520
668
  });
521
669
 
522
- await t.test("redirect loop with relative location", async () => {
670
+ await t.step("redirect loop with relative location", async () => {
523
671
  relativeLoopAttempts = 0;
524
672
  await rejects(
525
673
  () => fetchDocumentLoader("https://example.com/redirect-loop-relative"),
@@ -539,7 +687,7 @@ test("getDocumentLoader()", async (t) => {
539
687
  headers: { "Content-Type": "text/html; charset=utf-8" },
540
688
  });
541
689
 
542
- await t.test("ReDoS resistance (CVE-2025-68475)", async () => {
690
+ await t.step("ReDoS resistance (CVE-2025-68475)", async () => {
543
691
  const start = performance.now();
544
692
  // The malicious HTML will fail JSON parsing, but the important thing is
545
693
  // that it should complete quickly (not hang due to ReDoS)
@@ -688,12 +836,13 @@ test("getDocumentLoader() logs DNS failures as such", async (t) => {
688
836
  "Deno" in globalThis &&
689
837
  (await Deno.permissions.query({ name: "net" })).state !== "granted"
690
838
  ) {
691
- t.skip("requires the net permission");
692
- return;
839
+ throw new Error(
840
+ "This DNS test requires Deno network permission (--allow-net).",
841
+ );
693
842
  }
694
843
  const loader = getDocumentLoader();
695
844
  for (const result of ["throws", "empty", "private"] as const) {
696
- await t.test(result, async () => {
845
+ await t.step(result, async () => {
697
846
  // Stubbing works only because url.ts uses the default node:dns/promises
698
847
  // import; see the FIXME there.
699
848
  const originalLookup = dns.lookup;
@@ -749,7 +898,7 @@ test("getDocumentLoader() bounds alternate document chains", async (t) => {
749
898
  const loader = getDocumentLoader({ allowPrivateAddress: true });
750
899
  for (const mode of ["header", "html", "mixed"] as const) {
751
900
  for (const hops of [20, 21]) {
752
- await t.test(`${mode}: ${hops} hops`, async () => {
901
+ await t.step(`${mode}: ${hops} hops`, async () => {
753
902
  fetchMock.mockGlobal();
754
903
  let requests = 0;
755
904
  fetchMock.get(`begin:${base}`, ({ url }) => {
@@ -794,7 +943,7 @@ test("getDocumentLoader() bounds alternate document chains", async (t) => {
794
943
  "redirect-intermediate",
795
944
  ]
796
945
  ) {
797
- await t.test(`${mode}: cycle`, async () => {
946
+ await t.step(`${mode}: cycle`, async () => {
798
947
  fetchMock.mockGlobal();
799
948
  let requests = 0;
800
949
  fetchMock.get(`begin:${base}`, ({ url }) => {
@@ -828,49 +977,55 @@ test("getDocumentLoader() bounds alternate document chains", async (t) => {
828
977
  });
829
978
  }
830
979
 
831
- await t.test("alternate followed by 20 redirects shares the limit", async () => {
832
- fetchMock.mockGlobal();
833
- let requests = 0;
834
- fetchMock.get(`begin:${base}`, ({ url }) => {
835
- requests++;
836
- const index = Number(new URL(url).pathname.split("/").at(-1));
837
- if (index === 21) return Response.json({ done: true });
838
- return index === 0
839
- ? alternate(`${base}1`, false)
840
- : Response.redirect(`${base}${index + 1}`, 302);
841
- });
842
- try {
843
- await rejects(loader(`${base}0`), (error: unknown) => {
844
- ok(error instanceof FetchError);
845
- ok(error.message.includes("Too many redirections (21)"));
846
- return true;
980
+ await t.step(
981
+ "alternate followed by 20 redirects shares the limit",
982
+ async () => {
983
+ fetchMock.mockGlobal();
984
+ let requests = 0;
985
+ fetchMock.get(`begin:${base}`, ({ url }) => {
986
+ requests++;
987
+ const index = Number(new URL(url).pathname.split("/").at(-1));
988
+ if (index === 21) return Response.json({ done: true });
989
+ return index === 0
990
+ ? alternate(`${base}1`, false)
991
+ : Response.redirect(`${base}${index + 1}`, 302);
847
992
  });
848
- deepStrictEqual(requests, 21);
849
- } finally {
850
- fetchMock.hardReset();
851
- }
852
- });
993
+ try {
994
+ await rejects(loader(`${base}0`), (error: unknown) => {
995
+ ok(error instanceof FetchError);
996
+ ok(error.message.includes("Too many redirections (21)"));
997
+ return true;
998
+ });
999
+ deepStrictEqual(requests, 21);
1000
+ } finally {
1001
+ fetchMock.hardReset();
1002
+ }
1003
+ },
1004
+ );
853
1005
 
854
- await t.test("relative alternate after redirect and isolated calls", async () => {
855
- fetchMock.mockGlobal();
856
- fetchMock.get(`${base}start`, Response.redirect(`${base}html`, 302));
857
- fetchMock.get(`${base}html`, alternate("./document", true));
858
- fetchMock.get(`${base}document`, Response.json({ done: true }));
859
- try {
860
- for (let i = 0; i < 2; i++) {
861
- const results = await Promise.all([
862
- loader(`${base}start`),
863
- loader(`${base}start`),
864
- ]);
865
- for (const result of results) {
866
- deepStrictEqual(result.document, { done: true });
867
- deepStrictEqual(result.documentUrl, `${base}document`);
1006
+ await t.step(
1007
+ "relative alternate after redirect and isolated calls",
1008
+ async () => {
1009
+ fetchMock.mockGlobal();
1010
+ fetchMock.get(`${base}start`, Response.redirect(`${base}html`, 302));
1011
+ fetchMock.get(`${base}html`, alternate("./document", true));
1012
+ fetchMock.get(`${base}document`, Response.json({ done: true }));
1013
+ try {
1014
+ for (let i = 0; i < 2; i++) {
1015
+ const results = await Promise.all([
1016
+ loader(`${base}start`),
1017
+ loader(`${base}start`),
1018
+ ]);
1019
+ for (const result of results) {
1020
+ deepStrictEqual(result.document, { done: true });
1021
+ deepStrictEqual(result.documentUrl, `${base}document`);
1022
+ }
868
1023
  }
1024
+ } finally {
1025
+ fetchMock.hardReset();
869
1026
  }
870
- } finally {
871
- fetchMock.hardReset();
872
- }
873
- });
1027
+ },
1028
+ );
874
1029
 
875
1030
  function alternate(next: string, html: boolean): Response {
876
1031
  return html
@@ -893,7 +1048,7 @@ test("getDocumentLoader() preserves cancellation across alternates", async (t) =
893
1048
  const base = "https://example.com/alternate-abort/";
894
1049
  const loader = getDocumentLoader({ allowPrivateAddress: true });
895
1050
  for (const html of [false, true]) {
896
- await t.test(html ? "HTML" : "Link header", async () => {
1051
+ await t.step(html ? "HTML" : "Link header", async () => {
897
1052
  fetchMock.mockGlobal();
898
1053
  const controller = new AbortController();
899
1054
  let requests = 0;
package/src/docloader.ts CHANGED
@@ -288,6 +288,8 @@ export interface GetDocumentLoaderOptions extends DocumentLoaderFactoryOptions {
288
288
  * - <https://w3id.org/security/multikey/v1>
289
289
  * - <https://w3id.org/fep/ef61>
290
290
  * - <https://w3id.org/fep/7aa9>
291
+ * - <https://w3id.org/fep/6757>
292
+ * - <https://w3id.org/fep/22cd>
291
293
  * - <https://purl.archive.org/socialweb/webfinger>
292
294
  * - <http://schema.org/>
293
295
  * @param options Options for the document loader.
@@ -1,4 +1,4 @@
1
- import { deepStrictEqual } from "node:assert";
1
+ import { deepStrictEqual, throws } from "node:assert";
2
2
  import { test } from "node:test";
3
3
  import util from "node:util";
4
4
  import { LanguageString } from "./langstr.ts";
@@ -11,6 +11,23 @@ test("new LanguageString()", () => {
11
11
  deepStrictEqual(new LanguageString("Hello", new Intl.Locale("en")), langStr);
12
12
  });
13
13
 
14
+ test("new LanguageString() with extlang tags", () => {
15
+ // Intl.Locale objects have no own enumerable properties, so their base names
16
+ // are compared instead:
17
+ deepStrictEqual(new LanguageString("你好", "zh-YUE").locale.baseName, "yue");
18
+ deepStrictEqual(
19
+ new LanguageString("你好", "zh-yue-HK").locale.baseName,
20
+ "yue-HK",
21
+ );
22
+ // Whether cmn is canonicalized to zh depends on the runtime:
23
+ deepStrictEqual(
24
+ new LanguageString("你好", "zh-cmn-Hant").locale.baseName,
25
+ new Intl.Locale("cmn-Hant").baseName,
26
+ );
27
+ throws(() => new LanguageString("Hello", "en-USA"), RangeError);
28
+ throws(() => new LanguageString("你好", "zh-yue-"), RangeError);
29
+ });
30
+
14
31
  test("Deno.inspect(LanguageString)", () => {
15
32
  const langStr = new LanguageString("Hello, 'world'", "en");
16
33
  deepStrictEqual(
package/src/langstr.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { normalizeLanguageTag } from "./langtag.ts";
2
+
1
3
  /**
2
4
  * A language-tagged string which corresponds to the `rdf:langString` type.
3
5
  */
@@ -12,12 +14,14 @@ export class LanguageString extends String {
12
14
  * Constructs a new `LanguageString`.
13
15
  * @param value A string value written in the given language.
14
16
  * @param language The language of the string. If a string is given, it will
15
- * be parsed as a `Intl.Locale` object.
17
+ * be parsed as a `Intl.Locale` object. Extended language
18
+ * subtags are normalized first, e.g., `zh-yue` is parsed as
19
+ * `yue`.
16
20
  */
17
21
  constructor(value: string, language: Intl.Locale | string) {
18
22
  super(value);
19
23
  this.locale = typeof language === "string"
20
- ? new Intl.Locale(language)
24
+ ? new Intl.Locale(normalizeLanguageTag(language))
21
25
  : language;
22
26
  }
23
27
  }
@@ -0,0 +1,53 @@
1
+ import { deepStrictEqual } from "node:assert";
2
+ import { test } from "node:test";
3
+ import { normalizeLanguageTag } from "./langtag.ts";
4
+
5
+ test("normalizeLanguageTag() removes extlang subtags", () => {
6
+ const cases: [string, string][] = [
7
+ ["zh-yue", "yue"],
8
+ ["zh-YUE", "yue"],
9
+ ["ZH-Yue-hk", "yue-hk"],
10
+ ["zh-yue-HK", "yue-HK"],
11
+ ["zh-cmn-Hant", "cmn-Hant"],
12
+ ["zh-yue-u-nu-hanidec", "yue-u-nu-hanidec"],
13
+ ["sgn-ase", "ase"],
14
+ ["ar-arz", "arz"],
15
+ ["ms-zlm", "zlm"],
16
+ ["kok-gom", "gom"],
17
+ ["koK-gom", "gom"],
18
+ ["lv-ltg", "ltg"],
19
+ ["sw-swc", "swc"],
20
+ ["uz-uzs", "uzs"],
21
+ // Valid tags never have more than one extlang, so only the first one is
22
+ // removed and the result is still rejected by Intl.Locale:
23
+ ["zh-yue-cmn", "yue-cmn"],
24
+ ["zh-yue-", "yue-"],
25
+ ];
26
+ for (const [input, expected] of cases) {
27
+ deepStrictEqual(normalizeLanguageTag(input), expected, input);
28
+ }
29
+ });
30
+
31
+ test("normalizeLanguageTag() leaves other tags unchanged", () => {
32
+ const tags = [
33
+ "",
34
+ "en",
35
+ "en-US",
36
+ "en-USA",
37
+ "en-yue",
38
+ "zh",
39
+ "zh-Hant-TW",
40
+ "yue",
41
+ "yue-HK",
42
+ "x-private",
43
+ "i-klingon",
44
+ "zh-min-nan",
45
+ "koK-gom",
46
+ "zh-yuK",
47
+ "constructor-yue",
48
+ "toString-yue",
49
+ ];
50
+ for (const tag of tags) {
51
+ deepStrictEqual(normalizeLanguageTag(tag), tag, tag);
52
+ }
53
+ });