Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
195731b
chore(deps): adopt authkit 0.10.0
SirLouen Aug 23, 2026
c67c1f7
feat(graphres): name the reason and its data on every refused answer
SirLouen Aug 23, 2026
ece29db
feat(sdk): let a plugin name the reason and data its error carries
SirLouen Aug 23, 2026
2e4faac
feat(graphres): name the reason on the gate answers and plugin errors
SirLouen Aug 23, 2026
5c3fc17
feat(plugins): name the reason each refused path answers with
SirLouen Aug 23, 2026
7666420
docs: catalogue every reason a refused operation names
SirLouen Aug 23, 2026
d72b549
feat(postgres): store a per-user setting under its key
SirLouen Aug 23, 2026
e1b24b1
feat(locale): resolve the locale from the choice, the header and the …
SirLouen Aug 23, 2026
06b3f66
feat(graph): answer and store the caller's locale
SirLouen Aug 23, 2026
a3634d4
test: pin the locale paths and every error branch they carry
SirLouen Aug 23, 2026
9a009c5
test(cmd): prove the locale wiring through the real binary
SirLouen Aug 23, 2026
a4e30cd
test(e2e): prove the locale resolution against a running server
SirLouen Aug 23, 2026
c45d211
docs: describe the locale the graph answers and stores
SirLouen Aug 23, 2026
e3ea658
chore(deps): pin the translation runtime and the brick
SirLouen Aug 23, 2026
d632033
build(frontend): resolve one copy of the translation packages
SirLouen Aug 23, 2026
0ca710c
test(frontend): gate the repository against a second translation copy
SirLouen Aug 23, 2026
dcca078
feat(frontend): settle the locale before the app mounts
SirLouen Aug 23, 2026
ee21d2d
feat(sdk): re-export the translation seam plugins read through
SirLouen Aug 23, 2026
d7083bb
feat(sdk): let a plugin declare the text domain its strings answer under
SirLouen Aug 23, 2026
41e2545
feat(plugins): declare one text domain per plugin
SirLouen Aug 23, 2026
b9ccbb9
feat(frontend): load one catalogue entry per declared domain
SirLouen Aug 23, 2026
3dc976e
feat(graph): list every locale AlphOne serves
SirLouen Aug 23, 2026
c54bc65
feat(frontend): let the reader choose the language from the served list
SirLouen Aug 23, 2026
ab5acf8
docs: describe the supported locales the graph lists
SirLouen Aug 23, 2026
e2f8a52
build(frontend): refuse a bare string in JSX
SirLouen Aug 23, 2026
9a9cff2
feat(frontend): wrap every core string under the alphone domain
SirLouen Aug 23, 2026
60ea606
build: refuse a bare string in any workspace's JSX
SirLouen Aug 23, 2026
4fffb37
feat(fields): wrap every string under the plugin's own domain
SirLouen Aug 23, 2026
acbae79
feat(importer): wrap every string under the plugin's own domain
SirLouen Aug 23, 2026
ee106d9
feat(whatsapp): wrap every string under the plugin's own domain
SirLouen Aug 23, 2026
89c1817
test(sdk): name the probe hook for what it is
SirLouen Aug 23, 2026
fda09ff
feat(sdk): re-export the locale the display remembers
SirLouen Aug 23, 2026
9fad950
feat(frontend): show dates in the locale the interface stands in
SirLouen Aug 23, 2026
9c0087f
feat(plugins): show dates and sizes in the reader's locale
SirLouen Aug 23, 2026
a83852c
feat(sdk): render a refused answer from the reason it names
SirLouen Aug 24, 2026
2fecd50
feat(frontend): say what every core reason means
SirLouen Aug 24, 2026
a62843e
feat(plugins): say what every plugin reason means
SirLouen Aug 24, 2026
9fafce4
build(i18n): extract one catalogue template per text domain
SirLouen Aug 24, 2026
9097724
chore(i18n): commit the catalogue template of every domain
SirLouen Aug 24, 2026
3315761
test(i18n): gate the templates against drift and a stray domain
SirLouen Aug 24, 2026
bf294c0
build: ignore only the repository's own scripts directory
SirLouen Aug 24, 2026
eec864c
build(i18n): carry translations home for every domain
SirLouen Aug 24, 2026
9faf2df
build(i18n): name the sync targets and the secrets they read
SirLouen Aug 24, 2026
ea119b6
feat(ci): carry the translations that arrived each Monday
SirLouen Aug 24, 2026
2717ee2
test(i18n): pin the locale list and the project each domain reads
SirLouen Aug 24, 2026
7a862f5
chore(i18n): carry the first Spanish translations
SirLouen Aug 24, 2026
d97a508
test(i18n): prove every translation renders
SirLouen Aug 24, 2026
e43e7e4
test(e2e): read the interface in the language a reader chose
SirLouen Aug 24, 2026
6cc0f13
chore(i18n): carry the Spanish drafts every domain now answers in
SirLouen Aug 24, 2026
0f4e7f6
docs: explain adding a translatable string and translating AlphOne
SirLouen Aug 24, 2026
69b403c
test(i18n): prove every menu entry reads the reader's language
SirLouen Aug 24, 2026
6985618
fix(i18n): wrap every string the linter could not see
SirLouen Aug 24, 2026
812cdfc
chore(i18n): carry the Spanish for the strings that were missed
SirLouen Aug 24, 2026
ccb3068
test(i18n): refuse any string a reader meets outside a catalogue
SirLouen Aug 24, 2026
38a3be8
docs: enhance translation documentation with project links and instru…
SirLouen Aug 24, 2026
d7e773d
fix(frontend): mark the option a select stands on as selected
SirLouen Aug 24, 2026
3c38fec
feat(importer): say each row outcome in the reader's language
SirLouen Aug 24, 2026
3edeaff
test(graphres): pin the reason the anonymous gate refuses
SirLouen Aug 24, 2026
aba3e40
test(i18n): prove each plugin answers the catalogue it ships
SirLouen Aug 24, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -15,3 +15,10 @@ ALPHONE_WHATSAPP_VERIFY_TOKEN=
ALPHONE_WHATSAPP_APP_SECRET=
ALPHONE_WHATSAPP_ACCESS_TOKEN=
ALPHONE_WHATSAPP_PHONE_NUMBER_ID=

# POEditor sync, one project per text domain.
# POEDITOR_API_TOKEN=
# POEDITOR_PROJECT_ALPHONE=
# POEDITOR_PROJECT_ALPHONE_FIELDS=
# POEDITOR_PROJECT_ALPHONE_IMPORTER=
# POEDITOR_PROJECT_ALPHONE_WHATSAPP=
51 changes: 51 additions & 0 deletions .github/workflows/translations.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: translations

on:
schedule:
- cron: "0 6 * * 1"
workflow_dispatch:

permissions:
contents: write
pull-requests: write

concurrency:
group: translations
cancel-in-progress: false

jobs:
sync:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6.0.10
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
with:
node-version-file: .node-version
cache: pnpm
cache-dependency-path: pnpm-lock.yaml
- run: pnpm install --frozen-lockfile
- run: make translations
env:
POEDITOR_API_TOKEN: ${{ secrets.POEDITOR_API_TOKEN }}
POEDITOR_PROJECT_ALPHONE: ${{ secrets.POEDITOR_PROJECT_ALPHONE }}
POEDITOR_PROJECT_ALPHONE_FIELDS: ${{ secrets.POEDITOR_PROJECT_ALPHONE_FIELDS }}
POEDITOR_PROJECT_ALPHONE_IMPORTER: ${{ secrets.POEDITOR_PROJECT_ALPHONE_IMPORTER }}
POEDITOR_PROJECT_ALPHONE_WHATSAPP: ${{ secrets.POEDITOR_PROJECT_ALPHONE_WHATSAPP }}
- run: make catalogs
- id: moved
run: echo "any=$(git status --porcelain languages plugins/*/languages frontend/src/languages plugins/*/frontend/languages | head -c1)" >> "$GITHUB_OUTPUT"
- if: steps.moved.outputs.any != ''
uses: peter-evans/create-pull-request@271a8d0340265f705b14b6d32b9829c1cb33d45e # v7.0.8
with:
branch: translations/batch
title: "chore(i18n): carry the translations that arrived"
body: |
The translation platform exported new work since the last batch.

Every catalogue here was compiled from its source, so the byte
comparison gate proves the shipped files match.
commit-message: "chore(i18n): carry the translations that arrived"
delete-branch: true
2 changes: 1 addition & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,4 @@ go.work.sum
/deploy/
.claude/
.mcp.json
scripts/
/scripts/
15 changes: 15 additions & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }],
"react/jsx-no-literals": "error"
},
"overrides": [
{
"files": ["**/test/**", "**/*.test.ts", "**/*.test.tsx", "sdk/frontend/testing.tsx"],
"rules": { "react/jsx-no-literals": "off" }
}
]
}
12 changes: 12 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ generate:
go run ./cmd/schemagen
pnpm exec graphql-codegen --config codegen.ts

pot:
pnpm --filter @alphone/frontend exec node scripts/write-pot.ts

catalogs:
pnpm --filter @alphone/frontend exec node scripts/write-catalogs.ts

translations:
cd frontend && node --env-file-if-exists=$(CURDIR)/.env scripts/sync-translations.ts

translations-retire:
cd frontend && node --env-file-if-exists=$(CURDIR)/.env scripts/retire-translations.ts

outdated:
@echo "=== direct Go modules with updates ==="
@go list -m -u -f '{{if and (not .Indirect) .Update}} {{.Path}}: {{.Version}} -> {{.Update.Version}}{{end}}' all 2>/dev/null | grep . || echo " (all current)"
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,14 @@ A plugin-first CRM. Go backend exposing a JSON API, React SPA frontend.
Setup, self-hosting, and reference guides live at
[docs.alph.one](https://docs.alph.one/).

## Translating

The interface speaks whatever language its catalogues carry. Translation
happens on [POEditor](https://poeditor.com/join/project/1skIO0ryto), no Git
needed, and a weekly job carries the finished work back in one batch.
[Translate AlphOne](https://docs.alph.one/contributing/translate-alphone/)
walks it, and names the separate project each plugin keeps.

## License

Copyright (C) 2026 Manuel 'SirLouen' Camargo
Expand Down
29 changes: 29 additions & 0 deletions cmd/alphone/main_exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,33 @@ func TestMainBinarySeedFillsTheDemoImportField(t *testing.T) {
}
}

func TestMainBinaryStoresAndAnswersTheLocale(t *testing.T) {
t.Parallel()

binary, env := coverBinary(t)
databaseURL := testDatabaseURL(t)
var stderr bytes.Buffer
seedCmd := exec.Command(binary, "seed")
seedCmd.Dir = t.TempDir()
seedCmd.Env = append(env, "ALPHONE_DATABASE_URL="+databaseURL)
seedCmd.Stderr = &stderr
if err := seedCmd.Run(); err != nil {
t.Fatalf("seed: %v, stderr: %s", err, stderr.String())
}
addr, secret := servedSeededBinary(t, databaseURL)

set := postGraph(t, addr, secret, `{"query":"mutation { setLocale(locale: \"es-ES\") }"}`)
if len(set.Errors) > 0 || set.Data.SetLocale != "es-ES" {
t.Fatalf("setLocale = %q with errors %v, want the choice stored through the real wiring",
set.Data.SetLocale, set.Errors)
}

asked := postGraph(t, addr, secret, `{"query":"{ locale }"}`)
if asked.Data.Locale != "es-ES" {
t.Errorf("locale = %q, want the stored choice back from the real binary", asked.Data.Locale)
}
}

func TestMainBinaryServesUntilSignalled(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -325,6 +352,8 @@ type graphAnswer struct {
Node map[string]any `json:"node"`
} `json:"edges"`
} `json:"contacts"`
SetLocale string `json:"setLocale"`
Locale string `json:"locale"`
} `json:"data"`
Errors []struct {
Message string `json:"message"`
Expand Down
1 change: 1 addition & 0 deletions cmd/alphone/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ func run(
Live: hub,
Auth: auth,
Admin: admin,
Settings: postgres.NewUserSettingStore(pool),
LoginLimiter: ratelimit.NewLimiter(ratelimit.Config{}),
}, registered)
if err != nil {
Expand Down
7 changes: 7 additions & 0 deletions docs/astro.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,13 @@ export default defineConfig({
label: 'Extending',
items: [{ slug: 'extending/screens' }, { slug: 'extending/graph' }],
},
{
label: 'Contributing',
items: [
{ slug: 'contributing/add-a-translatable-string' },
{ slug: 'contributing/translate-alphone' },
],
},
{
label: 'Reference',
items: [
Expand Down
145 changes: 145 additions & 0 deletions docs/src/content/docs/contributing/add-a-translatable-string.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
---
title: Add a translatable string
description: How to write a new interface string so it reaches translators, and what the gates check.
---

This page is for you if you are writing code that shows words to a
reader. Every such word has to reach the translators, and the repository
refuses to build if one does not.

## The short version

Wrap the string, name your text domain, then run `make pot` and commit
the template it regenerates. That is the whole contract. The rest of
this page explains what to wrap and the few places where wrapping needs
care.

## The functions

Everything a reader sees in AlphOne is React, so every string lives in
TypeScript. Import the gettext functions from `@alphone/frontend-sdk`,
never from anywhere else, so the whole application shares one
translation runtime.

```tsx
import { __, _x, sprintf } from '@alphone/frontend-sdk'

__('Add contact', 'alphone')
sprintf(__('A name runs to %(max)d characters at most.', 'alphone'), { max })
_x('Status', 'account status', 'alphone')
```

Use `__` for ordinary text. Use `sprintf` around it when a value goes
inside. A placeholder is named, like `%(max)d`, so a translator can move
it to wherever their language needs it. The letter says what fills the
hole, `d` for a number and `s` for text. Use `_x` when one English word
means two different things, which is covered below. The kit also exports
`_n` for wording that changes with a count, which no string needs yet.

## Name your own domain

The last argument is always the text domain, and it is always a literal.
Core code under `frontend/src` and `sdk/frontend` names `alphone`. A
plugin names its own domain, which is `alphone-` plus its folder name,
so the fields plugin writes `'alphone-fields'`. Each domain is its own
catalogue and its own translation project, so a string filed under the
wrong domain would ship in the wrong catalogue. A test reads every
source file and fails when a call names a domain the file does not own.

## A new plugin declares its catalogue

A plugin that shows words ships its own catalogues and tells the host
where they are. The manifest carries one `locale` entry.

```ts
const catalogs = import.meta.glob<{ default: Catalog }>('./languages/*.json')

export const plugin: FrontendPlugin = {
id: 'whatsapp',
locale: { domain: 'alphone-whatsapp', load: globCatalogs(catalogs) },
}
```

The host loads the catalogue matching the reader's language before the
first render, so a plugin never waits for its own words.

## Error messages have their own seam

The server answers a failed request with a short reason code. A template
turns that code into a sentence for the reader. A plugin declares its
templates as a function on the manifest, and the function runs when an
error arrives rather than when the file loads, so the catalogue the
reader loaded is the one that answers.

```ts
export function errorTemplates(): Record<string, string> {
return {
message_content_required: __('Write something to send.', 'alphone-whatsapp'),
upstream_failed: __('WhatsApp did not accept the message.', 'alphone-whatsapp'),
}
}
```

## Never wrap these

Class names, test ids, route paths, GraphQL field names, reason codes,
and any status or type value sent to the server. Those are identifiers,
not words, and translating one breaks the software.

Also leave alone the product names AlphOne and WhatsApp, and anything a
person typed into the product. A custom field's label reads as its
author wrote it, in whatever language they wrote it, and no catalogue
reaches it.

## When one word means two things

English reuses words that other languages separate. Status means one
thing for an account and another for a message. Give each use a context
and translators will see them as separate entries.

```tsx
_x('Status', 'account status', 'alphone')
_x('WhatsApp', 'admin section', 'alphone-whatsapp')
```

Only a word standing alone needs this. A word inside a whole sentence
carries its own meaning already.

## The trap that costs an afternoon

Calling a gettext function at the top level of a module runs it once,
when the file is first loaded. That can happen before the catalogue
arrives, so the text freezes in English and never changes again. Every
test still passes, because tests run in English.

The fix is to read the string when it is used rather than when the file
loads. The navigation items do this with a getter.

```ts
nav: [{
get label() {
return _x('WhatsApp', 'admin section', 'alphone-whatsapp')
},
to: '/whatsapp',
icon: whatsappIcon,
}],
```

The getter costs nothing, so prefer it for anything imported eagerly.

## What the gates check

Run `make pot` after adding a string, and commit the templates it
regenerates. There is one template per domain.

A test rebuilds every template and compares it to the committed one byte
for byte, so a forgotten `make pot` fails the build. The domain test
above catches a string filed under a domain its file does not own. The
linter refuses bare text in the admin's markup, so a string that never
met a gettext function cannot ship. Further tests check every committed
translation, so an entry the template no longer names, a placeholder the
translation renamed, or a translation that fails to render all fail the
build. Nothing here depends on a human remembering.

If you also changed a translation, run `make catalogs` too. Compiled
catalogues are committed, and they are compared the same way.
Loading
Loading