diff --git a/scripts/split-description.mjs b/scripts/split-description.mjs index 199f5d2ec..38d3f6241 100644 --- a/scripts/split-description.mjs +++ b/scripts/split-description.mjs @@ -21,6 +21,63 @@ import { readFileSync, writeFileSync } from "node:fs"; const LOCALES = ["de", "en", "es", "fr", "it", "pl"]; const BOUNDARY = /(?<=[.!?])\s+(?=[A-ZÄÖÜÀÂÉÈÊÎÔÙÛÇŁŚŹŻĄĆĘŃÓ0-9„"„«])/u; +/** + * Abbreviations whose full stop is not a sentence end. + * + * The boundary above demands an upper-case letter after the space, which + * already rejects `z. B.` mid-sentence in German (`B.` continues lower-case + * more often than not) — but not reliably: "… z. B. Blutdruck." splits after + * `z.`, because `B` IS upper-case. The same hole exists for `d. h.`, `u. a.`, + * `e.g.` / `i.e.` before a capitalised noun, and French `p. ex.`. A split + * there produces a head that ends mid-abbreviation and a tail that starts + * with a fragment, in a file nobody re-reads afterwards. + */ +const ABBREVIATIONS = [ + "bzw.", + "ca.", + "vgl.", + "usw.", + "e.g.", + "i.e.", + "cf.", + "etc.", + "ej.", + "es.", + "np.", + "tzn.", + "ecc.", + "ex.", +]; + +/** + * A single letter followed by a full stop is an abbreviation part, never a + * sentence: `z. B.`, `d. h.`, `u. a.`, `p. ex.`. Both halves have to be + * caught, which is why this is a shape rather than a list — catching only + * `z.` still splits after the `B.`. + */ +const SINGLE_LETTER = /(^|\s)\p{L}\.$/u; + +/** Split on sentence boundaries, skipping the ones an abbreviation created. */ +function splitSentences(text) { + const rough = text.split(BOUNDARY); + const out = []; + for (const part of rough) { + const previous = out[out.length - 1]; + const lower = previous?.toLowerCase() ?? ""; + const endsInAbbreviation = + previous !== undefined && + (SINGLE_LETTER.test(previous) || + ABBREVIATIONS.some( + (abbr) => lower.endsWith(` ${abbr}`) || lower === abbr, + )); + // Merge, then let the NEXT iteration re-test the merged tail: `z. B.` + // needs two merges, and a single pass would stop after the first. + if (endsInAbbreviation) out[out.length - 1] = `${previous} ${part}`; + else out.push(part); + } + return out; +} + function get(bundle, path) { return path .split(".") @@ -56,7 +113,7 @@ for (const locale of LOCALES) { console.log(`${locale} ${key}: MISSING`); continue; } - const parts = value.split(BOUNDARY); + const parts = splitSentences(value); if (parts.length < 2) { console.log(`${locale} ${key}: single sentence, untouched`); continue; diff --git a/src/__tests__/account-payload-consumer-guard.test.ts b/src/__tests__/account-payload-consumer-guard.test.ts index 4361eb4c5..58d85f597 100644 --- a/src/__tests__/account-payload-consumer-guard.test.ts +++ b/src/__tests__/account-payload-consumer-guard.test.ts @@ -92,6 +92,14 @@ const ACCOUNT_ENTRY_CONSUMERS = [ join(SRC, "components", "layout", "account-switcher-menu.tsx"), ]; const ACTIVE_RECORD_CONSUMER = join(SRC, "hooks", "use-record-capabilities.ts"); + +/** + * The names an access ENTRY is bound to at the two consumer sites. Pinning + * these is what stops the matcher from accepting `.level` on an unrelated + * object; a new consumer that binds it under a third name adds that name here, + * which is a deliberate edit rather than a silent widening. + */ +const ENTRY_BINDINGS = ["entry", "account"]; const ACCOUNT_ACCESS_ADDITIVE_FIELDS = [ "level", "sections", @@ -245,14 +253,18 @@ describe("account payload consumer guard", () => { const readerCount = ACCOUNT_ACCESS_ADDITIVE_FIELDS.reduce( (count, field) => { - // The reader binds the entry under its own name (`entry`, `account`, - // …), so the matcher pins the property access, not the variable. An - // empty match set fails rather than passing quietly. - const read = new RegExp(`\\w\\.${field}(?![\\w$])`); + // The read has to be a read OF THE PAYLOAD ENTRY, so the matcher names + // the bindings the entry actually arrives under. `\w\.` would + // have accepted `.level` on any object in the file — a chart's zoom + // level, a log record's level — and reported a consumer that does not + // exist. An empty match set fails rather than passing quietly. + const read = new RegExp( + `\\b(?:${ENTRY_BINDINGS.join("|")})\\.${field}(?![\\w$])`, + ); const readers = entrySources.filter((source) => read.test(source)); expect( readers.length, - `no client reader for account access field \`${field}\``, + `no client reader for account access field \`${field}\` bound as ${ENTRY_BINDINGS.join(" / ")}`, ).toBeGreaterThan(0); expect(activeSource).toMatch(new RegExp(`active\\.${field}(?![\\w$])`)); return count + readers.length + 1; diff --git a/src/__tests__/i18n-english-leak-guard.test.ts b/src/__tests__/i18n-english-leak-guard.test.ts index c2fe27177..150b5a624 100644 --- a/src/__tests__/i18n-english-leak-guard.test.ts +++ b/src/__tests__/i18n-english-leak-guard.test.ts @@ -102,7 +102,6 @@ const LEGIT_IDENTICAL = new Set([ "admin.overview.snapshotOfflineGeoOff", "admin.overview.snapshotOfflineGeoOn", "admin.webPushVapidTitle", - "insights.healthScore.provenance.sources.appleHealth", "measurements.sourceAppleHealth", "onboarding.source.more.appleHealth", "settings.ai.providerChain.types.anthropic", @@ -114,7 +113,6 @@ const LEGIT_IDENTICAL = new Set([ "mood.tag.fastFood", // Technical / config labels conventionally kept verbatim. "admin.hostMetrics.diskBusyPercent", - "admin.overview.snapshotBuildSha", "admin.servicesGlobal", "admin.webPushVapidPublicKeyPlaceholder", "settings.ai.baseUrlLabel", @@ -231,4 +229,21 @@ describe("i18n English-leak guard", () => { ).toEqual([]); }, ); + + it("carries no stale LEGIT_IDENTICAL entry", () => { + // An allowlist that outlives the key it excused is an allowlist nobody + // can reason about: the next reader cannot tell which entries are load- + // bearing. Two were already dead when this assertion was written — one + // from a key removed long before, one from the duplicated version fields + // that came off `/admin`. The sibling allowlists in + // `card-description-length.test.ts` and `account-payload-consumer-guard` + // carry the same check; this one did not. + const stale = [...LEGIT_IDENTICAL].filter( + (key) => typeof enMap.get(key) !== "string", + ); + expect( + stale, + "these keys no longer exist in en.json — drop them from LEGIT_IDENTICAL", + ).toEqual([]); + }); }); diff --git a/src/components/admin/backups-section.tsx b/src/components/admin/backups-section.tsx index c6746717b..083bffea8 100644 --- a/src/components/admin/backups-section.tsx +++ b/src/components/admin/backups-section.tsx @@ -223,7 +223,7 @@ export function RestoreSkipReport({
diff --git a/src/components/settings/__tests__/one-piece-anatomy.test.tsx b/src/components/settings/__tests__/one-piece-anatomy.test.tsx index daaaaafc9..6b9c50846 100644 --- a/src/components/settings/__tests__/one-piece-anatomy.test.tsx +++ b/src/components/settings/__tests__/one-piece-anatomy.test.tsx @@ -170,9 +170,17 @@ describe("the abolished body gutter stays abolished", () => { const source = readFileSync(file, "utf8"); for (const [i, line] of source.split("\n").entries()) { // Comments may name the class they retired; class strings may not. + // Skipping any line that CONTAINS `//` was too coarse — a class + // string on the same line as a URL (`https://…`) would have been + // waved through. Only the part of the line after a line-comment + // marker is exempt, and a marker inside a string is not one. if (line.trimStart().startsWith("*")) continue; - if (line.includes("//")) continue; - if (/\bpl-7\b/.test(line)) { + const marker = line.indexOf("//"); + const code = + marker === -1 || /["'`]/.test(line.slice(0, marker)) + ? line + : line.slice(0, marker); + if (/\bpl-7\b/.test(code)) { offenders.push(`${file.slice(ROOT.length + 1)}:${i + 1}`); } } diff --git a/src/components/settings/account-section/avatar-section.tsx b/src/components/settings/account-section/avatar-section.tsx index 90c6b2314..2ed7576a1 100644 --- a/src/components/settings/account-section/avatar-section.tsx +++ b/src/components/settings/account-section/avatar-section.tsx @@ -114,7 +114,7 @@ export function AvatarSection() { Two strings rather than one merged key: the same wording is the upload's own constraint copy, and a translator keeps the shorter unit either way. */} -

+

{t("settings.avatar.description")} {t("settings.avatar.hint")}

diff --git a/src/components/settings/low-stock-card.tsx b/src/components/settings/low-stock-card.tsx index d8a2374d9..aebcca62f 100644 --- a/src/components/settings/low-stock-card.tsx +++ b/src/components/settings/low-stock-card.tsx @@ -311,8 +311,8 @@ export function LowStockCard({ aria-live="polite" className={ msgType === "error" - ? "text-destructive mt-3 text-sm" - : "text-muted-foreground mt-3 text-sm" + ? "text-destructive text-sm" + : "text-muted-foreground text-sm" } > {msg} diff --git a/src/components/settings/managed-record-settings-section.tsx b/src/components/settings/managed-record-settings-section.tsx index 6474ae523..853763c45 100644 --- a/src/components/settings/managed-record-settings-section.tsx +++ b/src/components/settings/managed-record-settings-section.tsx @@ -256,7 +256,7 @@ function ProfileSettingsForm({ } return ( -
+