██╗ ██╗ █████╗ ████████╗ █████╗
██║ ██║██╔══██╗╚══██╔══╝██╔══██╗
███████║███████║ ██║ ███████║
██╔══██║██╔══██║ ██║ ██╔══██║
██║ ██║██║ ██║ ██║ ██║ ██║
╚═╝ ╚═╝╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝
A lightweight Translation Management System (TMS) — sync i18n translation keys between your codebase and Google Sheets with a single CLI command.
Hata is a lightweight translation management tool ✅ that bridges the gap between your codebase's translation keys and your translators. No more copy-pasting between JSON files and spreadsheets — developers push keys from base.json to a shared Google Sheet, translators fill in the translations without touching any code, and developers pull the completed translations back as ready-to-use JSON files.
If you're looking for a simple Translation Management System (TMS) that works with Google Sheets, hata is built for you.
base.json → (push) → Google Sheet → (pull) → locales/*.json → Your App
init— Interactive setup wizard with locale picker, alias config, and export format selectionpush— Sync keys frombase.json→ Google Sheet, filling thebasecolumn; never touches language columnspull— Generate per-language JSON files from the sheet (flat or nested output)diff— Show what's out of sync betweenbase.jsonand the sheetimport— Bulk-import an existing locale JSON file into a sheet column (great for migrating existing projects)- Supports Service Account and OAuth authentication
- Flat and nested
base.jsoninput support (both flattened to dot-notation keys) - Nested or flat JSON output — controlled by
nested_jsonoption in config - Language aliases — export as
en.json/id.jsoninstead ofen-US.json/id-ID.json - Interpolation passthrough (
Hello {{name}}) - Interactive locale selector with search/filter (250+ locales)
go install github.com/amrilsyaifa/hata@latestgit clone https://github.com/amrilsyaifa/hata.git
cd hata
go build -o hata .
# Move to your PATH (macOS/Linux)
mv hata /usr/local/bin/hataDownload the latest binary from the Releases page.
- Go to Google Cloud Console
- Create a project → Enable Google Sheets API
- Create a Service Account → Download the JSON credentials
- Share your Google Sheet with the service account's email address (Editor access)
- Go to Google Cloud Console
- Create a project → Enable Google Sheets API
- Go to APIs & Services → OAuth consent screen
- User Type: External
- Fill in App name and support email → Save
- Under Test users → add your Google email → Save
- Go to APIs & Services → Credentials → + Create Credentials → OAuth client ID
- Application type: Web application (or Desktop app)
- Name: anything (e.g.
hata) - Under Authorized redirect URIs → click Add URI → enter:
http://localhost:8085 - Click Create
- Click Download JSON → save as
.i18n/credentials.jsonin your project
mkdir -p .i18n
# Move your downloaded credentials file
mv ~/Downloads/client_secret_*.json .i18n/credentials.json
# Update i18n.config.yml
hata init
# Choose: OAuth, credentials path: .i18n/credentials.json, token path: .i18n/token.json./hata push- Browser opens → log in with your Google account
- Grant access to Google Sheets
- Browser shows "Authorization successful!" — close it
- Token saved to
.i18n/token.jsonautomatically - All future runs reuse the cached token — no browser needed
Note: If you see
redirect_uri_mismatch, make surehttp://localhost:8085is added to your OAuth client's Authorized redirect URIs in Google Cloud Console.
hata initThis will guide you through:
- Project ID
- Google Sheet ID (from the sheet URL:
https://docs.google.com/spreadsheets/d/YOUR_SHEET_ID/edit) - Auth method (Service Account or OAuth)
- Language selection (interactive picker — Space to select, Enter to confirm)
- Short aliases for each locale (e.g.
enforen-US,idforid-ID) — used as output filenames - Base file and output directory paths
- Export format — nested JSON or flat JSON
It generates i18n.config.yml in your project root.
base.json is your source of truth. Both flat and nested formats are supported:
Flat:
{
"auth.login": "Login",
"auth.logout": "Logout",
"auth.welcome": "Hello {{name}}",
"home.title": "Welcome to our app"
}Nested:
{
"auth": {
"login": "Login",
"logout": "Logout",
"welcome": "Hello {{name}}"
},
"home": {
"title": "Welcome to our app"
}
}Both produce the same dot-notation keys in the sheet (auth.login, home.title, etc.).
hata pushNew keys are appended to the sheet with the base text pre-filled. Language columns (en-US, id-ID, …) are left empty for translators to fill in. Existing rows are never deleted or overwritten.
| key | base | en-US | id-ID |
|---|---|---|---|
| auth.login | Login | Login | Masuk |
| auth.logout | Logout | Logout | Keluar |
| auth.welcome | Hello {{name}} | Hello {{name}} | Halo {{name}} |
| home.title | Welcome to our app | Welcome to our app | Selamat datang |
The
basecolumn is managed byhata push. Translators only edit the language columns.
hata pullGenerates one JSON file per language. If aliases are configured (en-US → en), files are named locales/en.json, locales/id.json, etc. Output format (nested or flat) is controlled by nested_json in config.
Nested output (nested_json: true, default):
{
"auth": {
"login": "Login",
"logout": "Logout",
"welcome": "Hello {{name}}"
},
"home": {
"subtitle": "Get started below",
"title": "Welcome to our app"
}
}Flat output (nested_json: false):
{
"auth.login": "Login",
"auth.logout": "Logout",
"auth.welcome": "Hello {{name}}",
"home.subtitle": "Get started below",
"home.title": "Welcome to our app"
}If you already have locales/id.json with many translations, import it directly:
# First push your keys to create sheet rows
hata push
# Then import the existing translations into the id-ID column
hata import --file ./locales/id.json --lang id-ID
hata import --file ./locales/en.json --lang en-US- Accepts nested or flat JSON (auto-flattened)
- Updates only keys that already exist in the sheet
- Prints a list of any keys not yet in the sheet
hata diffMissing in sheet:
- auth.register
Unused in base:
- old.key
i18n.config.yml:
project_id: my-project
sheet:
id: "1abc123xyz..." # from your Google Sheet URL
name: "Translations" # sheet tab name
auth:
type: service_account # or "oauth"
credentials_path: ".i18n/credentials.json"
token_path: ".i18n/token.json" # used by oauth only
languages:
- en-US
- id-ID
# Optional: short aliases used as output filenames when pulling.
# Sheet columns still use the full locale code (en-US, id-ID).
aliases:
en-US: en # → locales/en.json
id-ID: id # → locales/id.json
paths:
base: "./base.json"
output: "./locales"
options:
nested_json: true # true = nested JSON output, false = flat key output
sort_keys: true # sort keys alphabetically in output
keep_unused: true # keep stale keys in sheet (don't auto-delete)Security: Add
.i18n/to your.gitignoreto avoid committing credentials.
| Command | Description |
|---|---|
hata init |
Interactive setup — creates i18n.config.yml |
hata push |
Sync new keys from base.json to sheet |
hata pull |
Download translations from sheet → locale JSON files |
hata diff |
Show keys that are out of sync |
hata import -f <file> -l <lang> |
Import an existing locale file into a sheet column |
hata clean |
Interactively remove stale keys from Google Sheet |
| Flag | Short | Description |
|---|---|---|
--file |
-f |
Path to the locale JSON file (required) |
--lang |
-l |
Locale code as it appears in the sheet header, e.g. id-ID (required) |
hata import --file ./locales/id.json --lang id-ID
hata import -f ./locales/en.json -l en-USFinds keys that exist in the Google Sheet but are no longer present in your base.json. Presents an interactive multi-select list, then permanently deletes the confirmed rows from the sheet.
hata cleanFlow:
- Reads all keys from the sheet and compares against
base.json. - Displays a multi-select list of stale keys (Space to toggle, Enter to confirm).
- Asks for a final confirmation before deleting.
- Deletes selected rows from the sheet in a single batch update.
Note: Deletions cannot be undone. Review the key list carefully before confirming.
Install a compatible i18n library (e.g., react-i18next or next-intl):
npm install react-i18next i18nextAdd a hata pull step to your build/dev workflow:
# package.json
{
"scripts": {
"i18n:pull": "hata pull",
"dev": "hata pull && next dev",
"build": "hata pull && next build"
}
}Load the generated JSON files:
// i18n.js
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
import id from './locales/id.json';
i18n.use(initReactI18next).init({
resources: { en: { translation: en }, id: { translation: id } },
lng: 'en',
fallbackLng: 'en',
});
export default i18n;Use in components:
import { useTranslation } from 'react-i18next';
function LoginButton() {
const { t } = useTranslation();
return <button>{t('auth.login')}</button>;
}Use with Next.js next-intl:
// next.config.js
import createNextIntlPlugin from 'next-intl/plugin';
const withNextIntl = createNextIntlPlugin();
export default withNextIntl({});// messages/en.json ← point hata output here
// app/[locale]/layout.tsx
import { NextIntlClientProvider } from 'next-intl';npm install i18next react-i18nextCopy locales/ into your project (e.g., src/locales/), then update i18n.config.yml:
paths:
output: "./src/locales"// src/i18n.js
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
import id from './locales/id.json';
i18n.use(initReactI18next).init({
resources: { en: { translation: en }, id: { translation: id } },
lng: 'en',
fallbackLng: 'en',
interpolation: { escapeValue: false },
});
export default i18n;// App.js
import './src/i18n';
import { useTranslation } from 'react-i18next';
export default function App() {
const { t } = useTranslation();
return <Text>{t('home.title')}</Text>;
}npm install vue-i18n// src/i18n.js
import { createI18n } from 'vue-i18n';
import en from './locales/en.json';
import id from './locales/id.json';
export default createI18n({
locale: 'en',
fallbackLocale: 'en',
messages: { en, id },
});<!-- Component -->
<template>
<button>{{ $t('auth.login') }}</button>
</template>package main
import (
"encoding/json"
"fmt"
"os"
)
func loadLocale(lang string) (map[string]interface{}, error) {
data, err := os.ReadFile(fmt.Sprintf("locales/%s.json", lang))
if err != nil {
return nil, err
}
var messages map[string]interface{}
return messages, json.Unmarshal(data, &messages)
}Or use a library like go-i18n:
go get github.com/nicksnyder/go-i18n/v2bundle := i18n.NewBundle(language.English)
bundle.RegisterUnmarshalFunc("json", json.Unmarshal)
bundle.LoadMessageFile("locales/en.json")
bundle.LoadMessageFile("locales/id.json")Add a hata pull step to your CI or Rakefile, then copy the output to your Rails locale path:
# i18n.config.yml — point output to Rails locale dir
paths:
output: "./config/locales"Rails expects YAML by default. To use JSON, add i18n-js or convert with a Rake task:
# Rakefile
task :pull_translations do
system('hata pull')
endAlternatively use hata pull and process with a script to convert JSON → YAML:
require 'json'
require 'yaml'
Dir['config/locales/*.json'].each do |f|
lang = File.basename(f, '.json')
data = JSON.parse(File.read(f))
File.write("config/locales/#{lang}.yml", { lang => data }.to_yaml)
endThe project has unit tests covering all internal packages. Run them locally with:
go test ./internal/...Run with verbose output and the race detector (mirrors what CI runs):
go test -v -race -count=1 ./internal/...| Package | What's covered |
|---|---|
internal/auth |
saveToken, loadToken, token file creation, savingTokenSource persist / no-write |
internal/config |
Round-trip YAML save/load, missing file, invalid YAML |
internal/diff |
In-sync, missing-in-sheet, unused-in-base, empty input, sorted output |
internal/i18n |
ReadBase (valid/missing/bad JSON), SortedKeys, FlatToNested (simple/nested/deep/empty), GenerateLocaleFiles (flat & nested) |
internal/locale |
Display format, CodeFromDisplay roundtrip, unique locale codes, common locale presence |
Every pull request targeting main must pass two required checks defined in .github/workflows/ci.yml:
| Job | What it does |
|---|---|
| Test | go build ./... + go test -v -race -count=1 ./... |
| Lint | golangci-lint with a 5-minute timeout |
The Merge button on a PR is disabled until both checks are green. To enforce this hard requirement, enable branch protection in GitHub:
- Go to Settings → Branches → Add rule for
main - Enable "Require status checks to pass before merging"
- Add
TestandLintas required status checks - Enable "Require branches to be up to date before merging"
# .github/workflows/i18n.yml
name: Sync Translations
on:
schedule:
- cron: '0 8 * * 1' # Every Monday at 8am
workflow_dispatch:
jobs:
pull:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: '1.25'
- name: Install hata
run: go install github.com/amrilsyaifa/hata@latest
- name: Write credentials
run: |
mkdir -p .i18n
echo '${{ secrets.GOOGLE_CREDENTIALS }}' > .i18n/credentials.json
- name: Pull translations
run: hata pull
- name: Commit updated locales
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add locales/
git diff --cached --quiet || git commit -m "chore: update translations"
git pushStore your service account credentials JSON as a GitHub secret named
GOOGLE_CREDENTIALS.
hata [command] [flags]
Commands:
init Initialize hata configuration interactively
push Sync translation keys from base.json to Google Sheet
pull Generate per-language JSON files from Google Sheet
diff Show differences between base.json and Google Sheet
help Help about any command
Flags:
--config string Config file path (default "i18n.config.yml")
-h, --help Help for hata
your-project/
├── base.json ← Your translation keys (source of truth)
├── i18n.config.yml ← Hata configuration
├── .i18n/
│ └── credentials.json ← Google credentials (gitignored)
└── locales/ ← Generated output (can be gitignored)
├── en.json
└── id.json
Amril Syaifa
- GitHub: @amrilsyaifa
- Repository: github.com/amrilsyaifa/hata
MIT © Amril Syaifa