🌐 Complete Catalan translations in frontend (#11741)

* 🌐 Complete Catalan translations in frontend

Complete the Catalan (ca.po) locale to 100% coverage against en.po,
using es.po as support reference. Adds the 1439 missing entries
across workspace, dashboard, labels, shortcuts, subscription,
errors, modals and onboarding, keeping vosaltres treatment and
IEC/Termcat terminology consistent with the existing strings.
Normalizes placeholders and plural forms, drops the 14 stale
obsolete entries and canonicalizes the file with the repo
translations script.

Closes #11739

AI-assisted-by: muse-spark-1.3-contributor

* 📚 Add frontend translations memory with Catalan criteria

Record the PO workflow, the sync fuzzy-flag gotcha and the
Catalan glossary and tone agreed upon while completing ca.po,
and link the new memory from the frontend core routing.

AI-assisted-by: muse-spark-1.3-contributor

* 🔧 Add gettext to devenv image

Provide msgfmt and msgattrib in the dev environment for
checking PO translation files.

AI-assisted-by: muse-spark-1.3-contributor

* 🌐 Fix Catalan translations and add PO checker

Review of the missing-whitespace pattern found ~90 glued words
across 75 entries, plus 4 lost plural forms and 2 placeholder
mismatches verified against tr call sites. All fixed in ca.po.

Adds frontend/scripts/check-translations.js (vocabulary-free PO
QA: glued words, punctuation, placeholders, plurals) with
--self-test, wired as pnpm run check-translations and
documented in mem:frontend/translations.

AI-assisted-by: muse-spark-1.3-contributor

* 🌐 Multi-locale PO checker with word catalogs

Split the checker engine from its word lists: ca/es catalogs now
live in scripts/check-translations/words.<locale>.txt and all
messages are in English. Adds an es seed (calibrated to zero
errors) and fixes 7 typos it found in es.po. Universal checks
(placeholders, plurals, punctuation) run without a catalog.

AI-assisted-by: muse-spark-1.3-contributor

* 🌐 Merge PO checker into translations.js

Fold check-translations.js into translations.js as a check
subcommand reusing its locale helpers; word lists stay in
scripts/check-translations/words.<locale>.txt. Also fixes the
getopts stopEarly bug that made -l useless after the command
(sync -l ca synced every locale), drops dead lodash import
and code, unifies help and exit codes. Removes the
check-translations package alias; use translations.js
check -l <locale> with explicit -l.

AI-assisted-by: muse-spark-1.3-contributor

* 🌐 Keep unused placeholders out of the gate

Reverts the %s-stripping on unused auth.terms-privacy-agreement:
the links mirror its markdown sibling and a reactivation may
need them. Placeholder mismatches on #, unused keys now warn
instead of failing, and the rule is recorded in
mem:frontend/translations.

AI-assisted-by: muse-spark-1.3-contributor
This commit is contained in:
Andrey Antukh 2026-09-22 14:10:01 +02:00 committed by GitHub
parent 2255266d45
commit bc3cb4bddf
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 7782 additions and 112 deletions

View File

@ -37,6 +37,7 @@ See `mem:scripts/paren-repair`.
UI and packages:
- App UI components, SCSS modules, style-system boundaries, accessibility, i18n, and render performance: `mem:frontend/ui-conventions-and-style-system`.
- JS/TS packages, shared UI package, text editor, Storybook, and package builds: `mem:frontend/ui-packages-text-editor-workflow`.
- PO translation workflow and per-locale conventions: `mem:frontend/translations`.
Workspace behavior:
- Workspace state, commits, persistence, undo, repo calls, and refs: `mem:frontend/workspace-state-persistence-subtleties`.

View File

@ -0,0 +1,82 @@
# Frontend Translations
PO-based UI i18n. Files: `frontend/translations/*.po`. `en.po` is the
source of truth (`msgid` = key, `msgstr` = English); `es.po` is a
high-coverage support reference, never the base.
## Workflow
- Canonicalize with `node ./scripts/translations.js sync -l <locale>`
from `frontend/`: sorts entries, syncs `#:` comments from `en`,
deletes keys missing in `en`.
- `sync` copies ALL comment flags from `en`, including `#, fuzzy`.
Translated entries must NOT stay fuzzy: strip the flag after
translating (mirror `es.po`, which keeps fuzzy only on genuinely
untranslated entries). Fuzzy entries are excluded from
`msgfmt --statistics` translated counts.
- Canonical files carry NO `#~` obsolete blocks (`en`/`es` have zero).
Drop them; `sync` does not resurrect them.
- Definition of done: `msgfmt --check <locale>.po` exit 0 and
`msgfmt --statistics` shows 0 fuzzy, 0 untranslated.
## Entry rules
- New entries take `#:` refs from `en`; copy `#, unused`, never `#, fuzzy`.
- `en` keys with empty `msgstr` (or `#, fuzzy` + empty): translate from
`es`/source context, never leave empty.
- Entries whose `en` uses `msgid_plural` need `msgstr[0]`/`msgstr[1]`
(header: `nplurals=2; plural=n != 1`). Keep the `msgid_plural` line:
a singular `msgstr` on a plural key silently breaks count selection
at runtime (the app build reads 1-elem `msgstr` as singular).
- `sync` re-adds `#, fuzzy` on EVERY run for entries fuzzy in `en`;
re-strip after the last sync, never before it.
- Preserve verbatim: `%s`/`%d`, `{var}`/`{{...}}`, markdown
`[text](%s)`, HTML tags, `\n` positions, brand names (Penpot),
key names (Ctrl/Shift/Alt), technical terms (SVG, CSS, HSV, RGB).
## Catalan (ca) conventions
- Normative IEC/Termcat Catalan. Address the user in VOSALTRES
(2nd person plural): "Deseu", "Creeu", "Ja teniu un compte?".
Buttons/menus use short imperatives ("Crea", "Mou", "Restaura").
- Established glossary (reuse exactly, do not re-coin): layer=capa,
board=tauler, stroke=traç, fill=Emplenat, blur=Difuminat,
shadow=Ombra, clipboard=porta-retalls, delete=Elimina,
rename=Canvia el nom, shortcut=drecera, grid=graella (keep "grid"
where the file already does, e.g. grid-layout editing),
plugins/extensions UI=extensions, layout=Disposició,
gradient=Degradat, wireframing kept as loanword.
- Ela geminada uses the middle dot: Cancel·la, paral·lel, al·lega.
- ALL-CAPS source stays ALL-CAPS in Catalan; keep `$175`-style
amounts in IEC format (`175 $/mes`) only where `es` already adapts.
- Shortcut/action names (`shortcuts.*`) are noun/infinitive labels,
not sentences. Error strings are direct, no hedging.
- Same English source in different contexts may legitimately differ
(verb "Copia" vs noun "Còpia"; "Desactivat" vs "Deshabilitada"
agreeing with "drecera"). Normalize only true duplicates.
## QA before commit
- Run `node ./scripts/translations.js check -l <locale>` from
`frontend/` (no default locale: pass `-l` explicitly): 0 errors
required; review warnings by hand. Word lists live in
`frontend/scripts/check-translations/words.<locale>.txt`
(`[elision]` `[function]` `[common]` `[ok]` `[brands]`); new valid
words that trip the gate go to `[ok]`; `check --self-test`
covers the detector rules. Without a catalog only the universal
checks run. `#, fuzzy` entries are skipped (known-pending,
owned elsewhere).
- Placeholder parity per entry (singular AND each plural form,
also enforced by the script); verify `%s` against the `tr` call
site when `en`/`es`/code disagree (a `%s` the code never passes
renders literally; a dropped one swallows the argument). On
`#, unused` keys the script only warns: never "fix" them by
deleting placeholders or links, a reactivation may need them.
- Glued words (AI batches drop spaces at wrap boundaries): the
script flags function-word splits (`del'equip`, `lapolítica`,
`sinecessiteu`), `,/.`/`:` without following space,
lowercase+Uppercase joins (`delPenpot`, `oCapitalize`) and `%s`
glued to a word.
- Balanced `[]`/`()` in markdown links; no double spaces; no glued
words around `·`; trailing spaces match the source.
- `git diff --stat` must touch only `frontend/translations/<locale>.po`.

View File

@ -476,6 +476,7 @@ RUN set -ex; \
nginx \
fd-find \
bat \
gettext \
\
fontconfig \
woff-tools \

View File

@ -0,0 +1,505 @@
# Word catalog for check-translations.js (Catalan).
# Comment lines start with #. Blank lines are ignored.
# [function] short function words; a token starting with one of
# these is likely missing a space (del-equip style).
# [common] ordinary words used to judge the other half of a split.
# [ok] correct words the splitter would flag; never report
# these. Add new valid words here when the gate trips.
# [brands] extra locale-specific brands/terms kept as-is.
# [elision] letters elided with an apostrophe.
[elision]
ldsmntc
[function]
i
o
a
e
de
del
dels
la
el
els
les
lo
un
una
uns
unes
al
als
en
amb
per
pel
pels
que
com
no
ni
si
ja
se
es
ho
hi
li
me
te
ne
em
et
us
vos
ens
son
són
és
més
mes
tot
tota
molt
tan
tant
on
quan
perquè
pero
però
doncs
fins
entre
sobre
sota
cap
cada
altre
seva
seu
meva
teu
nostre
vostre
aquest
aquesta
això
allò
jo
tu
ell
ella
nosaltres
vosaltres
ells
[common]
corregir
poder
obtenir
mantindran
targeta
controladors
importants
futura
donant
tingui
informar
diversitat
continuï
juntament
revocaran
habilitar
desament
autenticació
family
aplicació
còpies
configurat
necessita
conflictes
trigar
res
directament
enviï
correccions
admet
exportacions
aplicacions
càrrec
envia
enllaç
actuals
ajuda
moment
valor
equips
conjunt
projecte
fitxer
compte
usuari
persona
persones
cosa
temps
part
text
nom
contrasenya
sessió
idioma
versió
canvi
arxiu
error
avís
filtre
cerca
vista
pestanya
botó
camp
llista
taula
imatge
forma
capa
fons
color
mida
data
correu
propietari
propietària
membre
opcions
prova
suprimireu
interactuïn
ajudarem
quedi
configurat
permetre
continueu
breu
manteniment
present
començar
donant
css
font
prioritzar
suborganitzacions
podreu
poden
facturació
[ok]
ajudarem
aplana
coincideixi
coma
comes
comprova
comprovar
comprovar-ho
comuna
convidarem
estarem
existeixen
existeixi
fase
gratuïta
interna
mateixa
meves
molta
oberta
obertes
permeten
permeti
segura
targeta
teus
usa
usen
niar
negreta
emplena
emplenament
edita
selector
atributs
sobreescriuran
compartides
desenvolupadors
administradors
desbloquejar
desbloquegeu
opcionalment
autoreferència
especificant
previsualització
previsualitza
predeterminat
predeterminats
predeterminada
predefinides
predefinida
desactivades
seleccionada
seleccionades
descarregueu
centralitzat
horitzontalment
verticalment
multijugador
multiorganització
complementari
multiplicador
actualitzarà
activades
activador
cancel·laran
comptaran
configurar
confirmar-ho
conservaran
desagrupa
desbloqueja
desenganxa
despublica
despublicar
desselecciona-ho
duplicades
envia-ho
importada
importades
interactius
intercanvia
lliscament
lliuraran
migracions
mixte
personalitzeu-les
plantilles
privadesa
realitzant
repositori
restauraran
selecciona-ho
superposa
transferiu
unir-se
usar-los
autodesat
demanar-ho
desactivar-los
abandonar-la
accedir-hi
accelereu
activar-los
actualitzeu
adoptarà
afegir-hi
afegir-ne
afegiu
afegiu-ne
agrupant-la
ajudar-nos
ajudar-vos
ajudeu-nos
ajudieu-nos
ajustar-lo
ajusteu
ajustis
alineació
amplia
ampliada
amplieu
animacions
aplicant
apliqueu
arrossegueu
assegurar-vos
avançar-vos
avisos
baixades
cancel·lada
cancel·lant
cercador
coincidents
col·laboració
col·laborar
commuteu
compatibilitat
concedir-hi
consumeixin
conèixer-vos
definir-ne
definiu
depuració
desbloca
descarta-ho
descriviu
desemmascara
deshabilita
deshabilitada
deshabilitades
desplaçar
desvincula
dissenyeu
editar-lo
editen
editeu
eliminar-lo
el·lipse
el·lipses
emplenats
encabir-ho
enfosqueix
escriviu-nos
espaiat
espaiats
especifiqueu
estils
expliqueu-nos
exporta
exportant
exportar
flexibilitat
gaudireu
il·limitat
il·limitats
il·lumina
il·lustracions
importats
incrusta
inhabilitada
inicieu
insereix
inspecciona
inspeccionar-ne
instal·la
instal·lació
instal·lada
instal·lades
instal·lat
instantànies
integracions
intentar-ho
marca-ho
migració
milloreu
milloreu-ho
notificacions
obsolet
obteniu
ometeu-ho
omplir-ho
opacitat
orientar-vos
personalitzada
personalitzades
pestanya
porta-ho
porta-retalls
promoveu
prototipar
prototipatge
publicar-la
reassignar
reassigneu
remapejant
remapeja
remapejar
silenciats
sol·licitat
sol·licitud
sol·licituds
suprimiran
tipogràfic
tipogràfica
tipogràfics
tipogràfiques
torna-ho
valorant
vinculades
vincular
visiteu
volteja
unir-s'hi
l'opacitat
l'espaiat
l'interlineat
explora'n
edita'l
desant
aquesta
aquestes
baixa
capa
capes
compartir
comproveu
desa
deseu
dreta
esteu
files
fixa
inicia
inicials
meus
només
nous
noves
pel
perfil
perquè
pes
pla
seus
totes
treballo
una
vosaltres
vàlida
vàlides
desactiva
desactivat
desactivada
desconnecta
descobreix
connectades
alguna
canvia
compost
comprovant
del
dels
des
enviarem
mixtes
noms
quina
senzilla
uneix
ves
vista
vés
desconegut
fixa
pel
pels
tota
comentari
comunitat
set
desvinculat
separades
[brands]

View File

@ -0,0 +1,321 @@
# Word catalog for check-translations.js (Spanish).
# Comment lines start with #. Blank lines are ignored.
# [function] short function words; a token starting with one of
# these is likely missing a space.
# [common] ordinary words used to judge the other half of a split.
# [ok] correct words the splitter would flag; never report
# these. Add new valid words here when the gate trips.
# [brands] extra locale-specific brands/terms kept as-is.
# [elision] letters elided with an apostrophe (Spanish has none).
[elision]
[function]
el
la
los
las
un
una
unos
unas
al
del
de
en
y
e
o
u
que
como
con
por
para
sin
sobre
entre
hasta
desde
donde
cuando
porque
pues
si
no
ni
mas
más
muy
tan
tanto
todo
todos
toda
todas
cada
otro
otra
otros
otras
este
esta
estos
estas
ese
esa
eso
aquel
aquella
aquello
mi
mis
tu
tus
su
sus
nuestro
nuestra
nuestros
nuestras
vuestro
vuestra
vuestros
vuestras
me
te
se
nos
os
le
les
lo
yo
él
ella
ello
nosotros
vosotros
ellos
ellas
esto
eso
aquello
algo
alguien
nada
nadie
quien
quienes
cual
cuales
cuanto
cuanta
cuantos
cuantas
[common]
habilitar
crear
eliminar
guardar
abrir
cerrar
enviar
compartir
invitar
mover
copiar
pegar
subir
bajar
editar
configurar
activar
desactivar
permitir
necesitar
poder
deber
querer
saber
hacer
poner
salir
entrar
volver
llegar
pasar
quedar
dar
estar
ser
haber
tener
ir
ver
usar
buscar
cambiar
seguir
encontrar
mostrar
ocultar
añadir
quitar
seleccionar
aplicar
cancelar
aceptar
probar
comprobar
generar
descargar
equipo
archivo
proyecto
cuenta
nombre
correo
error
aviso
ventana
página
botón
campo
lista
tabla
imagen
forma
capa
color
tamaño
fecha
enlace
nuevo
nueva
grande
pequeño
bueno
actual
siguiente
anterior
mismo
otra
primer
último
único
libre
público
[ok]
abandonarla
abrirlo
activarlos
actuales
actualizada
adicionales
administradores
algunos
alto
aplicarse
asignarse
aun
aunque
autoguardada
autoguardado
autoreferencia
ayudarnos
ayudarte
borradores
borrarlo
cambiada
caracteres
como
comparte
compartir
comprobar
comprueba
compuesto
comunidad
conectarlo
conectarse
conjunto
conocerte
consigue
contactarnos
contener
contiene
contexto
danos
demás
desactivarlos
deseleccionar
desuscripción
durante
editores
eje
eliminarlo
eliminarlos
ella
eso
esos
este
estoy
estándar
expirada
funcionalidades
granulares
hay
hubo
importada
iniciarse
invitaciones
lectores
letras
manifiesto
menos
nosotros
otras
pero
personales
peso
primero
principales
propiedades
proveedores
publicarla
publicarlo
puesto
regenerada
restaurarlo
restaurarlos
seleccionada
selector
servidores
sino
solicitudes
soportada
sufijo
tanto
temas
tenerte
tipográfico
tono
unidades
unir
unirse
uno
unos
usarlas
utilizada
verla
versiones
verticales
vincularse
visuales
volverlo
porque
[brands]

View File

@ -1,11 +1,10 @@
#!/usr/bin/env node
import getopts from "getopts";
import { promises as fs, createReadStream } from "fs";
import { promises as fs, createReadStream } from "node:fs";
import gt from "gettext-parser";
import l from "lodash";
import path from "path";
import readline from "readline";
import path from "node:path";
import readline from "node:readline";
const baseLocale = "en";
@ -24,18 +23,6 @@ async function* getFiles(dir) {
}
}
async function translationExists(locale) {
const target = path.normalize("./translations/");
const targetPath = path.join(target, `${locale}.po`);
try {
const result = await fs.stat(targetPath);
return true;
} catch (cause) {
return false;
}
}
async function readLocaleByPath(path) {
const content = await fs.readFile(path);
return gt.po.parse(content, "utf-8");
@ -78,7 +65,7 @@ async function processLocale(options, f) {
} else if (locales === undefined) {
} else {
console.error(`Invalid value found on locales parameter: '${locales}'`);
process.exit(-1);
process.exit(2);
}
for await (const { name } of scanLocales()) {
@ -336,42 +323,513 @@ async function synchronize(options, ...other) {
});
}
// Brands kept as-is in every locale.
const GENERIC_BRANDS = [
"GitHub",
"GitLab",
"YouTube",
"InVision",
"innerShadow",
"dropShadow",
"iOS",
"macOS",
];
const TOKEN_RE = /[\p{L}\p{M}]+(?:[·'\-][\p{L}\p{M}]+)*/gu;
const SKIP_RE = /[%{@/\\=<>|#0-9]/;
const PLACEHOLDER_RES = [/%[sd]/g, /\{[^}]*\}/g, /%\([^)]*\)[sd]/g];
const PUNCT_RE = /[,.:;!?…»)\]]([A-Za-zÀ-Úà-ú«("“‘$])/gu;
const CAMEL_RE = /[a-zàèéíòóúüç·]([A-ZÀÈÉÍÒÓÚÜ][a-zàèéíòóúü]+)/gu;
const PH_GLUED_RE = /%[sd](?=[A-Za-zÀ-Úà-ú])/gu;
const APOSTROPHE_DIGIT_RE = /[a-zàèéíòóúüç·]d['][0-9]/gu;
function tokenize(text) {
return [...text.matchAll(TOKEN_RE)].map((m) => m[0].toLowerCase());
}
function elisionBase(token, elision) {
if (!elision) return null;
const m = token.match(new RegExp(`^[${elision}]['](.+)$`));
return m ? m[1] : null;
}
function countIn(text, re) {
re.lastIndex = 0;
return [...text.matchAll(re)].length;
}
// A word is valid as one half of a split. Never the whole token:
// a repeated glue (`del'equip` x3) must not validate itself by frequency.
function validPart(word, words, freq) {
return (
words.functionWords.has(word) ||
words.okWords.has(word) ||
words.commonWords.has(word) ||
(freq.get(word) ?? 0) >= 1
);
}
// Every (left, right, rule) split of a token, including each
// hyphen-separated segment.
function* splits(tok, words, freq) {
const cands = [tok, ...tok.split("-")];
const seen = new Set();
for (const c of cands) {
if (c.length < 3) continue;
for (let k = 1; k < c.length; k++) {
const left = c.slice(0, k);
const right = c.slice(k);
if (left.length < 1) continue;
if (right.length < 2 && !words.functionWords.has(right)) continue;
const key = left + "|" + right;
if (seen.has(key)) continue;
seen.add(key);
const rightBase = elisionBase(right, words.elision) ?? right;
const rightOk = validPart(rightBase, words, freq);
const leftOk = validPart(left, words, freq);
if (words.functionWords.has(left) && rightOk)
yield [left, right, "func-left"];
else if (words.functionWords.has(right) && right.length <= 4 && leftOk)
yield [left, right, "func-right"];
else if (
c.length >= 8 &&
left.length >= 3 &&
right.length >= 2 &&
leftOk &&
rightOk
)
yield [left, right, "content"];
}
}
}
const RULE_ORDER = { "func-left": 0, "func-right": 1, content: 2 };
function bestSplit(tok, words, freq) {
let best = null;
for (const [left, right, rule] of splits(tok, words, freq)) {
if (left.includes("-")) continue;
if (
!best ||
RULE_ORDER[rule] < RULE_ORDER[best[2]] ||
(RULE_ORDER[rule] === RULE_ORDER[best[2]] && left.length > best[0].length)
) {
best = [left, right, rule];
}
}
return best;
}
function folded(s) {
return s.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase();
}
function distance(a, b) {
const dp = Array.from({ length: a.length + 1 }, (_, i) => [i]);
for (let j = 1; j <= b.length; j++) dp[0][j] = j;
for (let i = 1; i <= a.length; i++) {
for (let j = 1; j <= b.length; j++) {
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1),
);
}
}
return dp[a.length][b.length];
}
// Resemblance to the source: a correct word almost always resembles
// its cognate in `en`/`es`; a glued one never does. A pure affix
// (1-4 letters more or less) doesn't count: that is exactly the
// shape of a glue (`lapolitica` vs `politica`, `desdel` vs `desde`).
function isCognate(tok, refToks, elision) {
const base = tok.replace(new RegExp(`^[${elision || "-"}][']`), "");
const t = folded(base);
const limit = t.length <= 4 ? 0 : 1;
for (const r of refToks) {
const rt = folded(r);
if (Math.abs(rt.length - t.length) > Math.max(limit, 4)) continue;
const d = distance(t, rt);
if (d === 0) return true;
if (d > limit) continue;
if (isAffix(t, rt)) continue;
return true;
}
return false;
}
function isAffix(t, rt) {
const dif = Math.abs(t.length - rt.length);
if (dif < 1 || dif > 4) return false;
return (
t.startsWith(rt) || t.endsWith(rt) || rt.startsWith(t) || rt.endsWith(t)
);
}
function refToks(...texts) {
const toks = new Set();
for (const text of texts) for (const t of tokenize(text)) toks.add(t);
return toks;
}
function cleanContext(frag) {
return !/https?:|www\.|@|\|target:|\.mcp\.json/.test(frag);
}
function checkPunctuation(text, brands) {
const found = [];
for (const m of text.matchAll(PUNCT_RE)) {
const punct = m[0][0];
const next = m[1];
const frag = text.slice(Math.max(0, m.index - 30), m.index + 32);
if (!cleanContext(frag)) continue;
if (m[0] === "](") continue; // markdown link
if (m[0] === ":s" && frag.includes("|target:")) continue; // [x|target:self]
if (/%[sd]\.\(/.test(frag)) continue; // %s.(suffix)... notation
if (punct === "." && !/[A-ZÀÈÉÍÒÓÚÜ«"“(%$]/.test(next)) continue;
found.push({ what: m[0], frag });
}
for (const m of text.matchAll(CAMEL_RE)) {
const frag = text.slice(Math.max(0, m.index - 30), m.index + 32);
if (brands.some((mk) => frag.includes(mk))) continue;
found.push({ what: m[0], frag });
}
for (const m of text.matchAll(PH_GLUED_RE)) {
const frag = text.slice(Math.max(0, m.index - 30), m.index + 32);
found.push({ what: m[0], frag });
}
for (const m of text.matchAll(APOSTROPHE_DIGIT_RE)) {
const frag = text.slice(Math.max(0, m.index - 30), m.index + 32);
found.push({ what: m[0], frag });
}
return found;
}
function loadFile(path) {
return fs.readFile(path).then((buf) => gt.po.parse(buf, "utf-8"));
}
function parseCatalog(text, locale) {
const words = {
functionWords: new Set(),
commonWords: new Set(),
okWords: new Set(),
brands: [],
elision: "",
};
const sections = { function: 1, common: 1, ok: 1, brands: 1, elision: 1 };
let current = null;
for (const raw of text.split("\n")) {
const line = raw.trim();
if (line === "" || line.startsWith("#")) continue;
const sec = line.match(/^\[([a-z]+)\]$/);
if (sec) {
if (!sections[sec[1]]) {
throw new Error(`words.${locale}.txt: unknown section [${sec[1]}]`);
}
current = sec[1];
continue;
}
if (!current) {
throw new Error(`words.${locale}.txt: word outside any section`);
}
if (current === "elision") {
if (!/^[a-z]+$/.test(line)) {
throw new Error(`words.${locale}.txt: bad [elision] line`);
}
words.elision += line;
} else if (current === "brands") {
words.brands.push(line);
} else {
words[`${current}Words`].add(line.toLowerCase());
}
}
return words;
}
async function loadCatalog(locale) {
const path = `./scripts/check-translations/words.${locale}.txt`;
try {
return parseCatalog(await fs.readFile(path, "utf-8"), locale);
} catch (err) {
if (err.code === "ENOENT") return null;
throw err;
}
}
function isFuzzy(entry) {
return (entry.comments?.flag ?? "").split(/,\s*/).includes("fuzzy");
}
async function check(locale, words) {
const [data, dataEn, dataEs] = await Promise.all([
loadFile(`./translations/${locale}.po`),
loadFile("./translations/en.po"),
locale === "es" ? null : loadFile("./translations/es.po").catch(() => null),
]);
const entries = data.translations[""];
const entriesEn = dataEn.translations[""];
const entriesEs = dataEs ? dataEs.translations[""] : {};
const brands = [...GENERIC_BRANDS, ...(words?.brands ?? [])];
const errors = [];
const warnings = [];
const freq = new Map();
for (const [msgid, e] of Object.entries(entries)) {
if (msgid === "" || isFuzzy(e)) continue;
for (const t of e.msgstr)
for (const w of tokenize(t)) {
freq.set(w, (freq.get(w) ?? 0) + 1);
}
}
for (const [msgid, e] of Object.entries(entries)) {
if (msgid === "" || isFuzzy(e)) continue;
const texts = e.msgstr;
const eEn = entriesEn[msgid];
const textsEn = eEn ? eEn.msgstr : [];
const eEs = entriesEs[msgid];
const textsEs = eEs ? eEs.msgstr : [];
if (eEn?.msgid_plural && !e.msgid_plural) {
errors.push(
`${msgid}: source uses msgid_plural but ${locale} lacks msgstr[0]/[1]`,
);
}
texts.forEach((text, i) => {
if (!text) return;
const textEn = textsEn[i] ?? "";
const textEs = textsEs[i] ?? "";
const refs = refToks(textEn, textEs);
if (textEn) {
const unused = (e.comments?.flag ?? "")
.split(/,\s*/)
.includes("unused");
for (const re of PLACEHOLDER_RES) {
const nEn = countIn(textEn, re);
const nLoc = countIn(text, re);
if (nEn !== nLoc) {
// Unused keys are never rendered: report, don't fail.
// "Fixing" them by deleting placeholders can destroy
// content that a reactivation may need.
(unused ? warnings : errors).push(
`${msgid}[${i}]: placeholder mismatch ${re.source}: en=${nEn} ${locale}=${nLoc}`,
);
}
}
}
for (const t of checkPunctuation(text, brands)) {
errors.push(
`${msgid}: glued punctuation ${JSON.stringify(t.what)} ...${t.frag}...`,
);
}
if (!words) return;
for (const tok of tokenize(text)) {
if (SKIP_RE.test(tok)) continue;
if (words.okWords.has(tok)) continue;
// No whole-token frequency skip: a repeated glue
// (`del'equip` x3) must never validate itself by frequency.
const base = elisionBase(tok, words.elision);
if (base && validPart(base, words, freq)) continue;
if (isCognate(tok, refs, words.elision)) continue;
const part = bestSplit(tok, words, freq);
if (!part) continue;
const [left, right, rule] = part;
const line = `${msgid}: glued word ${JSON.stringify(tok)} -> ${left} + ${right}`;
if (rule === "content") warnings.push(`${line} (review)`);
else errors.push(line);
}
});
}
return { errors, warnings };
}
const FIXTURES = [
// [text, expectsError]
["Els membres del'equip continuaran.", true],
["Accepteu lapolítica de privadesa.", true],
["Si necessiteu més informació,contacteu amb nosaltres.", true],
["Revisions delPenpot disponibles.", true],
["Seleccioneu Lowercase oCapitalize.", true],
["Cobreix fins a %seditors nous.", true],
["Bienvenido acasa nueva.", true],
["Les biblioteques compartides.", false],
["Edita el webhook.", false],
["Emplenament del grup.", false],
["S'està desant el fitxer.", false],
["Els components no es poden niar.", false],
["Gira horitzontalment.", false],
["Desbloquegeu les funcions.", false],
["Atributs SVG importats.", false],
["Commuta la negreta.", false],
["Bibliotecas compartidas.", false],
];
async function selfTest(catalog) {
// Minimal vocabulary for the fixtures.
const freq = new Map(
"els membres continuaran accepteu de privadesa si necessiteu més informació amb nosaltres revisions disponibles seleccioneu lowercase infrequent les biblioteques compartides edita el webhook emplenament del grup està desant fitxer components no es poden niar gira commuta la negreta desbloquegeu les funcions atributs svg importats horitzontalment podeu crear equip política casa bibliotecas compartidas bienvenido nueva"
.split(" ")
.map((w) => [w, 3]),
);
const words = {
functionWords: catalog.functionWords,
commonWords: new Set(),
okWords: new Set(),
brands: [],
elision: catalog.elision,
};
let bad = 0;
for (const [text, expectsError] of FIXTURES) {
const found = [];
for (const t of checkPunctuation(text, GENERIC_BRANDS))
found.push(`punct:${t.what}`);
for (const tok of tokenize(text)) {
if (SKIP_RE.test(tok) || words.functionWords.has(tok)) continue;
if (words.okWords.has(tok)) continue;
const base = elisionBase(tok, words.elision);
if (base && validPart(base, words, freq)) continue;
const part = bestSplit(tok, words, freq);
if (part && part[2] !== "content") found.push(`tok:${tok}`);
}
if (found.length > 0 !== expectsError) {
console.error(
`SELF-TEST FAILED: ${JSON.stringify(text)} expected error=${expectsError}, found=${JSON.stringify(found)}`,
);
bad++;
}
}
if (bad > 0) process.exit(1);
console.log(`SELF-TEST OK: ${FIXTURES.length} cases`);
}
const HELP_TOP = `PO translation toolkit.
Usage: node ./scripts/translations.js <command> [options]
Available options (work before or after the command):
--locale -l <locale> : restrict the command to one locale
--verbose -v : verbose output
--help -h : this help, or help for <command>
Available subcommands (run from \`frontend/\`):
`;
const COMMANDS = {
rehash: {
args: "",
help: 'Scan ./src for (tr "key") usages and update en.po references.',
run: (options, params) => rehash(options, ...params),
},
sync: {
args: "[-l <locale>]",
help: "Copy #: references and flags from en.po into each locale.",
run: (options, params) => synchronize(options, ...params),
},
delete: {
args: "<prefix> [-l <locale>]",
help: "Delete every entry whose key starts with <prefix>.",
run: (options, params) => {
if (!params[0]) {
console.error("delete needs a <prefix>");
process.exit(2);
}
return deleteByPrefix(options, ...params);
},
},
fuzzy: {
args: "<prefix> [-l <locale>]",
help: "Mark as fuzzy every entry whose key starts with <prefix>.",
run: (options, params) => {
if (!params[0]) {
console.error("fuzzy needs a <prefix>");
process.exit(2);
}
return markFuzzy(options, ...params);
},
},
check: {
args: "-l <locale> [--self-test]",
help: "QA a locale PO: glued words, placeholders, plurals.",
run: async (options, params) => {
if (options["self-test"]) {
const catalog = await loadCatalog("ca");
if (!catalog) {
console.error("SELF-TEST FAILED: cannot load words.ca.txt");
process.exit(2);
}
await selfTest(catalog);
return;
}
const locale = options.l ?? options.locale;
if (!locale) {
console.error("check needs -l <locale>");
process.exit(2);
}
const catalog = await loadCatalog(locale);
if (!catalog) {
console.log(
`note: no word catalog for '${locale}', lexical checks skipped`,
);
}
let result;
try {
result = await check(locale, catalog);
} catch (err) {
console.error(
`Could not read translations/${locale}.po: ${err.message}`,
);
process.exit(2);
}
for (const w of result.warnings) console.log(`warn: ${w}`);
for (const e of result.errors) console.error(`error: ${e}`);
console.log(
`${locale}: ${result.errors.length} errors, ${result.warnings.length} warnings`,
);
process.exit(result.errors.length > 0 ? 1 : 0);
},
},
};
const options = getopts(process.argv.slice(2), {
boolean: ["h", "v"],
boolean: ["h", "v", "self-test"],
string: ["l"],
alias: {
help: ["h"],
locale: ["l"],
verbose: ["v"],
},
stopEarly: true,
});
const [command, ...params] = options._;
if (command === "rehash") {
await rehash(options, ...params);
} else if (command === "sync") {
await synchronize(options, ...params);
} else if (command === "delete") {
await deleteByPrefix(options, ...params);
} else if (command === "fuzzy") {
await markFuzzy(options, ...params);
} else {
console.log(`Translations manipulation script.
How to use:
./scripts/translation.js <options> <subcommand>
Available options:
--locale -l : specify a concrete locale
--verbose -v : enables verbose output
--help -h : prints this help
Available subcommands:
rehash : reads and writes all translations files, sorting and validating
sync : synchronize baselocale file with all other locale files
delete <prefix> : delete all entries that matches the prefix
fuzzy <prefix> : mark as fuzzy all entries that matches the prefix
`);
function printHelp() {
console.log(HELP_TOP);
for (const [name, cmd] of Object.entries(COMMANDS)) {
console.log(` ${name} ${cmd.args}\n ${cmd.help}\n`);
}
}
if (!command || options.h || options.help) {
if (command && !COMMANDS[command]) {
console.error(`Unknown command '${command}'.`);
printHelp();
process.exit(2);
}
if (command) {
const cmd = COMMANDS[command];
console.log(
`Usage: node ./scripts/translations.js ${command} ${cmd.args}\n\n${cmd.help}`,
);
} else {
printHelp();
}
} else if (!COMMANDS[command]) {
console.error(`Unknown command '${command}'.`);
printHelp();
process.exit(2);
} else {
await COMMANDS[command].run(options, params);
}

File diff suppressed because it is too large Load Diff

View File

@ -112,7 +112,7 @@ msgstr "La contraseña ha sido cambiada"
#: src/app/main/ui/auth/recovery_request.cljs:50
msgid "auth.notifications.profile-not-verified"
msgstr ""
"El perfil aun no ha sido verificado, por favor valida el perfil antes de "
"El perfil aún no ha sido verificado, por favor valida el perfil antes de "
"continuar."
#: src/app/main/ui/auth/recovery_request.cljs:33
@ -1818,7 +1818,7 @@ msgstr "Parece que no es una imagen válida."
msgid "errors.member-is-muted"
msgstr ""
"El perfil que esta invitando tiene los emails silenciados (por reportes de "
"spam o alto indice de rebote)."
"spam o alto índice de rebote)."
#: src/app/main/errors.cljs:483
msgid "errors.migration-in-progress"
@ -1859,7 +1859,7 @@ msgstr "El perfil esta blockeado"
#: src/app/main/ui/auth/recovery_request.cljs:53, src/app/main/ui/dashboard/team.cljs:225, src/app/main/ui/dashboard/team.cljs:731, src/app/main/ui/dashboard/team.cljs:1108, src/app/main/ui/onboarding/team_choice.cljs:109, src/app/main/ui/settings/change_email.cljs:33
msgid "errors.profile-is-muted"
msgstr ""
"Tu perfil tiene los emails silenciados (por reportes de spam o alto indice "
"Tu perfil tiene los emails silenciados (por reportes de spam o alto índice "
"de rebote)."
#: src/app/main/data/auth.cljs:346, src/app/main/ui/auth/register.cljs:91
@ -2642,7 +2642,7 @@ msgstr "Crear nuevo token de acceso"
#: src/app/main/ui/settings/integrations.cljs:632
msgid "integrations.access-tokens.empty.add-one"
msgstr "Pulsa el botón \"Crear nuevo token de accesso\" para generar uno."
msgstr "Pulsa el botón \"Crear nuevo token de acceso\" para generar uno."
#: src/app/main/ui/settings/integrations.cljs:631
msgid "integrations.access-tokens.empty.no-access-tokens"
@ -2655,7 +2655,7 @@ msgstr "Tokens de acceso personales"
#: src/app/main/ui/settings/integrations.cljs:611
msgid "integrations.access-tokens.personal.description"
msgstr ""
"Los tokens de accesso personales funcionan como una alternativa a nuestro "
"Los tokens de acceso personales funcionan como una alternativa a nuestro "
"sistema de autenticación usuario/password y se pueden usar para permitir a "
"otras aplicaciones acceso a la API interna de Penpot"
@ -2665,7 +2665,7 @@ msgstr "Copiar al portapapeles"
#: src/app/main/ui/settings/integrations.cljs:253
msgid "integrations.create-access-token.title"
msgstr "Crear token de accesso"
msgstr "Crear token de acceso"
#: src/app/main/ui/settings/integrations.cljs:252
msgid "integrations.create-access-token.title.created"
@ -8505,19 +8505,19 @@ msgstr "Opciones avanzadas"
#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:695, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:702
msgid "workspace.options.layout-item.layout-item-max-h"
msgstr "Altura.Max"
msgstr "Altura máx."
#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:633, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:640
msgid "workspace.options.layout-item.layout-item-max-w"
msgstr "Ancho.Max"
msgstr "Ancho máx."
#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:664, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:672
msgid "workspace.options.layout-item.layout-item-min-h"
msgstr "Altura.Min"
msgstr "Altura mín."
#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:600, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:609
msgid "workspace.options.layout-item.layout-item-min-w"
msgstr "Ancho.Min"
msgstr "Ancho mín."
#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs
#, unused
@ -9785,7 +9785,7 @@ msgstr "Nombre del grupo"
#: src/app/main/ui/workspace/tokens/sets.cljs
#, unused
msgid "workspace.tokens.grouping-set-alert"
msgstr "La agrupación de sets aun no está soportada."
msgstr "La agrupación de sets aún no está soportada."
#: src/app/main/ui/workspace/tokens/import/modal.cljs:232
msgid "workspace.tokens.import-button-prefix"