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:
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user