Files
ihasmail/web/src/lib/__tests__/app-name-strings.test.ts
T
jcoffey 07b39eb9b6 Call the app by its name in every sentence that names it (#406)
APP_NAME renames an instance, but only the sign-in page, the title bar and
a few headings used it. Two dozen sentences wrote "ihasmail" into
themselves, so a renamed instance still told people to keep an ihasmail
tab open and offered to open mail links "in ihasmail".

Those sentences now take the name as {app}, which also lets a translator
put it where their language wants it. brand.ts grew useAppName() for
components and currentAppName() for the few places that build strings
outside React.

Left as they are: the Files folder "ihasmail", the Sieve script
"ihasmail" and ihasmail.org. Those name things a person can go and look
at, and renaming them would rename real data.

All nine catalogues keep their translations: the name inside each one
became the placeholder. Three of the strings had no translation before
and still fall back to English.

A test walks the sources and the catalogues so a new sentence can't
hard-code the name again.
2026-09-19 14:27:34 -07:00

74 lines
2.9 KiB
TypeScript

/*
* An instance renamed with APP_NAME should be called by its name everywhere,
* not only on the sign-in page and in the title bar. So no sentence shown to
* a person may write "ihasmail" into itself: it takes the name as {app}.
*
* The exceptions are the places where "ihasmail" is not the app's name but a
* literal a person could go and look at: the Files folder, the Sieve script
* and the project's own address. Renaming those would rename real data.
*/
import { describe, expect, it } from "vitest";
import { readFileSync, readdirSync, statSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
/** Strings that name a stored thing, not the app. */
const LITERALS = [
"Images are stored in your Files (folder “ihasmail”) and embedded when you send.",
"“{name}” will be deactivated (not deleted) and a new “ihasmail” script will take over.",
"Another script (“{name}”) is active. Saving rules here will activate the “ihasmail” script instead.",
"ihasmail.org",
"ihasmail",
];
function sources(dir: string, out: string[] = []): string[] {
for (const name of readdirSync(dir)) {
const path = join(dir, name);
if (statSync(path).isDirectory()) {
if (name === "locales" || name === "__tests__") continue;
sources(path, out);
} else if (/\.tsx?$/.test(name)) {
out.push(path);
}
}
return out;
}
/** Every translated string in a file, however `t` was imported. */
function translatedStrings(code: string): string[] {
return [...code.matchAll(/\b(?:t|tNode|translate)\(\s*"((?:[^"\\]|\\.)*)"/g)].map((m) =>
JSON.parse(`"${m[1]}"`),
);
}
describe("text that names the app", () => {
it("takes the name as {app} instead of writing ihasmail into the sentence", () => {
const offenders: string[] = [];
for (const file of sources(SRC)) {
for (const s of translatedStrings(readFileSync(file, "utf8"))) {
if (s.includes("ihasmail") && !LITERALS.includes(s)) {
offenders.push(`${file.slice(SRC.length)}: ${s.slice(0, 60)}`);
}
}
}
expect(offenders).toEqual([]);
});
it("keeps a placeholder in every translation of those strings", () => {
const catalogs = readdirSync(join(SRC, "locales")).filter((f) => f.endsWith(".ts") && f !== "index.ts");
const wrong: string[] = [];
for (const name of catalogs) {
const code = readFileSync(join(SRC, "locales", name), "utf8");
for (const m of code.matchAll(/^\s*"((?:[^"\\]|\\.)*)": "((?:[^"\\]|\\.)*)",$/gm)) {
const key = JSON.parse(`"${m[1]}"`);
const value = JSON.parse(`"${m[2]}"`);
// A key that takes the name must not hard-code it in the translation.
if (key.includes("{app}") && value.includes("ihasmail")) wrong.push(`${name}: ${key.slice(0, 50)}`);
}
}
expect(wrong).toEqual([]);
});
});