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.
This commit is contained in:
jcoffey
2026-09-19 14:27:34 -07:00
committed by GitHub
parent 8a7ff5d42f
commit 9e3844e94a
25 changed files with 346 additions and 225 deletions
@@ -0,0 +1,73 @@
/*
* 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([]);
});
});
+24
View File
@@ -1,3 +1,5 @@
import { useSession } from "@/store/session";
/**
* What this instance calls itself, when nothing has said otherwise yet.
*
@@ -11,3 +13,25 @@
* three copies of a default is how two of them end up stale.
*/
export const DEFAULT_APP_NAME = "ihasmail";
/**
* What this instance calls itself, right now.
*
* Text that names the app reads it from here rather than writing "ihasmail"
* into the sentence, so an instance renamed with `APP_NAME` is called by its
* name everywhere, not only on the sign-in page and in the title bar. The
* name goes into the sentence as the `{app}` placeholder, which also lets a
* translator put it where their language wants it.
*
* Two shapes for the same fact: the hook for components, and the plain
* function for the few places that build strings outside React (the service
* worker's facts, for one). Both fall back to the default until the session
* arrives.
*/
export function useAppName(): string {
return useSession((s) => s.session?.ihasmail?.appName)?.trim() || DEFAULT_APP_NAME;
}
export function currentAppName(): string {
return useSession.getState().session?.ihasmail?.appName?.trim() || DEFAULT_APP_NAME;
}
+1 -1
View File
@@ -120,7 +120,7 @@ export function plural(n: number, forms: PluralForms, vars?: Vars): string {
*
* So the sentence stays whole and the elements are placeholders in it:
*
* tNode("Open {scheme} links in ihasmail.", { scheme: <code>mailto:</code> })
* tNode("Open {scheme} links in {app}.", { scheme: <code>mailto:</code> }, { app: "ihasmail" })
*
* A translator sees one sentence with a named hole and can put the hole
* wherever their language wants it.
+2 -1
View File
@@ -16,6 +16,7 @@
* was installed, which is the same condition background notifications already
* carry — a push subscription has to be renewed from a tab too.
*/
import { currentAppName } from "@/lib/brand";
import { withBase } from "../basePath";
import { SW_CACHE_NAME } from "./swCache";
import { t } from "../i18n";
@@ -57,7 +58,7 @@ export async function publishWorkerFacts(accountId: string | null, archiveId: st
noSubject: t("(no subject)"),
archive: t("Archive"),
markRead: t("Mark as read"),
failed: t("Could not do that — open ihasmail and try again"),
failed: t("Could not do that — open {app} and try again", { app: currentAppName() }),
},
};
try {