mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
Translations: Add gettext placeholder and whitespace lint script
This commit is contained in:
parent
4acbfd3d1b
commit
139f91ccf9
2 changed files with 250 additions and 0 deletions
2
Makefile
2
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)
|
||||
|
|
|
|||
248
scripts/gettext-lint.mjs
Normal file
248
scripts/gettext-lint.mjs
Normal file
|
|
@ -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/<locale>/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} | ||||