feat(i18n): let translators edit the catalogues in the browser with Fink

Add an inlang project so Fink opens the repository directly:
English as the source, the nine shipped locales, and the
message-format plugin pinned to 4.5.5 reading messages/{locale}.json.
That plugin keeps the nested keys and {placeholder} tokens as they
are; the i18next and next-intl plugins Fink also supports either
rewrite numeric-keyed objects as arrays or misread quotes around a
placeholder as ICU escapes.

Checked against these files with the inlang CLI and SDK: all nine
locales load with no errors and no findings, a load and save without
edits leaves every file byte-identical, and an edit changes exactly
the edited lines. inlang's working files beside settings.json ignore
themselves in git and are kept out of the format check.

CONTRIBUTING gains the browser workflow (fork, push, pull request),
what a reviewer checks, and that machine translation is a draft a
reader of the language goes through before the pull request; the
new-locale steps now name the real locale list. A test keeps the
project's locales equal to the shipped ones and the plugin pinned.
This commit is contained in:
Marc-André Bombeck committed 2026-10-22 15:03:16 +02:00
1 parent dc9beaefb7
commit 2d16183b58
5 files changed
+95 -11

No files matched your search

+5
View File
@@ -29,3 +29,8 @@ scripts/bench/results
# Golden copies of the backup file and the restored account, written by the
# parity test itself (BACKUP_PARITY_WRITE=1), so a rewrite diffs cleanly.
tests/integration/__golden__
# inlang writes its working files (cache, metadata, README) next to the
# project settings when its CLI or SDK runs; only settings.json is tracked.
project.inlang/*
!project.inlang/settings.json
+23 -4
View File
@@ -134,6 +134,26 @@ To suggest a fix or improvement:
3. Edit `messages/<locale>.json`. Keep the JSON valid; the i18n integrity test will flag missing or extra keys against `en.json` before CI accepts the PR.
4. Open a PR titled `i18n(<locale>): <area>`. Describe what was unclear, awkward, or wrong in the previous wording.
### Translating in the browser
Every locale can be edited in [Fink](https://fink.inlang.com/?repo=MBombeck/HealthLog&project=project.inlang), inlang's browser editor, without cloning the repository. Fink reads `project.inlang/settings.json`, which points it at `messages/<locale>.json`, shows each message next to its translations, and flags strings that are missing or use other placeholders than English.
1. Open the link and sign in with GitHub.
2. Without write access to this repository, choose **Fork**. Fink asks you to install the Inlang GitHub App on your own account so it can push to your fork.
3. Edit, then choose **Push**. Fink commits to the fork; **Open pull request** takes you to GitHub's compare page, where you submit the change against `main`.
Fink writes the same nested JSON this repository uses and changes only the lines you edited. A string you add for a key the locale did not have yet goes to the end of its section instead of the position it has in `en.json`, which is harmless.
What a reviewer checks on a translation pull request:
- Only `messages/*.json` changed, and only the strings the description names.
- Every `{placeholder}` of the English string is still there and spelled the same. The i18n integrity test fails the build otherwise.
- Medical terms are the ones the language's clinical guidelines use, and the register matches the rest of the locale (`du` in German, `você` in Brazilian Portuguese).
- Words the app or the Telegram bot matches literally stay as they are: slash commands, `taken <name>`, and the unit symbols.
- Machine translation is fine as a first draft, but someone who reads the language has gone through every string before the pull request is opened, and the description says so.
To run the checks Fink shows on a local checkout: `npx @inlang/cli@3.4.4 check --project ./project.inlang`. It reads the files and changes nothing.
### Adding a new locale
If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
@@ -141,9 +161,8 @@ If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
1. Open an issue first using the **Translation request** template. We'll confirm the locale is in scope and decide whether it joins the AI-initial-community-maintained tier or the maintained tier.
2. Copy `messages/en.json` to `messages/<locale>.json`.
3. Translate. AI assistance is welcome as a starting point but the PR description should note whether it's an AI-initial pass or a fully human-reviewed one — that information drives the banner state.
4. Add the locale to the supported list in `src/lib/i18n/locales.ts` (or wherever the registry lives — grep `SUPPORTED_LOCALES`).
5. The Coach system prompts at `src/lib/ai/prompts/coach-prompt.ts` and `src/lib/ai/prompts/insight-generator.ts` and the doctor-report PDF strings need translation too. Include those in the same PR.
6. Run `pnpm test -- i18n-integrity` locally before submitting.
4. Add the locale to `locales` in `src/lib/i18n/config.ts` and to `locales` in `project.inlang/settings.json`, then run `pnpm typecheck`. Every per-locale table that still lacks the new locale fails to compile, and that list is the work: date and number formatting, the reply-language footers of the Coach and insights prompts, the outbound safety banks, the prose-grounding patterns and the no-key fallback copy. Include those in the same PR.
5. Run `pnpm test` before submitting. The i18n guards hold the new bundle to the English keys and placeholders.
### Style guide for translators
@@ -156,7 +175,7 @@ If you want to introduce a locale that isn't shipped (e.g. `sv`, `cs`):
### Long-term
When the community grows beyond ad-hoc PRs, we'll spin up a self-hosted [Weblate](https://weblate.org/) instance. That replaces the JSON-PR workflow with a web UI but keeps every translation under our own roof — no third-party telemetry. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
Fink covers browser editing today. It edits the files in this repository directly and keeps no copy of the translations anywhere else. If the community outgrows it, a self-hosted [Weblate](https://weblate.org/) instance remains the fallback. Tracking issue: `v1.6 — Weblate self-hosted on edge-01`.
## Code of Conduct
+8 -7
View File
@@ -109,13 +109,14 @@ Next.js 16 (App Router, React Server Components), TypeScript strict, PostgreSQL
## Documentation
| | |
| ---------------------- | ------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| | |
| ---------------------- | ------------------------------------------------------------ |
| User and operator docs | [docs.healthlog.dev](https://docs.healthlog.dev) |
| Release history | [CHANGELOG.md](CHANGELOG.md) |
| API contract | [`docs/api/openapi.yaml`](docs/api/openapi.yaml) |
| Security policy | [SECURITY.md](SECURITY.md) |
| Contributing | [CONTRIBUTING.md](CONTRIBUTING.md) |
| Translating | [CONTRIBUTING.md#translations](CONTRIBUTING.md#translations) |
## Status
+11
View File
@@ -0,0 +1,11 @@
{
"$schema": "https://inlang.com/schema/project-settings",
"baseLocale": "en",
"locales": ["en", "de", "fr", "es", "it", "pl", "ko", "nl", "pt-BR"],
"modules": [
"https://cdn.jsdelivr.net/npm/@inlang/plugin-message-format@4.5.5/dist/index.js"
],
"plugin.inlang.messageFormat": {
"pathPattern": "./messages/{locale}.json"
}
}
@@ -0,0 +1,48 @@
/**
* The inlang project that the Fink browser editor opens.
*
* `project.inlang/settings.json` names the locales Fink offers and the files it
* writes. A locale added to the i18n config but not here never shows
* up in the editor, and one listed here but not shipped would invite
* translations of a bundle the app never loads. The plugin is pinned to an
* exact version: it decides how the nested JSON is written back, and the
* round trip was verified against that version only (an unpinned URL would
* pick up a new serialiser on its own).
*/
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, it } from "vitest";
import { defaultLocale, locales } from "@/lib/i18n/config";
const SETTINGS = join(__dirname, "../../project.inlang/settings.json");
interface InlangSettings {
baseLocale: string;
locales: string[];
modules: string[];
"plugin.inlang.messageFormat"?: { pathPattern?: string };
}
const settings = JSON.parse(readFileSync(SETTINGS, "utf8")) as InlangSettings;
describe("inlang project settings", () => {
it("offers exactly the shipped locales, English as the source", () => {
expect(settings.baseLocale).toBe(defaultLocale);
expect([...settings.locales].sort()).toEqual([...locales].sort());
});
it("points the message-format plugin at the catalogues", () => {
expect(settings["plugin.inlang.messageFormat"]?.pathPattern).toBe(
"./messages/{locale}.json",
);
});
it("pins the one plugin to an exact version", () => {
expect(settings.modules).toHaveLength(1);
expect(settings.modules[0]).toMatch(
/^https:\/\/cdn\.jsdelivr\.net\/npm\/@inlang\/plugin-message-format@\d+\.\d+\.\d+\/dist\/index\.js$/,
);
});
});