dialecto_in_context

Copy Markdown View Source

In-context editing for Phoenix apps translated with gettext, for use with Dialecto.

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:

    # 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:

    # 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:

    # 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.

OptionEnv varDefaultWhat it is for
urlDIALECTO_URLhttps://app.dialecto.euThe Dialecto that serves the overlay and sidebar. Set it when you run Dialecto yourself, for example http://localhost:4500.
projectDIALECTO_PROJECTnoneThe 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.
catalogsDIALECTO_CATALOGSpriv/gettextThe catalogs folder whose uncommitted files the sidebar warns about.
enabledDIALECTO_IN_CONTEXTonenabled: false or DIALECTO_IN_CONTEXT=off (also false, 0 or no) turns the add-on off.
pathsDIALECTO_PATHSevery pageComma-separated path prefixes whose pages get the editor, to keep it off admin screens, say.
exclude_pathsDIALECTO_EXCLUDE_PATHSnoneComma-separated path prefixes whose pages never get the editor, even under paths. Use it when you want every page but a few.
# 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_sessions 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:

    plug Plug.Session, @session_options
    if Code.ensure_loaded?(DialectoInContext.Plug), do: plug(DialectoInContext.Plug)
  3. Add the hook to your LiveView sessions:

    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:

    # 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:

    {: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

mix deps.get
mix test

See AGENTS.md for the layout, conventions and the rules to keep.

FileRole
lib/dialecto_in_context.exEntry point and version.
lib/dialecto_in_context/gettext.exThe @before_compile backend wrapper, plural selection and draft overrides.
lib/dialecto_in_context/plug.exThe /__dialecto/* routes, the origin rules and the loader injection.
lib/dialecto_in_context/config.exSettings from the environment and app config.
lib/dialecto_in_context/{marker,args}.exThe invisible marker codec and the argument payload.
lib/dialecto_in_context/{store,overrides}.exMarking state and saved drafts, in an ETS table.
lib/dialecto_in_context/git_context.exThe 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.