Packages
dialecto_in_context
0.1.0
Dev-only in-context editing for Phoenix apps translated with gettext (Dialecto).
Current section
Files
Jump to
Current section
Files
dialecto_in_context
README.md
README.md
# dialecto_in_context
In-context editing for Phoenix apps translated with gettext, for use with [Dialecto](https://dialecto.eu).
While the editor is open, every translated string on a page of your running dev app can be clicked and edited in a
Dialecto sidebar, with the gettext rules intact: contexts (`pgettext`), one field per plural form, and `%{name}`
placeholders. Your edits become Dialecto's usual minimal-diff pull request against your translation files.
By default this is a dev-only dependency. The two opt-in lines in Setup compile to nothing where the dependency is
absent, so production carries no trace of it. A site that wants editors to work on the live site can run it in
production too; see "Editing the live site", which changes nothing for visitors.
## What it does
The add-on does three things in your dev server:
- It wraps your gettext backend so that, only while the editor is open, each translation is returned with an
invisible marker that records the domain, the message key (the msgid, or the msgctxt and msgid), the locale and
the arguments the string was formatted with. That is how the editor knows which catalog entry a piece of text on
the page came from.
- It adds a plug to your endpoint that injects Dialecto's overlay script into your pages and answers three small
routes under `/__dialecto/` for the overlay.
- It renders drafts: strings you have edited in the sidebar show through your app's real gettext path after a
reload, until you discard them or open the pull request.
## What Dialecto reads, and what it never reads
Dialecto reads only your translation files, never your source code. The Dialecto GitHub App reads only the
translation-file paths confirmed for your project. Each time it reads, it also matches the names in the repository's
file listing, never their contents, against a published pattern list, and shows the project's managers any
translation files it finds outside those paths; nothing under them is read until someone adds the path. This add-on
does not widen that: it never reads, scans or uploads your source code or your catalogs.
The pattern list is published in the Dialecto repository at `lib/dlocal/localize/catalog_discovery.ex`
(`patterns/0`, `patterns/1` and `excluded_folders/0`). For gettext it looks for `**/LC_MESSAGES/*.po` and
`**/*.pot`; for other formats it looks for `**/config/locales/**/*.yml`, `**/_locales/*/messages.json` and JSON
files named after your source locale. Folders such as `node_modules`, `vendor`, `build`, `test` and anything
starting with a dot are never proposed.
That is an improvement over reading the whole source tree, which GitHub's permission would allow. One caveat is part
of the design: GitHub's Contents permission applies to the whole repository, so GitHub does not enforce this
boundary. Dialecto's own code does. A team that wants the boundary enforced outside Dialecto's code can block the
App's read access and let its CI push the translation files instead (CI-push mode); the generated workflow lists
exactly which paths it sends, in your own repository.
The catalogs reach Dialecto from a push (the App reads the confirmed paths) or from your CI. The add-on itself never
sends them.
## What runs where
On your machine, in your dev server:
- The Gettext wrapper and the plug, as described above.
- `git`, run locally on request by `GET /__dialecto/context` to read the `origin` remote, the current branch and
commit, and which files under your catalogs folder have uncommitted changes. The answer goes to the page that
asked and nowhere else.
In your browser, from Dialecto:
- The overlay script, loaded from `<url>/assets/in-context/overlay.js` with `defer`, where `url` defaults to
`https://app.dialecto.eu`. The page renders even when Dialecto is slow or unreachable.
- The Dialecto sidebar, which the overlay frames. You sign in to your Dialecto account there, and the first time a
Dialecto window asks you to allow it. Your edits are saved there as drafts on your project, and the pull request is
opened from there.
- The overlay talks to your dev server (`/__dialecto/context`, `/__dialecto/overrides`, `/__dialecto/marking`) and to
the sidebar by browser messaging. The text and identities of strings on the page, which come from your
translations and their arguments, are what the editor works on, so avoid opening the editor on pages that show
data you would not want in Dialecto.
The add-on makes no outbound request of its own. Your Elixir code never calls Dialecto's servers.
## Setup
1. Add the dependency for `:dev` only:
```elixir
# mix.exs
{:dialecto_in_context, "~> 0.1", only: :dev}
```
A local checkout also works: `{:dialecto_in_context, path: "../dialecto_in_context", only: :dev}`.
2. Mark the gettext backend, right after `use Gettext.Backend`:
```elixir
# lib/my_app_web/gettext.ex
defmodule MyAppWeb.Gettext do
use Gettext.Backend, otp_app: :my_app
if Code.ensure_loaded?(DialectoInContext.Gettext),
do: @before_compile(DialectoInContext.Gettext)
end
```
3. Add the plug to the endpoint, before the router and before `Plug.Parsers`, so it reads its own small JSON bodies:
```elixir
# lib/my_app_web/endpoint.ex
if Code.ensure_loaded?(DialectoInContext.Plug), do: plug(DialectoInContext.Plug)
```
4. Recompile the backend (`mix compile --force`, or touch the file) and run `mix phx.server`. An "Edit text" button
appears at the bottom right of every page.
The add-on finds the project on its own: it reads the app's git remote and Dialecto matches it to your project.
Requirements: Elixir 1.18 or later, `gettext ~> 1.0`, `expo ~> 1.0` and `plug ~> 1.15`.
## Configuration
An environment variable wins over `config :dialecto_in_context, ...`, which wins over the default. The names match
the Astro add-on's.
| Option | Env var | Default | What it is for |
| --- | --- | --- | --- |
| `url` | `DIALECTO_URL` | `https://app.dialecto.eu` | The Dialecto that serves the overlay and sidebar. Set it when you run Dialecto yourself, for example `http://localhost:4500`. |
| `project` | `DIALECTO_PROJECT` | none | The project's `owner/name` slug or its number. Use it when the git remote doesn't identify the project, or when several projects share one repository. |
| `catalogs` | `DIALECTO_CATALOGS` | `priv/gettext` | The catalogs folder whose uncommitted files the sidebar warns about. |
| `enabled` | `DIALECTO_IN_CONTEXT` | on | `enabled: false` or `DIALECTO_IN_CONTEXT=off` (also `false`, `0` or `no`) turns the add-on off. |
| `paths` | `DIALECTO_PATHS` | every page | Comma-separated path prefixes whose pages get the editor, to keep it off admin screens, say. |
| `exclude_paths` | `DIALECTO_EXCLUDE_PATHS` | none | Comma-separated path prefixes whose pages never get the editor, even under `paths`. Use it when you want every page but a few. |
```elixir
# config/dev.exs
config :dialecto_in_context, url: "http://localhost:4500", paths: ["/app"]
```
## Editing the live site
The same add-on can run in production, inert for visitors and on only for an editor who arrives with a signed
launch link from Dialecto ("Edit on live site"). An edit made this way is an ordinary staged edit in Dialecto; the
site never writes anything itself.
### When to use it
Live-site editing costs visitors nothing: no Dialecto script, no markers, the same bytes. It suits Phoenix sites
too, not only static ones.
- **Do** wrap in `DialectoInContext.unmarked/1` anything that leaves the page during a marked render: a mail, text
you memoise or put in a cache (Cachex, ETS, `:persistent_term`), translated text you write to the database,
PubSub or channel broadcasts, and job arguments. Markers are invisible characters; they would be stored and sent
to everyone.
- **Do** add `on_mount DialectoInContext.LiveView` to the `live_session`s that render translated text. Only the
render of a mount or a navigation (`handle_params`) is marked: before a `handle_event`, `handle_info` or
`handle_async` runs the flag is off, so nothing those callbacks write carries markers, and text re-rendered
after an interaction loses its marker until the next navigation.
- **Do** serve the site over HTTPS only, and set `origin` to exactly what the browser shows: `www` or the apex,
and the port. The launch cookie is `Secure`.
- **Do** keep the server clock right (NTP): a launch link must arrive within two minutes of being minted.
- **Don't** put a CDN or cache in front that stores HTML and ignores cookies. Marked pages are sent `Cache-Control:
private, no-store` and `Vary: Cookie`, but a cache that disregards them defeats this: leave the site out or do
not enable live mode.
- **Don't** set `mode: :dev` in production. A production build refuses it (and logs a warning), because the
development routes trust a loopback `Host` that a proxy on the same host lets anyone fake.
- **Know** that the launch URL, token included, appears in reverse-proxy access logs. It is worth eight hours of
seeing marked pages and no edits. A link to a static file path keeps its token in the URL, because
`Plug.Static` answers before this plug. The editor needs the `Sec-Fetch-Dest` header (Safari 16.4 or newer).
### Setup
1. Drop `only: :dev` from the dependency, so it is compiled in production. The guarded lines in "Setup" above keep
working: they compile away only where the dependency is absent.
2. Put the plug **after `Plug.Session`**, still before the router:
```elixir
plug Plug.Session, @session_options
if Code.ensure_loaded?(DialectoInContext.Plug), do: plug(DialectoInContext.Plug)
```
3. Add the hook to your LiveView sessions:
```elixir
live_session :default, on_mount: [DialectoInContext.LiveView] do
live "/", HomeLive
end
```
4. Turn it on with the settings from the repo's Dialecto settings page. They are public and safe to commit:
```elixir
# config/runtime.exs (or config/prod.exs)
config :dialecto_in_context,
mode: :live,
repo: "owner/name",
origin: "https://www.example.com",
public_key: "ed25519:…"
```
**How a release gets `:live`:** `mode` is read at runtime, so it needs no build flag, but it is never on by
itself. Unset, it is `:dev` in a dev or test build and `:off` in any other (decided when the package compiles),
so a production release is inert until its config sets `mode: :live`. In `:live` the plug does nothing without a
`repo` and a valid `public_key`.
`origin` is the site's own origin. It is never read from `Host` or `X-Forwarded-*`, which a proxy can rewrite;
if you leave it out, the plug uses the endpoint's configured URL. If the app sends a CSP header, the plug adds
Dialecto (`url`) to `frame-src` on an editor's pages, and only on those.
### What happens
- A `GET` with `?dialecto_edit=<token>` is verified inside your app (an Ed25519 signature under the repo's key,
made for this purpose only, with `start_by` within two minutes, and the token's origin equal to `origin`). A good
one becomes the `dialecto_edit` cookie (`HttpOnly; Secure; SameSite=Lax; Path=/`, ending at the token's `exp`,
at most eight hours) and, so LiveView can see it, the token's expiry (an integer, never the token) in the
session, and the answer is `303` to the same URL without the parameter. A bad token is ignored, the parameter is
still removed, and the reason (late, expired, bad signature, wrong origin, wrong repo) is logged at `info`.
- While the cookie is valid, `GET` and `HEAD` requests for a document or an iframe, and Turbo-style same-origin
fetches that ask for HTML, are marked and carry the overlay loader (with the page's CSP nonce, if it has one). The overlay is served from your own origin at `/__dialecto/overlay.js`, never as a script from Dialecto.
`POST`, fetches and JSON responses are never marked.
- `POST /__dialecto/stop` (the overlay's "Stop editing") clears the cookie and the session copy.
- In `:live` the development routes (`/__dialecto/context`, `/overrides`, `/marking`) do not exist and answer 404,
and the global marking flag and the saved-draft store are never read.
- A visitor's request gets exactly the response your app gives without the add-on.
A launch token that leaks is worth at most eight hours of seeing marked pages: it does not let anyone change text.
Editing needs Dialecto's own sign-in, scoped to the repo and the site.
### Refreshing the overlay copy
`priv/static/overlay.js` is a copy of Dialecto's built overlay. Maintainers refresh it with `mix esbuild in_context`
in the Dialecto repository and then `mix dialecto_in_context.refresh_overlay` here. A test in the Dialecto
repository fails when the copy differs from a fresh build.
## Safety
These describe development mode. Live mode's rules are under "Editing the live site".
- Dev only: the dependency is `only: :dev`, and the `Code.ensure_loaded?/1` guards compile the opt-in lines away
elsewhere.
- Loopback only: the request `Host` must be `localhost`, `127.0.0.1` or `[::1]`, which also defeats DNS rebinding.
A POST must carry the site's own `Origin`, and a request with a foreign `Origin` or `Sec-Fetch-Site: cross-site`
is refused. The overlay never loads into an iframe, such as Dialecto's own sidebar.
- Markers are on only while the editor is open and heartbeating. A closed tab can't leave them on: marking lapses
90 seconds after the last heartbeat.
- With marking off and no drafts, a lookup costs two ETS reads on top of gettext's own.
## Run your own fork
You can run your own version of this package.
1. Fork the repository.
2. Depend on your fork, pinned to a commit:
```elixir
{:dialecto_in_context, git: "https://github.com/your-org/your-fork.git", ref: "<commit-sha>", only: :dev}
```
or, from a checkout, `{:dialecto_in_context, path: "../your-fork", only: :dev}`.
What the fork covers is the Elixir package: the gettext wrapper, the plug, the git context and the settings. It has
no hidden dependencies on Dialecto's servers; its Hex dependencies are `gettext`, `expo` and `plug`. What it does not
include is the overlay and the sidebar. Those are served by a Dialecto instance, which is `https://app.dialecto.eu`
unless you set `url`. A fork still needs a Dialecto to load them from, hosted by Dialecto or run by you with `url`
pointing at it.
## Develop
```sh
mix deps.get
mix test
```
See `AGENTS.md` for the layout, conventions and the rules to keep.
| File | Role |
| --- | --- |
| `lib/dialecto_in_context.ex` | Entry point and version. |
| `lib/dialecto_in_context/gettext.ex` | The `@before_compile` backend wrapper, plural selection and draft overrides. |
| `lib/dialecto_in_context/plug.ex` | The `/__dialecto/*` routes, the origin rules and the loader injection. |
| `lib/dialecto_in_context/config.ex` | Settings from the environment and app config. |
| `lib/dialecto_in_context/{marker,args}.ex` | The invisible marker codec and the argument payload. |
| `lib/dialecto_in_context/{store,overrides}.ex` | Marking state and saved drafts, in an ETS table. |
| `lib/dialecto_in_context/git_context.ex` | The git remote, branch, commit and uncommitted catalogs. |
## License
No license is declared yet. A license will be added before the first release.
## Security
Please report vulnerabilities privately and not in a public issue. Security contact: to be announced before the
first release. Until then, report to the maintainers listed on the repository.
## Contributing
Issues and pull requests are welcome. Keep changes small and tested, and read `AGENTS.md` first: it lists the
behavior that cannot change without a major version.