In-context editor

Install the in-context editor

Add the in-context editor to your app with the React adapter or the framework-agnostic core, and mark the strings reviewers can edit.

There are two packages:

PackageUse it for
@transglot/reactReact 18 and 19 apps. A TransglotProvider component and a useIncontextT hook.
@transglot/incontextAny other page: Vue, Svelte, Angular, server-rendered HTML or plain scripts. mountIncontextEditor and wrapT. The React adapter is a thin layer over it.

Both need a publishable key whose allowed origins include the page's origin.

Not the runtime SDK

@transglot/react is the in-context editor. To deliver published translations to your users, use @transglot/react-i18n.

Install

npm install @transglot/react

@transglot/incontext is installed with it. React and React DOM 18 or 19 are peer dependencies.

To capture screenshots for the screenshot library when a reviewer asks for a suggestion, also install the optional peer dependency:

npm install html2canvas

Without it, everything else works and suggestions use the text alone.

React

Render the provider only for people who may review translations, and mark strings with the t from useIncontextT():

src/ReviewProvider.tsx
import { TransglotProvider } from '@transglot/react';
import type { ReactNode } from 'react';
import { i18n } from './i18n';

export function ReviewProvider({ user, locale, children }: { user: User; locale: string; children: ReactNode }) {
  if (!user.canReviewTranslations) {
    return children;
  }

  return (
    <TransglotProvider
      publishableKey="taip_your_publishable_key"
      url="https://api.transglot.ai"
      locale={locale}
      hostT={(key, ...args) => i18n.t(key, ...args)}
      reviewer={user.email}
      writableKeys={user.reviewableKeys}
      getNonce={(keys) =>
        fetch('/api/transglot-nonce', {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ keys }),
        })
          .then((response) => response.json())
          .then((body) => body.nonce)
      }
      enterOnMount
    >
      {children}
    </TransglotProvider>
  );
}
src/Hero.tsx
import { useIncontextT } from '@transglot/react';

export function Hero() {
  const t = useIncontextT();

  return <h1>{t('home.hero.title')}</h1>;
}

useIncontextT() calls your hostT and adds the invisible watermark to every string it returns. The visible text is the same. It throws useIncontextT() must be used inside a <TransglotProvider>. outside the provider, so when the provider is rendered only for reviewers, read the context yourself and fall back to your own t for everyone else:

src/useT.ts
import { TransglotContext } from '@transglot/react';
import { useContext } from 'react';
import { i18n } from './i18n';

export function useT() {
  const incontext = useContext(TransglotContext);

  return incontext ? incontext.t : i18n.t;
}

getNonce is what lets reviewers save; without it the editor is read-only. It calls an endpoint on your own server, which you write: see Your backend: the nonce endpoint.

TransglotProvider props

publishableKey, url, getNonce, writableKeys, fetchImpl and client are read once, on the first render. enterOnMount, theme, consoleUrl and captureElement are read when the editor mounts. Changing locale switches the editor's locale.

Prop

Type

Other exports

  • useTransglotContext(): the provider's context value ({ client, registry, t, locale, reviewer }). Throws outside the provider.
  • TransglotContext: the React context itself, null outside the provider.
  • createEditingSession, and the types TransglotProviderProps, TransglotContextValue, EditingSession, EditingSessionOptions, EditingState, LocaleSummary, re-exported from @transglot/incontext.

Any framework

mountIncontextEditor builds the whole editor. wrapT marks the strings your app renders, against the registry the editor returns:

src/incontext.ts
import { mountIncontextEditor, wrapT } from '@transglot/incontext';
import { messages, render } from './app';

let locale = 'fr';

const editor = mountIncontextEditor({
  url: 'https://api.transglot.ai',
  publishableKey: 'taip_your_publishable_key',
  locale,
  enterOnMount: currentUser.canReviewTranslations,
  reviewer: () => currentUser.email,
  writableKeys: currentUser.reviewableKeys,
  getNonce: (keys) =>
    fetch('/api/transglot-nonce', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ keys }),
    })
      .then((response) => response.json())
      .then((body) => body.nonce),
  onLocaleChange: (next) => {
    locale = next;
    render();
  },
});

export const t = wrapT(
  (key: string) => messages[locale]?.[key] ?? key,
  editor.registry,
  () => editor.session.state().locale,
);

Use the wrapped t wherever your app renders text. Only strings that went through it can be opened with ALT+click.

Better still, load this module only for reviewers, so visitors never download the editor.

Without a bundler

The package is published as ES modules, so a page can load it from a CDN that serves npm packages as ESM, for example jsDelivr:

index.html
<h1 id="title"></h1>

<script type="module">
  import { mountIncontextEditor, wrapT } from 'https://cdn.jsdelivr.net/npm/@transglot/incontext@0.2.0/+esm';

  const messages = { fr: { 'home.title': 'Bienvenue' } };

  const editor = mountIncontextEditor({
    url: 'https://api.transglot.ai',
    publishableKey: 'taip_your_publishable_key',
    locale: 'fr',
    onLocaleChange: () => render(),
  });

  const t = wrapT((key) => messages[editor.session.state().locale]?.[key] ?? key, editor.registry, () =>
    editor.session.state().locale,
  );

  function render() {
    document.querySelector('#title').textContent = t('home.title');
  }

  render();
  editor.session.enter();
</script>

Pin the version in the URL so an update never reaches your page unannounced.

mountIncontextEditor options

Prop

Type

The returned handle

MemberDescription
sessionThe editing session (below).
registryThe registry to pass to wrapT.
clientThe API client the editor uses.
shadowRoot()The shadow root the editor's bar, panel and highlight render into, isolated from your page's styles.
destroy()Removes the editor and its listeners and leaves editing mode. Strings keep their watermarks, so a later mount can resolve them again. Safe to call more than once.

The session lets you build your own controls:

editor.session.enter(); // turn editing mode on
editor.session.leave(); // turn it off
editor.session.state(); // { active, locale, sourceLocale, locales, canWrite, canAddLocale, ... }
const unsubscribe = editor.session.subscribe((state) => updateMyToolbar(state));
await editor.session.setLocale('de');

wrapT(t, registry, locale)

wrapT(t: HostT, registry: Registry, locale: () => string): WrappedT returns a function with the same arguments as t. Each string it returns is registered as a key and locale pair and gets the invisible watermark. A non-string return value passes through unmarked. locale is called on every call, so pass a getter that reads the editor's current locale, as in the examples.

createRegistry() creates a registry yourself, if you want one before mounting; pass it as registry.

Your backend: the nonce endpoint

The publishable key ships in your page, so on its own it opens a read-only session. To let a reviewer save, the browser needs a single-use nonce that only your server can obtain:

  1. The editor calls your getNonce(keys) with the keys in writableKeys, when the session starts and each time it is renewed.
  2. Your getNonce calls an endpoint on your own server, for example POST /api/transglot-nonce.
  3. That endpoint checks the signed-in user may review, then calls POST https://api.transglot.ai/v1/incontext/session-nonce with a project API token that has the write ability (a secret that stays on your server), the publishable key, and the key names this user may change.
  4. transglot answers { "nonce": "...", "expires_at": "..." }. Your endpoint returns the nonce to the browser, and getNonce resolves with it.
  5. The editor sends the nonce with the publishable key when it opens the session, and the session can then save exactly those keys.
server/routes/transglot-nonce.ts
app.post('/api/transglot-nonce', requireReviewer, async (req, res) => {
  const keys = keysThisUserMayEdit(req.user, req.body.keys);

  const response = await fetch('https://api.transglot.ai/v1/incontext/session-nonce', {
    method: 'POST',
    headers: {
      Authorization: `Bearer ${process.env.TRANSGLOT_TOKEN}`,
      'Content-Type': 'application/json',
      Accept: 'application/json',
    },
    body: JSON.stringify({
      publishable_key: 'taip_your_publishable_key',
      keys,
    }),
  });

  if (!response.ok) {
    res.json({ nonce: null });
    return;
  }

  const { nonce } = await response.json();
  res.json({ nonce });
});

When getNonce resolves with null, the session is read-only: Save is disabled with the reason shown, and no screenshots are captured. A nonce can be used once and expires about a minute after it is issued, which is why the editor asks for a new one on every session. The request fields, the add_locale grant and the error responses are in The nonce request.

What can be edited in place

A watermark is appended to the string your t returns, so it only works while that string renders as one text node.

  • A translation with inline markup (Stamped with <strong>the carrier time</strong>) renders as several nodes. Reviewers can still save it, but the page is not updated afterwards; the editor tells the reviewer so. Split such sentences into one key per element if you want every part to update live.
  • Attribute text (placeholder, title, alt, aria-label) has no text node, so it is never marked and cannot be opened with ALT+click.

While editing mode is on, ALT+click belongs to the editor: a click that does not land on a marked string opens nothing and does not reach your page's own handlers. The editor's bar and panel are exempt, and leaving editing mode gives the gesture back.

Errors

Failures are IncontextError instances with a stable slug, a message, an optional hint (what to do), and retryAfterSeconds when the server sent Retry-After. Common slugs:

slugMeaning
origin-not-allowedThe publishable key is unknown or deleted, the page's origin is not in its allowed origins, or the write nonce was already used or has expired. The server gives the same answer for all of these on purpose.
plan-limit, plan-feature-requiredThe account's plan does not include the in-context editor.
network-errortransglot could not be reached.
rate-limitedStill rate limited after retries.
invalid-urlurl is not an https URL (or http on localhost). Thrown when the client is created.

A save refused because someone else saved the same string first is an IncontextConflictError, a subclass with the slug row-changed and a row property holding the version now on the server. The editor handles it by showing Their version to the reviewer.

With mountIncontextEditor, pass onError to route failures into your own UI. The React provider does not take onError; the editor shows failures in its own UI.

On this page