@fedify/vocab-runtime 2.0.31-dev.2306 → 2.0.31-dev.2310
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 +2 -2
- package/dist/mod.cjs +50 -3
- package/dist/mod.d.cts +28 -2
- package/dist/mod.d.ts +28 -2
- package/dist/mod.js +50 -4
- package/dist/tests/{body-CGeu_wlY.mjs → body-DhJNq-PQ.mjs} +1 -1
- package/dist/tests/{body-KqP1lgFZ.cjs → body-N4Kc5pwz.cjs} +1 -1
- package/dist/tests/body.test.cjs +1 -1
- package/dist/tests/body.test.mjs +1 -1
- package/dist/tests/docloader.test.cjs +41 -44
- package/dist/tests/docloader.test.mjs +32 -35
- package/dist/tests/langstr.test.cjs +12 -2
- package/dist/tests/langstr.test.mjs +13 -3
- package/dist/tests/langtag-Bm8oKhIO.cjs +50 -0
- package/dist/tests/langtag-QZZgy903.mjs +45 -0
- package/dist/tests/langtag.test.cjs +46 -0
- package/dist/tests/langtag.test.d.cts +1 -0
- package/dist/tests/langtag.test.d.mts +1 -0
- package/dist/tests/langtag.test.mjs +46 -0
- package/dist/tests/multibase/multibase.test.cjs +7 -7
- package/dist/tests/multibase/multibase.test.mjs +5 -5
- package/dist/tests/{request-75RoXeN4.cjs → request-CFKB-Yqd.cjs} +1 -1
- package/dist/tests/{request-JVGlYdPG.mjs → request-DDAEJJqw.mjs} +1 -1
- package/dist/tests/request.test.cjs +1 -1
- package/dist/tests/request.test.mjs +1 -1
- package/package.json +6 -3
- package/scripts/test-bun.mjs +18 -0
- package/src/docloader.test.ts +75 -68
- package/src/langstr.test.ts +18 -1
- package/src/langstr.ts +6 -2
- package/src/langtag.test.ts +53 -0
- package/src/langtag.ts +76 -0
- package/src/mod.ts +1 -0
- package/src/multibase/multibase.test.ts +5 -5
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fedify/vocab-runtime",
|
|
3
|
-
"version": "2.0.31-dev.
|
|
3
|
+
"version": "2.0.31-dev.2310+e604de57",
|
|
4
4
|
"homepage": "https://fedify.dev/",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -72,7 +72,8 @@
|
|
|
72
72
|
"@types/node": "^24.2.1",
|
|
73
73
|
"fetch-mock": "^12.5.4",
|
|
74
74
|
"tsdown": "^0.21.6",
|
|
75
|
-
"typescript": "^6.0.0"
|
|
75
|
+
"typescript": "^6.0.0",
|
|
76
|
+
"@fedify/fixture": "2.0.0"
|
|
76
77
|
},
|
|
77
78
|
"dependencies": {
|
|
78
79
|
"@js-temporal/polyfill": "^0.5.1",
|
|
@@ -89,6 +90,8 @@
|
|
|
89
90
|
"build": "pnpm --filter @fedify/vocab-runtime... run build:self",
|
|
90
91
|
"prepublish": "pnpm build",
|
|
91
92
|
"pretest": "pnpm build",
|
|
92
|
-
"test": "cd dist/tests && node --test"
|
|
93
|
+
"test": "cd dist/tests && node --test",
|
|
94
|
+
"pretest:bun": "pnpm build",
|
|
95
|
+
"test:bun": "node scripts/test-bun.mjs"
|
|
93
96
|
}
|
|
94
97
|
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { access, glob } from "node:fs/promises";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
|
|
5
|
+
const sources = await Array.fromAsync(glob("src/**/*.test.ts"));
|
|
6
|
+
if (sources.length === 0) throw new Error("No vocab-runtime tests found.");
|
|
7
|
+
|
|
8
|
+
const tests = sources.map((source) =>
|
|
9
|
+
source.replaceAll("\\", "/").replace(/^src\//, "dist/tests/")
|
|
10
|
+
.replace(/\.ts$/, ".mjs")
|
|
11
|
+
).sort();
|
|
12
|
+
for (const test of tests) await access(test);
|
|
13
|
+
|
|
14
|
+
const result = spawnSync("bun", ["test", "--timeout", "60000", ...tests], {
|
|
15
|
+
stdio: "inherit",
|
|
16
|
+
});
|
|
17
|
+
if (result.error) throw result.error;
|
|
18
|
+
process.exitCode = result.status ?? 1;
|
package/src/docloader.test.ts
CHANGED
|
@@ -2,7 +2,7 @@ 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 "
|
|
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";
|
|
@@ -37,7 +37,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
37
37
|
},
|
|
38
38
|
});
|
|
39
39
|
|
|
40
|
-
await t.
|
|
40
|
+
await t.step("ok", async () => {
|
|
41
41
|
deepStrictEqual(await fetchDocumentLoader("https://example.com/object"), {
|
|
42
42
|
contextUrl: null,
|
|
43
43
|
documentUrl: "https://example.com/object",
|
|
@@ -94,7 +94,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
94
94
|
},
|
|
95
95
|
});
|
|
96
96
|
|
|
97
|
-
await t.
|
|
97
|
+
await t.step("Link header", async () => {
|
|
98
98
|
deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
|
|
99
99
|
contextUrl: "https://www.w3.org/ns/activitystreams",
|
|
100
100
|
documentUrl: "https://example.com/link-ctx",
|
|
@@ -117,7 +117,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
119
|
|
|
120
|
-
await t.
|
|
120
|
+
await t.step("Link header relative url", async () => {
|
|
121
121
|
deepStrictEqual(await fetchDocumentLoader("https://example.com/link-ctx"), {
|
|
122
122
|
contextUrl: "https://www.w3.org/ns/activitystreams",
|
|
123
123
|
documentUrl: "https://example.com/link-ctx",
|
|
@@ -143,7 +143,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
143
143
|
);
|
|
144
144
|
});
|
|
145
145
|
|
|
146
|
-
await t.
|
|
146
|
+
await t.step("wrong Link header syntax", async () => {
|
|
147
147
|
deepStrictEqual(
|
|
148
148
|
await fetchDocumentLoader("https://example.com/obj-w-wrong-link"),
|
|
149
149
|
{
|
|
@@ -172,7 +172,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
172
172
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
173
173
|
});
|
|
174
174
|
|
|
175
|
-
await t.
|
|
175
|
+
await t.step("HTML <link>", async () => {
|
|
176
176
|
deepStrictEqual(
|
|
177
177
|
await fetchDocumentLoader("https://example.com/html-link"),
|
|
178
178
|
{
|
|
@@ -201,7 +201,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
201
201
|
headers: { "Content-Type": "application/xhtml+xml; charset=utf-8" },
|
|
202
202
|
});
|
|
203
203
|
|
|
204
|
-
await t.
|
|
204
|
+
await t.step("XHTML <link>", async () => {
|
|
205
205
|
deepStrictEqual(
|
|
206
206
|
await fetchDocumentLoader("https://example.com/xhtml-link"),
|
|
207
207
|
{
|
|
@@ -232,7 +232,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
232
232
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
233
233
|
});
|
|
234
234
|
|
|
235
|
-
await t.
|
|
235
|
+
await t.step("HTML <a>", async () => {
|
|
236
236
|
deepStrictEqual(await fetchDocumentLoader("https://example.com/html-a"), {
|
|
237
237
|
contextUrl: null,
|
|
238
238
|
documentUrl: "https://example.com/object",
|
|
@@ -255,7 +255,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
255
255
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
256
256
|
});
|
|
257
257
|
|
|
258
|
-
await t.
|
|
258
|
+
await t.step("Wrong Content-Type", async () => {
|
|
259
259
|
deepStrictEqual(
|
|
260
260
|
await fetchDocumentLoader("https://example.com/wrong-content-type"),
|
|
261
261
|
{
|
|
@@ -273,7 +273,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
273
273
|
|
|
274
274
|
fetchMock.get("https://example.com/404", { status: 404 });
|
|
275
275
|
|
|
276
|
-
await t.
|
|
276
|
+
await t.step("not ok", async () => {
|
|
277
277
|
await rejects(
|
|
278
278
|
() => fetchDocumentLoader("https://example.com/404"),
|
|
279
279
|
FetchError,
|
|
@@ -281,7 +281,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
281
281
|
);
|
|
282
282
|
});
|
|
283
283
|
|
|
284
|
-
await t.
|
|
284
|
+
await t.step("preloaded contexts", async () => {
|
|
285
285
|
for (const [url, document] of Object.entries(preloadedContexts)) {
|
|
286
286
|
deepStrictEqual(await fetchDocumentLoader(url), {
|
|
287
287
|
contextUrl: null,
|
|
@@ -297,7 +297,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
297
297
|
// See: https://www.w3.org/TR/cid-1.0/#json-ld-context
|
|
298
298
|
// https://github.com/fedify-dev/fedify/issues/932
|
|
299
299
|
fetchMock.get("https://www.w3.org/ns/cid/v1", { status: 503 });
|
|
300
|
-
await t.
|
|
300
|
+
await t.step("preloaded CID v1 context", async () => {
|
|
301
301
|
const url = "https://www.w3.org/ns/cid/v1";
|
|
302
302
|
deepStrictEqual(await fetchDocumentLoader(url), {
|
|
303
303
|
contextUrl: null,
|
|
@@ -311,7 +311,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
311
311
|
// document referencing it fails before application handlers run. It has to
|
|
312
312
|
// be resolved from the built-in copy rather than over the network.
|
|
313
313
|
// See: https://github.com/fedify-dev/fedify/issues/982
|
|
314
|
-
await t.
|
|
314
|
+
await t.step("preloaded FEP-ef61 context", async () => {
|
|
315
315
|
const url = "https://w3id.org/fep/ef61";
|
|
316
316
|
ok(url in preloadedContexts);
|
|
317
317
|
deepStrictEqual(await fetchDocumentLoader(url), {
|
|
@@ -334,7 +334,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
334
334
|
// A Codeberg Pages outage must not prevent loading the FEP-7aa9 context.
|
|
335
335
|
// See: https://github.com/fedify-dev/fedify/issues/1078
|
|
336
336
|
fetchMock.get("https://w3id.org/fep/7aa9", { status: 502 });
|
|
337
|
-
await t.
|
|
337
|
+
await t.step("preloaded FEP-7aa9 context", async () => {
|
|
338
338
|
const url = "https://w3id.org/fep/7aa9";
|
|
339
339
|
deepStrictEqual(await fetchDocumentLoader(url), {
|
|
340
340
|
contextUrl: null,
|
|
@@ -371,7 +371,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
371
371
|
// A Codeberg Pages outage must not prevent loading or expanding FEP-6757.
|
|
372
372
|
// See: https://github.com/fedify-dev/fedify/issues/1211
|
|
373
373
|
fetchMock.get("https://w3id.org/fep/6757", { status: 502 });
|
|
374
|
-
await t.
|
|
374
|
+
await t.step("preloaded FEP-6757 context", async () => {
|
|
375
375
|
const url = "https://w3id.org/fep/6757";
|
|
376
376
|
deepStrictEqual(await fetchDocumentLoader(url), {
|
|
377
377
|
contextUrl: null,
|
|
@@ -412,7 +412,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
412
412
|
// A Codeberg Pages outage must not prevent expanding translation metadata.
|
|
413
413
|
// See: https://github.com/fedify-dev/fedify/issues/1214
|
|
414
414
|
fetchMock.get("https://w3id.org/fep/22cd", { status: 502 });
|
|
415
|
-
await t.
|
|
415
|
+
await t.step("preloaded FEP-22cd context", async () => {
|
|
416
416
|
const url = "https://w3id.org/fep/22cd";
|
|
417
417
|
const articleId = "https://example.com/articles/1";
|
|
418
418
|
const translatorId = "https://example.com/users/alice";
|
|
@@ -491,7 +491,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
491
491
|
);
|
|
492
492
|
});
|
|
493
493
|
|
|
494
|
-
await t.
|
|
494
|
+
await t.step("deny non-HTTP/HTTPS", async () => {
|
|
495
495
|
await rejects(
|
|
496
496
|
() => fetchDocumentLoader("ftp://localhost"),
|
|
497
497
|
UrlError,
|
|
@@ -525,7 +525,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
525
525
|
},
|
|
526
526
|
});
|
|
527
527
|
|
|
528
|
-
await t.
|
|
528
|
+
await t.step("allowPrivateAddress: false", async () => {
|
|
529
529
|
await rejects(
|
|
530
530
|
() => fetchDocumentLoader("https://localhost/object"),
|
|
531
531
|
UrlError,
|
|
@@ -542,7 +542,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
542
542
|
|
|
543
543
|
const fetchDocumentLoader2 = getDocumentLoader({ allowPrivateAddress: true });
|
|
544
544
|
|
|
545
|
-
await t.
|
|
545
|
+
await t.step("allowPrivateAddress: true", async () => {
|
|
546
546
|
const expected = {
|
|
547
547
|
contextUrl: null,
|
|
548
548
|
documentUrl: "https://localhost/object",
|
|
@@ -579,7 +579,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
579
579
|
};
|
|
580
580
|
});
|
|
581
581
|
|
|
582
|
-
await t.
|
|
582
|
+
await t.step("too many redirects", async () => {
|
|
583
583
|
redirectAttempts = 0;
|
|
584
584
|
await rejects(
|
|
585
585
|
() => fetchDocumentLoader("https://example.com/too-many-redirects/0"),
|
|
@@ -605,7 +605,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
605
605
|
};
|
|
606
606
|
});
|
|
607
607
|
|
|
608
|
-
await t.
|
|
608
|
+
await t.step("redirect loop", async () => {
|
|
609
609
|
loopAttempts = 0;
|
|
610
610
|
await rejects(
|
|
611
611
|
() => fetchDocumentLoader("https://example.com/redirect-loop-a"),
|
|
@@ -624,7 +624,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
624
624
|
};
|
|
625
625
|
});
|
|
626
626
|
|
|
627
|
-
await t.
|
|
627
|
+
await t.step("redirect loop with relative location", async () => {
|
|
628
628
|
relativeLoopAttempts = 0;
|
|
629
629
|
await rejects(
|
|
630
630
|
() => fetchDocumentLoader("https://example.com/redirect-loop-relative"),
|
|
@@ -644,7 +644,7 @@ test("getDocumentLoader()", async (t) => {
|
|
|
644
644
|
headers: { "Content-Type": "text/html; charset=utf-8" },
|
|
645
645
|
});
|
|
646
646
|
|
|
647
|
-
await t.
|
|
647
|
+
await t.step("ReDoS resistance (CVE-2025-68475)", async () => {
|
|
648
648
|
const start = performance.now();
|
|
649
649
|
// The malicious HTML will fail JSON parsing, but the important thing is
|
|
650
650
|
// that it should complete quickly (not hang due to ReDoS)
|
|
@@ -793,12 +793,13 @@ test("getDocumentLoader() logs DNS failures as such", async (t) => {
|
|
|
793
793
|
"Deno" in globalThis &&
|
|
794
794
|
(await Deno.permissions.query({ name: "net" })).state !== "granted"
|
|
795
795
|
) {
|
|
796
|
-
|
|
797
|
-
|
|
796
|
+
throw new Error(
|
|
797
|
+
"This DNS test requires Deno network permission (--allow-net).",
|
|
798
|
+
);
|
|
798
799
|
}
|
|
799
800
|
const loader = getDocumentLoader();
|
|
800
801
|
for (const result of ["throws", "empty", "private"] as const) {
|
|
801
|
-
await t.
|
|
802
|
+
await t.step(result, async () => {
|
|
802
803
|
// Stubbing works only because url.ts uses the default node:dns/promises
|
|
803
804
|
// import; see the FIXME there.
|
|
804
805
|
const originalLookup = dns.lookup;
|
|
@@ -854,7 +855,7 @@ test("getDocumentLoader() bounds alternate document chains", async (t) => {
|
|
|
854
855
|
const loader = getDocumentLoader({ allowPrivateAddress: true });
|
|
855
856
|
for (const mode of ["header", "html", "mixed"] as const) {
|
|
856
857
|
for (const hops of [20, 21]) {
|
|
857
|
-
await t.
|
|
858
|
+
await t.step(`${mode}: ${hops} hops`, async () => {
|
|
858
859
|
fetchMock.mockGlobal();
|
|
859
860
|
let requests = 0;
|
|
860
861
|
fetchMock.get(`begin:${base}`, ({ url }) => {
|
|
@@ -899,7 +900,7 @@ test("getDocumentLoader() bounds alternate document chains", async (t) => {
|
|
|
899
900
|
"redirect-intermediate",
|
|
900
901
|
]
|
|
901
902
|
) {
|
|
902
|
-
await t.
|
|
903
|
+
await t.step(`${mode}: cycle`, async () => {
|
|
903
904
|
fetchMock.mockGlobal();
|
|
904
905
|
let requests = 0;
|
|
905
906
|
fetchMock.get(`begin:${base}`, ({ url }) => {
|
|
@@ -933,49 +934,55 @@ test("getDocumentLoader() bounds alternate document chains", async (t) => {
|
|
|
933
934
|
});
|
|
934
935
|
}
|
|
935
936
|
|
|
936
|
-
await t.
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
requests
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
await rejects(loader(`${base}0`), (error: unknown) => {
|
|
949
|
-
ok(error instanceof FetchError);
|
|
950
|
-
ok(error.message.includes("Too many redirections (21)"));
|
|
951
|
-
return true;
|
|
937
|
+
await t.step(
|
|
938
|
+
"alternate followed by 20 redirects shares the limit",
|
|
939
|
+
async () => {
|
|
940
|
+
fetchMock.mockGlobal();
|
|
941
|
+
let requests = 0;
|
|
942
|
+
fetchMock.get(`begin:${base}`, ({ url }) => {
|
|
943
|
+
requests++;
|
|
944
|
+
const index = Number(new URL(url).pathname.split("/").at(-1));
|
|
945
|
+
if (index === 21) return Response.json({ done: true });
|
|
946
|
+
return index === 0
|
|
947
|
+
? alternate(`${base}1`, false)
|
|
948
|
+
: Response.redirect(`${base}${index + 1}`, 302);
|
|
952
949
|
});
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
950
|
+
try {
|
|
951
|
+
await rejects(loader(`${base}0`), (error: unknown) => {
|
|
952
|
+
ok(error instanceof FetchError);
|
|
953
|
+
ok(error.message.includes("Too many redirections (21)"));
|
|
954
|
+
return true;
|
|
955
|
+
});
|
|
956
|
+
deepStrictEqual(requests, 21);
|
|
957
|
+
} finally {
|
|
958
|
+
fetchMock.hardReset();
|
|
959
|
+
}
|
|
960
|
+
},
|
|
961
|
+
);
|
|
958
962
|
|
|
959
|
-
await t.
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
963
|
+
await t.step(
|
|
964
|
+
"relative alternate after redirect and isolated calls",
|
|
965
|
+
async () => {
|
|
966
|
+
fetchMock.mockGlobal();
|
|
967
|
+
fetchMock.get(`${base}start`, Response.redirect(`${base}html`, 302));
|
|
968
|
+
fetchMock.get(`${base}html`, alternate("./document", true));
|
|
969
|
+
fetchMock.get(`${base}document`, Response.json({ done: true }));
|
|
970
|
+
try {
|
|
971
|
+
for (let i = 0; i < 2; i++) {
|
|
972
|
+
const results = await Promise.all([
|
|
973
|
+
loader(`${base}start`),
|
|
974
|
+
loader(`${base}start`),
|
|
975
|
+
]);
|
|
976
|
+
for (const result of results) {
|
|
977
|
+
deepStrictEqual(result.document, { done: true });
|
|
978
|
+
deepStrictEqual(result.documentUrl, `${base}document`);
|
|
979
|
+
}
|
|
973
980
|
}
|
|
981
|
+
} finally {
|
|
982
|
+
fetchMock.hardReset();
|
|
974
983
|
}
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
}
|
|
978
|
-
});
|
|
984
|
+
},
|
|
985
|
+
);
|
|
979
986
|
|
|
980
987
|
function alternate(next: string, html: boolean): Response {
|
|
981
988
|
return html
|
|
@@ -998,7 +1005,7 @@ test("getDocumentLoader() preserves cancellation across alternates", async (t) =
|
|
|
998
1005
|
const base = "https://example.com/alternate-abort/";
|
|
999
1006
|
const loader = getDocumentLoader({ allowPrivateAddress: true });
|
|
1000
1007
|
for (const html of [false, true]) {
|
|
1001
|
-
await t.
|
|
1008
|
+
await t.step(html ? "HTML" : "Link header", async () => {
|
|
1002
1009
|
fetchMock.mockGlobal();
|
|
1003
1010
|
const controller = new AbortController();
|
|
1004
1011
|
let requests = 0;
|
package/src/langstr.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/langtag.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extended language subtags (extlangs) registered in the IANA Language Subtag
|
|
3
|
+
* Registry, grouped by their prefixes. Every extlang record has exactly one
|
|
4
|
+
* prefix and a `Preferred-Value` equal to the extlang subtag itself, so
|
|
5
|
+
* replacing the prefix and the extlang with the extlang alone yields
|
|
6
|
+
* the canonical form.
|
|
7
|
+
*
|
|
8
|
+
* Generated from the registry with File-Date: 2026-09-17.
|
|
9
|
+
*/
|
|
10
|
+
const EXTLANGS: Record<string, string> = {
|
|
11
|
+
ar: "aao abh abv acm acq acw acx acy adf aeb aec afb ajp apc apd arb arq " +
|
|
12
|
+
"ars ary arz auz avl ayh ayl ayn ayp bbz pga shu ssh",
|
|
13
|
+
kok: "gom knn",
|
|
14
|
+
lv: "ltg lvs",
|
|
15
|
+
ms: "bjn btj bve bvu coa dup hji jak jax kvb kvr kxd lce lcf liw max meo " +
|
|
16
|
+
"mfa mfb min mqg msi mui orn ors pel pse tmw urk vkk vkt xmm zlm zmi " +
|
|
17
|
+
"zsm",
|
|
18
|
+
sgn: "ads aed aen afg ajs ase asf asp asq asw bfi bfk bog bqn bqy bvl bzs " +
|
|
19
|
+
"cds csc csd cse csf csg csl csn csq csr csx doq dse dsl dsz dyl ecs " +
|
|
20
|
+
"ehs esl esn eso eth fcs fse fsl fss gds gse gsg gsm gss gus hab haf " +
|
|
21
|
+
"hds hks hos hps hsh hsl icl iks ils inl ins ise isg isr jcs jhs jks " +
|
|
22
|
+
"jls jos jsl jus kgi kvk lbs lgs lls lsb lsc lsg lsl lsn lso lsp lst " +
|
|
23
|
+
"lsv lsw lsy lws mdl mfs mre msd msr mzc mzg mzy nbs ncs nsi nsl nsp " +
|
|
24
|
+
"nsr nzs okl pgz pks prl prz psc psd psg psl pso psp psr pys rib rms " +
|
|
25
|
+
"rnb rsi rsl rsm rsn sdl sfb sfs sgg sgx slf sls sqk sqs sqx ssp ssr " +
|
|
26
|
+
"svk swl syy szs tse tsm tsq tss tsy tza ugn ugy ukl uks vgt vsi vsl " +
|
|
27
|
+
"vsv wbs xki xml xms yds ygs yhs ysl ysm zhk zib zsl",
|
|
28
|
+
sw: "swc swh",
|
|
29
|
+
uz: "uzn uzs",
|
|
30
|
+
zh: "cdo cjy cmn cnp cpx csp czh czo gan hak hnm hsn luh lzh mnp nan sjc " +
|
|
31
|
+
"wuu yue",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const EXTLANG_PAIRS: ReadonlySet<string> = new Set(
|
|
35
|
+
Object.entries(EXTLANGS).flatMap(([prefix, extlangs]) =>
|
|
36
|
+
extlangs.split(" ").map((extlang) => `${prefix}-${extlang}`)
|
|
37
|
+
),
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
const PREFIX_PATTERN = /^[A-Za-z]{2,3}$/;
|
|
41
|
+
const EXTLANG_PATTERN = /^[A-Za-z]{3}$/;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Normalizes a BCP 47 language tag that has an extended language subtag
|
|
45
|
+
* (extlang), such as `zh-yue`, into its canonical form, such as `yue`, as
|
|
46
|
+
* described in [RFC 5646, Section 4.5][1]. `Intl.Locale` rejects extlang
|
|
47
|
+
* tags, so tags should be normalized before being passed to it.
|
|
48
|
+
*
|
|
49
|
+
* Only registered extlangs that follow their registered prefix are removed;
|
|
50
|
+
* any other tag is returned unchanged, so the result still needs to be
|
|
51
|
+
* validated, e.g., by `Intl.Locale`.
|
|
52
|
+
*
|
|
53
|
+
* NOTE: This function is marked as internal in the 2.0.x–2.4.x patch
|
|
54
|
+
* releases. When merging it into the *main* branch, remove the `@internal`
|
|
55
|
+
* tag, formalize it as a public API (`@since 2.5.0`), and add it to
|
|
56
|
+
* the changelog fragment.
|
|
57
|
+
*
|
|
58
|
+
* [1]: https://www.rfc-editor.org/rfc/rfc5646.html#section-4.5
|
|
59
|
+
*
|
|
60
|
+
* @param tag The BCP 47 language tag to normalize.
|
|
61
|
+
* @returns The normalized language tag.
|
|
62
|
+
* @internal
|
|
63
|
+
*/
|
|
64
|
+
export function normalizeLanguageTag(tag: string): string {
|
|
65
|
+
const subtags = tag.split("-");
|
|
66
|
+
if (subtags.length < 2) return tag;
|
|
67
|
+
const [prefix, extlang, ...rest] = subtags;
|
|
68
|
+
// Checked before case folding, since `toLowerCase()` maps some non-ASCII
|
|
69
|
+
// characters (e.g., U+212A KELVIN SIGN) to ASCII letters:
|
|
70
|
+
if (!PREFIX_PATTERN.test(prefix) || !EXTLANG_PATTERN.test(extlang)) {
|
|
71
|
+
return tag;
|
|
72
|
+
}
|
|
73
|
+
const language = extlang.toLowerCase();
|
|
74
|
+
if (!EXTLANG_PAIRS.has(`${prefix.toLowerCase()}-${language}`)) return tag;
|
|
75
|
+
return [language, ...rest].join("-");
|
|
76
|
+
}
|
package/src/mod.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { deepStrictEqual } from "node:assert";
|
|
2
|
-
import { test } from "
|
|
2
|
+
import { test } from "@fedify/fixture";
|
|
3
3
|
import * as constants from "./constants.ts";
|
|
4
4
|
import { decodeMultibase, encodeMultibase } from "./mod.ts";
|
|
5
5
|
import type { BaseName } from "./types.d.ts";
|
|
@@ -76,7 +76,7 @@ test("multibase.encode and decode", async (t) => {
|
|
|
76
76
|
];
|
|
77
77
|
|
|
78
78
|
for (const [name, input, expectedOutput] of testCases) {
|
|
79
|
-
await t.
|
|
79
|
+
await t.step(`Encoding/Decoding ${name} with ${input}`, () => {
|
|
80
80
|
const encoded = encodeMultibase(name, encodeText(input));
|
|
81
81
|
deepStrictEqual(
|
|
82
82
|
decodeText(encoded),
|
|
@@ -96,7 +96,7 @@ test("multibase.encode and decode", async (t) => {
|
|
|
96
96
|
});
|
|
97
97
|
}
|
|
98
98
|
|
|
99
|
-
await t.
|
|
99
|
+
await t.step("should allow base32pad full alphabet", () => {
|
|
100
100
|
const encodedStr = "ctimaq4ygg2iegci7";
|
|
101
101
|
const decoded = decodeMultibase(encodedStr);
|
|
102
102
|
const encoded = encodeMultibase("c", decoded);
|
|
@@ -105,12 +105,12 @@ test("multibase.encode and decode", async (t) => {
|
|
|
105
105
|
});
|
|
106
106
|
|
|
107
107
|
test("constants", async (t) => {
|
|
108
|
-
await t.
|
|
108
|
+
await t.step("constants indexed by name", () => {
|
|
109
109
|
const names = constants.names;
|
|
110
110
|
deepStrictEqual(Object.keys(names).length, 23);
|
|
111
111
|
});
|
|
112
112
|
|
|
113
|
-
await t.
|
|
113
|
+
await t.step("constants indexed by code", () => {
|
|
114
114
|
const codes = constants.codes;
|
|
115
115
|
deepStrictEqual(Object.keys(codes).length, 23);
|
|
116
116
|
});
|