diff --git a/Makefile b/Makefile index 16991d533..7681e7c85 100644 --- a/Makefile +++ b/Makefile @@ -181,6 +181,8 @@ gettext-compile: $(MAKE) -C frontend gettext-compile gettext-clear-fuzzy: ./scripts/gettext-clear-fuzzy.sh +gettext-lint: + node scripts/gettext-lint.mjs clean: rm -f *.log .test* [ ! -f "$(BINARY_NAME)" ] || rm -f $(BINARY_NAME) diff --git a/scripts/gettext-lint.mjs b/scripts/gettext-lint.mjs new file mode 100644 index 000000000..91be306ba --- /dev/null +++ b/scripts/gettext-lint.mjs @@ -0,0 +1,248 @@ +#!/usr/bin/env node +// +// gettext-lint.mjs — validate PhotoPrism's gettext catalogs for objective, +// non-linguistic defects that break rendering at runtime: +// +// 1. Placeholder integrity — the set of substitution placeholders in a +// translation (msgstr) must match the source string (msgid). The frontend +// (vue3-gettext) uses named `%{name}` placeholders; the backend (gotext) +// uses printf verbs (`%s`, `%d`, `%.1f`, …). A translation that renames, +// drops, or corrupts a placeholder silently fails to interpolate. +// 2. Whitespace / newline edges — leading/trailing spaces and a trailing +// newline are structural; msgstr should mirror msgid at its edges. +// +// It also shells out to `msgfmt -c --check-format` to surface gettext's own +// c-format fatals. Human summary to stderr, JSONL findings to stdout, and a +// non-zero exit when any finding exists (so it can gate CI if desired). +// +// Dependency-free (Node stdlib only). Run from the repository root: +// +// node scripts/gettext-lint.mjs # lint everything +// node scripts/gettext-lint.mjs --json # JSONL findings only (no summary) +// +// Frontend catalogs: frontend/src/locales/*.po +// Backend catalogs: assets/locales//default.po + +import { readFileSync, readdirSync, existsSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { basename, join } from "node:path"; + +const FRONTEND_DIR = "frontend/src/locales"; +const BACKEND_DIR = "assets/locales"; +const jsonOnly = process.argv.includes("--json"); + +// --- Minimal, multiline-aware .po parser ----------------------------------- +// Returns [{ msgctxt, msgid, msgstr, plurals: [msgstr[0], msgstr[1], …] }, …], +// excluding the header entry (empty msgid). Escapes are kept verbatim, so an +// escaped newline reads as the two characters `\` + `n`. +function parsePo(path) { + const entries = []; + let cur = null; + let field = null; // "msgctxt" | "msgid" | "msgid_plural" | "msgstr" | "plural:N" + + const push = () => { + if (cur && (cur.msgid.length || cur.msgstr.length || cur.plurals.length)) { + entries.push(cur); + } + }; + + for (const raw of readFileSync(path, "utf8").split("\n")) { + const line = raw.replace(/\r$/, ""); + if (line === "" || line.startsWith("#")) { + continue; // blank lines and comments (incl. flags/refs) are not needed + } + const kw = line.match(/^(msgctxt|msgid|msgid_plural|msgstr(?:\[(\d+)\])?)\s+"(.*)"$/); + if (kw) { + const key = kw[1]; + const val = kw[3]; + if (key === "msgid") { + push(); + cur = { msgctxt: "", msgid: "", msgstr: "", plurals: [] }; + field = "msgid"; + cur.msgid += val; + } else if (key === "msgctxt") { + field = "msgctxt"; + cur.msgctxt += val; + } else if (key === "msgid_plural") { + field = "msgid_plural"; // ignored for placeholder checks (uses msgid) + } else { + // msgstr or msgstr[N] + const idx = kw[2]; + if (idx === undefined || idx === "0") { + field = "msgstr"; + cur.msgstr += val; + } else { + field = `plural:${idx}`; + } + if (idx !== undefined) { + cur.plurals[Number(idx)] = (cur.plurals[Number(idx)] || "") + val; + } + } + continue; + } + const cont = line.match(/^"(.*)"$/); + if (cont && cur && field) { + if (field === "msgid") cur.msgid += cont[1]; + else if (field === "msgctxt") cur.msgctxt += cont[1]; + else if (field === "msgstr") cur.msgstr += cont[1]; + else if (field.startsWith("plural:")) { + const n = Number(field.slice(7)); + cur.plurals[n] = (cur.plurals[n] || "") + cont[1]; + } + // msgid_plural continuations are intentionally dropped + } + } + push(); + return entries; +} + +// --- Placeholder extraction ------------------------------------------------- +// Named `%{name}` (frontend). Literal `%%` is not a placeholder. +function namedPlaceholders(s) { + return (s.replace(/%%/g, "").match(/%\{[^}]*\}/g) || []).sort(); +} +// printf verbs (backend). Matches Go fmt verbs incl. width/precision/index. +function printfVerbs(s) { + return (s.replace(/%%/g, "").match(/%(?:\d+\$)?[-+ #0]*\d*(?:\.\d+)?[bcdeEfFgGoqstTvxXsdp]/g) || []).sort(); +} + +function multisetEqual(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false; + return true; +} + +// Edge-whitespace / trailing-newline signature. +function edgeSig(s) { + return { + lead: /^ /.test(s), + trail: / $/.test(s), + nl: s.endsWith("\\n"), + }; +} +function edgeEqual(a, b) { + const x = edgeSig(a); + const y = edgeSig(b); + return x.lead === y.lead && x.trail === y.trail && x.nl === y.nl; +} + +// --- msgfmt -c -------------------------------------------------------------- +function msgfmtCheck(path) { + const r = spawnSync("msgfmt", ["-c", "--check-format", "-o", "/dev/null", path], { + encoding: "utf8", + }); + if (r.error) return []; // msgfmt not installed → skip silently + const out = `${r.stdout || ""}${r.stderr || ""}`; + // Keep only fatal/error lines, drop header-field warnings (Weblate-owned). + return out + .split("\n") + .filter((l) => l.trim() && !/warning: header field/.test(l) && !/warning: /.test(l)) + .map((l) => l.trim()); +} + +// --- Lint one catalog ------------------------------------------------------- +function lintFile(path, side) { + const findings = []; + const getPlaceholders = side === "backend" ? printfVerbs : namedPlaceholders; + for (const e of parsePo(path)) { + if (!e.msgid) continue; // header + // Compare the primary msgstr and any plural forms that carry content. + const targets = [e.msgstr, ...e.plurals.filter(Boolean)]; + for (const str of targets) { + if (!str) continue; // untranslated → not a defect + const idP = getPlaceholders(e.msgid); + const strP = getPlaceholders(str); + if (!multisetEqual(idP, strP)) { + findings.push({ + file: path, + side, + check: "placeholder", + msgctxt: e.msgctxt || undefined, + msgid: e.msgid, + msgstr: str, + expected: idP, + found: strP, + }); + } + if (!edgeEqual(e.msgid, str)) { + const ie = edgeSig(e.msgid); + // A msgid that itself carries edge whitespace is a *source-string* smell: + // the stray space usually separates adjacent `{{ }}` interpolations and is + // rendering-sensitive, so it must be fixed at the source (and verified in a + // browser), not blindly trimmed from each translation. Reported separately + // from a genuine translation-only whitespace defect (clean msgid). + findings.push({ + file: path, + side, + check: ie.lead || ie.trail || ie.nl ? "source-whitespace" : "whitespace", + msgctxt: e.msgctxt || undefined, + msgid: e.msgid, + msgstr: str, + idEdges: ie, + strEdges: edgeSig(str), + }); + } + } + } + for (const err of msgfmtCheck(path)) { + findings.push({ file: path, side, check: "msgfmt", detail: err }); + } + return findings; +} + +// --- Collect catalogs ------------------------------------------------------- +function frontendFiles() { + if (!existsSync(FRONTEND_DIR)) return []; + return readdirSync(FRONTEND_DIR) + .filter((f) => f.endsWith(".po")) + .map((f) => join(FRONTEND_DIR, f)) + .sort(); +} +function backendFiles() { + if (!existsSync(BACKEND_DIR)) return []; + const out = []; + for (const d of readdirSync(BACKEND_DIR, { withFileTypes: true })) { + if (!d.isDirectory()) continue; + const p = join(BACKEND_DIR, d.name, "default.po"); + if (existsSync(p)) out.push(p); + } + return out.sort(); +} + +// --- Run -------------------------------------------------------------------- +const raw = []; +for (const f of frontendFiles()) raw.push(...lintFile(f, "frontend")); +for (const f of backendFiles()) raw.push(...lintFile(f, "backend")); + +// Collapse source-whitespace findings to one per (side, msgid): the same stray +// space in the source string surfaces once per locale, but it's a single defect. +const all = []; +const seenSource = new Set(); +for (const f of raw) { + if (f.check === "source-whitespace") { + const key = `${f.side}${f.msgid}`; + if (seenSource.has(key)) continue; + seenSource.add(key); + } + all.push(f); +} + +for (const finding of all) process.stdout.write(`${JSON.stringify(finding)}\n`); + +if (!jsonOnly) { + const by = (side, check) => + all.filter((f) => f.side === side && f.check === check).length; + const files = (check) => new Set(all.filter((f) => f.check === check).map((f) => f.file)).size; + const w = (s) => process.stderr.write(`${s}\n`); + w(""); + w("gettext-lint summary"); + w("--------------------"); + w(` placeholder frontend=${by("frontend", "placeholder")} backend=${by("backend", "placeholder")} (files=${files("placeholder")})`); + w(` whitespace frontend=${by("frontend", "whitespace")} backend=${by("backend", "whitespace")} (translation-side defects, files=${files("whitespace")})`); + w(` source-whitespace frontend=${by("frontend", "source-whitespace")} backend=${by("backend", "source-whitespace")} (distinct source strings — fix in source, verify rendering)`); + w(` msgfmt frontend=${by("frontend", "msgfmt")} backend=${by("backend", "msgfmt")}`); + w(` TOTAL findings: ${all.length}`); + w(""); +} + +process.exit(all.length ? 1 : 0);