Use American English spelling throughout

This commit is contained in:
2026-09-15 11:45:58 -07:00
parent 6ba89696ee
commit d1731efdb9
169 changed files with 786 additions and 795 deletions
+1 -1
View File
@@ -59,7 +59,7 @@ export function baseUrlOf(basePath) {
*
* The comparison is deliberately not `startsWith(base)`: that would let
* `/mailbox` in under a `/mail` mount and serve it the app shell, which is
* both wrong and a small open door for a neighbouring site on the same host.
* both wrong and a small open door for a neighboring site on the same host.
*/
export function stripBasePath(basePath, pathname) {
const base = normalizeBasePath(basePath);
+22 -22
View File
@@ -2,15 +2,15 @@
"""
Generate the palette CSS blocks in web/src/styles/app.css.
Every colour here comes from the palette's own project (all MIT); the values
Every color here comes from the palette's own project (all MIT); the values
are recorded in .palette-sources/palettes-upstream.md. What this script adds is
the *derivation*: ihasmail needs thirty-odd tokens and these projects publish
between twelve and twenty, so the tiers in between are computed rather than
guessed, and every text colour is then checked against the surface it sits on.
guessed, and every text color is then checked against the surface it sits on.
The check is the reason this is a script and not a hand-written block. ihasmail
claims WCAG AA, and several of these palettes do not meet it as published --
Dracula's comment grey on its own background is about 3.0:1, well under the 4.5
Dracula's comment gray on its own background is about 3.0:1, well under the 4.5
that normal text needs. Lifting those tiers by eye is how a claim quietly stops
being true; here it is arithmetic, and the script fails loudly if a token it
emitted would not pass.
@@ -30,7 +30,7 @@ BEGIN = "/* === generated palettes: begin === */"
END = "/* === generated palettes: end === */"
# ---------------------------------------------------------------- colour maths
# ---------------------------------------------------------------- color maths
def parse(hex_: str) -> tuple[float, float, float]:
h = hex_.lstrip("#")
@@ -66,18 +66,18 @@ def rgba(hex_: str, alpha: float) -> str:
return f"rgba({r}, {g}, {b}, {alpha})"
def toward_contrast(colour: str, bg: str, target: float, dark_ui: bool) -> str:
"""Nudge `colour` away from `bg` until it clears `target`.
def toward_contrast(color: str, bg: str, target: float, dark_ui: bool) -> str:
"""Nudge `color` away from `bg` until it clears `target`.
Towards white on a dark background and towards black on a light one, so a
lifted tier keeps its hue instead of washing out to grey.
Toward white on a dark background and toward black on a light one, so a
lifted tier keeps its hue instead of washing out to gray.
"""
if contrast(colour, bg) >= target:
return colour
if contrast(color, bg) >= target:
return color
anchor = "#ffffff" if dark_ui else "#000000"
best = colour
best = color
for i in range(1, 101):
candidate = mix(colour, anchor, i / 100)
candidate = mix(color, anchor, i / 100)
best = candidate
if contrast(candidate, bg) >= target:
return candidate
@@ -90,7 +90,7 @@ def toward_contrast(colour: str, bg: str, target: float, dark_ui: bool) -> str:
# ihasmail's own palette has a hand-written dark block further up the file --
# it is the identity this project is painted in, and regenerating it would
# quietly move colours nobody asked to move. Only its light half is derived
# quietly move colors nobody asked to move. Only its light half is derived
# here, which is why it appears in LIGHT_ONLY.
LIGHT_ONLY = {"ihasmail"}
@@ -261,17 +261,17 @@ def build(pid: str, mode: str, src: dict[str, str]) -> tuple[dict[str, str], lis
bg, fg = src["bg"], src["fg"]
notes: list[str] = []
def lift(name: str, colour: str, target: float) -> str:
out = toward_contrast(colour, bg, target, dark)
if out != colour:
notes.append(f"{name} {colour} -> {out} ({contrast(colour, bg):.2f} -> {contrast(out, bg):.2f})")
def lift(name: str, color: str, target: float) -> str:
out = toward_contrast(color, bg, target, dark)
if out != color:
notes.append(f"{name} {color} -> {out} ({contrast(color, bg):.2f} -> {contrast(out, bg):.2f})")
return out
# Body text is lifted like every other text tone rather than exempted.
# Most of these palettes publish a body colour around 4.5:1 -- their own
# Most of these palettes publish a body color around 4.5:1 -- their own
# target -- and ihasmail asks 7:1 of the text a reader looks at all day.
# Rejecting a palette over that would have cost five of the six added in
# 2026-09; nudging the published colour along its own hue costs nothing a
# 2026-09; nudging the published color along its own hue costs nothing a
# reader can name, and the shift is recorded in the header of the
# generated block like every other one.
fg = lift("fg", fg, TEXT_ON_BG["fg"])
@@ -365,11 +365,11 @@ def main() -> int:
"/*",
" * Written by scripts/build-palettes.py -- edit the sources there, not here.",
" *",
" * Every colour is from the palette's own project (all MIT); the published",
" * Every color is from the palette's own project (all MIT); the published",
" * values are recorded in .palette-sources/palettes-upstream.md. The tiers",
" * between them are derived, and every text colour is checked against the",
" * between them are derived, and every text color is checked against the",
" * surface it sits on: 4.5:1 for prose, 3:1 for borders and marks. Several",
" * of these palettes do not meet that as published -- Dracula's comment grey",
" * of these palettes do not meet that as published -- Dracula's comment gray",
" * is about 3.0:1 on its own background -- so those tiers are lifted, which",
" * is why this is arithmetic rather than a hand-written block.",
" */",
+13 -13
View File
@@ -1,6 +1,6 @@
#!/usr/bin/env node
/*
* Check a catalogue against the strings the code actually asks for.
* Check a catalog against the strings the code actually asks for.
*
* Two failures, and only one of them is visible without this.
*
@@ -8,9 +8,9 @@
* as an untranslated word on screen, which somebody will eventually notice.
*
* A *stale* key -- one whose English no longer exists, usually because it was
* mistyped when the catalogue was written -- is silent. The translation sits
* mistyped when the catalog was written -- is silent. The translation sits
* in the file looking correct, is never looked up, and the app renders English
* for ever. Nothing warns, because a catalogue is only ever read by key.
* for ever. Nothing warns, because a catalog is only ever read by key.
*/
/*
* The parser, not the compiler.
@@ -32,13 +32,13 @@ import { readFileSync, globSync } from "node:fs";
/*
* Two sets, because there are two questions and they need different nets.
*
* `wanted` is what a catalogue *owes*: the strings that actually reach t(),
* `wanted` is what a catalog *owes*: the strings that actually reach t(),
* tc() or plural(). Coverage is measured against it, so it has to stay strict
* -- widening it would count every CSS class and JMAP method name as an
* untranslated string.
*
* `seen` is every string literal in the source, and answers only "is this
* catalogue key still written down anywhere". Stale detection needs the wide
* catalog key still written down anywhere". Stale detection needs the wide
* net: a key reaches t() as a variable often enough that a strict set reports
* mostly false alarms.
*/
@@ -70,7 +70,7 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
if (ts.isJsxText(n)) { const text = n.text.trim(); if (text) seen.add(text); }
/*
* A `label:` in a constant is still a string somebody has to translate --
* it reaches t() one render later -- so it stays part of what a catalogue
* it reaches t() one render later -- so it stays part of what a catalog
* owes, and out of coverage it would flatter the number.
*/
if (ts.isPropertyAssignment(n) && n.name.getText(src) === "label" && ts.isStringLiteral(n.initializer)) wanted.add(n.initializer.text);
@@ -81,10 +81,10 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
if (ts.isCallExpression(n) && ts.isIdentifier(n.expression)) {
const fn = n.expression.text, a0 = n.arguments[0];
if ((fn === "t" || fn === "translate" || fn === "tNode") && a0 && ts.isStringLiteral(a0)) wanted.add(a0.text);
// tc(context, source) keys the catalogue on both, joined by the same
// tc(context, source) keys the catalog on both, joined by the same
// control character tc() uses. Without this the contextual entries all
// looked stale, which is the checker's own false alarm rather than a
// catalogue problem.
// catalog problem.
if (fn === "tc" && a0 && ts.isStringLiteral(a0) && n.arguments[1] && ts.isStringLiteral(n.arguments[1])) {
// Only the contextual key is required. The plain one is tc()'s
// fallback, not a second obligation -- asking for both would report
@@ -104,8 +104,8 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
}
/*
* A catalogue and a picker entry are two halves of one thing, and either half
* alone is dead weight. A catalogue with no entry in UI_LANGUAGES never
* A catalog and a picker entry are two halves of one thing, and either half
* alone is dead weight. A catalog with no entry in UI_LANGUAGES never
* reaches a reader -- it builds, it passes every test, and the language simply
* is not offered. That happened to Dutch: the entry was added by a text
* replacement anchored on a line that did not exist on that branch, so it was
@@ -113,17 +113,17 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
*/
const languagesSrc = readFileSync("web/src/lib/languages.ts", "utf8");
const registered = new Set([...languagesSrc.matchAll(/tag:\s*"([\w-]+)"/g)].map((m) => m[1]));
const catalogues = new Set(globSync("web/src/locales/*.ts").map((f) => f.split("/").pop().replace(".ts", "")));
const catalogs = new Set(globSync("web/src/locales/*.ts").map((f) => f.split("/").pop().replace(".ts", "")));
let failed = false;
for (const tag of catalogues) {
for (const tag of catalogs) {
if (!registered.has(tag)) {
failed = true;
console.log(`!! ${tag}.ts exists but is not in UI_LANGUAGES — the language is never offered\n`);
}
}
for (const tag of registered) {
if (tag !== "en" && !catalogues.has(tag)) {
if (tag !== "en" && !catalogs.has(tag)) {
failed = true;
console.log(`!! UI_LANGUAGES offers ${tag} but there is no ${tag}.ts — it would fall back to English\n`);
}
+9 -9
View File
@@ -11,11 +11,11 @@
* A string reaches a reader translated if either is true:
*
* 1. it is wrapped where it is written -- t(), tc(), tNode(), plural()
* 2. it is a catalogue key, translated somewhere else
* 2. it is a catalog key, translated somewhere else
*
* The second case is a real convention here, not a loophole: constant tables
* hold English and the render site calls `t(s.label)`. What this refuses is a
* string that is neither -- one no catalogue has a key for, which therefore
* string that is neither -- one no catalog has a key for, which therefore
* cannot be translated at all, however many languages ship.
*/
/*
@@ -56,7 +56,7 @@ const EQUALITY = new Set([
/*
* Product names, example addresses and URL scaffolding. These reach t() and
* are deliberately absent from every catalogue -- translating "ihasmail" or
* are deliberately absent from every catalog -- translating "ihasmail" or
* "[email protected]" would be a bug, not a feature -- so they would otherwise
* be reported for ever.
*/
@@ -81,7 +81,7 @@ const found = [];
for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("__tests__") && !f.includes("/locales/"))) {
const src = ts.createSourceFile(file, readFileSync(file, "utf8"), ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
/*
* `strict` withdraws the catalogue-key exemption. It exists for English held
* `strict` withdraws the catalog-key exemption. It exists for English held
* in a constant and translated where it renders; a literal written straight
* into a JSX attribute has no later render site to be translated at -- no
* component here passes its props through t() -- so being a key only means
@@ -119,7 +119,7 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
/*
* English assembled around values: `aria-label={`Remove ${email}`}`.
*
* The literal cannot be a catalogue key as written, so whether it is a key is
* The literal cannot be a catalog key as written, so whether it is a key is
* not asked. Neither is looksLikeUi, which reads the opening of a sentence:
* `${name} — shared by ${owner}` opens with a value and its words come after.
* Any run of letters between the values counts. The only template literals
@@ -151,7 +151,7 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
&& n.expression.expression.getText(src) === "toast" && TOASTS.has(n.expression.name.text)) {
const a0 = n.arguments[0];
if (a0 && ts.isStringLiteral(a0) && !wrapped.has(a0)) report(a0, a0.text);
/* A template literal cannot be a catalogue key at all, so it is always a find. */
/* A template literal cannot be a catalog key at all, so it is always a find. */
if (a0 && ts.isTemplateExpression(a0)) report(a0, a0.head.text + "{}");
}
ts.forEachChild(n, visit);
@@ -160,11 +160,11 @@ for (const file of globSync("web/src/**/*.{ts,tsx}").filter((f) => !f.includes("
}
if (!found.length) {
console.log("i18n literals: none -- every user-visible string is wrapped or has a catalogue key");
console.log("i18n literals: none -- every user-visible string is wrapped or has a catalog key");
process.exit(0);
}
console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalogue can translate:\n`);
console.log(`${found.length} user-visible string(s) the extractor cannot see and no catalog can translate:\n`);
for (const f of found) console.log(` ${f.file}:${f.line}\n ${JSON.stringify(f.text)}`);
console.log("\nWrap them in t() / plural(), or -- for a label held in a constant and");
console.log("translated where it renders -- make sure the English is a catalogue key.");
console.log("translated where it renders -- make sure the English is a catalog key.");
process.exit(process.argv.includes("--check") ? 1 : 0);
+3 -3
View File
@@ -1,10 +1,10 @@
#!/usr/bin/env node
/*
* Every source string a catalogue needs, straight out of the calls.
* Every source string a catalog needs, straight out of the calls.
*
* The English text is the key, so the catalogue's keys are not a list somebody
* The English text is the key, so the catalog's keys are not a list somebody
* maintains -- they are whatever t(), tNode() and plural() are actually asked
* for. Reading them from the code means a catalogue can never drift out of
* for. Reading them from the code means a catalog can never drift out of
* step with the app in the one direction that matters: a key that no longer
* exists is dead weight, but a call with no key is an untranslated string
* nobody noticed.