Full rewrite of the docs site under app/[locale]/ with next-intl in localePrefix:"always" mode. Every page now exists at both /en/<path> and /es/<path>; the root / shows a meta-refresh + JS redirect to /<defaultLocale>/ so GitHub Pages serves something on the apex URL. Highlights: - 107 doc pages migrated to file-per-page JSON namespaces under messages/en/ and messages/es/. Spanish content is fully translated (no copy-of-English placeholders). - New documentation for the Active Suppressions section in the Settings tab and the per-event Dismiss dropdown in the Health Monitor modal. - New screenshots: dismiss-duration-dropdown.png and an updated health-suppression-settings.png. - Pagefind integrated for client-side search; index is built on every CI deploy (not committed). - RSS feeds: per-locale at /<locale>/rss.xml plus root /rss.xml for backward compat. - Removed the dead app/[locale]/guides/[slug]/ route — every guide now has its own static page and no markdown source remains. - Fixed orphan link /guides/nvidia -> /guides/nvidia-manual in docs/hardware/nvidia-host. - Removed obsolete components (footer2, calendar, drawer). Verified locally with `npm ci && npm run build`: 2804 files in out/, 231 pages indexed by pagefind, root redirect intact, both locale roots and the new Active Suppressions docs render OK.
10 KiB
Contributing translations
The ProxMenux documentation site is built with Next.js (App Router) and serves every page under two URLs:
/en/<path>— English, the source of truth/es/<path>— Spanish, in progress
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 already renders at both/en/...and/es/.... - Locale-aware navigation via
@/i18n/navigation(<Link>,useRouter,usePathname). Use these instead ofnext/linkfor 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_MESSAGEplaceholder.
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
└── app/[locale]/
└── docs/
└── monitor/
└── page.tsx # the page itself
Naming convention
common.jsonandindex.jsonat 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>.jsonat 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.jsonis consumed asgetTranslations({ 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/es/<same-path>/(Spanish), 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:
- Refactor the page to read its strings from a JSON file (this part
touches the
.tsx). - 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
cd web
npm run dev
Open http://localhost:3000/<locale>/<page-path> and check that:
- All your translated strings render correctly.
- No
MISSING_MESSAGEtext appears (means a key is in the page's.tsxbut missing from your JSON). - The page still passes
npm run buildcleanly.
5. Open the PR
One page per PR is the convention. Title format:
docs(i18n/<locale>): translate <route>
Examples:
docs(i18n/es): translate /docs/monitordocs(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).
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
- Make the page an async Server Component that receives
params: Promise<{ locale: string }>. - Add
generateStaticParams()that returns one entry per locale (seerouting.localesini18n/routing.ts). - If the page exports
metadata, replace it withgenerateMetadata()that reads fromgetTranslations({ namespace: '<page>.meta' }). - Call
setRequestLocale(locale)near the top of the component body. - Call
await getTranslations({ locale, namespace: 'docs.<section>.<page>' })and rename it tot. - Replace every English string in JSX with
t('key')(plain text) ort.rich('key', { code, strong, em, link, ... })for strings with inline tags. - 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
CopyableCodeetc.) — 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.
Adding a new locale
If you want to add a language that isn't in the project yet:
- Add the locale code to
routing.ts:export const routing = defineRouting({ locales: ["en", "es", "fr"], // add your code here defaultLocale: "en", localePrefix: "always", }) - Create the
messages/<locale>/folder. - Copy
messages/en/common.jsonover and translate it. This is mandatory — without it the navbar and footer fall back to English on every page. - Start translating individual pages one PR at a time.
- 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:
- The page file is case A (uses
getTranslations()) and your JSON path doesn't match the namespace it expects. Check the page'sgetTranslations({ namespace: '...' })call and mirror it in your folder structure. - The page is case B (still hard-coded). It needs the
.tsxrefactored first — that part is a developer task, not a translator task. - The dev server is serving cached output. Stop it (Ctrl+C), remove
web/.next/, and runnpm run devagain.
What about translations of the Monitor (the AppImage), not just the docs?
This guide only covers the public documentation site in web/.
The Monitor's dashboard UI in AppImage/ is a separate project and
not currently i18n-enabled. Translating the Monitor would require a
parallel effort.
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.