Files
ProxMenux/web/CONTRIBUTING-TRANSLATIONS.md
T
MacRimi ab8c4a56ea Apply one Spanish style to the Monitor and the terminal menus
- The Spanish interface addresses the reader as tú, uses the infinitive on buttons and menu options, and uses Spain vocabulary (pulsar, introducir, añadir, the Intro key)
- Backup, snapshot, host, pool and slot stay in English, and Proxmox web interface paths keep their English labels
- Mistranslated texts, missing spaces and gender agreements corrected
- Spanish documentation quotes the updated screen labels
- The translation guide describes the Spanish style
- The command description fixture follows the updated Spanish text
2026-10-18 20:06:06 +02:00

15 KiB

Contributing translations

The ProxMenux documentation site is built with Next.js (App Router) and serves every published page under locale-prefixed URLs:

  • /en/<path> — English, the source of truth
  • /es/<path> — Spanish
  • /de/<path> — German
  • /fr/<path> — French
  • /it/<path> — Italian
  • /pt/<path> — Portuguese
  • /sk/<path> — Slovak, with English fallback where needed
  • /sv/<path> — Swedish

We use next-intl for the i18n plumbing. Anyone can translate the docs without writing TypeScript: most of the work is filling in a JSON file. This guide explains the workflow end to end.

Default policy: small, focused PRs. One page per pull request. Big bundles of "I translated 30 pages at once" are hard to review and merge cleanly when several contributors are working in parallel.


What's already wired

Out of the box you get:

  • Routing under app/[locale]/... — every page renders for every locale enabled in i18n/routing.ts.
  • Locale-aware navigation via @/i18n/navigation (<Link>, useRouter, usePathname). Use these instead of next/link for internal hrefs so the active [locale] prefix is preserved.
  • A language switcher in the navbar (<LanguageSwitcher />).
  • Automatic message discovery: any JSON file under messages/<locale>/... is loaded and merged into a single namespace tree. You never need to register a new file anywhere, the build picks it up automatically.
  • Fallback to English when a translation is missing — pages render in English instead of breaking with a MISSING_MESSAGE placeholder.

File layout

web/
├── i18n/
│   ├── routing.ts          # supported locales + default
│   ├── request.ts          # per-request config (uses loadMessages)
│   ├── loadMessages.ts     # walks messages/<locale>/ and builds the tree
│   └── navigation.ts       # locale-aware Link, useRouter, etc.
├── messages/
│   ├── en/
│   │   ├── common.json     # shared strings (nav, footer, language switcher)
│   │   └── docs/
│   │       └── monitor/
│   │           └── index.json   # page-specific strings for /docs/monitor
│   ├── es/
│       ├── common.json
│       └── docs/
│           └── monitor/
│               └── index.json
│   └── sk/
│       └── ...
└── app/[locale]/
    └── docs/
        └── monitor/
            └── page.tsx    # the page itself

Naming convention

  • common.json and index.json at any folder level → keys are merged at the current namespace level (no extra nesting). Use these for "this whole folder" or "this whole section" defaults.
  • <name>.json at any folder level → keys go under the <name> namespace.
  • Sub-directories nest as additional namespaces, with kebab-case converted to camelCase in the JS API. So messages/en/docs/monitor/access-auth.json is consumed as getTranslations({ namespace: 'docs.monitor.accessAuth' }).

Workflow: translate one page

1. Pick a page

Browse app/[locale]/docs/ and find a page that:

  • Has no entry yet under messages/<locale>/<same-path>/, and
  • Is not already mid-translation by someone else (check open PRs).

If you're translating to a new locale, start with the smallest pages so you can submit early PRs and get feedback before tackling the big ones.

2. Check whether the page is already i18n-ready

There are two cases:

Case A — the page already uses getTranslations() (look for an import from next-intl/server and t() calls in JSX). Your job is straightforward: only create the messages/<locale>/<path>.json file with the translated strings. You don't touch the .tsx file at all.

Case B — the page still has hard-coded English strings in JSX. You need both:

  1. Refactor the page to read its strings from a JSON file (this part touches the .tsx).
  2. Provide the English JSON (the source of truth) and the translated JSON.

The pilot page app/[locale]/docs/monitor/page.tsx is the reference implementation for case B — copy its patterns.

3. Add the JSON

Create messages/<locale>/<same-path-as-page>.json (or index.json if the page is the section index). Mirror the English file's structure exactly — every key must exist in both, only the values change.

If a key contains inline HTML-style tags (<code>, <strong>, <em>, <link>, <linkApi>, etc.), keep them in the same positions in your translation. They're not real HTML — they're placeholders that the page renders via t.rich() and substitutes for real React nodes. Example:

{
  "intro": "Eight first-class sections, backed by their own API endpoints."
}
{
  "intro": "Ocho secciones principales, respaldadas por sus propios endpoints de API."
}
{
  "footer": "See the <link>Architecture</link> page for details."
}
{
  "footer": "Mira la página de <link>Architecture</link> para más detalles."
}

4. Test locally

From the repository root, check the available catalogs without using a translation provider:

python3 .github/scripts/check_web_docs_catalogs.py

For a full locale-completion change, add --require-complete to also require every English file and leaf. This validates schema, placeholders, rich-text tags, literal code and rendering metadata; it does not replace linguistic review. Partial translations can continue to use the English fallback.

cd web
npm run dev

Open http://localhost:3000/<locale>/<page-path> and check that:

  • All your translated strings render correctly.
  • No MISSING_MESSAGE text appears (means a key is in the page's .tsx but missing from your JSON).
  • The page still passes npm run build cleanly.

5. Open the PR

One page per PR is the convention. Title format:

docs(i18n/<locale>): translate <route>

Examples:

  • docs(i18n/es): translate /docs/monitor
  • docs(i18n/fr): translate /docs/monitor/notifications

In the description, mention which page you translated and whether you also had to refactor the .tsx (case B) or only added JSON (case A).


Automated baseline and incremental updates

The Build web documentation translations GitHub Action can create a machine-translated baseline and fill newly added English keys later. It uses .github/scripts/build_web_docs_i18n.py and supports a locale list, a file or directory scope, a per-locale file limit and a dry run.

The builder is resumable and writes each completed JSON file atomically. By default it preserves every non-empty translated value, including wording reviewed by native speakers. It also protects rich-text tags, placeholders, URLs, paths, commands, variables and official product names before sending a string to the translation provider. --refresh deliberately overwrites the selected scope and should only be used when those translations are going to be reviewed again.

Run a coverage report without contacting a translation service:

python .github/scripts/build_web_docs_i18n.py \
  --languages de,fr,it,pt,sk,sv \
  --section docs \
  --check

Translate a small resumable batch locally:

python .github/scripts/build_web_docs_i18n.py \
  --languages de \
  --section docs/monitor \
  --max-files 5

Machine translation is a starting point, not the final authority. Native contributors can edit the generated JSON normally; later incremental runs will keep their non-empty wording unchanged.


Workflow: convert a page from hard-coded English to i18n (case B)

This is the more involved path. Use the pilot app/[locale]/docs/monitor/page.tsx as the reference.

High-level changes to the .tsx

  1. Make the page an async Server Component that receives params: Promise<{ locale: string }>.
  2. Add generateStaticParams() that returns one entry per locale (see routing.locales in i18n/routing.ts).
  3. If the page exports metadata, replace it with generateMetadata() that reads from getTranslations({ namespace: '<page>.meta' }).
  4. Call setRequestLocale(locale) near the top of the component body.
  5. Call await getTranslations({ locale, namespace: 'docs.<section>.<page>' }) and rename it to t.
  6. Replace every English string in JSX with t('key') (plain text) or t.rich('key', { code, strong, em, link, ... }) for strings with inline tags.
  7. For lists / tables of structured items (e.g. table rows, nav items), pull the array from getMessages() and iterate.

JSON file structure

Use the pilot's messages/en/docs/monitor/index.json as the template. The high-level shape is:

{
  "meta": {
    "title": "...",
    "description": "...",
    "ogTitle": "...",
    "ogDescription": "...",
    "twitterTitle": "...",
    "twitterDescription": "..."
  },
  "header": {
    "title": "...",
    "description": "...",
    "section": "..."
  },
  "<section1>": { ... },
  "<section2>": { ... }
}

Group keys by the section they appear in. Keep nesting shallow (2-3 levels max) so the JSON stays readable for translators.

Rich-text placeholder tags

When a paragraph contains inline elements like <code> or <strong>, encode them in the JSON exactly as you want them to appear — but remember they're placeholders, not real HTML. The .tsx registers React renderers for each tag name in the call:

t.rich("intro", {
  code: (chunks) => <code>{chunks}</code>,
  strong: (chunks) => <strong>{chunks}</strong>,
  link: (chunks) => (
    <Link href="/docs/monitor" className="text-blue-600 hover:underline">
      {chunks}
    </Link>
  ),
})

Translators just have to keep the tags in roughly the same positions. Don't introduce new tag names in the JSON unless the .tsx also registers a renderer for them.


What's intentionally NOT translatable

  • Code blocks (inside CopyableCode etc.) — only the comment lines (# Comment here) should be moved to JSON if they're explanatory. Don't translate code keywords, command names or paths.
  • URLs and paths (/docs/monitor, https://github.com/...) — these stay identical across locales.
  • External link labels like "GitHub", "ProxMenux" — proper nouns and product names stay in their original form.
  • Variable names, environment variables, file names (auth.json, MONITOR_VERSION, /var/log/journal/) — never translated.

Language style: Spanish (es)

The same criterion applies to the Monitor (AppImage/messages/es), the terminal menus (lang/es.json) and the documentation (web/messages/es). It is Spain Spanish, written with rigour but without formality.

  • Address the reader as tú, never usted: «Introduce la ruta», «Comprueba la conexión», «Déjalo vacío», «tus backups».
  • Buttons, menu options, tab names and short action labels use the infinitive, without address: «Guardar», «Eliminar la GPU de la configuración», «Activar 2FA», «Añadir GPU a un LXC». A full instruction sentence uses tú: «Selecciona una opción».
  • Status messages say what happened: «Backup creado», «No se necesitan cambios».
  • Spain vocabulary: pulsar (not presionar), introducir (not ingresar), volver (not regresar), elegir or seleccionar, hacer clic, añadir (not agregar), fallo (not falla as a noun), usuario (not operador), monitorización (not monitoreo). The key is Intro, not Enter or Entrar.
  • Destructive actions use eliminar, not quitar.
  • Activate and deactivate: an action or switch of the user is activar/desactivar; a technical state can stay habilitado/deshabilitado.
  • Kept in English: backup, snapshot, host, pool, slot, datastore, the application categories of OCI manager Apps and Helper-Scripts, product names, and the labels of other software as they appear on its screen (for example Datacenter > Storage in the Proxmox web interface).
  • Translated: pila (stack), puente (bridge), pasarela (gateway) when they are common nouns; Secure Gateway is a product name and stays as it is.
  • One canonical form for repeated phrases, for example «Pulsa Intro para volver al menú…».
  • Documentation quotes screen texts exactly as the Spanish interface shows them, and page titles end in «Documentación de ProxMenux».

Adding a new locale

If you want to add a language that isn't in the project yet:

  1. Create messages/<locale>/common.json and the page catalogs. The automated builder can provide the initial baseline when explicitly dispatched. It does not run on merge: changing English and its translations together must not trigger machine retranslation of the reviewed text. Offline validation still runs automatically for website changes.
  2. Review the shared navigation and a representative set of documentation pages with a native speaker.
  3. Add the locale code to routing.ts only when the locale is ready to be exposed publicly:
    export const routing = defineRouting({
      locales: ["en", "es", "fr"],  // add your code here
      defaultLocale: "en",
      localePrefix: "always",
    })
    
  4. Add its human-readable label to the language switcher.
  5. Continue reviewing individual pages one PR at a time.
  6. Mention in your first PR that you're seeding the locale so reviewers know to expect a follow-up batch.

FAQ

My translated page still shows English text. Why?

Three common causes:

  1. The page file is case A (uses getTranslations()) and your JSON path doesn't match the namespace it expects. Check the page's getTranslations({ namespace: '...' }) call and mirror it in your folder structure.
  2. The page is case B (still hard-coded). It needs the .tsx refactored first — that part is a developer task, not a translator task.
  3. The dev server is serving cached output. Stop it (Ctrl+C), remove web/.next/, and run npm run dev again.

What about translations of the Monitor (the AppImage), not just the docs?

This guide only covers the public documentation site in web/. The Monitor dashboard uses the separate catalogs under AppImage/messages/ and its own translation workflow.

Where can I see what's missing?

Compare the directory trees:

diff -rq web/messages/en web/messages/es

Anything listed as "Only in en" still needs a Spanish version. (Swap es for your locale.)

My PR conflicts with another translator's PR.

Because we keep one page per PR, the only realistic conflict zone is messages/<locale>/common.json (shared strings) or app/[locale]/docs/<section>/page.tsx (refactored at the same time). Rebase on develop and re-resolve; ping the other contributor in the PR thread if the merge is non-obvious.