mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
refactor(i18n): a description is one sentence, in all six languages
Measured before the sweep: 317 description and hint strings under the `settings` and `admin` namespaces, median 83 characters, 113 of them two sentences or more. The rule the design standards now carry is one sentence, guideline 120 characters, and this is the sweep that makes it true. Almost nothing was retranslated. A second sentence is already written in every language — it is just in the wrong slot. `scripts/split-description.mjs` cuts a description at its first sentence boundary in all six bundles at once and either drops the tail or files it under its own key; the twenty-one keys that moved now render in the card body as foreground `text-sm`, which is where multi-sentence prose belongs and where a reader can actually read it (the slot is muted `text-xs`). Eight tails were dropped outright, because "Off by default" under a switch, "Topics and frequency are adjustable" above the topic switches, and "Only admins can create invitations" on an admin-gated route are not information. Twenty-eight keys are gone: the privacy page's intro, which was a table of contents for the four cards under it; two About descriptions that listed verbatim the labels and the buttons beneath them; a card description that repeated its own title; the API-token sentence that said what the paragraph below it said with the actual instruction; and the eight integration secondary sentences. Six descriptions were rewritten shorter in all six languages where trimming was honest rather than lossy. On the strings that changed: 4391 characters of English down to 2290, and 32 multi-sentence descriptions down to zero. The rule is now a gate. `card-description-length.test.ts` reads the keys that actually reach a card header's description slot — statically, from the JSX — and checks them in EN and DE, because a rule that only holds in English produces a slot that wraps to two lines in half the locales. Its exception list is empty and carries a reason requirement plus a test that removes a stale entry. Verified by putting a second sentence back: it fails.
This commit is contained in:
1 parent
330f60115a
commit
ba0cd63cf5
33 files changed
+779
-392
No files matched your search
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Measures the description / hint strings under the `settings` and `admin`
|
||||
* namespaces against the design standards' §3 rule: one sentence, guideline
|
||||
* ≤ 120 characters.
|
||||
*
|
||||
* node scripts/measure-description-lengths.mjs [locale=de] [--top N]
|
||||
*
|
||||
* A review tool, not a gate — the gate is
|
||||
* `src/components/settings/__tests__/card-description-length.test.ts`, which
|
||||
* checks the strings that actually reach a card header's description slot.
|
||||
* This one sweeps wider so a long hint in a body still shows up in a sweep.
|
||||
*/
|
||||
import { readFileSync } from "node:fs";
|
||||
|
||||
const locale = process.argv[2]?.startsWith("--") ? "de" : (process.argv[2] ?? "de");
|
||||
const topIdx = process.argv.indexOf("--top");
|
||||
const top = topIdx === -1 ? 40 : Number(process.argv[topIdx + 1]);
|
||||
|
||||
const NAME = /(description|subtitle|hint|note|explainer|help|body)$/i;
|
||||
|
||||
function walk(node, path, out) {
|
||||
for (const [key, value] of Object.entries(node)) {
|
||||
const next = path ? `${path}.${key}` : key;
|
||||
if (typeof value === "string") {
|
||||
if (NAME.test(key)) out.push([next, value]);
|
||||
} else if (value && typeof value === "object") {
|
||||
walk(value, next, out);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Sentence count: terminators followed by a space + capital, or at the end. */
|
||||
function sentences(text) {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return 0;
|
||||
const matches = trimmed.match(/[.!?](\s+[^\s]|$)/g);
|
||||
return Math.max(1, matches ? matches.length : 1);
|
||||
}
|
||||
|
||||
const bundle = JSON.parse(readFileSync(`messages/${locale}.json`, "utf8"));
|
||||
const entries = [];
|
||||
for (const ns of ["settings", "admin"]) {
|
||||
if (bundle[ns]) walk(bundle[ns], ns, entries);
|
||||
}
|
||||
|
||||
const lengths = entries.map(([, v]) => v.length).sort((a, b) => a - b);
|
||||
const at = (q) => lengths[Math.min(lengths.length - 1, Math.floor(lengths.length * q))];
|
||||
const multi = entries.filter(([, v]) => sentences(v) >= 2);
|
||||
|
||||
console.log(`locale ${locale} — ${entries.length} description/hint strings`);
|
||||
console.log(
|
||||
`length: min ${lengths[0]}, median ${at(0.5)}, p75 ${at(0.75)}, p90 ${at(0.9)}, max ${lengths.at(-1)}`,
|
||||
);
|
||||
console.log(
|
||||
`> 120 chars: ${lengths.filter((n) => n > 120).length}, > 200: ${lengths.filter((n) => n > 200).length}`,
|
||||
);
|
||||
console.log(`>= 2 sentences: ${multi.length}, >= 3: ${entries.filter(([, v]) => sentences(v) >= 3).length}`);
|
||||
console.log(`\nLONGEST ${top}:`);
|
||||
for (const [key, value] of [...entries]
|
||||
.sort((a, b) => b[1].length - a[1].length)
|
||||
.slice(0, top)) {
|
||||
console.log(`${String(value.length).padStart(4)}ch ${sentences(value)}S ${key}`);
|
||||
console.log(` ${value.slice(0, 130)}${value.length > 130 ? "…" : ""}`);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Splits an over-long description at its first sentence boundary, in every
|
||||
* locale, WITHOUT retranslating anything: the tail is already written in each
|
||||
* language, it is just in the wrong slot.
|
||||
*
|
||||
* node scripts/split-description.mjs --dry <key> [<key> …]
|
||||
* node scripts/split-description.mjs drop <key> [<key> …]
|
||||
* node scripts/split-description.mjs move <key> <tailKey>
|
||||
*
|
||||
* drop — the description keeps sentence 1; the tail is deleted.
|
||||
* move — the description keeps sentence 1; the tail lands under `tailKey`,
|
||||
* to be rendered in the card body as foreground `text-sm`.
|
||||
* --dry — print what each locale would become, change nothing.
|
||||
*
|
||||
* A terminator followed by whitespace and an upper-case letter (or a digit,
|
||||
* or an opening quote) is a sentence boundary. `Claude.ai` and `z. B.` do not
|
||||
* match: the first has no whitespace, the second continues lower-case.
|
||||
*/
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
|
||||
const LOCALES = ["de", "en", "es", "fr", "it", "pl"];
|
||||
const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u;
|
||||
|
||||
function get(bundle, path) {
|
||||
return path
|
||||
.split(".")
|
||||
.reduce((node, part) => (node == null ? node : node[part]), bundle);
|
||||
}
|
||||
function set(bundle, path, value) {
|
||||
const parts = path.split(".");
|
||||
const last = parts.pop();
|
||||
let node = bundle;
|
||||
for (const part of parts) {
|
||||
if (typeof node[part] !== "object" || node[part] === null) node[part] = {};
|
||||
node = node[part];
|
||||
}
|
||||
node[last] = value;
|
||||
}
|
||||
|
||||
const [mode, ...rest] = process.argv.slice(2);
|
||||
const dry = mode === "--dry";
|
||||
const keys = dry ? rest : mode === "move" ? [rest[0]] : rest;
|
||||
const tailKey = mode === "move" ? rest[1] : null;
|
||||
if (!mode || keys.length === 0) {
|
||||
console.error("usage: --dry|drop|move <key> [tailKey]");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const locale of LOCALES) {
|
||||
const file = `messages/${locale}.json`;
|
||||
const bundle = JSON.parse(readFileSync(file, "utf8"));
|
||||
let changed = false;
|
||||
for (const key of keys) {
|
||||
const value = get(bundle, key);
|
||||
if (typeof value !== "string") {
|
||||
console.log(`${locale} ${key}: MISSING`);
|
||||
continue;
|
||||
}
|
||||
const parts = value.split(BOUNDARY);
|
||||
if (parts.length < 2) {
|
||||
console.log(`${locale} ${key}: single sentence, untouched`);
|
||||
continue;
|
||||
}
|
||||
const head = parts[0].trim();
|
||||
const tail = parts.slice(1).join(" ").trim();
|
||||
if (dry) {
|
||||
console.log(`${locale} ${key}\n head(${head.length}): ${head}\n tail: ${tail}`);
|
||||
continue;
|
||||
}
|
||||
set(bundle, key, head);
|
||||
if (tailKey) set(bundle, tailKey, tail);
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
writeFileSync(file, `${JSON.stringify(bundle, null, 2)}\n`, "utf8");
|
||||
console.log(`${locale}: written`);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user