mirror of
https://github.com/photoprism/photoprism.git
synced 2026-07-17 16:49:04 +00:00
248 lines
9.3 KiB
JavaScript
248 lines
9.3 KiB
JavaScript
#!/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} |