A Cloudflare-native translation workbench for i18next projects. Translators add
languages, fill in strings against the live English source, build a shared
glossary, and open a GitHub PR that writes src/locales/<lang>/translation.json
back to the source repo — no Git or developer knowledge required on their end.
Built for Grimoire and running at
translate.grimoiremods.com, but it's a template: the engine is project
agnostic, so any i18next repo (e.g. another Deadlock mod) can stand up its own
instance by editing one config file and pointing it at a different repo. See
Use this for your own project.
- The app reads English source strings from a GitHub repo
(
GITHUB_REPO):src/locales/en/translation.json. The source is cached per-isolate for 5 minutes and falls back to the last good copy if GitHub is briefly unavailable. - Translators add target languages and save string values into Cloudflare D1.
Placeholders like
{{count}}are validated — mismatches are rejected. - A glossary helper surfaces frequently-repeated terms so contributors agree on (or skip) them once and stay consistent.
- Download exports a language as
src/locales/<lang>/translation.json; Upload reads an edited file of that shape back into D1 (unknown keys and placeholder mismatches are skipped; by default it only fills blanks). - PR creates or updates a
translations/<lang>branch and opens a GitHub pull request against the source repo's default branch.
Optionally, an in-client suggestion loop lets end users propose translations from inside the app itself; this requires a separate social backend (see Optional: the suggestion loop).
- Astro 6 + React 19
- Cloudflare Workers with Assets
- Cloudflare D1 — target-language drafts, contributors, glossary, suggestions
- Cloudflare KV — sessions
- Cloudflare Access — translator login (production)
- GitHub REST API — reading
ensource strings and opening PRs
This repo is a GitHub template. Click Use this template (or fork it), then:
Everything project-specific lives in one file: the app name, the short header
badge, the public URL (used in PR descriptions), the name of the end-user app
you're translating, and the priority glossary terms (brand/product nouns that
usually shouldn't be translated). Edit those values; nothing else in src/
needs changing for branding.
name— the Worker name (e.g.qol-lock-translate).routes[].pattern— your custom domain, or removeroutesto use the*.workers.devURL.vars.GITHUB_REPO/vars.GITHUB_BRANCH— the repo holdingsrc/locales/en/translation.jsonand the branch PRs target.vars.SOCIAL_BASE_URL— optional; remove it unless you run a social backend.d1_databases[].database_idandkv_namespaces[].id— replace with your own (created in step 3).
pnpm exec wrangler d1 create <your-db-name> # -> copy database_id into wrangler.jsonc
pnpm exec wrangler kv namespace create SESSION # -> copy id into wrangler.jsonc
pnpm db:migrate:remote # apply migrations to D1(database_name in wrangler.jsonc and the --remote/--local migration
scripts in package.json reference the db name — keep them in sync.)
Create a Cloudflare Access application for your workbench URL, then set the Worker secrets (see Cloudflare setup for detail):
pnpm exec wrangler secret put CF_ACCESS_TEAM
pnpm exec wrangler secret put CF_ACCESS_AUD
pnpm exec wrangler secret put GITHUB_TOKENThe GitHub token is a fine-grained PAT scoped to your GITHUB_REPO with
Contents: Read and write, and Pull requests: Read and write.
pnpm run deployThat's the whole hand-off — no code changes beyond site.config.ts and config.
pnpm install
cp .dev.vars.example .dev.vars
pnpm devIn dev the middleware authenticates with TRANSLATOR_EMAIL from .dev.vars.
Production verifies the Cloudflare Access JWT instead.
The Grimoire D1 database has already been created and migrated; its id is
committed in wrangler.jsonc. If you ever recreate it:
pnpm exec wrangler d1 create grimoire-translateCopy the generated database_id into wrangler.jsonc, then apply migrations:
pnpm db:migrate:remoteGrimoire-specific: on the maintainer's machine the current Wrangler token cannot auto-list account ids. Prefix remote Wrangler commands with the account id from
grimoire-admin/.dev.vars:ACCOUNT_ID=$(awk -F= '/^CF_ACCOUNT_ID=/{print $2}' ../grimoire-admin/.dev.vars) CLOUDFLARE_ACCOUNT_ID="$ACCOUNT_ID" pnpm db:migrate:remote
Create a Cloudflare Access application for your workbench URL (Grimoire's is
https://translate.grimoiremods.com). Grimoire's translator allowlist process
is documented in docs/translator-access.md.
Set these Worker secrets:
pnpm exec wrangler secret put CF_ACCESS_TEAM
pnpm exec wrangler secret put CF_ACCESS_AUD
pnpm exec wrangler secret put GITHUB_TOKENUse the same CLOUDFLARE_ACCOUNT_ID=... prefix if Wrangler prints an account id
lookup error. If secret put returns an authentication error, run
pnpm exec wrangler login with an account user that has Workers edit
permission, or switch to an API token that can edit Worker scripts and secrets.
CF_ACCESS_TEAM is the Zero Trust team subdomain without
.cloudflareaccess.com. CF_ACCESS_AUD is the Access app AUD tag.
pnpm run deploywrangler.jsonc binds the Worker to its custom domain.
.github/workflows/deploy.yml is a manual workflow_dispatch deploy. It
expects these repository secrets:
CLOUDFLARE_API_TOKEN— token that can edit Workers, D1, and Worker secretsCLOUDFLARE_ACCOUNT_ID— Cloudflare account idCF_ACCESS_TEAM— Zero Trust team subdomain without.cloudflareaccess.comCF_ACCESS_AUD— AUD tag from the workbench's Access appGRIMOIRE_TRANSLATE_GITHUB_TOKEN— fine-grained GitHub PAT for the source repo
The workflow applies D1 migrations, deploys the Worker, then uploads runtime secrets. That ordering supports the first deploy, when the Worker script does not exist yet.
The /api/live/* endpoints power an in-client suggestion loop where end users
propose translations from inside the app. They authenticate against a separate
social backend via SOCIAL_BASE_URL (Grimoire uses its own Discord-login
Worker). If SOCIAL_BASE_URL is unset, those endpoints return 501 and the
rest of the workbench works normally — so a new project can ignore this feature
entirely until it has a backend to wire up.
This is a focused MVP, not a Weblate replacement. It intentionally does not include translation memory, comments, conflict resolution, or hosting multiple projects from one deployment — each project runs its own instance.