Skip to content

Translations

ernolf edited this page Oct 24, 2026 · 2 revisions

🌐 Translations

Nextcloud apps get their translations from Transifex through the Nextcloud translation bot, which pushes them into the repository. Joining that sync is a checklist, the translation request at nextcloud/docker-ci, and most of what it asks the repository to carry follows from what ncmake reads anyway: the app id from appinfo/info.xml, the build outputs from .gitignore. Three targets in the translations module cover the repository side and the source strings. Transifex itself is not touched: no API call and no token, the sync stays with the bot.

Tip

TL;DR: make dev-init once per machine, then make l10n-init PR=1 writes l10n/.gitkeep, .l10nignore and .tx/config, covers them in REUSE.toml and opens the pull request. Once it is merged, make l10n-check validates the setup the way the Nextcloud check of the request does, on every branch the bot syncs. Then invite the bot and open the request, as l10n-init prints. Before that, make l10n-lint finds the source strings the translation team would reject, and l10n-lint FIX=1 rewrites the ones that have a fix.

📝 What l10n-init writes

make l10n-init writes each of the three files the bot needs that is missing, and leaves every file that already exists as it is:

File Content
l10n/.gitkeep empty; the translation tool finds the app by its l10n/ directory
.l10nignore the trees the string extraction must skip: js/ and vendor/ where .gitignore marks them as build output or they exist, vendor-bin/ where it exists
.tx/config the template of the translation request, with the app id as the resource and in the names of the .po and .pot files

The app id in .tx/config is the one from appinfo/info.xml, not the name of the checkout, because that is the name the translation tool gives the .pot file it writes. A resource or file name that differs from it makes the sync fail.

Where the app has a REUSE.toml, the new files join the annotation that already covers one of the sync files, .tx/config or .tx/backport for example. Without such an annotation they get one of their own, under the copyright and license that REUSE.toml states in its header comment. When the header states neither, l10n-init says so and leaves the annotation to you, so make reuse names what is still missing.

Without flags it only writes the files and prints the git add line for them. COMMIT=1 commits them with -s on the branch ncmake/l10n/init without pushing, and PR=1 also pushes and opens the pull request through gh, as the other writing targets do (Workflows explains the flow).

🤝 The steps outside the repository

Two steps of the checklist are settings on GitHub, and l10n-init prints them after the files:

  1. Invite the bot. It pushes the translations with its own account, so for an app outside the nextcloud organisation nextcloud-bot needs write access as a collaborator. l10n-init prints the gh api command that sends the invitation.
  2. Let it past the branch rules. Once it has accepted: the bot pushes its commits directly, without a pull request. Add it to the bypass list of the ruleset that protects main, mode Exempt. Classic branch protection has no such list, so a branch under it cannot take the bot's pushes; see the Nextcloud documentation of the setup.

Then open the translation request and work through its checklist; make l10n-check before that shows whether the repository side holds.

🔍 What l10n-check checks

Before the first sync, Nextcloud runs translations/validateSyncSetup.sh of nextcloud/docker-ci against the repository. That script clones over ssh and cannot run on your machine as it is, so make l10n-check runs its steps instead, on what origin has after a git fetch:

  • The branches are main, master and those .tx/backport on main lists, or without that file the default list of the script, which follows the server's stable* branches and is read from the script at run time. Each counts as far as origin has it.
  • Per branch it checks that .tx/config and l10n/.gitkeep exist and notes a missing .l10nignore. Then it runs the translation tool's prepare-source-validation and validateTranslationFiles.sh over a copy of the branch, in the image the bot itself runs in, ghcr.io/nextcloud/continuous-integration-translations. The validation is the Nextcloud one, not a copy that drifts.

The copy is a full checkout of the branch under build/cache/l10n-check/, because the translation tool writes its source files and translationfiles/ into the app it reads. It is taken through a separate git index, so a file that .gitattributes marks export-ignore is checked too.

On top of the script it reports what makes a sync fail or extract the wrong strings without any message:

  • an .l10nignore that does not exclude js/, vendor/ or vendor-bin/ under the same rule l10n-init applies. A line with a carriage return, trailing blanks or a leading slash is named on its own: the tool compares each line unchanged with paths relative to the app root, so such a line never matches
  • a .tx/config whose resource, file_filter or source_file does not name the app id
  • a .tx/backport entry that is not a stable* branch, or that origin does not have

Findings are marked [!] for what is worth fixing and [i] for what is worth knowing. A [!] or a failed validation makes the target exit non-zero, once every branch is checked. It writes nothing into the checkout and needs podman or docker; RUNTIME=bare stops with an error, since the validation runs in the image.

🧹 What l10n-lint finds

Before the first sync, the Nextcloud translation team reviews the English source strings and locks the resource on Transifex until the ones it rejects are fixed. Much of what it rejects is mechanical: a typographic apostrophe, three dots instead of the ellipsis, a contraction, a number without the plural form. make l10n-lint finds those before the review does.

  • The strings are the ones the sync sends. The translation tool writes its template in the image the bot runs in, for a copy of the working tree with the tracked and untracked files as git lists them, so a string counts before it is committed.
  • Each finding names the string, the rules it breaks with their reason, the fix where a rule has one, and the file and line. The template points the strings of a .vue file and of info.xml at placeholder files, so l10n-lint locates every string in the sources itself: as the literal argument of a t() or n() call, or as the text of an element of appinfo/info.xml. A string it cannot locate is reported all the same, with that note.
  • FIX=1 rewrites every string that has a fix, in the sources, and keeps the line breaks and escapes of the literal. l10n/*.json stays as it is: it comes from Transifex, and a changed source string is a new string there anyway. What has no automatic fix, a leading space or a missing plural form, stays a finding.

[!] marks a must rule, which the review rejects, and makes the target exit 1. [i] marks a should rule, a style call. Without FIX=1 it writes nothing into the checkout; the copy stays under build/cache/l10n-lint/. It needs podman or docker.

Option Effect
FIX=1 rewrite every string that has a fix, in the sources
L10N_RULES=<file> a rule file of one's own instead of the shipped one
JSON=<file> write the result as JSON to <file> as well
ARGS=--self-test only test the rule file against its examples

📜 The rule file

The rules are not code. lib/l10n-rules.json holds every rule as one entry, and the analyser lib/l10n-lint.mjs reads it and carries no rule of its own. The file comes with the analyser through the per-machine cache, so a rule merged in ncmake reaches every app with the next refresh.

Today it holds R1 to R9, the Dos and Don'ts and Correct plurals of the developer manual:

Rule Finds Fix
R1 ’ or ´ as an apostrophe inside a word '
R2 three dots … with a non-breaking space (U+00A0) before it
R3 a plain space before … the non-breaking space
R4 Don't, Won't Do not, Will not
R5 Can not Cannot
R6 id, ids, but not the placeholder {id} ID, IDs
R7 a leading or trailing space, mostly a concatenated string none, one string with placeholders
R8 a varying number in t(): %d, %n, {count}, {seconds} and the like none, n() with %n
R9 n() without %n in either form none, %n or t()

One rule

{
  "id": "R5",
  "weight": "must",
  "source": "guidelines",
  "note": "Cannot is one word",
  "match": "\\b([Cc])an not\\b",
  "replace": "$1annot",
  "examples": [
    { "bad": "Can not save", "good": "Cannot save" },
    { "bad": "It can not be saved", "good": "It cannot be saved" }
  ],
  "ok": ["Cannot save"]
}
Key Meaning
id required R and a number; a number is never reused
weight required must: the review rejects it, [!], exit 1; should: a style call, [i]
source required a key of sources at the top of the file, today guidelines and plurals; a new source is one more line there
note required the reason, printed with every finding
match one of three a regular expression (JavaScript, with the u flag) that finds the mistake
replace with match the replacement, $1 for a group; without it the rule only reports
patterns one of three a list of { "match", "replace" } for one rule with several spellings, as in R4
require one of three a regular expression the string has to contain; it is found when no form of the string does, as %n in R9
calls optional ["t"] or ["n"]: only strings of t(), or only plurals of n(); without it both
examples required at least one { "bad", "good" }; good is required when the rule has a replacement
ok optional strings the rule has to leave alone, for the edge cases

In JSON every backslash of a regular expression is doubled, \b is written "\\b", and a non-breaking space is "\u00a0". The rules run in the order of the file, each on what the fixes before it left: R2 turns Loading... into Loading … with the non-breaking space, so R3 never sees a plain space there.

The examples are tests

The file is checked whole before a rule judges an app. An unknown key, a duplicate id or a pattern that does not compile stops the run with the place in the file. Then every rule is held against its own examples:

  • every bad string is found,
  • with a replacement, the fix of bad gives exactly good,
  • neither good nor any ok string is found.

One failing example stops the run and is named. The Smoke workflow of ncmake runs the same self-test on every pull request.

Adding a rule

More rules come from the review practice of the translation team. They are collected in ncmake#257, and comments with proposals or corrections are welcome there, one line per rule is enough. A rule can also go straight in as a pull request against lib/l10n-rules.json: the entry at the end with the next free number and its examples, checked in an ncmake checkout with Node 18 or newer:

node lib/l10n-lint.mjs --self-test

🌿 Maintenance lines

The bot syncs a release line named after the app version, stable0.7 for example, only when .tx/backport on main lists it, and that list then replaces the server's branch names. make stable-branch offers the pull request that adds the new branch (Releasing has the details). Once it is merged, make l10n-check checks the new line with the others, and the ruleset bypass for the bot has to cover the new branch as well.

❓ FAQ

Does ncmake run the sync? No. The bot syncs on Nextcloud's side; ncmake prepares the repository and checks it.

Can I edit the files after l10n-init? Yes. They are ordinary committed files, and l10n-init never overwrites one. l10n-check holds an edited file against the same rules.

My app commits its built js/. Does it still go into .l10nignore? Yes. The extraction reads every file it is not told to skip, the bundle included. That is why js/ counts whether it is git-ignored or committed.

Can I use rules of my own? Yes: make l10n-lint L10N_RULES=<file> reads that file instead of the shipped one, under the same checks. A rule that would help every app is better proposed in ncmake#257.

Why does l10n-check pull an image? The validation runs the translation tool and gettext in the versions the bot uses. The image is pulled and removed like every other ncmake image, see How ncmake understands your app.

Clone this wiki locally