diff --git a/.env.example b/.env.example index 094fdff8..9d83476a 100644 --- a/.env.example +++ b/.env.example @@ -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= diff --git a/.github/workflows/translations.yml b/.github/workflows/translations.yml new file mode 100644 index 00000000..9702df31 --- /dev/null +++ b/.github/workflows/translations.yml @@ -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 diff --git a/.gitignore b/.gitignore index 494fe094..412a40a6 100644 --- a/.gitignore +++ b/.gitignore @@ -11,4 +11,4 @@ go.work.sum /deploy/ .claude/ .mcp.json -scripts/ \ No newline at end of file +/scripts/ \ No newline at end of file diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 00000000..3b8d4884 --- /dev/null +++ b/.oxlintrc.json @@ -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" } + } + ] +} diff --git a/Makefile b/Makefile index d777f494..0179c845 100644 --- a/Makefile +++ b/Makefile @@ -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)" diff --git a/README.md b/README.md index 5e6b6ac2..9e0f92b9 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/alphone/main_exec_test.go b/cmd/alphone/main_exec_test.go index c79cd6de..293bd781 100644 --- a/cmd/alphone/main_exec_test.go +++ b/cmd/alphone/main_exec_test.go @@ -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() @@ -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"` diff --git a/cmd/alphone/run.go b/cmd/alphone/run.go index a1cf93e8..a70f2899 100644 --- a/cmd/alphone/run.go +++ b/cmd/alphone/run.go @@ -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 { diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 58a1ce20..25afc276 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -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: [ diff --git a/docs/src/content/docs/contributing/add-a-translatable-string.md b/docs/src/content/docs/contributing/add-a-translatable-string.md new file mode 100644 index 00000000..994c6ca4 --- /dev/null +++ b/docs/src/content/docs/contributing/add-a-translatable-string.md @@ -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 { + 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. diff --git a/docs/src/content/docs/contributing/translate-alphone.md b/docs/src/content/docs/contributing/translate-alphone.md new file mode 100644 index 00000000..438aeeb0 --- /dev/null +++ b/docs/src/content/docs/contributing/translate-alphone.md @@ -0,0 +1,101 @@ +--- +title: Translate AlphOne +description: How to translate the interface into your language, what happens to your work, and when it ships. +--- + +This page is for you if you speak a language other than English and want +AlphOne to speak it too. You do not need to write code, and you do not +need to use Git. + +## What you are translating + +AlphOne keeps every sentence the interface shows in a list, separate +from the code. Each entry has an English original and a place for your +translation. The English original is called the source string, and it +never changes when you translate it. + +There are four lists rather than one. The core interface keeps one, and +each plugin keeps its own, so the WhatsApp plugin's words live apart +from the core's. Most strings live in the core list, and the plugin +lists are short. + +The lists live in a file format called PO, short for Portable Object, +which is the format the GNU gettext tools have used for decades. You +will not have to edit those files by hand. A website does it for you. + +## Where the work happens + +Translation happens on POEditor, a website built for exactly this. You +sign in, pick your language, and you see the English on one side and a +box for your language on the other. You fill in the boxes. Each of the +four lists is its own project there, and the same account reaches all +of them. + +Join whichever projects you want to work on. The first one holds most of +the words, and the other three are short. + +- [AlphOne](https://poeditor.com/join/project/1skIO0ryto), the + interface itself +- [Fields](https://poeditor.com/join/project/PcKGmTZXQO), the plugin + for custom contact fields +- [Importer](https://poeditor.com/join/project/jdlnVb9xvL), the plugin + that reads contacts from a file +- [WhatsApp](https://poeditor.com/join/project/KAvWfAmLEv), the plugin + for WhatsApp conversations + +Ask a maintainer to add your language if it is not listed yet. Any +language can be added, and a language with a single contributor is +welcome. A new language also needs a small change in AlphOne itself, +because the server keeps its own list of the languages a reader may +pick. + +## Things worth knowing before you start + +**Placeholders must survive.** Some strings carry a marker like +`%(name)s` or `%(max)d`. These are holes the software fills in with a +name or a number. Copy every marker into your translation exactly as it +appears. You may move a marker to wherever your language needs it, and +you should when word order differs. You may never rename one, drop one +or add one. The repository checks this before your work ships, but a +correct marker saves everyone a round trip. + +**Context tells two identical words apart.** English reuses one word for +different things. Status means one thing for an account and another for +a message. When a string carries a context note, translate the meaning +that note describes. Your language may well need two different words +where English used one, and that is the reason the context exists. + +**An empty box is safe.** A string you have not translated yet shows in +English. Nothing breaks when a list is half done, so partial work ships +without harm. + +**Some words stay in English.** The names AlphOne and WhatsApp are never +translated. + +**Write the way the software speaks.** AlphOne addresses the reader +directly and plainly. Keep sentences short, and prefer the everyday word +over the technical one when both exist. + +## What happens to your translation + +Nothing you translate goes live immediately, and that is deliberate. + +Once a week, an automated job collects everything translated since the +last collection and opens a single pull request against the repository. +One request carries every language and every list that moved. If nobody +translated anything that week, no request is opened. + +A maintainer reviews and merges it, the same way code is reviewed. Your +work then ships with the next release. A reader picks your language on +their Language screen, and the interface answers in it from then on. + +Translations are batched rather than sent one at a time so that +reviewing them stays practical. It also means there is no rush. +Translate at whatever pace suits you, and the next collection will pick +your work up. + +## Marking a translation as needing review + +A translation can be marked unverified, which gettext calls fuzzy. It +means the words are there but somebody should check them. Use it when +you are unsure, and a later reviewer will see that you flagged it. diff --git a/docs/src/content/docs/reference/graphql-api.md b/docs/src/content/docs/reference/graphql-api.md index d0467e06..c07ea1e8 100644 --- a/docs/src/content/docs/reference/graphql-api.md +++ b/docs/src/content/docs/reference/graphql-api.md @@ -80,9 +80,9 @@ mutation { } ``` -`login` is the only operation an anonymous caller may run. Anything else -answers `UNAUTHENTICATED` with HTTP 200, because a GraphQL error is not an HTTP -error: +`login` and the `locale` query are the only operations an anonymous caller may +run. Anything else answers `UNAUTHENTICATED` with HTTP 200, because a GraphQL +error is not an HTTP error: ```json { @@ -132,7 +132,9 @@ Three operations need the `manage_users` capability: `createUser`, "extensions": { "code": "UNAUTHORIZED", "scope": "users:write", - "capability": "manage_users" + "capability": "manage_users", + "reason": "capability_missing", + "meta": { "scope": "users:write", "capability": "manage_users" } } } ], @@ -187,6 +189,28 @@ authority. An operation runs only when both allow it. The token is checked first. A caller holding neither is told about the scope, because that is the half it can fix on its own. +## Locale + +AlphOne answers each reader in one locale. `locale` resolves it: the signed-in +account's stored choice wins, else the closest match to the `Accept-Language` +header, else `en-US`. The query is open to anonymous callers, so a login screen +can ask before anyone signs in. + +```graphql +query { locale } +``` + +`supportedLocales` lists every locale AlphOne serves, the default first, so a +screen can offer the choice without hardcoding the list. + +`setLocale` stores the calling account's choice and answers it back. It takes +a locale from the supported list and refuses anything else with the reason +`locale_unknown`, naming the list in `meta.supported`. + +```graphql +mutation { setLocale(locale: "es-ES") } +``` + ## Scalars | Scalar | Format | Example | @@ -297,7 +321,7 @@ A refused input looks like this: { "message": "contact: empty name", "path": ["createContact"], - "extensions": { "code": "VALIDATION" } + "extensions": { "code": "VALIDATION", "reason": "contact_name_required" } } ], "data": null @@ -307,6 +331,107 @@ A refused input looks like this: `path` names the field that failed, which matters when one operation asks for several. +### Reasons + +Beside the coarse `code`, a refused operation names a `reason`, a short fixed +name for the exact condition, and `meta`, the values its message mentions. A +client should match on `reason` and read `meta`, never parse the message. The +message can be reworded, a reason never is. An `INTERNAL` error names no +reason, its message and shape are deliberately bare. + +```json +{ + "errors": [ + { + "message": "graph: first must be between 1 and 200", + "extensions": { + "code": "VALIDATION", + "reason": "first_out_of_range", + "meta": { "min": 1, "max": 200 } + } + } + ], + "data": null +} +``` + +The reasons the core answers with: + +| Reason | Meta | When | +| ------ | ---- | ---- | +| `authentication_required` | | no usable credential | +| `credentials_invalid` | | a login that did not match | +| `rate_limited` | `retryAfter` | too many attempts | +| `scope_missing` | `scope` | the token lacks the area | +| `capability_missing` | `scope`, `capability` | the role lacks the capability | +| `contact_name_required` | | a contact needs a name | +| `identity_channel_required` | | an identity needs a channel | +| `identity_identifier_required` | | an identity needs an identifier | +| `identity_taken` | `ownerContactId` | the identity belongs to another contact | +| `channel_not_writable` | | the channel accepts no writes | +| `identity_not_found` | | the id names no identity | +| `contact_not_found` | | the id names no contact | +| `task_title_required` | | a task needs a title | +| `task_priority_unknown` | | the priority is not one AlphOne knows | +| `task_status_unknown` | | the status is not one AlphOne knows | +| `task_filter_choice_required` | | tasks take exactly one filter | +| `task_not_found` | | the id names no task | +| `origin_source_required` | | an origin event needs a source | +| `event_unknown` | | the event name is not one AlphOne knows | +| `webhook_url_invalid` | | the webhook URL does not parse | +| `webhook_events_required` | | a webhook needs at least one event | +| `webhook_not_found` | | the id names no webhook | +| `first_out_of_range` | `min`, `max` | the page size is outside the range | +| `locale_unknown` | `supported` | the locale is not one AlphOne serves | +| `cursor_malformed` | | the cursor is not one a field issued | +| `value_malformed` | | a scalar did not parse | +| `token_name_required` | | a token needs a name | +| `token_not_found` | | the id names no token | +| `scope_malformed` | | a scope is area colon access | +| `scopes_required` | | a scoped token needs at least one | +| `area_unknown` | | the area is not one the schema declares | +| `lifetime_negative` | | a lifetime is zero or more days | +| `lifetime_too_long` | `maxDays` | the lifetime is past the cap | +| `email_invalid` | | the address does not parse | +| `email_taken` | | the address belongs to another account | +| `name_required` | | an account needs a name | +| `name_too_long` | `max` | the name is past the cap | +| `password_too_short` | `min` | the password is under the floor | +| `password_too_long` | `max` | the password is past the cap | +| `user_not_found` | | the id names no account | +| `self_disable_refused` | | nobody disables its own account | +| `self_role_refused` | | nobody changes its own role | +| `last_privileged_refused` | | the last account able to manage users stays | +| `role_beyond_reach` | | the role holds more than the caller does | +| `role_unknown` | | the role is not one the deployment names | + +The stock plugins add their own: + +| Reason | Meta | When | +| ------ | ---- | ---- | +| `field_name_malformed` | | a field name is camelCase | +| `field_label_required` | | a field needs a label | +| `field_kind_unknown` | | the kind is not one the plugin knows | +| `field_name_reserved` | | the name is already a column of the type | +| `field_name_taken` | | another definition holds the name | +| `field_kind_locked` | | an archived definition pins the kind | +| `field_not_found` | | the id names no live definition | +| `field_unknown` | | no live definition holds the name | +| `value_kind_mismatch` | | the value does not match the declared kind | +| `values_not_an_object` | | values arrive as an object of names | +| `message_content_required` | | a message needs text | +| `conversation_not_found` | | the id names no conversation | +| `upstream_failed` | | the messaging platform did not accept | +| `import_not_found` | | the id names no import | +| `file_too_large` | `maxBytes` | the upload is past the cap | +| `file_unreadable` | | the file is not a CSV or spreadsheet AlphOne reads | +| `mapping_invalid` | | the mapping does not fit the columns | +| `mapping_required` | | committing needs a mapping first | +| `mapping_locked` | | the import no longer accepts a mapping | +| `already_committed` | | the import was committed before | + +A plugin you install may add more, each documented by the plugin. + ## Limits | Limit | Value | @@ -378,6 +503,7 @@ cannot drift. Point a client at the endpoint, or read | Area | Reads | Writes | | ---- | ----- | ------ | | Session | `me` | `login`, `logout` | +| Locale | `locale`, `supportedLocales` | `setLocale` | | Users | `users` | `createUser`, `setUserDisabled`, `setUserRole` | | Contacts | `contacts`, `contact` | `createContact`, `renameContact`, `addContactIdentity`, `deleteContactIdentity` | | Tasks | `tasks`, `task` | `createTask`, `updateTask` | diff --git a/frontend/.oxlintrc.json b/frontend/.oxlintrc.json deleted file mode 100644 index 6fa991da..00000000 --- a/frontend/.oxlintrc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "$schema": "./node_modules/oxlint/configuration_schema.json", - "plugins": ["react", "typescript", "oxc"], - "rules": { - "react/rules-of-hooks": "error", - "react/only-export-components": ["warn", { "allowConstantExport": true }] - } -} diff --git a/frontend/package.json b/frontend/package.json index e55018d4..afcb556a 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,11 +5,15 @@ "type": "module", "scripts": { "build": "tsc -b && vite build", + "catalogs": "node scripts/write-catalogs.ts", "cover": "vitest run --coverage", "dev": "vite", - "lint": "oxlint && pnpm -w exec eslint .", + "lint": "pnpm -w exec oxlint frontend sdk plugins && pnpm -w exec eslint .", "lint:css": "stylelint \"src/**/*.css\"", + "pot": "node scripts/write-pot.ts", "preview": "vite preview", + "translations": "node --env-file-if-exists=../.env scripts/sync-translations.ts", + "translations:retire": "node --env-file-if-exists=../.env scripts/retire-translations.ts", "typecheck": "tsc -b" }, "dependencies": { @@ -18,9 +22,11 @@ "@alphone/plugin-importer": "workspace:*", "@alphone/plugin-whatsapp": "workspace:*", "@gopherium/godmin": "0.7.0", + "@gopherium/gottext": "0.2.0", "@gopherium/react-auth": "0.6.0", "@tanstack/react-query": "^5.101.4", "@tanstack/react-router": "^1.170.23", + "@wordpress/i18n": "6.26.0", "@wordpress/theme": "1.1.0", "@wordpress/ui": "0.19.0", "graphql": "^17.0.2", @@ -36,8 +42,8 @@ "@types/react-dom": "^19.2.4", "@vitejs/plugin-react": "^6.0.5", "@vitest/coverage-v8": "^4.1.10", + "gettext-extractor": "4.0.6", "jsdom": "^29.1.1", - "oxlint": "^1.75.0", "stylelint": "^17.14.1", "typescript": "~6.0.2", "vite": "^8.2.1", diff --git a/frontend/scripts/config.ts b/frontend/scripts/config.ts new file mode 100644 index 00000000..a817bf61 --- /dev/null +++ b/frontend/scripts/config.ts @@ -0,0 +1,76 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { join } from 'node:path' + +import type { PotOptions } from '@gopherium/gottext/build' + +/** Domain names one text domain beside the sources and catalogues it owns. */ +export interface Domain { + /** name is the text domain every string of this catalogue answers under. */ + name: string + /** sources are the globs holding the strings, read from the repository root. */ + sources: string[] + /** languages is the directory the template and its translations sit in. */ + languages: string + /** catalogs is the directory the compiled catalogues are shipped from. */ + catalogs: string +} + +/** IGNORED lists the directories inside every glob that ship no string. */ +const IGNORED = ['**/test/**', '**/testdata/**', '**/scripts/**', '**/gql/**'] + +/** PINNING lists the packages pinning the translation runtime and the brick. */ +export const PINNING = ['frontend', 'sdk/frontend'] + +/** + * Returns the repository root the source globs resolve against. + * @returns The absolute path of the repository root. + */ +export function repositoryRoot(): string { + return join(import.meta.dirname, '..', '..') +} + +/** + * Returns every text domain AlphOne extracts, the core's own first. + * @returns The domains, in extraction order. + */ +export function domains(): Domain[] { + return [ + { + name: 'alphone', + sources: ['frontend/src/**/*.{ts,tsx}', 'sdk/frontend/**/*.{ts,tsx}'], + languages: 'languages', + catalogs: join('frontend', 'src', 'languages'), + }, + ...['fields', 'importer', 'whatsapp'].map((plugin) => ({ + name: `alphone-${plugin}`, + sources: [`plugins/${plugin}/frontend/**/*.{ts,tsx}`], + languages: join('plugins', plugin, 'languages'), + catalogs: join('plugins', plugin, 'frontend', 'languages'), + })), + ] +} + +/** + * Returns the extraction options one domain's catalogue template builds from. + * @param domain - The domain to extract. + * @param sources - The globs to read, the domain's own by default. + * @returns The options naming the domain, the root and the globs. + */ +export function potConfig(domain: Domain, sources: string[] = domain.sources): PotOptions { + return { + domain: domain.name, + root: repositoryRoot(), + sources, + ignored: IGNORED, + } +} + +/** + * Returns the environment variable naming one domain's platform project. + * @param domain - The text domain the project holds. + * @returns The variable name the sync reads the project id from. + */ +export function projectVariable(domain: string): string { + return `POEDITOR_PROJECT_${domain.toUpperCase().replace(/-/g, '_')}` +} diff --git a/frontend/scripts/locales.ts b/frontend/scripts/locales.ts new file mode 100644 index 00000000..b41ccf08 --- /dev/null +++ b/frontend/scripts/locales.ts @@ -0,0 +1,26 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +/** LOCALE_SOURCE is where the server declares the languages it answers in. */ +const LOCALE_SOURCE = ['internal', 'locale', 'locale.go'] + +/** + * Returns the languages the server answers in, as it declares them. + * @param root - The repository root the declaration sits under. + * @returns The languages, the default first. + */ +export function supportedLocales(root: string): string[] { + const source = readFileSync(join(root, ...LOCALE_SOURCE), 'utf8') + const declared = /Default\s*=\s*"([^"]+)"/.exec(source) + const listed = /supported\s*=\s*\[\]string\{([^}]*)\}/.exec(source) + if (declared === null || listed === null) { + throw new Error('the server declares no supported languages') + } + return listed[1] + .split(',') + .map((held) => held.trim()) + .filter((held) => held !== '') + .map((held) => (held === 'Default' ? declared[1] : held.replace(/"/g, ''))) +} diff --git a/frontend/scripts/retire-translations.ts b/frontend/scripts/retire-translations.ts new file mode 100644 index 00000000..6321fa89 --- /dev/null +++ b/frontend/scripts/retire-translations.ts @@ -0,0 +1,32 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' + +import { poeditorAt } from '@gopherium/gottext/sync' + +import { domains, projectVariable, repositoryRoot } from './config.ts' + +const token = process.env.POEDITOR_API_TOKEN +if (token === undefined) { + console.error('set POEDITOR_API_TOKEN to retire terms') + process.exit(1) +} + +const root = repositoryRoot() + +for (const domain of domains()) { + const variable = projectVariable(domain.name) + const project = process.env[variable] + if (project === undefined) { + console.error(`set ${variable} to retire ${domain.name}`) + process.exit(1) + } + const template = readFileSync(join(root, domain.languages, `${domain.name}.pot`), 'utf8') + const deleted = await poeditorAt({ token, project, domain: domain.name }).retireTerms(template) + console.log( + deleted === 0 + ? `${domain.name}: the platform held nothing to retire` + : `${domain.name}: terms retired: ${deleted}`, + ) +} diff --git a/frontend/scripts/sync-translations.ts b/frontend/scripts/sync-translations.ts new file mode 100644 index 00000000..b8cbcabe --- /dev/null +++ b/frontend/scripts/sync-translations.ts @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { poeditorAt, syncTranslations } from '@gopherium/gottext/sync' + +import { domains, projectVariable, repositoryRoot } from './config.ts' +import { supportedLocales } from './locales.ts' + +/** UPLOAD_SPACING_MS is the wait between two uploads. */ +const UPLOAD_SPACING_MS = 25_000 + +/** + * Waits the given milliseconds. + * @param ms - How long to wait. + * @returns A promise settling once the wait is over. + */ +function pause(ms: number): Promise { + return new Promise((settle) => setTimeout(settle, ms)) +} + +const token = process.env.POEDITOR_API_TOKEN +if (token === undefined) { + console.error('set POEDITOR_API_TOKEN to sync translations') + process.exit(1) +} + +const root = repositoryRoot() +const locales = supportedLocales(root) + +let uploaded = false + +for (const domain of domains()) { + if (uploaded) { + await pause(UPLOAD_SPACING_MS) + } + uploaded = true + const variable = projectVariable(domain.name) + const project = process.env[variable] + if (project === undefined) { + console.error(`set ${variable} to sync ${domain.name}`) + process.exit(1) + } + const languages = join(root, domain.languages) + mkdirSync(languages, { recursive: true }) + const done = await syncTranslations( + poeditorAt({ token, project, domain: domain.name }), + locales, + { + read: (locale) => { + const target = join(languages, `${locale}.po`) + return existsSync(target) ? readFileSync(target, 'utf8') : undefined + }, + write: (locale, source) => writeFileSync(join(languages, `${locale}.po`), source), + }, + readFileSync(join(languages, `${domain.name}.pot`), 'utf8'), + ) + console.log( + done.moved.length === 0 + ? `${domain.name}: no translation moved` + : `${domain.name}: translations moved: ${done.moved.join(', ')}`, + ) + for (const held of done.skipped) { + console.log(`${domain.name}: skipped ${held}`) + } + for (const held of done.kept) { + console.log(`${domain.name}: kept ${held}`) + } +} diff --git a/frontend/scripts/write-catalogs.ts b/frontend/scripts/write-catalogs.ts new file mode 100644 index 00000000..83065077 --- /dev/null +++ b/frontend/scripts/write-catalogs.ts @@ -0,0 +1,27 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { compileCatalog, serializeCatalog } from '@gopherium/gottext/build' + +import { domains, repositoryRoot } from './config.ts' + +const root = repositoryRoot() + +for (const domain of domains()) { + const sources = join(root, domain.languages) + const target = join(root, domain.catalogs) + if (!existsSync(sources)) { + continue + } + mkdirSync(target, { recursive: true }) + for (const file of readdirSync(sources)) { + if (!file.endsWith('.po')) { + continue + } + const locale = file.slice(0, -'.po'.length) + const compiled = compileCatalog(readFileSync(join(sources, file), 'utf8')) + writeFileSync(join(target, `${locale}.json`), serializeCatalog(compiled)) + } +} diff --git a/frontend/scripts/write-pot.ts b/frontend/scripts/write-pot.ts new file mode 100644 index 00000000..cceeb9bb --- /dev/null +++ b/frontend/scripts/write-pot.ts @@ -0,0 +1,16 @@ +// SPDX-License-Identifier: AGPL-3.0-or-later + +import { mkdirSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' + +import { pot } from '@gopherium/gottext/build' + +import { domains, potConfig, repositoryRoot } from './config.ts' + +const root = repositoryRoot() + +for (const domain of domains()) { + const languages = join(root, domain.languages) + mkdirSync(languages, { recursive: true }) + writeFileSync(join(languages, `${domain.name}.pot`), pot(potConfig(domain))) +} diff --git a/frontend/src/RailContent.tsx b/frontend/src/RailContent.tsx index f998c385..4580e6e8 100644 --- a/frontend/src/RailContent.tsx +++ b/frontend/src/RailContent.tsx @@ -1,6 +1,7 @@ // SPDX-License-Identifier: AGPL-3.0-or-later -import { Stack, Text } from '@alphone/frontend-sdk' +import { + __, Stack, Text } from '@alphone/frontend-sdk' import { AccountPanel } from '@gopherium/react-auth/wpds' import { Link, useRouterState } from '@tanstack/react-router' @@ -23,9 +24,9 @@ export function RailContent() { <> - AlphOne + {'AlphOne'} -