Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 44 additions & 0 deletions .github/skills/complete-i18n-translations/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
name: complete-i18n-translations
description: "Use when adding, changing, or reviewing i18n strings, translation keys, or src/i18n/lang/*.json files. Ensure every supported language is complete before finishing."
---

# Complete i18n translations

When changing user-facing text or translation keys, complete all supported
translations in the same change. Do not leave missing keys, empty values, or
English placeholders in other locales.

## Supported languages

The translation files are in `src/i18n/lang/`. The current supported languages
are `de`, `en`, `es`, `fr`, `it`, `nl`, and `no`. Check the files in that
directory, `src/i18n/index.js` and `src/i18n/datetime.js` if the supported
locales may have changed; do not rely on a hard-coded list if the repository
has moved on.

## Procedure

1. Add every new key, with an accurate, non-empty translation, to every
supported locale in `src/i18n/lang/<locale>.json`. When changing a string,
update all locale versions of that key. Preserve nested JSON structure,
placeholders (such as `{name}`), plural forms (`singular | plural`), and
existing terminology.
2. When removing a key from the code, remove it from every locale.
3. When adding a new language, add its `lang/<locale>.json` file and register
it in both `src/i18n/index.js` and `src/i18n/datetime.js`.
4. If a translation is uncertain, do not silently copy English into another
locale as a placeholder. Ask for a translation decision or clearly flag
the unresolved language to the user.
5. Run `npm run i18n:check` and resolve every reported issue. This check
validates key parity, non-empty string values, locale registration, and
JSON validity. It is required before reporting i18n work as complete.

## Completion criteria

- All translation files contain the changed keys in every supported language.
- No translation value is empty or a placeholder copied from another locale
without an explicit decision.
- Every supported locale is registered in `src/i18n/index.js` and
`src/i18n/datetime.js`.
- `npm run i18n:check` passes.
15 changes: 15 additions & 0 deletions .github/workflows/dev_opened_or_updated.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ env:
permissions: {}

jobs:
i18n:
permissions:
contents: read
runs-on: [self-hosted, common]
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v7
with:
node-version: "24"
check-latest: true
- name: Check missing translations
run: npm run i18n:check

build-and-push:
permissions:
contents: read
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,17 @@ When a new stable version of the Viewer is released, the `release` branch is aut

### Internationalization

When adding code in `develop` branch it will trigger an update on POEditor.
Translations live in `src/i18n/lang/<lang>.json`. The supported languages are the ones available in this directory,
and each of them must be registered in `src/i18n/index.js` and `src/i18n/datetime.js`.

The CI fails on pull requests if a translation is missing: every key must exist, with a non-empty value,
in all supported languages.

Run the check locally with:

```bash
npm run i18n:check
```

## License

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"lint": "eslint --fix",
"test:unit": "vitest run",
"test:e2e": "cypress open",
"i18n:check": "node scripts/i18n-check.mjs",
"release": "semantic-release"
},
"dependencies": {
Expand Down
115 changes: 115 additions & 0 deletions scripts/i18n-check.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
#! /usr/bin/env node

/* eslint-disable */

/**
* Check that every translation key exists, with a non-empty value, in every
* supported language.
*
* Supported languages are the ones available in `src/i18n/lang`.
* Exit with code 1 if anything is missing.
*/

import fs from "node:fs";
import path, { dirname } from "node:path";
import { fileURLToPath } from "node:url";

const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);

const ROOT_DIR = path.join(__dirname, "..");
const LANG_DIR = path.join(ROOT_DIR, "src/i18n/lang");
const I18N_INDEX_FILE = path.join(ROOT_DIR, "src/i18n/index.js");
const DATETIME_FILE = path.join(ROOT_DIR, "src/i18n/datetime.js");
const LANG_FILE_REGEXP = /^([a-z]{2})\.json$/;
const IS_GITHUB_ACTIONS = process.env.GITHUB_ACTIONS === "true";

const LANGUAGES = fs
.readdirSync(LANG_DIR)
.map(file => file.match(LANG_FILE_REGEXP)?.[1])
.filter(Boolean)
.sort();

const errors = [];

function report(file, message) {
errors.push({ file: path.relative(ROOT_DIR, file), message });
}

function flatten(obj, prefix = "", acc = new Map()) {
for (const [key, value] of Object.entries(obj)) {
const fullKey = prefix ? `${prefix}.${key}` : key;
if (value !== null && typeof value === "object" && !Array.isArray(value)) {
flatten(value, fullKey, acc);
} else {
acc.set(fullKey, value);
}
}
return acc;
}

/**
* Every key found in any language must exist with a non-empty string value
* in all languages.
*/
function checkLangFiles() {
const translations = {};
for (const lang of LANGUAGES) {
const file = path.join(LANG_DIR, `${lang}.json`);
try {
translations[lang] = flatten(JSON.parse(fs.readFileSync(file, "utf-8")));
} catch (error) {
report(file, `Invalid JSON: ${error.message}`);
}
}

const allKeys = new Set(
Object.values(translations).flatMap(keys => [...keys.keys()])
);

for (const [lang, keys] of Object.entries(translations)) {
const file = path.join(LANG_DIR, `${lang}.json`);
const missing = [...allKeys].filter(key => !keys.has(key)).sort();
const empty = [...keys]
.filter(([, value]) => typeof value !== "string" || value.trim() === "")
.map(([key]) => key)
.sort();
if (missing.length > 0) {
report(file, `${missing.length} missing key(s): ${missing.join(", ")}`);
}
if (empty.length > 0) {
report(file, `${empty.length} empty or non-string value(s): ${empty.join(", ")}`);
}
}
}

/**
* Check that every language is registered in the given source file.
*/
function checkRegistration(file, pattern) {
const source = fs.readFileSync(file, "utf-8");
const missing = LANGUAGES.filter(lang => !pattern(lang).test(source));
if (missing.length > 0) {
report(file, `Language(s) not registered: ${missing.join(", ")}`);
}
}

// ~~~{ Main }~~~

checkLangFiles();
checkRegistration(I18N_INDEX_FILE, lang => new RegExp(`import\\s+${lang}\\s+from\\s+["']\\./lang/${lang}\\.json["']`));
checkRegistration(DATETIME_FILE, lang => new RegExp(`\\b${lang}\\s*:`));

if (errors.length > 0) {
for (const { file, message } of errors) {
if (IS_GITHUB_ACTIONS) {
console.log(`::error file=${file}::${message}`);
} else {
console.error(`✖ ${file}: ${message}`);
}
}
console.error(`\ni18n check failed: ${errors.length} error(s) for languages [${LANGUAGES.join(", ")}].`);
process.exit(1);
}

console.log(`i18n check passed for languages [${LANGUAGES.join(", ")}].`);
Loading
Loading