Initial commit

This commit is contained in:
M. George Hansen 2025-08-15 14:00:52 +12:00
commit dbd45204bb
29 changed files with 7083 additions and 0 deletions

View file

@ -0,0 +1,58 @@
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import type I18n from "@websnacksjs/i18n";
import { type Fixtures, withFixture } from "../fixtures.ts";
let i18n: I18n<Fixtures["base"]>;
beforeEach(() => {
i18n = withFixture("base");
});
describe("when run in a server environment", () => {
it("throws an error when locale is not specified", async () => {
await assert.rejects(
i18n.loadMessages(),
"unable to auto detect locale in non-browser environment (did you supply a locale argument?)",
);
});
});
describe("when run in a browser environment w/ supported locale in <html> lang attrbute", () => {
beforeEach(() => {
Object.defineProperty(globalThis, "document", {
value: {
documentElement: {
lang: "fr",
},
} as Document,
});
return () => {
delete (globalThis as { document?: Document }).document;
};
});
it("loads appropriate messages for that auto detected locale", async () => {
const t = await i18n.loadMessages();
assert.equal(t("denial"), "Je ne l'ai pas frappée. Je ne l'ai pas.");
});
});
describe("when run in a browser environment w/ supported locale in Navigator.languages", () => {
beforeEach(() => {
Object.defineProperty(globalThis, "navigator", {
value: {
languages: ["de-Latn-DE", "fr-Latn-FR"],
},
});
return () => {
delete (globalThis as { navigator?: Navigator }).navigator;
};
});
it("loads appropriate messages for that auto detected locale", async () => {
const t = await i18n.loadMessages();
assert.equal(t("denial"), "Je ne l'ai pas frappée. Je ne l'ai pas.");
});
});

View file

@ -0,0 +1,61 @@
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import type I18n from "@websnacksjs/i18n";
import { type Fixtures, withFixture } from "../fixtures.js";
describe("i18n.supportedLocales()", () => {
let i18n: I18n<Fixtures["base"]>;
beforeEach(() => {
i18n = withFixture("base", {
supportedLocales: ["en", "fr", "fr-Arab"],
});
});
it("returns maximized locales for all declared, supported locales", () => {
assert.deepEqual(i18n.supportedLocales(), [
"en-Latn-US",
"fr-Latn-FR",
"fr-Arab-FR",
]);
});
});
describe("i18n.loadMessages(...)", () => {
let i18n: I18n<Fixtures["base"]>;
beforeEach(() => {
i18n = withFixture("base");
});
it("guesses region of locales w/o region tags", async () => {
const t = await i18n.loadMessages({
locale: "fr-Latn",
});
assert.equal(t.locale(), "fr-Latn-FR");
});
it("guesses script of locales w/ region tags", async () => {
const t = await i18n.loadMessages({
locale: "fr-FR",
});
assert.equal(t.locale(), "fr-Latn-FR");
});
it("guesses script & region of bare language locales", async () => {
const t = await i18n.loadMessages({
locale: "fr",
});
assert.equal(t.locale(), "fr-Latn-FR");
});
it("does NOT fallback to bare language locales", async () => {
await assert.rejects(
i18n.loadMessages({
locale: "en-Arab",
}),
{
message:
'no declared locale matches requested locale of "en-Arab-US" (maximized from "en-Arab")',
},
);
});
});

View file

@ -0,0 +1,127 @@
import assert from "node:assert/strict";
import { beforeEach, describe, it } from "node:test";
import type I18n from "@websnacksjs/i18n";
import { type Fixtures, withFixture } from "../fixtures.ts";
describe("new I18n(...)", () => {
it("throws error when passed messageUrlTemplate w/o :locale, :namespace placeholders", () => {
assert.throws(
() =>
withFixture("base", {
messagesUrlTemplate: new URL(
"../some/path",
import.meta.url,
),
}),
{
message:
'messagesUrlTemplate is missing required placeholders [":locale", ":namespace"]',
},
);
assert.throws(
() =>
withFixture("base", {
messagesUrlTemplate: new URL(
"../some/path/:namespace",
import.meta.url,
),
}),
{
message:
'messagesUrlTemplate is missing required placeholders [":locale"]',
},
);
});
it("throws error when passed empty array of supportedLocales", () => {
assert.throws(
() =>
withFixture("base", {
supportedLocales: [],
}),
{
message:
"supportedLocales is empty and no locales would be supported",
},
);
});
it("throws error when passed locale strings that don't conform to RFC 5646", () => {
const invalidLocales = ["en_US", "zh-CN-UTF-8", "en-GB@euro"];
assert.throws(
() =>
withFixture("base", {
supportedLocales: ["fr-Latn-FR", ...invalidLocales],
}),
{
message: `supportedLocales contains invalid RFC 5646 locale strings ["en_US","zh-CN-UTF-8","en-GB@euro"]`,
},
);
});
});
describe("i18n.loadMessages(...)", () => {
let i18n: I18n<Fixtures["base"]>;
beforeEach(() => {
i18n = withFixture("base");
});
it("throws error when requesting namespaces that weren't declared", async () => {
await assert.rejects(
i18n.loadMessages({
locale: "en",
// @ts-ignore
namespaces: ["doesnt-exist", "neither-does-this"],
}),
{
message:
'attempted to load messages from undeclared namespaces ["doesnt-exist", "neither-does-this"] ' +
"(did you declare them in the I18n constructor?)",
},
);
});
it("throws error when requesting locales that weren't declared", async () => {
await assert.rejects(
i18n.loadMessages({
locale: "de",
namespaces: ["drama"],
}),
{
message:
'no declared locale matches requested locale of "de-Latn-DE" (maximized from "de")',
},
);
});
});
describe("t(...)", () => {
let i18n: I18n<Fixtures["base"]>;
beforeEach(() => {
i18n = withFixture("base");
});
it("correctly translates common messages", async () => {
const t = await i18n.loadMessages({ locale: "fr" });
assert.equal(t("denial"), "Je ne l'ai pas frappée. Je ne l'ai pas.");
});
it("correctly substitutes placeholders in translations", async () => {
const t = await i18n.loadMessages({ locale: "fr" });
assert.equal(t("oh hai", { name: "Mark" }), "Oh salut, Mark !");
});
it("correctly translates messages with nested keys", async () => {
const t = await i18n.loadMessages({ locale: "fr" });
assert.equal(t("flower shop.doggy"), "Salut toutou !");
});
it("correctly translates namespaced messages", async () => {
const t = await i18n.loadMessages({
locale: "fr",
namespaces: ["drama"],
});
assert.equal(t("drama:tearing me apart"), "Tu me déchires, Lisa !");
});
});

View file

@ -0,0 +1,30 @@
import I18n, { type I18nOptions } from "@websnacksjs/i18n";
export type Fixtures = {
base: {
common: typeof import("./fixtures/base/messages/en/common.json");
drama: typeof import("./fixtures/base/messages/en/drama.json");
};
};
export const withFixture = <F extends keyof Fixtures>(
fixture: F,
overrides: Partial<I18nOptions<Fixtures[F]>> = {},
): I18n<Fixtures[F]> => {
switch (fixture) {
case "base": {
return new I18n<Fixtures["base"]>({
supportedLocales: ["en", "fr", "fr-Arab"],
namespaces: ["drama"],
messagesUrlTemplate: new URL(
"./fixtures/base/messages/:locale/:namespace.json",
import.meta.url,
),
...(overrides as Partial<I18nOptions<Fixtures["base"]>>),
});
}
default: {
throw new Error("unreachable");
}
}
};

View file

@ -0,0 +1,9 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"resolveJsonModule": true,
"noEmit": true,
"rootDir": "."
},
"include": ["./**/*", "./**/*.json"]
}