Architecture
Branch model
main protected, reviewed, the source of truth
│
│ pull request (created by a workflow)
│
i18n shared translation workspace, GitHub Pages source
mainholds source code and the accepted translations. Direct pushes are blocked by a ruleset; changes arrive through pull requests.i18nis the working branch. Translators commit here directly, and Pages is built from it.
Data flow
Browser (VuePress page)
│ api.github.com, token held in memory
├─ GET /user identity
├─ GET /repos/{owner}/{repo} permission + default branch
├─ GET /contents/.github/i18n.yml project configuration
├─ GET /contents/locales/* source and target strings
├─ POST /git/blobs → /git/trees → /git/commits one commit, many files
└─ PATCH /git/refs/heads/i18n fast-forward only
GitHub Actions
├─ translation-pr.yml i18n push → ensure pull request i18n → main
├─ sync-main-to-i18n.yml main push → merge main into i18n
└─ deploy-pages.yml i18n push → build VuePress → deploy Pages
Module map
| Path | Responsibility |
|---|---|
src/types.ts | Shared data model: user, repository, entry, change, config |
src/parser/ | flatten / setPath plus the YAML and JSON parsers behind one interface |
src/qa/ | Placeholder and HTML tag comparison, empty translation detection |
src/github/ | API client, error taxonomy, auth, permissions, contents, git data, pull requests, mock transport |
src/state/editorStore.ts | Single reactive store that orchestrates loading, editing and committing |
src/i18n/ | Loads locales/ at build time and renders the editor in four languages |
src/components/ | Vue 3 components for the editor |
docs/ | VuePress site, published to GitHub Pages |
The interface catalog
The editor's own strings are ordinary locale files in locales/, loaded through the same parser a translator's project would use. src/i18n/ flattens them at build time and exposes t(key, params) and tp(base, count, params); the plural helper picks base.one or base.other when those keys exist.
Error handling follows the same idea. GitHubApiError already carries a code, so the store turns a failure into { key, params, detail } and the component renders the key — no English sentence travels through the state layer. The status code determines the key, detail keeps GitHub's own wording for debugging, and locales/errors.*.json holds the reader-facing text.
Anything added to the interface needs a key in all four catalogs. tests/i18n.test.ts fails otherwise, and it also rejects a translation that drops a {placeholder} or an HTML tag.
Why the Git database API
PUT /contents/{path} writes one file per request, which would produce three commits for a three-file edit. The editor instead reads the branch head, creates blobs, builds one tree on top of the current tree, creates one commit with the translator as author, and fast-forwards the ref. A single save is a single commit, and the commit author is the translator — the bot never rewrites history.
Concurrency
Two translators can open the same file at the same base commit. The editor therefore:
- records the branch head SHA when the file is loaded,
- re-reads the ref immediately before creating the commit and before updating the ref,
- refuses to commit when the SHA no longer matches.
The ref update uses force: false, so GitHub itself rejects a non-fast-forward write. The failure surfaces as a 409 with a reload prompt instead of a silent overwrite.
Idempotency
commitFiles compares the tree it just built with the base tree. When they are identical, no commit is created and the caller is told the result was unchanged. Workflows follow the same rule: translation-pr.yml looks for an existing open i18n → main pull request and only opens one when none exists, and sync-main-to-i18n.yml exits without pushing when the branches already agree.
GitHub constraints worth knowing
These are properties of the platform, not bugs in this project:
- Events created with the default
GITHUB_TOKENdo not start new workflow runs. A push performed bysync-main-to-i18n.ymltherefore will not triggertranslation-pr.ymlordeploy-pages.yml. Supply aI18N_BOT_TOKENsecret (a fine-grained PAT withContents: read and writeandPull requests: read and write) to restore the chain; the workflows fall back toGITHUB_TOKENand then explicitly dispatch the follow-up workflow. - Rulesets can block automation. If
i18never requires pull requests, the bot must be added as a bypass actor, otherwise it cannot push. The ruleset created by this project blocks force pushes and deletion only. Repositoryrole permissions are not per-branch. GitHub cannot express "this user may pushi18nbut notmain" without rulesets. The front end hides the submit button for read-only users, but that is user experience, not a security boundary.- The Contents API caps files at 1 MB. Locale files that large need the blob API for reads as well; the editor currently reads through the Contents API.
- A repository must be public for GitHub Pages on a free plan. A private repository needs a paid plan for Pages.
- CORS.
api.github.comserves CORS headers, so the browser can call it directly. The device flow endpoints ongithub.comare intended for public clients; if a browser blocks them, the personal access token path always works. GITHUB_TOKENcannot create a Pages site. It carriespages: write, which is enough to publish, but creating the site needs repository administration. A failingactions/configure-pagesstep with "Resource not accessible by integration" means exactly this.- The
github-pagesenvironment only trusts the default branch by default. A deployment fromi18nis rejected before any step runs, which shows up as a job with no steps and a bare failure. Addingi18nas a custom deployment branch policy fixes it.scripts/setup-repo.shperforms both this and the step above. - GitHub Actions is not allowed to open pull requests by default. The repository setting
can_approve_pull_request_reviewsmust be switched on, otherwisegh pr createfails with "GitHub Actions is not permitted to create or approve pull requests".scripts/setup-repo.shsets it. The alternative is anI18N_BOT_TOKENsecret.
Extending the parser layer
Adding a format means implementing two methods and registering the parser:
interface TranslationParser {
parse(content: string): RawDocument
serialize(document: RawDocument, originalContent?: string): string
}
JSON5, TOML, PO, XLIFF, RESX and Markdown all fit this interface without touching the editor.
