--- description: Install the git8n CLI, sign in, read and propose translations from a terminal, a script or CI. --- # CLI `git8n` is a command line client of the API. It reads and proposes translations from a terminal, a script or a CI job. It also starts the [MCP server](./mcp.md). The CLI goes through `/i18n`, as the page does. The API checks the rights of the account on each call: the CLI can do no more than the account in the browser. ## Install it The CLI is the npm package [`@git8n/cli`](https://www.npmjs.com/package/@git8n/cli). It installs the command `git8n`, and needs Node 22 or later. ```sh npm install --global @git8n/cli ``` `npx @git8n/cli ` runs it with no install. ## Sign in ```sh git8n login --api https://git8n.example.com ``` The terminal shows a code and opens the page `/device` of the editor. Compare the code, then choose **Approuver**. The terminal then holds a session of your account for 14 days. [Access tokens](./tokens.md) tells how this works. The CLI keeps the token in `~/.config/git8n/credentials.json` (`$XDG_CONFIG_HOME` if it is set). Only your account can read this file. The address of the last `login` becomes the default: the next commands need no `--api`. `git8n logout` ends the session on the API and deletes the token of this computer. `git8n whoami` shows the account and what it may do in each repository. In a CI job, do not sign in. Create an access token on the page **Jetons d'accès**, and set two variables: ```sh export GIT8N_API_URL=https://git8n.example.com export GIT8N_API_TOKEN=g8n_… ``` ## Choose the repository and the language A command that reads keys works on one repository and one language: - **The repository** is the one of `--repo `, else of `GIT8N_REPO`, else the one that a GitHub remote of the current folder names. If the folder has no GitHub remote and the account opens one repository only, the CLI takes it. A folder of a repository that the account does not open stops the command. - **The language** is the one of `--lang`, else the only target language that the account can open in this repository. If the choice is not clear, the CLI stops with exit status 2 and lists the choices. ## Read ```sh git8n status # The progress of each language git8n keys --missing # The keys with no translation git8n keys --state review --values git8n get common:hello.title # One key, with its neighbours git8n search "mot de passe" # The keys whose id, source or translation holds a text git8n proposals # The batches that wait for a reviewer ``` A key is named `:`: the locale file, a colon, and the dotted path, for example `common:hello.title`. A long list shows 100 rows. The last line on stderr gives the `--cursor` of the next page. ## Propose `apply` reads a JSON object of translations from a file, or from stdin with `-`: ```sh echo '{"common:hello.title": "Hello", "common:bye": "Goodbye"}' \ | git8n apply - --lang en --dry-run ``` The input can also put the keys under their file: `{"common": {"hello": {"title": "Hello"}}}`. With `--file common`, the keys of that file only: `{"hello.title": "Hello"}`. `apply` reads the catalogue, then proposes the texts that differ from the translation now, as **one** commit on the branch `i18n/`. A reviewer then publishes them in the editor. `--dry-run` shows the plan and sends nothing. The CLI refuses the whole input, and sends nothing, if: - a key is not a key of the source; - a key is in a file that has no version in the language; - a text is empty. To leave a key empty on purpose, mark it in the editor. A key to review whose text stays the same is not proposed: the file would not change, and the key would stay to review. `apply` lists these keys. A reviewer keeps them in the editor. A proposal carries the revision of the source that the texts answer (`readAt`). By default, `apply` sends the revision it reads. If the texts answer an older read, pass its `commit` with `--read-at`: a publication then refuses a key whose source changed since. ## Translate with an LLM ```sh git8n prompt translate --to en > prompt.md ``` The prompt lists the missing keys, 50 at a time, with their source text and the keys beside them. It asks for one JSON object, and it ends with the `apply` command to run with the answer, `--read-at` included. With `--state review`, it asks again for the translations whose source changed, and shows the old source. ## For a script Each command takes `--json`. The answer is then one JSON document on stdout: `{"data": …}`, or `{"error": {"code", "message", "details"}}`. A script reads the exit status and the code, not the message. [CLI reference](../reference/cli.md) gives each command, each field and each code. ## In CI `git8n check` reads the locale files of the clone, with no server and no token. It fails on a file that does not parse, or on a translation that loses a placeholder. A missing or empty key is a warning: `--max-warnings 0` makes it fail too. ```yaml name: i18n on: pull_request # The check reads the clone. It needs no other right, and no secret. permissions: contents: read jobs: check: runs-on: ubuntu-latest steps: - uses: actions/checkout@v5 - uses: actions/setup-node@v5 with: node-version: 24 # Pin the version: a new version cannot change the check by itself. - run: npx @git8n/cli@0.1.1 check --reporter github ``` `--reporter github` writes one annotation for each problem, on the file of the pull request. The other reporters are `pretty` and `json`: there is no reporter for the job summary. [CLI reference](../reference/cli.md#check) gives every option and every rule. --- description: 'Connect an agent to the git8n MCP server in each client: what it reads, what it proposes, and what it never does.' --- # MCP server `packages/mcp` holds an MCP server. With it, an agent reads the translations and proposes texts, with no screen and no access to GitHub. The server translates nothing. It answers the catalogue, and the agent writes the words. It does not publish either: a person reviews and publishes. ## How it works The server speaks MCP over stdio. Each tool calls the HTTP API of the editor, under `/i18n`, with the token of one account. The API checks the rights of that account on each call: its repositories, its languages and its role. The server never reaches GitHub or the database, so it has no write path that skips these checks. ``` agent │ stdio ▼ MCP server │ HTTPS, Authorization: Bearer ▼ API /i18n checks the rights of the account │ GitHub App ▼ repository ``` ## Configure it The server needs two variables: | Variable | What it is | | ----------------- | ------------------------------------------------------------------- | | `GIT8N_API_URL` | The address of the editor, for example `https://git8n.example.com`. | | `GIT8N_API_TOKEN` | An access token of the account that the agent acts as. | The server sends the token as `Authorization: Bearer `. Create the token in the editor: account menu, then **Jetons d'accès**. [Access tokens](./tokens.md) tells how, and what a token can and cannot do. Give the agent its own token, so that you can revoke it alone. ## Connect it to an agent `npx -y @git8n/cli mcp` starts the server, with Node 22 or later. It takes the session of `git8n login` on the same computer, or the variables `GIT8N_API_URL` and `GIT8N_API_TOKEN`. [CLI](./cli.md#sign-in) tells how to sign in. ### Let your agent do it Copy this prompt into your agent. It tells the agent how to sign in, add the server to its client and check the connection, and which commands do not exist. ### Add it by hand Each tab adds the server for the person, in all their projects. A file in a project, such as `.cursor/mcp.json` or `.vscode/mcp.json`, adds it to that project only. With a `git8n login` on the same computer, leave out the variables. ::: code-group ```sh [Claude Code] claude mcp add --scope user git8n -- npx -y @git8n/cli mcp # With an access token and no login. The name comes before --env: # --env takes every KEY=value that follows it. claude mcp add --scope user git8n \ --env GIT8N_API_URL=https://git8n.example.com \ --env GIT8N_API_TOKEN=g8n_… \ -- npx -y @git8n/cli mcp ``` ```json [Cursor] { "mcpServers": { "git8n": { "type": "stdio", "command": "npx", "args": ["-y", "@git8n/cli", "mcp"], "env": { "GIT8N_API_URL": "https://git8n.example.com", "GIT8N_API_TOKEN": "${env:GIT8N_API_TOKEN}" } } } } ``` ```sh [VS Code] code --add-mcp '{"name":"git8n","type":"stdio","command":"npx","args":["-y","@git8n/cli","mcp"]}' # Or in .vscode/mcp.json, for one workspace: # { # "servers": { # "git8n": { "type": "stdio", "command": "npx", "args": ["-y", "@git8n/cli", "mcp"] } # } # } ``` ```toml [Codex] [mcp_servers.git8n] command = "npx" args = ["-y", "@git8n/cli", "mcp"] env = { GIT8N_API_URL = "https://git8n.example.com" } env_vars = ["GIT8N_API_TOKEN"] ``` ```json [Claude Desktop] { "mcpServers": { "git8n": { "command": "npx", "args": ["-y", "@git8n/cli", "mcp"], "env": { "GIT8N_API_URL": "https://git8n.example.com", "GIT8N_API_TOKEN": "g8n_…" } } } } ``` ```txt [Others] transport stdio command npx args -y @git8n/cli mcp env GIT8N_API_URL the address of the editor GIT8N_API_TOKEN an access token, if there is no git8n login ``` ::: The files: - **Cursor**: `~/.cursor/mcp.json`, or `.cursor/mcp.json` for one project. - **VS Code**: `code --add-mcp` writes the configuration of the user profile. `.vscode/mcp.json` is for one workspace. - **Codex**: `~/.codex/config.toml`, or `codex mcp add git8n -- npx -y @git8n/cli mcp`. - **Claude Desktop**: `~/Library/Application Support/Claude/claude_desktop_config.json` on macOS, `%APPDATA%\Claude\claude_desktop_config.json` on Windows. Quit and start the app again after a change. Do not commit a token in a project file. In Cursor, `${env:GIT8N_API_TOKEN}` reads it from your shell. In Codex, `env_vars` forwards it from your shell. ### Check the connection | Client | Check | | -------------- | ------------------------------------------------------------------------------------------ | | Claude Code | `claude mcp list` shows `git8n` as connected. In a session, `/mcp` lists its tools. | | Cursor | The server shows in **Customize**. Its errors are in the panel Output, under « MCP Logs ». | | VS Code | Ask the agent to call a tool of `git8n`, then expand the tool call in the conversation. | | Codex | `codex mcp list` shows `git8n`. In the terminal UI, `/mcp` lists the active servers. | | Claude Desktop | Quit and start the app again. The server shows under « Connectors ». | Then ask the agent to call `i18n_me`. It answers the account of the token or of the login, and what it may do. An error answers a code, for example `G8N2001` when nobody is signed in: [Error codes](../reference/errors/index.md) gives each one. The server writes only the protocol on stdout. It writes its errors on stderr. If a variable is missing, it stops with a message that names it. ## The tools | Tool | What it answers | | ------------------- | ---------------------------------------------------------------------------------- | | `i18n_repositories` | The repositories, with their source language and their targets. | | `i18n_me` | What the account of the token may do: its repositories, roles and languages. | | `i18n_keys` | The keys of a language, by state and by file, with the `commit` they were read at. | | `i18n_key` | One key: the source text, the target text and its state. | | `i18n_propose` | Writes a whole batch as **one** commit on `i18n/`. | | `i18n_proposals` | The batches that wait on a language. | [MCP tools](../reference/mcp-tools.md) gives the input and the answer of each tool. ## A typical session 1. `i18n_me`: find the repositories and the languages that the account may write. 2. `i18n_keys` with `state: "missing"`: get the first page of keys to translate, and its `commit`. 3. `i18n_key` for a key that needs context: the file and the dotted path tell what the text is for. 4. `i18n_propose` with every text and the `commit` of step 2 as `readAt`. 5. `i18n_proposals`: check that the batch waits on the language. A reviewer then publishes it in the editor. [Translate a new language with Claude Code](./translate-with-claude-code.md) and [Review and publish a batch](./review-a-batch.md) go through it step by step. ## Two rules **Carry the `commit` back.** `i18n_keys` and `i18n_key` answer the revision that they read. `i18n_propose` takes it as `readAt`. A publication uses it to refuse a key whose source changed after the proposal. The tool refuses a value that is not a full SHA of 40 characters. Do not invent one. **One call is one commit.** Send the texts of one language in one `i18n_propose`, up to 2000 keys, and not one call for each key. Every page of one listing answers the revision of the first page, so a long catalogue does not mix two revisions in one batch. Do not mix the values of two listings in one call: a batch carries one `readAt`. ## Errors A tool does not throw when the API refuses. It answers `isError` with the status and the message of the API, for example `403 — This account may not propose in de.` The agent reads the reason and can act on it. ## What the server does not do - **Publish.** A reviewer approves a translation. That is a judgement, and the API refuses a publication from an account that is not a reviewer. - **Add a key to the source.** A person writes the texts of the product. An agent translates them. - **Keep a translation after its source changed.** A reviewer does it in the editor. --- description: Read a batch of translations that an agent proposed, and publish what is right. --- # Review and publish a batch An agent proposes, and a person publishes. This page is the half of a reviewer: read the batch that an agent proposed, and publish what is right. To publish, the account needs the role « Relecteur » or « Propriétaire » in the language. [Accounts and rights](../guide/accounts.md) gives the roles. ## 1. Open the proposals Open the repository in the editor, and choose the language. Open the tab « Proposées »: each row shows the source, the text of the base branch and the proposal. ## 2. Read them Read each proposal against its source. The file and the dotted path of the key say what the text is for. Look at: - the placeholders and the tags of the source, which the proposal must keep; - the words of the product, which a glossary of the agent can miss; - a text that is right in the language, but too long for its place. To change a proposal, write the new text in its field. The editor proposes it as a new commit on `i18n/`, with your name. ## 3. Publish Tick the keys to publish in the column « Publier », or tick all of them in the head of the column. The bar at the bottom of the page shows « Publier N clés ». Click it. The publication copies the ticked keys to the base branch: one commit, or one pull request if the repository publishes by pull request. [Concepts](../guide/concepts.md#publication) gives both modes. ## 4. Read what was refused The publication leaves a key where it is, and says why, if: - the source text changed after the proposal: the translation answers an old text. Read it again, then propose a new text or keep it; - a more recent proposal changed the value after you ticked it; - the file has no version in this language. The other keys of the batch are published. A key that stays is still under « Proposées ». ## What an agent cannot do here The MCP server has no tool that publishes, and the API refuses a publication from a translator. An agent that holds the role « Relecteur » can still publish through the API: give the account of an agent the role « Traducteur » only. --- description: Create, scope and revoke the access tokens that an agent, a script or the CLI uses. --- # Access tokens An agent, a script or the CLI acts as an account. It sends a token of that account in each request to `/i18n`: ``` Authorization: Bearer ``` The API checks the rights of the account on each call: its repositories, its languages and its role. A token gives no more than its account has, and an access token can give less: see [Its scope](#its-scope). There are two ways to get a token. ## An access token, from the page « Jetons d'accès » For an agent, a CI job or the [MCP server](./mcp.md). 1. In the editor, open the account menu at the top right. 2. Choose **Jetons d'accès**. 3. Give the token a name, for example « Agent de traduction ». Choose its scope (« Proposer » by default) and its life: 30 days, 90 days, one year, or no end. 4. Choose **Créer le jeton**, and copy the token. The page shows a new token one time only. The editor keeps its SHA-256 and the first characters, `g8n_` and four more, so that you can tell your tokens apart. If you lose a token, revoke it and create another one. The list shows the scope of each token, when it was created, when it ends, and when it last served. **Révoquer** deletes a token: the API refuses it at once. An access token starts with `g8n_`. The API reads that prefix and finds the account of the token. A token that expired, or that was revoked, gets `401`. ## Its scope The scope of an access token limits what it can do inside the rights of its account. The API checks the scope before the rights. | Scope | What the token can call | | -------------------------------------- | ------------------------------------------------------------------------------ | | `read` (« Lecture seule ») | The routes that only read: `GET` and `HEAD`. | | `propose` (« Proposer »), the default | Those, and `POST /i18n/proposals`: a proposal. No publication, review or key. | | `full` (« Tous les droits du compte ») | Every route that the account can call: publish, add a key, change the members. | A call outside the scope gets `403` with `"reason": "token_scope"`, and the [CLI](./cli.md) stops with [`G8N2004`](../reference/errors/g8n2004.md). The scope cannot change: to widen it, create a new token and revoke the old one. Give an agent that reads the catalogue a `read` token, and an agent that translates a `propose` token. A `full` token of a reviewer can publish, and a `full` token of an owner can change the members. A token made before scopes existed has the scope `full`: it keeps what it could do. A session of `git8n login` is not an access token, and has every right of its account. ## A sign-in from a terminal For a person at a terminal. This is the device flow of OAuth (RFC 8628), as `gh auth login` does. `git8n login` of the [CLI](./cli.md) uses it. 1. The terminal asks for a code: ```sh curl -X POST https://git8n.example.com/api/auth/device/code \ -H 'Content-Type: application/json' \ -d '{"client_id":"git8n-cli"}' ``` The answer gives `device_code`, `user_code` (for example `W3ND6J6U`), `verification_uri_complete` and `interval` in seconds. The code expires after 10 minutes. The API refuses any other `client_id`. 2. The person opens `verification_uri_complete`, that is the page `/device` of the editor, and signs in if necessary. The page shows the code and the account. The person compares the code with the one of the terminal, then chooses **Approuver** or **Refuser**. On `/device` with no code, the page asks for it: `w3nd-6j6u` and `W3ND6J6U` are the same code. 3. The terminal polls every `interval` seconds: ```sh curl -X POST https://git8n.example.com/api/auth/device/token \ -H 'Content-Type: application/json' \ -d '{"grant_type":"urn:ietf:params:oauth:grant-type:device_code","device_code":"…","client_id":"git8n-cli"}' ``` Before the approval, the answer is `400` with `"error": "authorization_pending"`. After it, the answer gives `access_token`. The `access_token` is a session of the account, as the one of a browser. It lasts 14 days, and each day of use extends it. A sign-out of the terminal ends it. ::: warning Approve only your own code A person who approves a code gives the terminal the rights of their account. Approve a code only if you just started the command, and if the code on the page is the one of your terminal. ::: ## What a token cannot do Only the session of the browser can: - create, list and revoke the access tokens (`/i18n/tokens` answers `403` to a token); - approve or refuse a device code (`/api/auth/device`, `/device/approve` and `/device/deny` answer `403` to a request that carries `Authorization`). Thus a token cannot make another token that stays valid after you revoke the first one. --- description: Take a new language from nothing to a proposal with Claude Code and the MCP server. --- # Translate a new language with Claude Code This page takes a language from nothing to a proposal, with Claude Code and the [MCP server](./mcp.md). The agent proposes. A person reads the batch and publishes it: [Review and publish a batch](./review-a-batch.md). ## 1. Give the language to the repository An admin opens « Réglages » in the editor and adds the language to « Langues, la source en premier ». The source stays first. ## 2. Give the language to the account of the agent The agent acts as one account, with its rights. In « Réglages », then « Membres », give that account the role « Traducteur » and the new language. A translator proposes, and cannot publish. [Accounts and rights](../guide/accounts.md) gives the roles. Give the agent an account, or a token, of its own: you can then revoke it alone. [Access tokens](./tokens.md) tells how. ## 3. Connect Claude Code With a token of the account of the agent: ```sh claude mcp add git8n --scope user \ --env GIT8N_API_URL=https://git8n.example.com \ --env GIT8N_API_TOKEN=g8n_… \ -- npx -y @git8n/cli mcp claude mcp list ``` `claude mcp list` must show `git8n` as connected. After a `git8n login` on the same computer, leave out the two `--env`: the server then uses your session, and acts as your account. ## 4. Ask for the translation Name the repository and the language, and the rules of the product: > Translate the missing keys of `/` into German with the git8n MCP > server. Keep the placeholders such as `{{name}}` and the tags such as `<0>`. > Use the formal « Sie ». Propose every key in one batch. The agent then calls the tools in this order: 1. `i18n_me`: the repositories and the languages that the account can write. 2. `i18n_repositories`: the `id` of the repository. 3. `i18n_keys` with `state: "missing"`: 200 keys for each page, and the `commit` that they were read at. The agent reads each page while `more` is `true`. 4. `i18n_key`, for a key that needs its context. 5. `i18n_propose`, with every text and the `commit` of step 3 as `readAt`. One call is one commit on the branch `i18n/`, up to 2000 keys. 6. `i18n_proposals`: the batch waits on the language. [MCP tools](../reference/mcp-tools.md) gives the input and the answer of each tool. ## 5. Check the batch before the review `git8n check` reads the locale files of a clone, with no server. It finds a translation that loses a placeholder of its source. Run it on the branch of the proposal: ```sh git fetch origin i18n/de && git switch i18n/de npx @git8n/cli check --lang de ``` A batch with an error is a batch to propose again, before a reviewer reads it. --- description: 'The i18next JSON files that git8n reads and writes: layout, keys, plurals, placeholders, and limits.' --- # i18next JSON The JSON files of [i18next](https://www.i18next.com), as react-i18next and next-i18next read them, are the format of git8n. ## The folders One folder for each language, named with its tag. One file is one namespace: ``` locales/ ├── manifest.json optional ├── en/ │ ├── common.json │ └── checkout.json └── fr/ ├── common.json └── checkout.json ``` - A file name holds only letters, digits, `-` and `_`. A file such as `common.v2.json` is left out: `git8n check` warns (`skipped-file`), and the API writes a warning to its log. - A file of the source must exist in the source folder. A file that a target language does not have yet is a file of missing keys. - One file for each language, such as `locales/en.json`, is **not** read. Put it in `locales/en/translation.json`. [Libraries](./libraries.md) tells how each library loads that layout. [A repository and its files](../guide/repositories.md#the-files) gives every field of a repository. ## The keys A file is an object. Each string has a key, the dotted path to it, as i18next reads it: ```json { "cart": { "title": "Your cart", "empty": "Nothing here" } } ``` gives the keys `cart.title` and `cart.empty`. - A key can be **nested** (`{ "cart": { "empty": "…" } }`) or **flat**, with a dot in its name (`{ "cart.empty": "…" }`, i18next with `keySeparator: false`). Both give the key `cart.empty`. The editor changes a key in the shape that the file gives it. A new key is flat in an object whose keys hold dots and that holds no object, and nested everywhere else. - If a file holds one key in both shapes, a read keeps the last one in the file. A change of that key writes the new value in both, so that the read sees it. - A value is a string. A number, an array or `null` is an error of `git8n check` (`not-a-string`). The editor shows such a value as one text (`["a", "b"]` shows `a,b`), and a translation replaces it with a string. The arrays of `returnObjects` are thus not supported. - A key cannot hold an empty segment (`cart..empty`), nor name `__proto__`, `constructor` or `prototype`. The editor keeps the order of the keys, the indentation and the escapes of a file: a change replaces only its own value, and a new key adds one line. ## Placeholders `git8n check` compares the placeholders of each translation with those of its source. A translation that loses or adds one is an error (`placeholders`). | Placeholder | Example | Read as | | -------------- | --------------------------------- | ----------------------- | | Interpolation | `{{name}}`, `{{count, number}}` | `{{name}}`, `{{count}}` | | Nesting | `$t(cart.title)` | `$t(cart.title)` | | `` tags | `<0>Pay`, ``, `
` | each tag | | printf | `%s`, `%d`, `%1$s` | each one | The format of an interpolation, after the comma, can change: `{{count, number}}` and `{{count}}` are the same placeholder. ## Plurals A plural is a key with a suffix of each form: `_zero`, `_one`, `_two`, `_few`, `_many`, `_other`. A key counts as a plural when the source has its form `_other` and one more form. A source language with one form only, such as Japanese, writes `_other` alone. ```json { "items_one": "{{count}} item", "items_other": "{{count}} items" } ``` Each target language needs the forms of its own rules, from `Intl.PluralRules`: | Language | Forms | | -------- | ------------------------------- | | `en` | `one`, `other` | | `fr` | `one`, `other` (`many` allowed) | | `pl` | `one`, `few`, `many`, `other` | | `ja` | `other` | A form that a language needs and does not have is a warning of `git8n check` (`missing-key`). A form that only a number past 9999 takes is allowed and not needed: the `many` of French or Italian is for « 1 million d'articles », and with no `_many` i18next shows `_other`. A form other than `_other` can leave out `{{count}}`: « One item » is a correct `_one`. An ordinal, `t('place', { count, ordinal: true })`, writes the forms with `_ordinal_`: `place_ordinal_one`, `place_ordinal_two`, `place_ordinal_few`, `place_ordinal_other`. Each target language needs the forms of its ordinal rules, which are not those of its cardinal rules: English needs `one`, `two`, `few` and `other`, French `one` and `other`, Polish `other` only. The editor does not know plurals. It shows `items_one` and `items_other` as two keys, and it does not list a form that the source does not have, such as the `_few` of Polish. Only `git8n check` asks for it. ## Context A key with a context, such as `friend_male` and `friend_female`, is an ordinary key for git8n. Each one is translated on its own. ## Not supported - One file for each language (`locales/en.json`). - The plurals of i18next v3 and older (`key_plural`, `key_0`). git8n reads them as ordinary keys: `git8n check` does not ask for the forms of each language. - Values that are not strings: arrays, numbers, objects as values. - The formats of [Formats](./index.md#not-supported): YAML, gettext, XLIFF, ARB. --- description: What git8n understands of ICU MessageFormat in a text, and what it does not. --- # ICU MessageFormat A text in the files can use the syntax of [ICU MessageFormat](https://unicode-org.github.io/icu/userguide/format_parse/messages/), as FormatJS, next-intl and i18next-icu write it. The files stay [i18next JSON](./i18next.md): only the text of a key changes. ## Arguments `git8n check` reads each argument of a text, and compares them with those of the source: | In the source | Read as | | ----------------------------------------------- | ------------ | | `{name}` | `{name}` | | `{n, number}`, `{d, date, short}` | `{n}`, `{d}` | | `{count, plural, one {# item} other {# items}}` | `{count}` | | `{gender, select, male {He} other {They}}` | `{gender}` | | `{n, selectordinal, one {#st} other {#th}}` | `{n}` | - The format of an argument, after its first comma, can change: `{n, number}` and `{n}` are the same argument. - The branches of a choice are text. `#` is not an argument. - An argument inside a branch counts: in `{count, plural, one {{who} has one} other {{who} has #}}`, the text holds `{count}` and `{who}`. A translation whose branches all drop `{who}` loses it. - `offset:1` and the exact branches `=0`, `=1` are read. - A translation that renames an argument, `{total}` for `{count}`, loses one and adds one: two errors of `placeholders`. A text can mix the two syntaxes. When it holds an ICU choice, `{{` starts a branch and not an interpolation of i18next: `one {{who}}` is the branch `one` that holds `{who}`. ## Quoted text An apostrophe quotes text, as ICU reads it: - `''` writes one apostrophe: `It''s {name}` holds `{name}`. - `'{name}'` writes `{name}` literally. It is text, not an argument. - In a text with a choice, an apostrophe before `{`, `}` or `#` starts literal text, up to the next apostrophe: `one {'#' sold}` writes `#`. `'{{name}}'` stays an interpolation of i18next: « Delete '{{name}}'? » quotes the value. An apostrophe that quotes nothing, as in `l'{name}`, is text. ## Plurals An ICU plural is one key. In each translation, `git8n check` compares the branches of each `plural` and `selectordinal` with the rules of the language, from `Intl.PluralRules`: a form that the language needs and that the translation does not write, or a form that the language never uses, is a warning of [`icu-plural-forms`](../reference/rules/icu-plural-forms.md). A Polish plural needs `one`, `few`, `many` and `other`. An exact branch such as `=0` is always allowed. Exact branches that hold all the numbers of a form replace it: `=1` replaces `one` in English, but not in French, where `one` is 0 and 1. A form that only a number past 9999 takes, such as the `many` of French, is allowed and not needed. [i18next JSON](./i18next.md#plurals) gives the forms of some languages. ## Numbered arguments An argument can be a number, as in `{0} of {1}`: ICU reads it as the argument at that place of the list, and vue-i18n as a list argument. `{0}` is an argument as `{name}` is. --- description: The file formats and placeholder syntaxes that git8n reads, and the ones it does not. --- # Formats git8n reads and writes **JSON locale files**, one folder for each language. Inside a file, the text of a key can use the placeholders of i18next, of ICU MessageFormat, or of both. | Page | For | | ----------------------------- | ------------------------------------------------------------------ | | [i18next JSON](./i18next.md) | The files: folders, keys, plurals, and the placeholders of i18next | | [ICU MessageFormat](./icu.md) | `{name}`, and the choices `plural`, `select`, `selectordinal` | | [Libraries](./libraries.md) | react-i18next, next-i18next, vue-i18n, next-intl and FormatJS | ## Who reads the placeholders Two parts of git8n read a file, and they do not read it the same way: - **The editor and the API** read each key as plain text. They do not parse a placeholder or a plural. A proposal that loses `{{name}}` goes in as it is, from a person or from an agent. - **`git8n check`** parses the placeholders and the plurals of each text. It fails on a translation that loses or adds a placeholder of its source, and warns about a plural form that a language needs and does not have. Run `git8n check` in the CI of the repository: it is what catches a lost placeholder before a publication reaches users. [In CI](../agents/cli.md#in-ci) gives a workflow, and [the CLI reference](../reference/cli.md#check) every rule. ## Not supported git8n reads only files that end with `.json`. These formats are not read, and `git8n check` passes over them with no message: - YAML (`.yml`, `.yaml`), as Rails i18n and some vue-i18n projects write it; - gettext (`.po`, `.pot`); - XLIFF (`.xlf`, `.xliff`), as Angular writes it; - ARB (`.arb`), as Flutter writes it; - Android XML, iOS `.strings` and `.xcstrings`; - JavaScript or TypeScript modules that export messages; - JSON5 and JSON with comments. --- description: How react-i18next, vue-i18n, next-intl and other libraries fit the files that git8n edits. --- # Libraries git8n reads [i18next JSON](./i18next.md) files, in one folder for each language. This page tells, for each library, what fits that layout and what `git8n check` reads in its texts. Each claim was tried with `git8n check`. ## react-i18next and next-i18next They read i18next JSON, so everything on [i18next JSON](./i18next.md) applies. The default path of `i18next-http-backend` and of next-i18next is `public/locales//.json`, which `git8n check` finds with no `--locales`. ## i18next-icu The files are i18next JSON, and the texts are [ICU MessageFormat](./icu.md). Both pages apply. ## vue-i18n The texts of vue-i18n fit, with limits. Run `git8n check --syntax vue-i18n`: with no `--syntax`, the linked messages and the `|` plurals are text, as they are for i18next. | vue-i18n | git8n | | ------------------------------------------ | ------------------------------------------------------------------ | | Named `{name}`, `{user-id}` | Read, as an ICU argument | | List `{0}` | Read, as an ICU argument | | Plural `no car \| one car \| {count} cars` | `--syntax vue-i18n`: the branches are counted, `{count}` is read | | Linked `@:common.ok`, `@.lower:common.ok` | `--syntax vue-i18n`: read as `@:common.ok`, and its key must exist | | Literal `{'@'}`, escaped `\@` and `\|` | Text | - A translation keeps the links of its source. The modifier, `.lower` in `@.lower:common.ok`, can change: a language can need a capital where another does not. - The key of a link names a key of a file of the source language: `ok`, as one object of a language holds it once you merge the files, or `common.ok` for the key `ok` of `common.json`. git8n reads the merge from the links: one merge must resolve them all. When some links resolve in one merge only and others in the other only, the merge of the most links wins, and the other links are errors. With as many links in each form, no merge wins, and each of them is an error. A key that no file holds is an error of [`unknown-link`](../reference/rules/unknown-link.md). vue-i18n ends the key only at a space, `{`, `(`, `)`, `|` or `@`: in `@:nav.home!`, the key is `nav.home!`. Write `@:nav.home{'!'}`. - A translation has as many branches as its source. A plural of 2 branches and one of 3 do not pick the same branch for 0, so another number is a warning of [`plural-branches`](../reference/rules/plural-branches.md). An application that sets `pluralRules` for a language can need other numbers: `--max-warnings` lets them pass. - A `|` is a branch only in a source that has branches, and only in a text with no ICU choice: a `|` that a translation adds to a plain text is text. - `{0}` and the names `{user-id}`, `{total$}` are read with any `--syntax`. The layout is the other limit. A vue-i18n project often keeps one file for each language (`locales/en.json`), or its messages in JavaScript. git8n reads neither: put the messages in `locales//.json`, and merge the files of a language when you load them. ## next-intl next-intl writes [ICU MessageFormat](./icu.md), which `git8n check` reads. Its default layout, one file for each language (`messages/en.json`), is not read: `git8n check` stops with `G8N1005`. Put the messages in `messages//.json`, and merge them in the request config. ## FormatJS and react-intl The texts are ICU MessageFormat. Two habits of these projects do not fit: - one file for each language (`lang/en.json`); - flat ids that hold dots, such as `"app.title"`. git8n reads them, but the editor writes a key as nested objects: see [The keys](./i18next.md#the-keys). The files that `formatjs extract` writes hold objects (`{ "defaultMessage": …, "description": … }`), not strings: they are not read. ## Not supported - One file for each language, whatever the library. - Messages in JavaScript, TypeScript, YAML, gettext, XLIFF or ARB. - A link whose key comes from a value, `@:{name}`: its key is not checked. --- description: Admins, the roles of a member in a repository, what each role and an agent can do. --- # Accounts and rights A person signs in with GitHub, or with a link by email. The first sign-in creates the account, and the account opens nothing. ## Admins An admin connects the repositories. An account is an admin if: - its address is in `ADMIN_EMAILS`; - GitHub says that the address is verified. The address is the public address of the GitHub profile, or the primary address if the profile shows none. The editor reads it again at each sign-in, so a change on GitHub counts at the next sign-in. **To administer gives no right to write a language.** An admin who connects a repository becomes its owner. In any other repository, an admin needs a membership like any other account. ## Members of a repository A member holds rights in one repository only. Each repository has an organization of better-auth. A member is a row of `members` in it, with a role and one row of `member_languages` for each language. | Role | « Rôle » on the page | What it may do | | ---------- | -------------------- | -------------------------------------------- | | translator | Traducteur | Propose texts in its languages. | | reviewer | Relecteur | Also publish the proposals of its languages. | | owner | Propriétaire | Also manage the members of the repository. | **The source language** needs one more right: « Écrit la source (fr) », the column `writes_source` of the member row. With it, a member proposes changes to the source texts and adds keys with « Nouvelle clé ». The last owner of a repository cannot leave it or lose the role. Only an admin changes the settings of a repository: its branch, its folders, its languages and its mode of publication. ## What each one can do The API checks each right on each request. A member row grants its rights in one repository, and only in the languages of the row. « Écrit la source » is a right of its own: no role gives it, and it does not need a role that publishes. | Action | Translator | Reviewer | Owner | « Écrit la source » | Admin, with no member row | | ------------------------------------------------------ | ---------- | -------- | ----- | ------------------- | ------------------------- | | Read the catalogue and the proposals of the repository | Yes | Yes | Yes | Yes | Yes, in every repository | | Propose in one of its languages | Yes | Yes | Yes | — | No | | Publish one of its languages, or keep a translation | No | Yes | Yes | — | No | | Propose and publish the source, add a key | — | — | — | Yes | No | | Add, change and remove the members | No | No | Yes | — | Yes | | Connect a repository, change its settings | No | No | No | — | Yes | A dash means that the column does not decide it: the right comes from another column. An admin who connects a repository becomes its owner, and then also has the rights of the column « Owner » there. **An access token** acts as its account through the API, inside its scope: `read`, `propose` (the default) or `full`, the rights of the account. A `propose` token of a reviewer cannot publish. It cannot create, list or revoke the access tokens, and cannot approve a device code: only the session of the browser does that. Through the [MCP server](../agents/mcp.md), an agent can only read and propose: `i18n_propose` is the only tool that writes. To keep an agent from publishing, give it a `propose` token. [Access tokens](../agents/tokens.md) gives the details. **On GitHub**, a member needs nothing: no GitHub account if they sign in with a link by email, and no access to the repository, because the GitHub App writes every commit. Only the installation of the App needs a person who can install an App on the GitHub account of the repository. [The GitHub App](../operate/github-app.md) tells how. ## Add a member An admin, or an owner of the repository, opens **Réglages → Membres**. They add a person in one of two ways. **By address.** - A person with an account and this verified address becomes a member at once. - Any other person gets an invitation and a mail with a magic link. They sign in with the link and need no GitHub account. - The mail needs `SMTP_URL`. [Deploy](../operate/deploy.md) gives the variable. **By GitHub login.** The editor keeps the numeric ID of the login. The person becomes a member at their first sign-in with GitHub. A change of login on GitHub does not break the invitation. An invitation waits under « En attente de connexion » until the first sign-in. The owner can send the link again, or remove the invitation. ## Agents An agent acts as an account. It has the role and the languages of that account, and the API checks them on each call. [MCP server](../agents/mcp.md) tells how an agent connects. --- description: git8n compared with Fink, Tolgee, Crowdin and Weblate, and what git8n does not do. --- # Compare git8n This page compares git8n with four tools that also translate the locale files of an application: Fink, Tolgee, Crowdin and Weblate. Each of them does more than git8n in some areas. The table says where. **Checked on 2026-11-03.** The facts about the other tools come from their own documentation, linked under the table. A tool changes: read its page before you decide. ## The short answer - **git8n** edits the JSON locale files of a GitHub repository. Translators propose, reviewers publish, and each publication is a commit or a pull request. A translator can sign in with a link by email, with no GitHub account. Agents work through an MCP server and a CLI. You host it. - **Fink** is also an editor for the files of a GitHub repository, in the browser, with nothing to host. Everyone who saves needs a GitHub account, and rights are the rights of GitHub. - **Tolgee** keeps the texts in a Tolgee project. A CLI or the API moves them to and from the repository. It edits texts inside the running application. - **Crowdin** is a full localization platform for professional translators, with translation memory and machine translation. It keeps the texts in a Crowdin project, and sends them back as pull requests. - **Weblate** is an open source platform that treats the repository as the place of the texts, with a working copy and a database of its own, and many formats and hosts. ## The table | | git8n | [Fink](#sources) | [Tolgee](#sources) | [Crowdin](#sources) | [Weblate](#sources) | | -------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------ | -------------------------------------------------------------------- | --------------------------------------------------------- | ------------------------------------------------------------- | | Where the texts live | The repository only. The database holds accounts and rights, not texts | The repository only | A Tolgee project, pushed to or pulled from the repository by its CLI | A Crowdin project, synced with the repository | The repository, with a working copy and a database of Weblate | | Git hosts | GitHub | GitHub | Not checked | GitHub, GitLab, Bitbucket and more | Any git host, and Mercurial and Subversion | | Who needs a GitHub account | The admin who installs the GitHub App. Translators can sign in by email | Everyone who saves a change | Nobody: a Tolgee account | Nobody: a Crowdin account | Nobody: a Weblate account | | Review | A proposal is a commit on `i18n/`. A reviewer publishes it | A commit on a branch, or a pull request from a fork | Not checked | Pull requests from a service branch (`l10n_…` by default) | Commits pushed to the repository, or pull requests | | Rights | A role and a list of languages for each member, in each repository | The rights of GitHub: no accounts, invitations or roles of its own | Not checked | Not checked | Not checked | | MCP server | Yes, over stdio (`npx -y @git8n/cli mcp`) | Not checked | Yes, remote, with sign-in or an API key | Yes | Not in its documentation | | CLI | Yes (`@git8n/cli`), with `check`, a linter that needs no server | Not checked | Yes | Yes | Yes (`wlc`) | | Self-hosting | Yes, and required: there is no hosted service | No: hosted at fink.inlang.com | Yes, or Tolgee Cloud | No: a cloud service | Yes, or Hosted Weblate | | Formats | JSON files of nested objects, read with the dotted keys of i18next | i18next JSON, next-intl, the inlang message format | Many, i18next JSON included | Dozens, i18next JSON included | Many, i18next JSON included | | Translation memory, MT | No | No | Yes | Yes | Yes | | Licence | AGPL-3.0-only | MIT | Apache-2.0, with enterprise features under another licence | Proprietary | GPL-3.0 | « Not checked » means that this page did not find the fact in the documentation of the tool. It does not mean « no ». ## Sources - **Fink:** [Fink llms.txt](https://fink.inlang.com/llms.txt) (formats, GitHub only, no translation memory or machine translation), [Translators and non-developers](https://fink.inlang.com/docs/translators.md) (accounts and rights), [inlang-fink on GitHub](https://github.com/opral/inlang-fink) (licence). - **Tolgee:** [MCP server setup](https://docs.tolgee.io/platform/integrations/mcp_server/setup), [Push and pull strings](https://docs.tolgee.io/tolgee-cli/push-pull-strings), [Supported formats](https://docs.tolgee.io/platform/supported_formats), [Self-hosting](https://docs.tolgee.io/platform/self_hosting/getting_started), [LICENSE](https://github.com/tolgee/tolgee-platform/blob/main/LICENSE). Translation memory and machine translation: [Fink vs Tolgee](https://fink.inlang.com/compare/tolgee.md), a page of a competitor. - **Crowdin:** [GitHub integration](https://support.crowdin.com/github-integration/) (service branch `l10n_`), [Crowdin MCP Server](https://support.crowdin.com/developer/crowdin-mcp-server/), [crowdin-cli](https://github.com/crowdin/crowdin-cli), [Crowdin docs for LLMs](https://support.crowdin.com/llms-small.txt) (no self-hosted edition is described). Hosts, formats, translation memory and machine translation: [Fink vs Crowdin](https://fink.inlang.com/compare/crowdin.md), a page of a competitor. - **Weblate:** [Version control integration](https://docs.weblate.org/en/latest/vcs.md), [Code hosting](https://docs.weblate.org/en/latest/admin/code-hosting.md), [Formats](https://docs.weblate.org/en/latest/formats.md), [Translation memory and installation](https://docs.weblate.org/llms.txt) (the index has no MCP page), [Weblate Client](https://docs.weblate.org/en/latest/wlc.md), [WeblateOrg/weblate on GitHub](https://github.com/WeblateOrg/weblate) (licence). ## What git8n does not do - **No translation memory and no glossary.** Each key is translated on its own. - **No machine translation on the server.** `git8n prompt translate` writes a prompt for a model that you run, and an agent proposes through the MCP server. Both go through a review, as a person's proposal does. - **JSON only.** No YAML, `.po`, XLIFF, ARB or mobile string files. [A repository and its files](./repositories.md#the-files) gives the layout. - **GitHub only.** The editor reads and writes through a GitHub App. GitLab, Bitbucket and other hosts are not supported. - **No hosted service.** You deploy the editor yourself: [Deploy](../operate/deploy.md). - **No in-context editing and no screenshots.** A translator sees the key, the source text and the keys near it, not the screen of the application. ## When to pick another tool - **Fink**, if you want nothing to host and everybody already has a GitHub account with the rights they need. - **Tolgee**, if people must edit the texts inside the running application. - **Crowdin**, if an agency or professional translators work in the tool every day, with translation memory and machine translation. - **Weblate**, if your code is not on GitHub, or your files are not JSON. Coming from Crowdin? [Migrate from Crowdin](./migrate-from-crowdin.md). --- description: The parts of git8n, and what each action writes in git, from a proposal to a publication. --- # Concepts This page names the parts of the editor and follows a text from the source to the base branch. ## Repository A **repository** is a GitHub repository that the editor translates. An admin connects it, and its row holds: - the **base branch**, which the editor reads and publishes to; - the **folder of the locales**, with one folder for each language; - the **languages**, the source language first; - the **manifest**, which says which file each language uses; - the **mode of publication**: `commit` or `pull_request`. The editor reads the GitHub App installation of the repository. It never asks a person for a GitHub token. ## Source and target languages The **source language** is the first language of the repository. Its files define the keys. Every other language is a **target**. A **key** is a file name and a dotted path to a string, as i18next reads it: `{"cart": {"empty": "…"}}` in `common.json` gives the key `cart.empty` of the file `common`. ## Catalogue The **catalogue** of a language is every key of the source, beside what that language says about it. The API reads it from the base branch and answers the `commit` it read. Each key has a state: | State | What it means | | ------------ | -------------------------------------------------------------------------------------------- | | `missing` | The language has no text for the key, or an empty text that nobody marked. | | `translated` | The base branch holds a text for the key. | | `review` | The source changed after the translation. `was` holds the old source. | | proposed | A text waits on the branch `i18n/`. The page shows it beside the text of the base. | A key can be **left empty on purpose** in one language. The manifest holds the mark, with a hash of the source text. Such a key counts as translated until its source changes. [A repository and its files](./repositories.md#a-key-left-empty-on-purpose) gives the format. A file that the manifest marks `disabled` for a language has no key in its catalogue, and the API refuses a write to it. ## Proposal A **proposal** is one batch of texts for one language. The editor writes it as **one commit** on the branch `i18n/`. Three hundred keys are one commit. A proposal carries `readAt`: the `commit` of the catalogue that the texts answer. The commit message holds it as a `Read-at:` trailer. A publication uses it to find what the source said when the translator wrote the text. The editor writes nothing on the base branch at this step. A translator can propose, and cannot publish. ## Publication A **reviewer** reads the proposals of a language and approves some of them. The publication copies the approved keys from `i18n/` to the base branch: - in the mode `commit`, as one commit on the base branch; - in the mode `pull_request`, as one commit on `i18n/publish`, which one pull request carries to the base branch. The publication refuses a key, and says why, in these cases: | Reason | What happened | | ------------------------ | -------------------------------------------------------------------------------- | | `source-changed` | The source text changed since the proposal. The translation answers an old text. | | `changed-since-approval` | The branch holds another text now than the batch that the reviewer approved. | | `not-on-the-branch` | The key, or the commit named for it, is not on `i18n/`. | | `no-revision` | The commit has no `Read-at:` trailer. | | `disabled` | The manifest says that the file has no version in this language. | Each commit has the name of the person as author, and an address that does not reach them. [A repository and its files](./repositories.md#the-author-of-a-commit) gives the details. ## A translation to read again When a source text changes on the base branch, its translations go to the state `review`. A reviewer can propose a new text, or keep the translation as it is. To keep it writes a commit with one `Reviewed:` trailer for each key, and no change to a file. The editor computes these states from the history of the base branch. No table holds them. ## What each action writes in git Every action that changes a text is a commit on GitHub, made by the GitHub App. No action writes a text in the database: it holds the repositories, the accounts and the rights only. After each write, the API drops what it holds of the repository, so the next read shows the new commit. | Action | Branch | Commit | Author of the commit | Pull request | | -------------------------------------------------------- | -------------------------------------------------------------------- | --------------------------------------------------------------------------------------------- | ------------------------------------------------ | ------------------------------------------------------------------------------------ | | Propose texts: the page, `git8n apply` or `i18n_propose` | `i18n/`. The first proposal makes it from the base branch. | One commit for the batch, with a `Read-at:` trailer | The account that proposes | None | | Propose a key again, before its publication | `i18n/` | One more commit. The last batch that changed a key is the one a reviewer reads and publishes. | The account that proposes | None | | Leave a key empty on purpose | `i18n/` | The commit of the proposal also writes the mark in the manifest of the branch | The account that proposes | None | | Publish, in the mode `commit` | The base branch | One commit with the approved keys | The reviewer | None | | Publish, in the mode `pull_request` | `i18n/publish` | One commit with the approved keys | The reviewer | The App opens one pull request `i18n/publish` → base branch, or updates the open one | | Keep a translation after its source changed | Where a publication goes | One commit with one `Reviewed:` trailer for each key, and no change to a file | The reviewer | As for a publication | | Change a source text | `i18n/`, then where a publication goes | A proposal, then a publication, as for a target language | The account that writes the source, at each step | As for a publication | | Add a key with « Nouvelle clé » | Where a publication goes, with no proposal | One commit that adds the key to the source | The account that writes the source | As for a publication | The author is the name of the account and an address that does not reach the person: [the author of a commit](./repositories.md#the-author-of-a-commit). **No action rejects or deletes a proposal.** A batch stays on `i18n/`. A reviewer publishes the keys they approve, and the others stay proposed. A new proposal of a key replaces the text that waits. In the mode `pull_request`, a pull request closed without a merge drops what it carried: its keys are proposals again. ## What the editor holds, and for how long GitHub is the source of truth, so the API reads GitHub and holds the answers for a short time: | What | Held for | | --------------------------------------------- | --------- | | The locale files and the catalogue | 5 minutes | | The progress of a repository on the home page | 1 minute | | The proposals that wait on a language | 1 minute | The GitHub App sends a `push` event to the API. A push, a merge on GitHub included, drops what the API holds for that repository. An open page reads again every 30 seconds, so it shows the new commit without a reload. Without the webhook, a change made on GitHub shows after 5 minutes. --- description: 'From the first sign-in to the first commit: connect a repository, translate, publish.' --- # Getting started git8n edits the JSON locale files of a GitHub repository. A translator proposes texts, a reviewer publishes them, and each publication is a commit on the repository. This page goes from the first sign-in to that commit. You need: - the address of an editor, for example `https://git8n.example.com`. To run your own, see [Run it locally](../operate/self-host.md) and [Deploy](../operate/deploy.md); - for steps 2 and 3, an admin account: an address of `ADMIN_EMAILS`; - a GitHub repository that holds one folder of JSON files for each language, for example `src/locales/en/common.json`. [A repository and its files](./repositories.md#the-files) gives the layout. The screenshots come from the demo on the [home page](/docs/raw/index.md#demo-title). Its repository, `acme/shop`, does not exist, and the demo sends nothing to GitHub. ## 1. Sign in Open the editor, then choose one of the two ways: - **« Se connecter avec GitHub »**, with a GitHub account; - **« Recevoir un lien de connexion »**, for a person that a repository invited by address. The mail holds a link, and the person needs no GitHub account. The first sign-in creates the account. A new account opens nothing until it is a member of a repository (step 3). ## 2. Connect a repository An admin does this step once for each repository. 1. Click **« Brancher un dépôt »**. GitHub opens the installation page of the GitHub App of the editor. Install it on the repository. 2. GitHub sends you back to the editor. Pick the repository. The editor finds the folder of the locale files, the languages and the manifest. 3. Check the form, then click **« Brancher ce dépôt »**. The table of the repository opens, and you are its owner. [A repository and its files](./repositories.md#the-settings-of-a-repository) gives each field of the form. ## 3. Give the rights Open **« Réglages »**, then **« Membres »**. Add a person by address or by GitHub login, and give them a role and their languages: - a **translator** proposes texts in their languages; - a **reviewer** also publishes the proposals of their languages; - an **owner** also manages the members. A person invited by address gets the mail of step 1. [Accounts and rights](./accounts.md) gives every rule. ## 4. Translate Open the repository and pick a language. The tab **« Manquantes »** lists the keys that the language does not hold yet. Write the texts, then click **« Proposer »**. ![The tab « Manquantes » with two German texts written, and the bar « Proposer 2 traductions » at the bottom](/docs/quickstart/translate.png) The proposal is one commit on the branch `i18n/`. Nothing changes on the base branch yet. ## 5. Review and publish A reviewer opens the tab **« Proposées »**. They tick the keys to publish in the column **« Publier »**, then click **« Publier »** in the bar at the bottom. ![The tab « Proposées » with two keys ticked, and the bar « Publier 2 clés »](/docs/quickstart/review.png) The editor writes the approved keys on the base branch, and says so. ![The message « 2 clés publiées sur main. »](/docs/quickstart/published.png) A key whose source text changed since the proposal is refused, with the reason. [Concepts](./concepts.md#publication) lists the reasons. ## 6. See the commit on GitHub Open the history of the base branch on GitHub. The publication is one commit, made by the GitHub App. Its author is the name of the reviewer who published it ([the author of a commit](./repositories.md#the-author-of-a-commit)). If the repository publishes by pull request, the commit is on `i18n/publish`, and one pull request carries it to the base branch: [how a publication reaches the base branch](./repositories.md#how-a-publication-reaches-the-base-branch). The links of the editor to a commit open it on GitHub. ## Next - [Concepts](./concepts.md): the catalogue, a proposal, a publication. - [MCP server](../agents/mcp.md): let an agent propose the texts. - [CLI](../agents/cli.md): check the locale files in CI with `git8n check`. --- description: Move the translations of a GitHub repository from Crowdin to git8n, step by step. --- # Migrate from Crowdin This page moves the translations of a GitHub repository from Crowdin to git8n. It follows the migration of a real product, and keeps the steps that it needed. Crowdin keeps the texts in a Crowdin project and syncs them with the repository. git8n keeps them in the repository only. So the migration moves no text: it makes sure that the repository holds every translation, then removes the sync. ## 1. Get every translation into the repository Crowdin sends the translations as pull requests from a service branch (`l10n_…` by default). **An open pull request of Crowdin can hold translations that exist nowhere else.** Before you remove anything: - list the open pull requests of the Crowdin branch; - read each diff. Merge the ones that bring translations, or copy their texts into the editor later. Close only the ones that bring nothing new. A diff that replaces a text of `main` with `""` or with an older wording brings nothing: close it. ## 2. Check the layout of the files git8n reads one folder per language, with the same file names in each: `//.json`. A file holds nested objects of strings. [A repository and its files](./repositories.md#the-files) gives the rules. If a language is only partly translated, or a file has no version in a language, write a [manifest](./repositories.md#the-manifest) rather than leave empty files. Then run the check on a clone: ```sh npx @git8n/cli check ``` [`check`](../reference/cli.md#check) reads the files with no server, and tells what the editor would refuse. ## 3. Connect the repository Install the GitHub App on the repository, then connect it in the editor and give the rights to your translators: [Getting started](./getting-started.md). A translator with no GitHub account signs in with a link by email. ## 4. Remove the sync Remove the files that drive Crowdin: - the workflow that calls the Crowdin CLI or action, for example `.github/workflows/crowdin.yml`, or the Crowdin GitHub integration on the Crowdin side; - `crowdin.yml`, wherever it is in the repository. Look at `.gitignore` too. A line that ignores these files hides them from a search that respects `.gitignore`, such as `grep -r` in some setups, but leaves them in the repository. Search with `git ls-files | grep -i crowdin`. ## 5. Revoke the credentials A workflow of Crowdin reads a project ID and a personal token, often as `CROWDIN_PROJECT_ID` and `CROWDIN_PERSONAL_TOKEN`. Delete both from the settings of the repository. **Check where the token is.** A token saved as a repository **variable**, and not as a secret, is plain text that every person with read access to the settings can see. Deleting the variable does not make it safe: revoke the token on Crowdin. ## 6. Decide what to do with the Crowdin project The project lives outside the repository, with its translators, its translation memory and its glossary. git8n has no translation memory or glossary, so they do not move. Closing the project is a decision for its owner, not a step of the clean-up: keep it, or keep an export of what you need, until you are sure. ## 7. Check every pull request Add `git8n check` to CI, so that a file that does not parse, or a translation that loses a placeholder, fails the pull request: [In CI](../agents/cli.md#in-ci). --- description: The locale files of a repository, its settings, its manifest, and how a publication reaches the base branch. --- # A repository and its locale files The editor translates the JSON locale files of a GitHub repository. These files stay the source of truth. The editor keeps no text of its own. ## The files The files of one language are in one directory, under a path that you choose: ``` / ├── manifest.json optional, see below ├── fr/ the source language │ ├── common.json │ └── checkout.json └── en/ ├── common.json └── checkout.json ``` - The name of a directory is a language tag, for example `fr` or `pt-BR`. - The name of a file has only letters, digits, `-` and `_`. The editor leaves out a file such as `common.v2.json`, and the API writes a warning to its log. - A file holds nested objects of strings. A key is the dotted path to a string, as i18next reads it: `{"cart": {"empty": "…"}}` gives `cart.empty`. - A file of the source must exist in the source directory. A file of another language can be absent: all its keys are then missing. [Formats](../formats/index.md) gives the placeholders and the plurals that a text can hold, and the formats that git8n does not read. ## Add a repository An admin adds a repository with `POST /i18n/repositories`. Install the GitHub App on the repository first: [The GitHub App](../operate/github-app.md) tells how. | Field | Required | What it is | | ---------------- | -------- | ----------------------------------------------------------------------------------------------- | | `installationId` | yes | The installation of the App, from `GET /i18n/installations`. | | `owner`, `name` | yes | The repository, for example `acme` and `acme-app`. | | `localesPath` | yes | The directory that holds the directories of the languages. | | `languages` | yes | The languages, **the source language first**. | | `branch` | no | The base branch, which the editor reads and publishes to. The default is `main`. | | `manifestPath` | no | The manifest, from the root of the repository. The default is `/manifest.json`. | | `publishMode` | no | `commit` or `pull_request`: how a publication reaches the base branch. The default is `commit`. | ```sh curl -X POST https:///i18n/repositories \ -H 'Content-Type: application/json' \ -d '{"installationId": 42, "owner": "acme", "name": "acme-app", "localesPath": "src/locales", "languages": ["fr", "en"]}' ``` These settings belong to the repository, so they are in the database and not in the environment of the API. To add a language, change the row. You do not need to deploy again. ## The page of every repository `/` lists the connected repositories. `/repositories/` opens the table of one of them, and « g8n git8n » in the header leads back to the list. The list reads `GET /i18n/overview`: | Parameter | Values | Default | | --------- | ----------------------------- | ------- | | `page` | from 1 | 1 | | `perPage` | 1 to 100 | 20 | | `q` | a part of `owner/name` | none | | `sort` | `name`, `created`, `progress` | `name` | | `order` | `asc`, `desc` | `asc` | Each row holds the share of keys published in each language that the account can open, and the open pull request of a repository that publishes by pull request. Both come from GitHub, so the API reads them only for the rows of the page. An order by progress reads every repository that the search keeps, four at a time. The API holds a progress and a pull request for one minute. A repository that GitHub does not answer stays in the list, with `progress: null` and the reason in `progressError`. `GET /i18n/repositories` keeps its answer: every row, with no progress. The MCP server and the picker of the table read it. ## How a publication reaches the base branch A repository publishes in one of two modes. An admin chooses it in the form « Brancher un dépôt », and changes it later on the page of the settings of the repository, which sends `PATCH /i18n/repositories/` with `{"publishMode": "…"}`. | Mode | A publication is | For | | -------------- | --------------------------------------------------------- | ------------------------------------------------ | | `commit` | one commit on the base branch | a base branch that accepts a push | | `pull_request` | one commit on `i18n/publish`, carried by one pull request | a base branch whose rules require a pull request | In the mode `pull_request`: - The first publication makes `i18n/publish` from the base branch, and opens the pull request `i18n/publish` → base branch. - A later publication adds a commit to the same branch, and writes the title and the body of the pull request again. They list every key and language that the pull request carries. Each commit names the reviewer who published. - When the base branch moved, the publication merges it into `i18n/publish` first. If the two changed the same lines, the publication stops with a 409 and forces nothing. Resolve the conflict on GitHub, or close the pull request. - After a merge or a close, the next publication starts `i18n/publish` again from the base branch, and opens a new pull request. A closed pull request drops what it carried: its keys are proposals again. - A key in the open pull request leaves the list of proposals. It shows as published in the catalogue after the merge, because the catalogue reads the base branch. - A key created from the editor goes into the pull request too. - `GET /i18n/publication?repository=` answers the last pull request, open or not, and whether GitHub can merge it. The header of the editor shows it. The App needs the permission « Pull requests: write » for this mode: [The GitHub App](../operate/github-app.md#add-pull-requests) tells how to add it to an App that exists. ## Change a text of the source The source language is in the list of languages, first, with « (source) » after its name. An account with `writes_source` changes its texts as a translator changes a target: it proposes, and the change waits on `i18n/`. The same account then publishes it. An admin without `writes_source` reads the source and writes nothing. - A proposal of the source changes a key that the source holds. To add a key, use « Nouvelle clé », which writes it at once. - A source text cannot be empty. - A publication refuses a key whose text changed on the base branch since the proposal, as for a target: the reason is `source-changed`. - The publication follows the mode of the repository: a commit, or the pull request of the publications. ### A translation to read again When a source text changes on the base branch, every translation of that key shows under « À revoir » in its language, with the old source beside the new one. The editor computes this from the history of the base branch. No table holds it: - a change of the source, after the last change of the translation, puts the key to read again; - a new translation of the key, published, clears it; - a reviewer can keep the translation as it is: « Garder … telles quelles » sends `POST /i18n/reviews`. The commit changes no file and carries one trailer per key, `Reviewed: :`. When it reaches the base branch, the key is up to date. Only the history since the repository was connected counts, and at most the last 300 commits of the base branch. A source that changed before, or further back, puts nothing to read again. A translation that is missing stays missing. ## The settings of a repository An admin opens « Réglages » in the header of the editor, at `/repositories//settings`. The page changes the branch, the folder of the locales, the manifest, the languages, the mode of publication and the formats of the messages. It sends `PATCH /i18n/repositories/` with the fields that changed. An absent field keeps its value. - A new branch, folder or manifest is read on GitHub before it is written. If GitHub does not hold it, the API answers 400 and changes nothing. - A refusal names its field in `errors`, as the other validation errors of the API do: `{ "message": "…", "errors": [{ "field": "branch", "message": "…" }] }`. - If GitHub fails to answer the check for another reason, the API answers 502 with the reason, and changes nothing. - `null`, or an empty string, gives a format its default back. A `null` manifest gives back `/manifest.json`. ### The formats of the messages A format is a text with variables between braces. `{{` writes `{`, and `}}` writes `}`. A variable that the format of its kind does not list is refused. | Format | Field of the API | Variables | Default | | ----------------------------- | --------------------------- | ----------------------------------------------------------------------------- | ----------------------------------------------------- | | The commit of a proposal | `proposalCommitTemplate` | `language`, `count`, `count_keys`, `author`, `repository`, `branch` | `i18n({language}): {count_keys} proposed by {author}` | | The commit of a publication | `publicationCommitTemplate` | `language`, `count`, `count_keys`, `action`, `author`, `repository`, `branch` | `i18n({language}): {count_keys} {action} by {author}` | | The title of the pull request | `pullRequestTitleTemplate` | `count`, `count_keys`, `languages`, `repository`, `branch` | `i18n: {count_keys} published ({languages})` | | The body of the pull request | `pullRequestBodyTemplate` | `count`, `count_keys`, `languages`, `keys_list`, `repository`, `branch` | a sentence, a blank line, then `{keys_list}` | | Variable | What it writes | | ------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- | | `language` | the language of the commit: `en` | | `languages` | the languages of the pull request, in alphabetical order: `de, en` | | `count` | how many keys: `3` | | `count_keys` | the same, with the word: `1 key`, `3 keys` | | `action` | `published`; `created` for a key that « Nouvelle clé » added to the source; `reviewed` for translations kept as they are after their source changed | | `author` | the name of the account that proposed or published | | `repository` | `owner/name` | | `branch` | the base branch | | `keys_list` | the keys of the pull request, one Markdown section for each language | Rules: - A commit format and the title are one line. The body takes several. - A commit subject longer than 100 characters, or a title longer than 256, is cut and ends with `…`. - The editor adds the lines it reads back under the format, whatever the format says: the `Read-at:` trailer of a proposal, and one line for each key of a publication (`- en common:hello`). The title and the body of the pull request are built from these lines, so a format can change the subject freely. - A format that a hand wrote in the database and that the API would refuse is not used: the message takes the default format. ## The manifest The manifest says, for each file and each language, if the application uses the translation. It is optional. Without a manifest, each file is `available` in each language. ```json { "source": "fr", "files": { "common": { "en": "available", "de": "available" }, "checkout": { "en": "partial" }, "legal": { "en": "disabled" } } } ``` | State | What the application does | What the editor does | | ----------- | ----------------------------------------------------- | ---------------------------------------------------- | | `available` | It loads the file. | It lists the keys, and a translator writes them. | | `partial` | It does not load the file yet. Users read the source. | It lists the keys, as for `available`. | | `disabled` | The file has no version in this language. | It lists no key, and it refuses a write to the file. | - A file or a language that the manifest does not name is `available`. - The source language is always `available`. The manifest cannot change it. - If `source` is there, it must be the first language of the repository row. If the two are different, the editor stops the read with an error. - If the manifest is not valid JSON or does not match this format, the editor stops the read. It does not read the manifest as empty, because that would enable each file that the manifest disables. The editor reads the manifest on the base branch. A proposal branch cannot change the state of a file. ### A key left empty on purpose A translation can be `""` because the language wants no text there, and not because nobody translated it. The manifest says so, per language, with a key named `:`: ```json { "empty": { "en": { "woodlock:hints.3_image": { "source": "615c50a46c72" } } } } ``` `source` is the first 12 hexadecimal digits of the SHA-256 of the source text when the key was marked, UTF-8 encoded: `printf '%s' '/img/box2-b-indice3.png' | shasum -a 256 | cut -c1-12`. | The key in the language | What the editor does | | -------------------------- | --------------------------------------------------------------------------------- | | `""`, and the hash matches | It is translated and counts as published. It shows « Vide voulu », counted apart. | | `""`, and the hash differs | The source changed since the mark: the key is « À revoir ». | | any other value, or no key | The mark is ignored. The key is translated or missing, as without it. | - « Marquer vide voulu » proposes `""` and the mark in one batch, on `i18n/`. A reviewer publishes it as a translation, in the mode of the repository. The mark then reaches the manifest of the base branch. - « Retirer « vide voulu » » proposes the key with no mark. A text proposed for a marked key removes the mark too, in the same batch. - « Garder » on a marked key « À revoir » writes the hash of the new source. - The source language has no mark: a source text says something. - The rest of the manifest keeps its bytes. A mark that is removed leaves the manifest as it was before the mark. An application that reads these files must read `empty` too. i18next falls back on the source for `""` when `returnEmptyString` is `false`, so a player would read the source where the language wants nothing. Keep the manifest out of the directories of the languages. In one of them, the editor reads it as a locale file named `manifest`. ## The author of a commit A proposal and a publication are commits. Their author is the name of the account and an address that does not reach the person: `@`. | Variable | Default | | --------------------- | ----------------------- | | `COMMIT_EMAIL_DOMAIN` | `users.noreply.invalid` | The default domain is reserved by RFC 2606: no mailbox and no GitHub account has it. **Do not set it to `users.noreply.github.com`.** An account ID of the editor is not a GitHub login. GitHub then gives the commit to the GitHub account with that name, if one exists. --- description: Fixes for the usual problems, one symptom for each title. --- # Troubleshooting Each title is a symptom. Find yours, then follow the link to the page that explains the cause. ## The editor still shows the old commit after a merge on GitHub The API holds what it reads of a repository for up to five minutes. A push to the repository, a merge on GitHub included, makes the API drop it, but only when the **webhook** of the GitHub App reaches the API. An open page reads again every 30 seconds, and then shows the new commit. If the commit is still old after a minute: - Open **Advanced** in the settings of the App. Each delivery shows there, with the answer of the API: `200` for a push. A failed delivery shows its error. - Check that the API has `GITHUB_WEBHOOK_SECRET`, with the same value as the App. Without it, the API takes no event, and a change shows after the five minutes of the cache. [The GitHub App](../operate/github-app.md#what-it-may-do) describes the webhook. ## The mail of an invitation, or of a sign-in link, does not arrive - **No `SMTP_URL`.** Without it, the API sends no mail. Outside production, it writes the mail in its log, with the link. In production, it writes only that no mail left. - **A Scaleway server.** Scaleway blocks the outbound ports 465 and 587: the connection waits, and no mail leaves. Scaleway TEM also listens on 2465 and 2587: `smtps://:@smtp.tem.scaleway.com:2465`. - **An address that may not receive a link.** The editor sends a link to an address of `ADMIN_EMAILS`, to an invited address, and to the verified address of a member. Any other address gets no mail, and the page shows the same answer, so that it does not tell which addresses are known. - **An old link.** A link works one time, and expires after 5 minutes. Ask for a new one. Look in the spam folder too. [Deploy](../operate/deploy.md#variables-of-the-app) lists the variables of the API. ## My access token, `i18e_…`, does not work any more The tokens of the editor start with `g8n_` since the editor became git8n. The API reads a token with another prefix as no token, and refuses the request. The CLI then stops with `G8N2001`. Create a new token on the page « Jetons d'accès », and revoke the old one. If you signed in with the old CLI, `i18n-editor login`, run `git8n login`: the CLI now keeps its session in `~/.config/git8n/credentials.json`, and does not read the old file. [Access tokens](../agents/tokens.md) tells how. ## « Brancher un dépôt » does not list my repository The page lists only the repositories that an installation of the GitHub App reaches. An installation on « Only select repositories » reaches only the repositories that somebody picked. On GitHub, an owner of the account that holds the repository opens the installation of the App (for an organisation: **Settings → GitHub Apps → git8n → Configure**), adds the repository, and saves. Then open « Brancher un dépôt » again. [The GitHub App](../operate/github-app.md#connect-a-repository) gives each step. ## A publication through a pull request fails The App needs the permission « Pull requests » to open the pull request of a publication, and each installation must accept it. Until then, the editor answers 503, and the header of the repository says « Permission « Pull requests » manquante ». A repository that publishes by commit keeps working. [Add the permission « Pull requests »](../operate/github-app.md#add-pull-requests) gives the two steps. ## Locally, I sign in, and the editor does not keep my session Open the page on `http://localhost:5173`, the address of `BETTER_AUTH_URL`, and not on the port of the API, 8001. The session cookie belongs to the address that the browser reads. [Run it locally](../operate/self-host.md#start-it) gives the commands. ## `git8n check` cannot find the locales The CLI stops with `G8N1005` and exit status 2. With no `--locales`, it tries `locales`, `src/locales`, `public/locales`, `src/i18n/locales` and `i18n`, in that order. It also needs a source language: `--source `, or `"source"` in the manifest. Run it from the root of the clone, or pass `--locales ` and `--source `. [The CLI reference](../reference/cli.md#check) gives every option, and [the error codes](../reference/errors/index.md) every code. --- description: git8n translates the JSON locale files of a GitHub repository, by hand or with an agent. layout: home title: git8n hero: name: git8n text: Translate the locale files of a GitHub repository tagline: Translators propose, reviewers publish, and each publication is a commit. Agents work through an MCP server, with no more rights than a person, and a token can give them less. actions: - theme: brand text: Getting started link: /guide/getting-started - theme: alt text: Try the editor link: '#demo-title' - theme: alt text: Agents and MCP link: /agents/mcp features: - title: The repository is the source of truth link: /guide/concepts details: The editor reads the JSON locale files on the base branch and keeps no text of its own. A GitHub App writes each change as a commit. - title: Propose, then publish link: /guide/concepts details: A proposal is one commit on the branch i18n/<language>. A reviewer publishes it to the base branch, by commit or by pull request. - title: Rights per repository link: /guide/accounts details: Each member has a role and a list of languages in each repository. People sign in with GitHub or with a link by email. - title: Built for agents link: /agents/mcp details: An MCP server reads the catalogue and proposes whole batches. Every page of this site is also plain Markdown, listed in llms.txt. --- --- description: 'Deploy the editor: the two images, the order of a deployment, and every variable.' --- # Deploy the editor A deployment has two images. Build both from the root of the repository: pnpm needs the workspace files. | Image | Dockerfile | What it runs | | ---------- | ------------------------- | ----------------------------------------------------------------------------------------------------------------- | | `app` | `packages/api/Dockerfile` | The API on `PORT`. It also serves the built page, and this site at `/docs`. The page and the API have one origin. | | `migrator` | `packages/db/Dockerfile` | `dist/migrate.mjs` on `DATABASE_URL`. It applies the migrations that are not applied yet, then stops. | ```sh docker build -f packages/api/Dockerfile -t git8n . docker build -f packages/db/Dockerfile -t git8n-migrator . ``` - The `app` image runs as the user `node`, listens on port 8001, and has a `HEALTHCHECK` on `/health`. - The `migrator` image holds one bundle and the SQL files, on a distroless Node image, as a user that is not root. It has no shell, no pnpm and no drizzle-kit. The migrator keeps the journal of drizzle-kit: the table `drizzle.__drizzle_migrations`, with the `when` of `meta/_journal.json` and the same hash of each file. A database that `drizzle-kit migrate` updated gets only the migrations that came after. ## Dokploy Two applications from the same repository and branch, with the build type **Dockerfile** and no build stage: | Application | Dockerfile path | Docker context path | Watch paths | | ----------- | ------------------------- | ------------------- | ---------------------------------- | | `app` | `packages/api/Dockerfile` | `.` | none: every push deploys it | | `migrator` | `packages/db/Dockerfile` | `.` | `packages/db/**`, `pnpm-lock.yaml` | The `migrator` has no domain, and its restart policy is « none »: it stops after it applies the migrations. ## Order of a deployment 1. Run the `migrator` to its end. It stops with the code 0 when the database is up to date, and it is safe to run again. 2. Then start or restart the `app`. **Run the migrator first.** A new version of the `app` can read a column that only its migration adds. ## Behind a proxy Put the `app` behind a reverse proxy that terminates TLS and sets `X-Forwarded-For` to the address of the client, as Traefik does. The sign-in limits the number of requests for each client address, and it reads the address from that header. Without the header, all the clients share one limit. ## Variables of the `app` | Variable | Required | What it is | Example | | ------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------- | | `DATABASE_URL` | yes | The PostgreSQL server. | `postgresql://i18n:secret@i18n-postgres:5432/i18n` | | `DATABASE_TLS` | no | `true` to connect with TLS when the URL does not say so. The default is `false`. | `true` | | `BETTER_AUTH_URL` | yes | The address of the editor, as the browser reads it. GitHub sends the browser back to it after the sign-in. | `https://git8n.sakuga.dev` | | `BETTER_AUTH_SECRET` | yes | At least 32 characters. It signs the sessions and encrypts the GitHub tokens. The app refuses to start without it. | the output of `openssl rand -hex 32` | | `GITHUB_CLIENT_ID` | yes | The client ID of the GitHub App, for the sign-in. | `Iv23li…` | | `GITHUB_CLIENT_SECRET` | yes | The client secret of the GitHub App. | a secret from the App settings | | `I18N_GITHUB_APP_ID` | yes | The ID of the GitHub App, for the repositories. | `1234567` | | `I18N_GITHUB_PRIVATE_KEY` | yes | The private key of the GitHub App, in PEM format. Write the newlines as they are, or as `\n`. | `-----BEGIN RSA PRIVATE KEY-----\n…` | | `ADMIN_EMAILS` | yes | The addresses of the admins, separated by commas. GitHub must say that each address is verified. | `alice@example.com,bob@example.com` | | `COMMIT_EMAIL_DOMAIN` | no | The domain of the address a commit gives its author. The default is `users.noreply.invalid`. | `users.noreply.example.com` | | `SMTP_URL` | no | The SMTP server that sends the magic links to invited people (DB-645). Without it, no mail leaves, and an invited person without GitHub cannot sign in. | `smtps://user:password@smtp.example.com:465` | | `MAIL_FROM` | no | The sender of the mails. The default is `git8n >`. | `git8n ` | | `REDIRECT_HOSTS` | no | The old hosts of the editor, separated by commas. A GET to one of them answers 301 to the same path on `BETTER_AUTH_URL`; the other methods, such as a webhook, are served there. | `i18n.example.com` | | `GITHUB_APP_SLUG` | no | The name of the GitHub App in its address, `github.com/apps/`. The page links to its installation page. | `git8n` | | `GITHUB_WEBHOOK_SECRET` | no | The secret of the webhook of the GitHub App (DB-651). Without it, the API takes no event, and a change made on GitHub shows after five minutes. | 32 random bytes in hex | | `PORT` | no | The port of the API. The image sets 8001. | `8001` | | `WEB_ROOT` | no | The folder of the built page. The image sets `/app/web`. | `/app/web` | **A Scaleway server blocks the outbound SMTP ports 465 and 587**: the connection waits and no mail leaves. Scaleway TEM also listens on 2465 and 2587, so write `smtps://:@smtp.tem.scaleway.com:2465`. [The GitHub App](./github-app.md) says how to create the App and get its four values. The callback URL of the App must be `BETTER_AUTH_URL` followed by `/api/auth/callback/github`. ## Variables of the `migrator` | Variable | Required | What it is | Example | | -------------- | -------- | ------------------------------------------------------------------------- | -------------------------------------------------- | | `DATABASE_URL` | yes | The same server as the `app`. For TLS, add `?sslmode=require` to the URL. | `postgresql://i18n:secret@i18n-postgres:5432/i18n` | ## Check a deployment - `GET /health` answers `200` and `{"status":"ok"}`. - `GET /` answers the page. - `GET /docs/` answers this site, and `GET /docs/llms.txt` lists its pages as Markdown. - `GET /i18n/me` answers `401` without a session. - « Se connecter avec GitHub » opens GitHub, then comes back to the editor. --- description: Create the GitHub App of the editor, its permissions and webhook, and connect a repository. --- # The GitHub App The editor uses one GitHub App for two things: - **The sign-in.** A person signs in with the OAuth flow of the App, by its client ID and client secret. - **The repositories.** The API reads and writes a repository through an installation of the App, by its ID and its private key. One App serves every repository: install it on a repository, then add that repository to the editor. A deployment of the editor needs its own App, because the App holds the private key that the API signs with, and its callback URL names the address of that deployment. ## What it may do | Permission | Access | Why | | ------------------------- | ------ | --------------------------------------------------------------------------------------------------- | | Contents | write | Read the locale files and the manifest. Write the branches of proposals and publish. | | Metadata | read | List the repositories that an installation reaches. GitHub always grants it. | | Pull requests | write | Open and update the pull request of the publications, for a repository in the mode `pull_request`. | | Email addresses (account) | read | Read the addresses of a person at the sign-in, with their `verified` flag. `ADMIN_EMAILS` needs it. | The App asks for nothing more. A proposal is a commit on the branch `i18n/`. A publication is a commit on the base branch, or, for a repository in the mode `pull_request`, a commit on `i18n/publish` and one pull request to the base branch. [A repository and its files](../guide/repositories.md#how-a-publication-reaches-the-base-branch) tells the two modes apart. A token asks for `pull_requests: write` only for a repository in the mode `pull_request`. A repository that publishes by commit keeps working on an installation that never accepted it. Its **webhook** sends one event, `push` (DB-651). The API holds what it reads of a repository for up to five minutes. A push, a merge on GitHub included, drops what it holds of that repository, so the next read shows the new commit. An open page reads again every 30 seconds, and shows the change without a reload. The API checks the signature of each event with `GITHUB_WEBHOOK_SECRET`. Without the webhook, a change made on GitHub shows after five minutes. ## Create it [`github-app.json`](/docs/github-app.json) is a manifest for the [manifest flow](https://docs.github.com/en/apps/sharing-github-apps/registering-a-github-app-from-a-manifest) of GitHub. The repository holds it at `docs/public/github-app.json`. The flow creates the App with the repository permissions and the callback URL of the sign-in, and gives back its ID, its private key, its client ID and its client secret in one step. **The manifest cannot ask for the email addresses.** GitHub refuses an account permission in `default_permissions` (« Default permission records resource is not included in the list »). Add it by hand after the creation, at step 5. 1. Change `name` if the name is taken: the name of an App is unique on GitHub. Change `callback_urls` to the address of your deployment, followed by `/api/auth/callback/github`, `setup_url` to the same address followed by `/setup`, and `hook_attributes.url` to the same address followed by `/api/github/webhook`. Change `redirect_url` to a page that you control. GitHub sends the browser there with a `code` after the creation. 2. Send the manifest to GitHub from a browser. GitHub accepts it only as a form field, so save this page as `create-app.html` and open it: ```html
``` For an App that belongs to a personal account, use `https://github.com/settings/apps/new`. 3. GitHub sends the browser to `redirect_url?code=`. Exchange the code within one hour: ```sh curl -s -X POST https://api.github.com/app-manifests//conversions \ | jq '{id, slug, client_id, client_secret, pem}' ``` 4. Give the four values to the API. [Deploy](./deploy.md) lists every variable of a deployment. | Variable | Value | | ------------------------- | ------------------------------ | | `I18N_GITHUB_APP_ID` | `id` of the answer | | `I18N_GITHUB_PRIVATE_KEY` | `pem` of the answer | | `GITHUB_CLIENT_ID` | `client_id` of the answer | | `GITHUB_CLIENT_SECRET` | `client_secret` of the answer | | `GITHUB_WEBHOOK_SECRET` | `webhook_secret` of the answer | Keep the private key and the two secrets out of the repository. 5. In the settings of the App on GitHub, open **Permissions & events**, then **Account permissions**, and set **Email addresses** to **Read-only**. Save. **Without it, the API cannot read `/user/emails`.** Then: - a person whose profile shows no public address cannot sign in, because the account gets no address; - nobody is an admin, because no address is verified for `ADMIN_EMAILS`. 6. If the App was created before its manifest held `setup_url`, open **General** in its settings. Under **Post installation**, set **Setup URL** to `https:///setup` and tick **Redirect on update**. Save. **Without it, GitHub does not come back to the editor** after an installation. The admin then opens the editor again and clicks « Ajouter un dépôt ». 7. If the App was created before its manifest held the webhook, open **General** in its settings. Under **Webhook**, tick **Active**, set **Webhook URL** to `https:///api/github/webhook`, and set **Webhook secret** to the value of `GITHUB_WEBHOOK_SECRET`. Save. Then open **Permissions & events**, tick **Push** under **Subscribe to events**, and save. The event needs no new permission: Contents gives it. To check it, open **Advanced** in the settings of the App. Each delivery shows there with the answer of the API: `200` for a push, `204` for a ping. ## Connect a repository An admin does it from the page: 1. « Brancher un dépôt » (or « Ajouter un dépôt », the last entry of the select of the repositories) opens `https://github.com/apps//installations/new` in the same tab. `GITHUB_APP_SLUG` gives the slug. 2. After the installation, GitHub sends the browser to the Setup URL, `/setup?installation_id=…&setup_action=install`. The page lists the repositories that the installations reach. 3. The admin picks one. `GET /i18n/detection` reads its default branch, the folders that hold one folder per language, and the manifest beside them. The form starts from that answer, and every field stays editable. 4. « Brancher ce dépôt » sends `POST /i18n/repositories`, then the table of the repository opens. [A repository and its files](../guide/repositories.md#add-a-repository) gives the fields. The routes stay open to a script: `GET /i18n/installations`, then `POST /i18n/repositories`. Sent twice, the POST answers the row that is there with `created: false`, and writes nothing. **A protected branch refuses a commit.** If the base branch requires a pull request, connect the repository in the mode `pull_request`: the form proposes it when a ruleset of the branch requires one. Do not add the App to the actors that bypass the rule: a publication would then reach the branch with no check. ## Add the permission « Pull requests » to an App that exists {#add-pull-requests} An App created before the mode `pull_request` has no permission on pull requests. GitHub refuses the token, and the editor answers 503 with the name of the missing permission. 1. In the settings of the App, open **Permissions & events**. Under **Repository permissions**, set **Pull requests** to **Read and write**. Save. GitHub asks for a note to the installations; it can stay empty. 2. **Each installation must accept it.** GitHub keeps the old permissions of an installation until an owner of the account accepts the new ones. An owner of the organisation opens **Settings → GitHub Apps → git8n → Configure**, and clicks **Review request**, then **Accept new permissions**. GitHub also sends this request by e-mail to the owners. Until step 2, a repository of that installation that publishes through a pull request fails, and its header says « Permission « Pull requests » manquante ». A repository that publishes by commit is not touched. --- description: The threats around tokens, agents and the GitHub App, each with its risk and its mitigation. --- # Security This page lists what can go wrong around the tokens, the agents and the GitHub App, and what stops it. Each threat has three parts: the attack, the risks, and the mitigation. ## Where the rights are checked The API checks the rights of an account on each request: its repositories, its languages and its role. These rights are rows of the database, read again on every write. The MCP server and the CLI are clients of this API, like the page. **They are not a security boundary.** They hold no right of their own, and they never reach GitHub or the database. A client that you change, or replace, can do no more than the account of its token. The API reaches GitHub through the GitHub App, never with the token of a person. A sign-in with GitHub leaves the token of that sign-in in the database, encrypted with `BETTER_AUTH_SECRET`, but no route of the API uses it to read or write a repository. ## A token that somebody steals **Attack.** Somebody copies an access token `g8n_…` from a CI log, a shell history or an agent configuration. Or they copy the file of `git8n login`. **Risks.** The token acts as its account, inside its scope, until it expires or somebody revokes it: - a `read` token reads the catalogue and the proposals of the account; - a `propose` token also proposes texts in the languages of the account; - a `full` token does all that the account does: for a reviewer, publish to the base branch; for an owner, add and remove members. A session of `git8n login` has every right of its account, as a `full` token. A token cannot create, list or revoke a token, and cannot approve a device code. Only the session of the browser can. So a stolen token cannot make another token that outlives it. **Mitigation.** - **Give a token the smallest scope**: `read` for an agent that only reads, `propose` (the default) for an agent that translates. Keep `full` for a job that must publish. - **Give each agent its own account**, with the smallest role that does the job. An agent that only proposes needs a translator, in its languages only. A token of your own account carries your role. - **Give a token a life.** A token lives 30 days, 90 days, one year, or has no end. The longest life with an end is 365 days. Choose the shortest that works. - **Revoke it** on the page « Jetons d'accès » as soon as it leaks, or when the agent stops. The API deletes the row: the next request with that token gets `401`. - **Watch the last use.** The page shows the day each token was last used. A token that you do not use and that shows a recent use is a stolen token. - The API keeps only the SHA-256 of a token, and its first characters to name it. A copy of the database gives no usable token. - `git8n login` keeps its session in `~/.config/git8n/credentials.json` (`$XDG_CONFIG_HOME/git8n` if set). The CLI writes the folder with mode `0700` and the file with mode `0600`: only your account of the computer reads them. The session ends after 14 days without use. `git8n logout` ends it on the API and deletes it from the file. - In CI, put the token in a secret of the CI, never in the workflow file. [Access tokens](../agents/tokens.md) tells how to create and revoke one. ## An agent that proposes a harmful text **Attack.** An agent writes a translation that is wrong on purpose or by mistake: a text that misleads the reader, a lost placeholder, or markup that the product renders as HTML. The agent can follow instructions hidden in a text that it reads, for example a source text or a comment of the repository. **Risks.** A proposal is any string. The API checks the shape of a batch (the file, the key, the language, the revision that the agent read), not the meaning of a text. If the text reaches the base branch, the product shows it to its users. **Mitigation.** - **An agent proposes, a person publishes.** A proposal is one commit on the branch `i18n/`, not on the base branch. The MCP server has no tool that publishes. A reviewer reads each text in the editor, and publishes or refuses it. - **Give the agent a translator's account.** A reviewer's token could publish through the API, with no person in between. - **Run `git8n check` in CI** on the base branch, or on the pull request of a publication. It fails on a translation that loses or adds a placeholder. [CLI](../agents/cli.md#in-ci) gives a workflow. - **Publish by pull request** on a repository with protected branches. Then the review rules of the repository apply to each publication too. [A repository and its files](../guide/repositories.md#how-a-publication-reaches-the-base-branch) tells the two modes apart. - In the product, escape a translation like any other text. A translation that holds markup must go through a component that allows only the tags you expect, for example `` of react-i18next. ## A member that you remove **Attack.** A person leaves the team, but keeps a session in a browser, a session of `git8n login`, or an access token. **Risks.** The session and the tokens stay valid for the account. Without a member row, they open nothing in that repository. **Mitigation.** - **Remove the member** in « Réglages », then « Membres ». The API reads the rights again on each request, so the removal applies to the next request, in every session and with every token of that account. The account keeps its other repositories. - To stop the account everywhere, remove it from each repository. Its tokens then reach nothing. The person can still revoke them, but you cannot revoke the tokens of another account. - The history stays: the commits of the person stay in the repository, with their author. ## The private key of the GitHub App leaks **Attack.** Somebody reads `I18N_GITHUB_PRIVATE_KEY` from the environment of the server, a backup, or a file that somebody committed. **Risks.** With the key and the ID of the App, an attacker acts as the App on every installation: they read the repositories that it reaches, and write to them with the permissions of the App (« Contents », and « Pull requests » if you gave it). This goes around the editor and its rights. **Mitigation.** - **Install the App on « Only select repositories »**, and only on the repositories that you translate. - **Protect the base branch** and publish by pull request. A write to a protected branch then needs a review on GitHub too. - Keep the key in the secret store of the host, never in a repository or an image. - **If the key leaks:** in the settings of the App on GitHub, generate a new private key, put it in `I18N_GITHUB_PRIVATE_KEY`, redeploy, then delete the old key. GitHub refuses the old key as soon as you delete it. [The GitHub App](./github-app.md) lists its permissions, and why it needs each. ## A webhook event that GitHub did not send **Attack.** Somebody sends requests to `/api/github/webhook`, which is public. **Risks.** An event that the API accepts empties what it holds of a repository, so that the next read goes to GitHub again. An event writes nothing. If the API accepted unsigned events, a flood of them would make each read slow, and use the rate limit of the App on GitHub. **Mitigation.** - The API checks the signature `X-Hub-Signature-256` of each event against `GITHUB_WEBHOOK_SECRET`, in constant time. An event with a wrong signature gets `401` and empties nothing. - **Without `GITHUB_WEBHOOK_SECRET`, the route does not exist**: it answers `404`. The editor then shows a change made on GitHub after the five minutes of its cache. - Use a long random secret. The API refuses one shorter than 16 characters. ## Report a vulnerability Do not open a public issue. Report it in private on GitHub: . [SECURITY.md](https://github.com/sakuga-software/git8n/blob/main/SECURITY.md) says what to include, the supported versions, and how soon we answer. --- description: 'Run the editor on your own computer: the packages, the commands and the checks.' --- # Run it locally This page is for a person who runs the editor on their own computer: to try it, to develop it, or before a [deployment](./deploy.md). To use an editor that somebody already runs, see [Getting started](../guide/getting-started.md). ## The packages | Package | What it is | | -------------- | ------------------------------------------------------------------------------------------------------ | | `packages/web` | The page: React Router as a single-page application, Tailwind v4 and HeroUI v3. | | `packages/api` | The API: Hono on Node. It holds the routes `/i18n/*`, the catalogue, the proposals and the GitHub App. | | `packages/mcp` | The typed client of the API, which the page also uses, the CLI, and an MCP server for agents. | | `packages/cli` | The npm package `@git8n/cli`: the CLI and the MCP server of `packages/mcp`, in one file. | | `packages/db` | The Drizzle schema of the tables, and their migrations. | | `docs` | This site. | ## Start it You need: - Node 26 (see `.node-version`); - pnpm 12 (`corepack enable` reads the version from `package.json`); - a PostgreSQL server; - **a GitHub App**, which signs a person in and reads and writes the repositories. For local work, create a second App whose callback URL is `http://localhost:5173/api/auth/callback/github`. [The GitHub App](./github-app.md) says how. ```sh pnpm install # Create the tables. DATABASE_URL=postgres://user:password@localhost:5432/i18n pnpm -F @git8n/db migrate # Start the API on port 8001. DATABASE_URL=postgres://user:password@localhost:5432/i18n \ GITHUB_CLIENT_ID= \ GITHUB_CLIENT_SECRET= \ ADMIN_EMAILS=you@example.com \ I18N_GITHUB_APP_ID= \ I18N_GITHUB_PRIVATE_KEY="$(cat app.private-key.pem)" \ pnpm -F @git8n/api dev # Start the page on port 5173. Vite passes /api and /i18n to the API. pnpm -F @git8n/web dev ``` Open `http://localhost:5173`, the address of `BETTER_AUTH_URL`, and not the port of the API: the session cookie belongs to the address that the browser reads. **To sign in before the App exists**, ask for a link by email with an address of `ADMIN_EMAILS`. Without `SMTP_URL`, the API sends no mail. Outside production, it writes the link in its log. Open that link. You then reach « Brancher un dépôt », which needs the App. Then follow [Getting started](../guide/getting-started.md) from step 2. [Deploy](./deploy.md) lists every variable of the API. ## Checks ```sh pnpm typecheck pnpm test pnpm -F @git8n/docs demo # the demo of the home page, built once pnpm -F @git8n/docs dev # this site, on port 5208 ``` The tests of the API run on PGlite, a PostgreSQL in memory. They need no server. --- description: Every command, option, environment variable and exit status of the git8n CLI. --- # CLI reference ``` git8n [options] ``` [CLI](../agents/cli.md) explains how to install and use it. ## Global options | Option | What it does | | ----------------- | --------------------------------------------------------------------------- | | `--api ` | The address of the editor. Default: `GIT8N_API_URL`, then the last `login`. | | `--json` | Answers one JSON document on stdout. See [JSON](#json). | | `-h`, `--help` | Shows the help. | | `-v`, `--version` | Shows the version. | A command refuses an option that it does not take, with `G8N1001`. ## Environment | Variable | What it is | | ----------------- | ------------------------------------------------------------- | | `GIT8N_API_URL` | The address of the editor. | | `GIT8N_API_TOKEN` | A token. It takes the place of the token of `login`. | | `GIT8N_REPO` | The repository, as `--repo`. | | `XDG_CONFIG_HOME` | The folder of `git8n/credentials.json`. Default: `~/.config`. | ## Commands ### `login` Signs in with a device code. Options: `--no-open` does not open the browser. The code and the address go to stderr. The token goes to the file of the credentials, never to stdout. `data`: `{ api, account: { name, email } | null, credentials }` ### `logout` Ends the session on the API and deletes the token of this computer. An access token stays valid: revoke it on the page **Jetons d'accès**. `data`: `{ api, signedOut, revoked }`. `revoked` is false when the API did not end the session, or for an access token. ### `whoami` `data`: `{ api, account, via, admin, memberships: [{ repository, repositoryId, role, languages, writesSource }] }` `via` is `login` or `GIT8N_API_TOKEN`. `account` is null for an access token. ### `status` Options: `--repo`. With a repository (named, or from the git remote), the progress of each language of it. Else, the progress of each repository of the account. `data`: `{ repository: { id, name, branch, source, targets }, languages: [LanguageProgress] }`, or `{ repositories: [{ id, name, source, targets, languages, error? }] }`. `LanguageProgress` is `{ language, published, proposed, missing, review, empty?, total }`. ### `keys` Options: `--repo`, `--lang`, `--state missing|review|translated|all` (default `all`), `--missing` (the same as `--state missing`), `--file `, `--values`, `--limit ` (default 100, from 1 to 2000), `--cursor `. `data`: `{ repository, language, source, commit, filesWithNoVersion, total, cursor, nextCursor, entries: [Entry] }` `Entry` is `{ id, file, key, state, empty? }`. With `--values`, it also holds `source`, `value` and, for a key to review, `was`. `commit` is the revision that every value was read at. `nextCursor` is null on the last page. ### `get ` Options: `--repo`, `--lang`. `data`: `{ repository, language, source, commit, entry: Entry, neighbours: [Entry] }` The neighbours are at most 5 keys of the same file with the same parent path. ### `search ` Options: `--repo`, `--lang`, `--file`, `--limit`, `--cursor`. The CLI reads the catalogue and keeps the keys whose id, source or translation holds the text, with no regard to case. `data`: `{ repository, language, commit, total, cursor, nextCursor, entries: [Entry] }` ### `apply ` Options: `--repo`, `--lang`, `--file `, `--dry-run`, `--read-at `. The input is a JSON object, in one of these forms: ```json { "common:hello.title": "Hello" } { "common": { "hello": { "title": "Hello" } } } ``` With `--file common`, the object holds the keys of that file only, nested or dotted. At most 2000 keys change in one proposal. `data`: `{ repository, language, readAt, changes: [{ id, file, key, from, to }], unchanged: [id], stillToReview: [id], proposed }` `stillToReview` holds the keys to review whose text the input keeps. A proposal of the same text changes no file, so the key stays to review: a reviewer keeps it in the editor. `proposed` is `{ commit, branch, readAt, keys, files }`, or null after a dry run or when nothing changed. ### `proposals` Options: `--repo`, `--lang`. `data`: `{ repository, language, batches: [{ commit, message, author, at, readAt }] }` ### `prompt translate` Options: `--to ` (or `--lang`), `--repo`, `--state missing|review` (default `missing`), `--file`, `--limit` (default 50, from 1 to 500), `--cursor`. Without `--json`, the CLI writes the prompt, in Markdown. `data`: `{ repository, language, source, commit, total, cursor, nextCursor, keys: [{ id, source, was?, value?, neighbours }], prompt }` ### `mcp` Starts the [MCP server](../agents/mcp.md) on stdin and stdout, with the token of `login` or of `GIT8N_API_TOKEN`. It writes nothing else on stdout. It takes no `--json`. ### `check` Reads the locale files of the folder where it runs, with no API and no login, and reports what is wrong in them. [In CI](../agents/cli.md#in-ci) shows a workflow. | Option | What it does | | --------------------- | ---------------------------------------------------------------------------------------------------------------------------- | | `--locales ` | The folder of the languages. Default: the first of `locales`, `src/locales`, `public/locales`, `src/i18n/locales`, `i18n`. | | `--manifest ` | The manifest. Default: `/manifest.json`, if it exists. | | `--source ` | The source language. Default: the `source` of the manifest. | | `--lang ` | The languages to check. Default: every folder of `` past the source. | | `--syntax ` | `i18next` (the default: i18next and ICU MessageFormat) or `vue-i18n`: also the linked messages `@:key` and the `\|` plurals. | | `--reporter ` | `pretty` (the default), `json` (as `--json`) or `github`: one annotation of GitHub Actions for each problem. | | `--max-warnings ` | Fails past `n` warnings. Default: no limit. | It applies the rules of the editor ([A repository and its files](../guide/repositories.md)): the dotted keys of i18next, the files that the manifest disables, and the keys it marks empty on purpose. [The rules of `check`](./rules/index.md) gives each rule, with its level, an invalid and a valid example, and the output of the check on them. Each problem names its rule, and the report ends with the address of the page of each rule it found. A placeholder is `{{name}}`, `{name}`, `{0}`, `$t(…)`, a tag such as `<0>` or `
`, or `%s`. With `--syntax vue-i18n`, a linked message such as `@:common.ok` is one too ([vue-i18n](../formats/libraries.md#vue-i18n)). The branches of an ICU plural are text: `{count, plural, one {# item}}` holds `{count}` only. A plural follows i18next: `items_one` and `items_other` are the forms of `items`. Each language needs the forms of its own rules (`Intl.PluralRules`): Polish needs `_few` and `_many`, Japanese only `_other`. A form that only a number past 9999 takes, such as the `_many` of French, is allowed and not needed. A form other than `_other` can leave out `{{count}}`: « One item ». The exit status is 1 on an error, or past `--max-warnings`, else 0. A wrong setup is `G8N1005`, with the status 2. `data`: `{ locales, source, languages, files, keys, errors, warnings, maxWarnings, failed, problems: [{ rule, severity, path, language, key, message, docs }] }`. `docs` is the address of the page of the rule. ## JSON With `--json`, stdout holds one JSON document: ```json { "data": { … } } ``` or, when the command fails: ```json { "error": { "code": "G8N3002", "message": "Name the repository with --repo .", "details": ["The repositories of this account:", " acme/app"], "docs": "https://git8n.sakuga.dev/docs/reference/errors/g8n3002.html" } } ``` Without `--json`, the answer goes to stdout and an error goes to stderr. ## Exit status | Status | Meaning | | ------ | ------------------------------------------------------------------------------ | | 0 | Done. | | 1 | The API refused, a key is unknown, the sign-in did not end, or `check` failed. | | 2 | The command, an option, the configuration or the input is wrong. | | 3 | No account is signed in, or the API refused the token. | ## Error codes [The error codes](./errors/index.md) gives each code, with its exit status and what to do. The CLI prints the address of the page of the code after the error, and `--json` gives it as `error.docs`. --- title: G8N1001 description: "The command or an option is wrong." --- # `G8N1001` The command or an option is wrong. The exit status is **2**. ## What to do Read the message: it names the option. Run `git8n --help` for the commands and their options. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N1002 description: "No address of the API: pass --api or set GIT8N_API_URL." --- # `G8N1002` No address of the API: pass --api or set GIT8N_API_URL. The exit status is **2**. ## What to do Run `git8n login --api ` one time: the address becomes the default. In CI, set `GIT8N_API_URL`. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N1003 description: "The input of apply is not valid." --- # `G8N1003` The input of apply is not valid. The exit status is **2**. ## What to do Give `apply` a JSON object of texts by key: `{ "common:hello.title": "Hello" }`, or nested by file. With `--file `, the keys of that file only. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N1004 description: "The file of the credentials is not valid." --- # `G8N1004` The file of the credentials is not valid. The exit status is **2**. ## What to do Delete `~/.config/git8n/credentials.json`, then run `git8n login` again. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N1005 description: "check cannot find the locales, the manifest or the source language." --- # `G8N1005` check cannot find the locales, the manifest or the source language. The exit status is **2**. ## What to do Pass `--locales `, and `--source ` if the manifest names no `source`. The message names what is missing. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N2001 description: "No account is signed in, or the API refused the token." --- # `G8N2001` No account is signed in, or the API refused the token. The exit status is **3**. ## What to do Run `git8n login`. In CI, set `GIT8N_API_TOKEN` to an access token that is not revoked or expired. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N2002 description: "The device code expired before somebody approved it." --- # `G8N2002` The device code expired before somebody approved it. The exit status is **1**. ## What to do Run `git8n login` again, and approve the code in the browser before it expires. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N2003 description: "The person refused the device code." --- # `G8N2003` The person refused the device code. The exit status is **1**. ## What to do Run `git8n login` again, and choose « Approuver » if the code is the one of the terminal. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N2004 description: "The scope of the access token does not allow the request." --- # `G8N2004` The scope of the access token does not allow the request. The exit status is **1**. ## What to do A token « Lecture seule » only reads, and a token « Proposer » reads and proposes. Make a token with the scope that the job needs on the page « Jetons d'accès », then set `GIT8N_API_TOKEN` to it. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N3001 description: "No repository matches." --- # `G8N3001` No repository matches. The exit status is **2**. ## What to do Run `git8n whoami` to see the repositories that the account opens, then pass one with `--repo `. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N3002 description: "The repository is not clear: pass --repo." --- # `G8N3002` The repository is not clear: pass --repo. The exit status is **2**. ## What to do Pass `--repo `, or set `GIT8N_REPO`. The message lists the choices. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N3003 description: "The language is not clear, or the account cannot open it: pass --lang." --- # `G8N3003` The language is not clear, or the account cannot open it: pass --lang. The exit status is **2**. ## What to do Pass `--lang `. If the account cannot open the language, ask an owner of the repository for it. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N3004 description: "A key is not a key of the source, or its file has no version in the language." --- # `G8N3004` A key is not a key of the source, or its file has no version in the language. The exit status is **1**. ## What to do Check the key with `git8n search` or `git8n keys`: a key is `:`, as in the source. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N4001 description: "The API refused the request." --- # `G8N4001` The API refused the request. The exit status is **1**. ## What to do Read the message of the API: it tells what it refused, for example a right that the account does not have. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: G8N4002 description: "The API failed, or did not answer." --- # `G8N4002` The API failed, or did not answer. The exit status is **1**. ## What to do Check the address of the API and the network, then try again. If it continues, tell the operator of the editor. [All the error codes](./index.md) · [The exit status](../cli.md#exit-status) --- title: The error codes of git8n description: Every error code of the CLI, with its exit status. --- # The error codes of `git8n` A script reads the code, never the message: the message can change. The CLI prints the address of the page of the code with each error, and `--json` gives it as `error.docs`. | Code | Status | Meaning | | --- | --- | --- | | [`G8N1001`](./g8n1001.md) | 2 | The command or an option is wrong. | | [`G8N1002`](./g8n1002.md) | 2 | No address of the API: pass --api or set GIT8N_API_URL. | | [`G8N1003`](./g8n1003.md) | 2 | The input of apply is not valid. | | [`G8N1004`](./g8n1004.md) | 2 | The file of the credentials is not valid. | | [`G8N1005`](./g8n1005.md) | 2 | check cannot find the locales, the manifest or the source language. | | [`G8N2001`](./g8n2001.md) | 3 | No account is signed in, or the API refused the token. | | [`G8N2002`](./g8n2002.md) | 1 | The device code expired before somebody approved it. | | [`G8N2003`](./g8n2003.md) | 1 | The person refused the device code. | | [`G8N2004`](./g8n2004.md) | 1 | The scope of the access token does not allow the request. | | [`G8N3001`](./g8n3001.md) | 2 | No repository matches. | | [`G8N3002`](./g8n3002.md) | 2 | The repository is not clear: pass --repo. | | [`G8N3003`](./g8n3003.md) | 2 | The language is not clear, or the account cannot open it: pass --lang. | | [`G8N3004`](./g8n3004.md) | 1 | A key is not a key of the source, or its file has no version in the language. | | [`G8N4001`](./g8n4001.md) | 1 | The API refused the request. | | [`G8N4002`](./g8n4002.md) | 1 | The API failed, or did not answer. | --- description: Every tool of the git8n MCP server, its input fields, and whether it writes. --- # MCP tools Each tool answers one text block that holds JSON. When the API refuses, the tool answers `isError: true` and the text ` — `. `repository` is always the `id` of a repository, from `i18n_repositories`. `language` is a tag such as `fr` or `pt-BR`. `file` is a file name such as `common`, never a path. **Call `i18n_me` first.** It names the repositories and the languages that the token can write. Then call `i18n_keys`, and send its `commit` back to `i18n_propose` as `readAt`. Only `i18n_propose` writes: each other tool only reads, and its MCP annotation `readOnlyHint` is `true`. ## `i18n_repositories` **The repositories to translate.** Which repositories the editor translates, their source language and their targets. **Input:** none. The repositories that the account can read. An admin reads all of them. Any other account reads the repositories where it is a member. **Answer:** a list of repositories. | Field | What it is | | --------------- | ---------------------------------------------- | | `id` | The identifier that the other tools take. | | `owner`, `name` | The repository on GitHub. | | `branch` | The base branch. | | `localesPath` | The folder of the locale files. | | `manifestPath` | The manifest, from the root of the repository. | | `publishMode` | `commit` or `pull_request`. | | `source` | The source language. | | `targets` | The other languages. | ## `i18n_me` **What this token may do.** The repositories where this account is a member, and in each one its role, the languages it may propose in, and whether it writes the source. A translator writes proposals and does not publish. A reviewer or an owner also publishes. **Input:** none. A token gives the rights of its account, and no more ([Access tokens](../agents/tokens.md)). **Answer:** ```json { "admin": false, "memberships": [{ "repositoryId": "…", "role": "translator", "languages": ["en", "de"], "writesSource": false }] } ``` `role` is `translator`, `reviewer` or `owner`. A translator proposes. A reviewer or an owner also publishes, in the editor. ## `i18n_keys` **The keys of a language.** Every key of the source beside what one language says about it, filtered by state and by file. The answer carries the `commit` the values were read at; a proposal takes it back. **Input:** | Field | Type | Required | What it is | | ------------ | ------------------------------------------------ | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- | | `repository` | string | yes | The repository to read, from i18n_repositories. At least 1 character. | | `language` | string | yes | The language to read, which the repository must translate into. Matches `^[A-Za-z0-9_-]+$`. | | `state` | `"missing"`, `"review"`, `"translated"`, `"all"` | no | Which keys to answer. `missing` is what there is work on. `review` is a translation whose source changed: `was` holds the old source. Default: `"missing"`. | | `file` | string | no | Only the keys of this file, for example `common`. Matches `^[A-Za-z0-9_-]+$`. | | `offset` | integer | no | Where to start, for a catalogue past one answer. Default: `0`. | 200 keys for each answer. **Answer:** | Field | What it is | | -------------------- | ------------------------------------------------------------------------------------ | | `commit` | The revision of the base branch that the values come from. Send it back as `readAt`. | | `source` | The source language. | | `language` | The language read. | | `total` | How many keys match the filter. | | `offset`, `returned` | Where the page starts, and how many keys it holds. | | `more` | `true` if another page follows. Call again with `offset + returned`. | | `filesWithNoVersion` | The files that this language does not have. They take no proposal. | | `entries` | The keys. See below. | Each entry: | Field | What it is | | -------- | ----------------------------------------------------------------------------- | | `file` | The file of the key. | | `key` | The dotted path of the key. | | `source` | The source text now. | | `value` | The text of the language on the base branch, or `null`. | | `state` | `missing`, `translated` or `review`. | | `was` | For `review` only: the source text that the translation answers. | | `empty` | `true` if the key is left empty on purpose in this language. `value` is `""`. | The server holds one reading of a catalogue for 5 minutes. Every page of a listing answers the `commit` of the first page. ## `i18n_key` **One key, with its context.** One key: what the source says, what the target says, and its state. The file and the dotted path are the context — `analysis.requests_export_hint` says what the string is for, and a translation without it is a wrong translation. **Input:** | Field | Type | Required | What it is | | ------------ | ------ | -------- | ------------------------------------------------------------------------------------------- | | `repository` | string | yes | The repository to read, from i18n_repositories. At least 1 character. | | `language` | string | yes | The language to read, which the repository must translate into. Matches `^[A-Za-z0-9_-]+$`. | | `file` | string | yes | The file of the key, for example `common`. Matches `^[A-Za-z0-9_-]+$`. | | `key` | string | yes | The dotted path of the key, as i18next reads it. At least 1 character. | **Answer:** `{ "found": true, "commit": "…", "source": "fr", "entry": { … } }`, with the same `entry` as `i18n_keys`. If the key does not exist, or if the file has no version in the language, the answer is `{ "found": false, "reason": "…" }`. ## `i18n_propose` It writes a commit on the repository, and deletes nothing. **Propose a batch of translations.** Writes a whole batch as one commit on the branch of its language. Three hundred keys are one commit, not three hundred. Nothing is published: a reviewer does that. **Input:** | Field | Type | Required | What it is | | ------------ | --------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `repository` | string | yes | The repository to read, from i18n_repositories. At least 1 character. | | `language` | string | yes | The language to read, which the repository must translate into. Matches `^[A-Za-z0-9_-]+$`. | | `readAt` | string | yes | The `commit` the keys were read at, taken from i18n_keys or i18n_key. Every page of one listing answers the same one. It is what lets a publication refuse a key whose source moved since — never invent one, and never mix the values of two listings in one call. Matches `^[0-9a-f]{40}$`. | | `values` | array of object | yes | The whole batch. One call is one commit, so three hundred keys are three hundred keys, not three hundred commits. From 1 to 2000 items. | Each item of `values`: | Field | Type | Required | What it is | | ------- | ------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `file` | string | yes | The file of the key, for example `common`. Matches `^[A-Za-z0-9_-]+$`. | | `key` | string | yes | The dotted path of the key. At least 1 character. | | `value` | string | yes | The text in the language of the batch. | | `empty` | boolean | no | true leaves the key empty on purpose in this language, with value "". Use it only when the source asks for no text in this language, never to skip a key. A key sent without it loses its mark. | The branch is `i18n/`. **Answer:** ```json { "commit": "…", "branch": "i18n/en", "readAt": "…", "keys": 42, "files": ["common", "checkout"] } ``` ## `i18n_proposals` **What is waiting on a language.** The batches the branch of a language holds and the base does not, with who wrote each one. **Input:** | Field | Type | Required | What it is | | ------------ | ------ | -------- | ------------------------------------------------------------------------------------------- | | `repository` | string | yes | The repository to read, from i18n_repositories. At least 1 character. | | `language` | string | yes | The language to read, which the repository must translate into. Matches `^[A-Za-z0-9_-]+$`. | **Answer:** a list of batches. | Field | What it is | | --------- | ------------------------------------ | | `commit` | The commit of the batch. | | `message` | Its commit message. | | `author` | The account that proposed it. | | `at` | When. | | `readAt` | The revision that the batch answers. | --- title: empty-source description: "A text of the source is empty." --- # `empty-source` **Warning** · A text of the source is empty. `git8n check` reports this rule as a warning. It fails only past `--max-warnings`. ## Why it matters A translator has nothing to translate, and every language gets an empty text. ## Invalid `locales/en/common.json` ```json { "title": "" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` ```text $ git8n check --source en locales/en/common.json warning common:title is empty in the source. empty-source Read more: empty-source https://git8n.sakuga.dev/docs/reference/rules/empty-source.html 0 errors, 1 warning in 2 files (en → fr, 1 key). ``` ## Valid `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 2 files (en → fr, 1 key). ``` ## What to do Write the text of the source, or delete the key. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: empty-value description: "A translation is empty, and the manifest does not mark it." --- # `empty-value` **Warning** · A translation is empty, and the manifest does not mark it. `git8n check` reports this rule as a warning. It fails only past `--max-warnings`. ## Why it matters An empty text shows nothing. It is often a key that somebody forgot, but some texts are empty on purpose in a language. ## Invalid `locales/en/common.json` ```json { "suffix": "Ltd" } ``` `locales/fr/common.json` ```json { "suffix": "" } ``` ```text $ git8n check --source en locales/fr/common.json warning common:suffix is empty in fr. empty-value Read more: empty-value https://git8n.sakuga.dev/docs/reference/rules/empty-value.html 0 errors, 1 warning in 2 files (en → fr, 1 key). ``` ## Valid `locales/manifest.json` ```json { "empty": { "fr": { "common:suffix": { "source": "0e6cd0ce0450" } } } } ``` `locales/en/common.json` ```json { "suffix": "Ltd" } ``` `locales/fr/common.json` ```json { "suffix": "" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 2 files (en → fr, 1 key). ``` ## What to do Translate the key. If the text must stay empty, mark it in the editor (« vide voulu »): the manifest then records the source text it was empty for. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: extra-file description: "A file has no version in the source." --- # `extra-file` **Warning** · A file has no version in the source. `git8n check` reports this rule as a warning. It fails only past `--max-warnings`. ## Why it matters The editor reads the files of the source. A file of a language alone is a file that nobody can see or translate there. ## Invalid `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` `locales/fr/legacy.json` ```json { "title": "Ancien" } ``` ```text $ git8n check --source en locales/fr/legacy.json warning legacy.json has no version in en. extra-file Read more: extra-file https://git8n.sakuga.dev/docs/reference/rules/extra-file.html 0 errors, 1 warning in 3 files (en → fr, 1 key). ``` ## Valid `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 2 files (en → fr, 1 key). ``` ## What to do Delete the file, or add its version to the source. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: extra-key description: "A key is not in the source." --- # `extra-key` **Warning** · A key is not in the source. `git8n check` reports this rule as a warning. It fails only past `--max-warnings`. ## Why it matters The application never asks for the key. It is often a key that the source renamed or deleted, and its translation is lost work. ## Invalid `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour", "welcome": "Bienvenue" } ``` ```text $ git8n check --source en locales/fr/common.json warning common:welcome is not in en. extra-key Read more: extra-key https://git8n.sakuga.dev/docs/reference/rules/extra-key.html 0 errors, 1 warning in 2 files (en → fr, 1 key). ``` ## Valid `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 2 files (en → fr, 1 key). ``` ## What to do Delete the key, or move its text to the new key of the source. A plural form that the language does not need is an extra key too. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: icu-plural-forms description: "An ICU plural does not have the forms of its language." --- # `icu-plural-forms` **Warning** · An ICU plural does not have the forms of its language. `git8n check` reports this rule as a warning. It fails only past `--max-warnings`. ## Why it matters An ICU plural picks a branch by the plural rules of the language. A form that the translation does not write shows the text of `other`, so a Polish « 5 elementów » becomes « 5 elementy ». A form that the language never uses is text that nobody reads. ## Invalid `locales/en/common.json` ```json { "items": "{count, plural, one {# item} other {# items}}" } ``` `locales/pl/common.json` ```json { "items": "{count, plural, one {# element} other {# elementu}}" } ``` ```text $ git8n check --source en locales/pl/common.json warning common:items: the plural {count} has no few many. icu-plural-forms Read more: icu-plural-forms https://git8n.sakuga.dev/docs/reference/rules/icu-plural-forms.html 0 errors, 1 warning in 2 files (en → pl, 1 key). ``` ## Valid `locales/en/common.json` ```json { "items": "{count, plural, one {# item} other {# items}}" } ``` `locales/pl/common.json` ```json { "items": "{count, plural, one {# element} few {# elementy} many {# elementów} other {# elementu}}" } ``` `locales/fr/common.json` ```json { "items": "{count, plural, =0 {aucun article} =1 {# article} other {# articles}}" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 3 files (en → fr, pl, 1 key). ``` ## What to do Write a branch for each form of the language, and delete the others. Polish needs `one`, `few`, `many` and `other`; Japanese needs `other` only. An exact branch such as `=0` is always allowed, and exact branches that hold all the numbers of a form replace it: `=1` replaces `one` in English, but not in French, where `one` is 0 and 1. A form that only a number past 9999 takes, such as the `many` of French, is allowed and not needed. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: The rules of git8n check description: Every rule of git8n check, with its level. --- # The rules of `git8n check` An error fails the check. A warning fails it only past `--max-warnings`. Each problem names its rule, and the CLI prints the address of its page. | Rule | Level | What it finds | | --- | --- | --- | | [`invalid-json`](./invalid-json.md) | error | The file is not a JSON object. | | [`invalid-manifest`](./invalid-manifest.md) | error | The manifest is not valid. | | [`not-a-string`](./not-a-string.md) | error | A value is not a string or an object. | | [`placeholders`](./placeholders.md) | error | The translation does not keep the placeholders of the source. | | [`icu-plural-forms`](./icu-plural-forms.md) | warning | An ICU plural does not have the forms of its language. | | [`unknown-link`](./unknown-link.md) | error | A linked message of vue-i18n names a key that does not exist. | | [`plural-branches`](./plural-branches.md) | warning | A plural of vue-i18n does not have the branches of the source. | | [`missing-key`](./missing-key.md) | warning | A key of the source has no translation. | | [`empty-value`](./empty-value.md) | warning | A translation is empty, and the manifest does not mark it. | | [`stale-empty`](./stale-empty.md) | warning | A key is empty on purpose, but its source changed since. | | [`empty-source`](./empty-source.md) | warning | A text of the source is empty. | | [`extra-key`](./extra-key.md) | warning | A key is not in the source. | | [`extra-file`](./extra-file.md) | warning | A file has no version in the source. | | [`skipped-file`](./skipped-file.md) | warning | The editor leaves out a file with this name. | --- title: invalid-json description: "The file is not a JSON object." --- # `invalid-json` **Error** · The file is not a JSON object. `git8n check` fails on this rule. ## Why it matters The application cannot load a file that does not parse, and the editor cannot read it or write it. Every key of the file is then missing in this language. ## Invalid `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour", } ``` ```text $ git8n check --source en locales/fr/common.json error Expected double-quoted property name in JSON at position 24 (line 3 column 1) invalid-json Read more: invalid-json https://git8n.sakuga.dev/docs/reference/rules/invalid-json.html 1 error, 0 warnings in 2 files (en → fr, 1 key). ``` ## Valid `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 2 files (en → fr, 1 key). ``` ## What to do Correct the JSON: a trailing comma, a missing quote and a comment are the usual causes. The top of the file must be an object. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: invalid-manifest description: "The manifest is not valid." --- # `invalid-manifest` **Error** · The manifest is not valid. `git8n check` fails on this rule. ## Why it matters The manifest tells the editor which files a language disables and which keys stay empty on purpose. A wrong entry is ignored, so the editor and the check read another state than the one you wrote. ## Invalid `locales/manifest.json` ```json { "files": { "common": { "fr": "done" } } } ``` `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` ```text $ git8n check --source en locales/manifest.json error locales/manifest.json is not a manifest: files.common.fr is not available, partial or disabled. invalid-manifest Read more: invalid-manifest https://git8n.sakuga.dev/docs/reference/rules/invalid-manifest.html 1 error, 0 warnings in 2 files (en → fr, 1 key). ``` ## Valid `locales/manifest.json` ```json { "files": { "common": { "fr": "partial" } } } ``` `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 2 files (en → fr, 1 key). ``` ## What to do Correct the manifest: it is a JSON object, its `source` is a language, and the state of a file is `available`, `partial` or `disabled`. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: missing-key description: "A key of the source has no translation." --- # `missing-key` **Warning** · A key of the source has no translation. `git8n check` reports this rule as a warning. It fails only past `--max-warnings`. ## Why it matters The application shows the text of the fallback language, or the key itself, in place of the translation. ## Invalid `locales/en/common.json` ```json { "title": "Hello", "save": "Save" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` ```text $ git8n check --source en locales/fr/common.json warning common:save has no fr text. missing-key Read more: missing-key https://git8n.sakuga.dev/docs/reference/rules/missing-key.html 0 errors, 1 warning in 2 files (en → fr, 2 keys). ``` ## Valid `locales/en/common.json` ```json { "title": "Hello", "save": "Save" } ``` `locales/fr/common.json` ```json { "title": "Bonjour", "save": "Enregistrer" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 2 files (en → fr, 2 keys). ``` ## What to do Translate the key, in the editor or with `git8n apply`. If the whole file must stay untranslated in this language, mark it `disabled` in the manifest. `--max-warnings` lets a number of warnings pass. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: not-a-string description: "A value is not a string or an object." --- # `not-a-string` **Error** · A value is not a string or an object. `git8n check` fails on this rule. ## Why it matters i18next reads a string, or an object of more keys. A number, an array or `null` shows as a broken text, and the editor cannot write it. ## Invalid `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour", "tags": [ "nouveau", "promo" ] } ``` ```text $ git8n check --source en locales/fr/common.json error common:tags holds an array. not-a-string Read more: not-a-string https://git8n.sakuga.dev/docs/reference/rules/not-a-string.html 1 error, 0 warnings in 2 files (en → fr, 1 key). ``` ## Valid `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 2 files (en → fr, 1 key). ``` ## What to do Write the text as a string. Put a list in more keys, or join it in one text. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: placeholders description: "The translation does not keep the placeholders of the source." --- # `placeholders` **Error** · The translation does not keep the placeholders of the source. `git8n check` fails on this rule. ## Why it matters The application fills each placeholder with a value. A translation that loses one hides the value, and a translation that adds one shows the raw placeholder. ## Invalid `locales/en/common.json` ```json { "greeting": "Hello {{name}}" } ``` `locales/fr/common.json` ```json { "greeting": "Bonjour" } ``` ```text $ git8n check --source en locales/fr/common.json error common:greeting: lost {{name}}. placeholders Read more: placeholders https://git8n.sakuga.dev/docs/reference/rules/placeholders.html 1 error, 0 warnings in 2 files (en → fr, 1 key). ``` ## Valid `locales/en/common.json` ```json { "greeting": "Hello {{name}}" } ``` `locales/fr/common.json` ```json { "greeting": "Bonjour {{name}}" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 2 files (en → fr, 1 key). ``` ## What to do Keep every placeholder of the source in the translation: `{{name}}`, `{name}`, `{0}`, `$t(…)`, a tag such as `<0>` or `
`, `%s`, and with `--syntax vue-i18n` a linked message `@:key`. The branches of an ICU plural are text: translate them. The modifier of a linked message, `@.lower:key`, can change. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: plural-branches description: "A plural of vue-i18n does not have the branches of the source." --- # `plural-branches` **Warning** · A plural of vue-i18n does not have the branches of the source. `git8n check` reports this rule as a warning. It fails only past `--max-warnings`. ## Why it matters vue-i18n picks a branch of `no car | one car | {count} cars` by the number of its branches: with 2, the first is for 1; with 3, the first is for 0 and the second for 1. A translation with other branches shows the wrong one. Only a source with branches is read as a plural, and a text with an ICU choice is never one. The rule runs with `--syntax vue-i18n` only. ## Invalid `locales/en/common.json` ```json { "cars": "no car | one car | {count} cars" } ``` `locales/fr/common.json` ```json { "cars": "une voiture | {count} voitures" } ``` ```text $ git8n check --source en --syntax vue-i18n locales/fr/common.json warning common:cars: 2 branches, and the source has 3. plural-branches Read more: plural-branches https://git8n.sakuga.dev/docs/reference/rules/plural-branches.html 0 errors, 1 warning in 2 files (en → fr, 1 key). ``` ## Valid `locales/en/common.json` ```json { "cars": "no car | one car | {count} cars" } ``` `locales/fr/common.json` ```json { "cars": "aucune voiture | une voiture | {count} voitures" } ``` ```text $ git8n check --source en --syntax vue-i18n 0 errors, 0 warnings in 2 files (en → fr, 1 key). ``` ## What to do Write as many branches as the source, separated by `|`. Write `{'|'}` for a `|` that is text. If the application sets `pluralRules` for this language, its number of branches can differ: `--max-warnings` lets a number of warnings pass. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: skipped-file description: "The editor leaves out a file with this name." --- # `skipped-file` **Warning** · The editor leaves out a file with this name. `git8n check` reports this rule as a warning. It fails only past `--max-warnings`. ## Why it matters The name of a file is the namespace of its keys. The editor reads only names of letters, digits, `-` and `_`, so it does not show this file. ## Invalid `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` `locales/en/common.v2.json` ```json { "save": "Save" } ``` `locales/fr/common.v2.json` ```json { "save": "Enregistrer" } ``` ```text $ git8n check --source en locales/en/common.v2.json warning common.v2.json: a file name has only letters, digits, - and _. skipped-file locales/fr/common.v2.json warning common.v2.json: a file name has only letters, digits, - and _. skipped-file Read more: skipped-file https://git8n.sakuga.dev/docs/reference/rules/skipped-file.html 0 errors, 2 warnings in 2 files (en → fr, 1 key). ``` ## Valid `locales/en/common.json` ```json { "title": "Hello" } ``` `locales/fr/common.json` ```json { "title": "Bonjour" } ``` `locales/en/common-v2.json` ```json { "save": "Save" } ``` `locales/fr/common-v2.json` ```json { "save": "Enregistrer" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 4 files (en → fr, 2 keys). ``` ## What to do Rename the file in each language, for example `common.v2.json` to `common-v2.json`. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: stale-empty description: "A key is empty on purpose, but its source changed since." --- # `stale-empty` **Warning** · A key is empty on purpose, but its source changed since. `git8n check` reports this rule as a warning. It fails only past `--max-warnings`. ## Why it matters The mark says that the text is empty for one source text. The source changed, so the empty text can now be wrong. ## Invalid `locales/manifest.json` ```json { "empty": { "fr": { "common:suffix": { "source": "0e6cd0ce0450" } } } } ``` `locales/en/common.json` ```json { "suffix": "Inc." } ``` `locales/fr/common.json` ```json { "suffix": "" } ``` ```text $ git8n check --source en locales/fr/common.json warning common:suffix is empty on purpose in fr, but its en text changed since. stale-empty Read more: stale-empty https://git8n.sakuga.dev/docs/reference/rules/stale-empty.html 0 errors, 1 warning in 2 files (en → fr, 1 key). ``` ## Valid `locales/manifest.json` ```json { "empty": { "fr": { "common:suffix": { "source": "57d4cdfb579e" } } } } ``` `locales/en/common.json` ```json { "suffix": "Inc." } ``` `locales/fr/common.json` ```json { "suffix": "" } ``` ```text $ git8n check --source en 0 errors, 0 warnings in 2 files (en → fr, 1 key). ``` ## What to do Read the new source. Translate the key, or mark it empty again in the editor: the mark then records the new source text. [All the rules](./index.md) · [The command `check`](../cli.md#check) --- title: unknown-link description: "A linked message of vue-i18n names a key that does not exist." --- # `unknown-link` **Error** · A linked message of vue-i18n names a key that does not exist. `git8n check` fails on this rule. ## Why it matters vue-i18n writes the text of the key in place of `@:key`. The rule runs with `--syntax vue-i18n` only. A key that no file holds shows the key itself, and vue-i18n warns in the console. The key ends only at a space, `{`, `(`, `)`, `|` or `@`, so in `@:nav.home!` the key is `nav.home!`. ## Invalid `locales/en/common.json` ```json { "ok": "OK", "confirm": "Press @:common.okay" } ``` `locales/fr/common.json` ```json { "ok": "OK", "confirm": "Appuyez sur @:common.okay" } ``` ```text $ git8n check --source en --syntax vue-i18n locales/en/common.json error common:confirm links to @:common.okay, which is a key of no file of en. unknown-link Read more: unknown-link https://git8n.sakuga.dev/docs/reference/rules/unknown-link.html 1 error, 0 warnings in 2 files (en → fr, 2 keys). ``` ## Valid `locales/en/common.json` ```json { "ok": "OK", "confirm": "Press @:common.ok" } ``` `locales/fr/common.json` ```json { "ok": "OK", "confirm": "Appuyez sur @:common.ok" } ``` ```text $ git8n check --source en --syntax vue-i18n 0 errors, 0 warnings in 2 files (en → fr, 2 keys). ``` ## What to do Name a key of the source: `ok` or `common.ok` for the key `ok` of `common.json`, the same form in every link: `ok` if the application merges the files of a language in one object, `common.ok` if it keeps one object for each file. Put a space or a literal, `{'!'}`, between the key and the text that follows it. [All the rules](./index.md) · [The command `check`](../cli.md#check)