shipglot

next-intl translation QA in CI

next-intl messages are ICU MessageFormat strings. A missing brace throws at render time, and a plural without the categories Polish or Russian need shows the wrong form. The gate parses every message with the FormatJS ICU parser and checks plural categories against CLDR for each target language.

What the gate reads

Format idNameExtensionsPlaceholders and pluralsLayout
next-intl-jsonnext-intl / FormatJS JSON.jsonICU MessageFormat: {name}, {count, plural, …}, {x, select, …}, <tag>…</tag>One file per locale

Detected layout

For a Next.js with next-intl project laid out like this:

messages/en.json
messages/fr.json
messages/pl.json
package.json

npx shipglot check finds the files without a config. npx shipglot init writes what it detected to shipglot.config.json:

{
  "sourceLocale": "en",
  "files": [
    {
      "format": "next-intl-json",
      "path": "messages/{locale}.json",
      "exclude": [
        "_*.json"
      ]
    }
  ]
}

Add "locales" to restrict target languages, "glossary" for approved terms, and "checks" to change a check's severity. The gate's README lists every option.

GitHub Actions workflow

Save as .github/workflows/translations.yml. The step fails on errors and annotates the pull request; warnings show in the job summary.

name: Translations
on:
  pull_request:
    paths:
      - "messages/**"
      - "shipglot.config.json"
jobs:
  shipglot:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v5
      - uses: aurelien64/shipglot@v0
        with:
          fail-on: error

To see findings in GitHub code scanning as well, add sarif-file: shipglot.sarif under with: and upload it with github/codeql-action/upload-sarif@v3 (the job then needs security-events: write).

Defects the gate catches

Each example below was run through the gate when this page was built, with the config above. The findings under it are the gate's output.

1. An ICU message that does not parse

The closing brace is missing, so next-intl throws when the component renders in French.

messages/en.json

{
  "Cart": {
    "items": "{count, plural, one {# item} other {# items}}"
  }
}

messages/fr.json

{
  "Cart": {
    "items": "{count, plural, one {# article} other {# articles}"
  }
}

Gate output

  • erroricu-syntaxfr
    Cart.items
    invalid ICU message: unclosed argument, expected } at offset 0
    messages/fr.json:3

2. Plural categories Polish needs

Polish uses one, few, many and other. With only one and other, 2, 3 and 4 get the other form: "2 plików" instead of "2 pliki".

messages/en.json

{
  "Files": {
    "count": "{count, plural, one {# file} other {# files}}"
  }
}

messages/pl.json

{
  "Files": {
    "count": "{count, plural, one {# plik} other {# plików}}"
  }
}

Gate output

  • Files.count
    plural {count} is missing categories few, many required by pl
    messages/pl.json:3

3. A renamed argument

next-intl passes {name}; the French message asks for {nom}, which is never supplied.

messages/en.json

{
  "Home": {
    "greeting": "Hello {name}"
  }
}

messages/fr.json

{
  "Home": {
    "greeting": "Bonjour flagged by placeholder-mismatch: {nom}"
  }
}

Gate output

  • Home.greeting
    placeholder mismatch: missing {name}; unexpected {nom}
    messages/fr.json:3

4. A rich-text tag dropped

t.rich() renders <link> as a component. Without it, the French sentence has no link.

messages/en.json

{
  "Signup": {
    "terms": "Read the <link>terms</link> before you continue."
  }
}

messages/fr.json

{
  "Signup": {
    "terms": "Lisez les conditions avant de continuer."
  }
}

Gate output

  • Signup.terms
    markup mismatch: missing <link>, </link>
    messages/fr.json:3

Run it on your machine

npx shipglot check
npx shipglot check --reporter json=shipglot.json

Exit code 0 means clean, 1 means findings at or above --fail-on, 2 means a usage or config error. Checks used on this page: icu-syntax, plural-forms, placeholder-mismatch, markup-mismatch.

Translation in the same workflow

With a Shipglot account, npx shipglot translate translates new and changed keys and writes them into these files before the check runs. Hosted translation is not open yet; see the signup page.