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.
74 lines
2.9 KiB
TypeScript
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([]);
|
|
});
|
|
});
|