@transglot/incontext
v0.2.0
Published
Zero-dependency runtime SDK for the transglot in-context editor: watermarking, ALT+click overlay, and the scoped-session API client.
Maintainers
Readme
@transglot/incontext
Zero-dependency runtime SDK core for the transglot in-context editor. It watermarks rendered strings, tracks them per page, survives host re-renders by re-walking those marks rather than by holding element references, opens the ALT+click Shadow-DOM overlay, optionally captures a screenshot, and talks to the scoped-session API.
It is also the whole editor: mountIncontextEditor() assembles every part below
into a working editing surface in one call, with no framework involved. The
framework adapters (for example @transglot/react) are thin layers
over that same call, so a Vue, Svelte, Angular or plain-<script> host gets the
identical editor without waiting for an adapter to exist.
This is NOT the runtime i18n loader. For loading published bundles at runtime,
see @transglot/runtime and its createClient contract.
Install
npm install @transglot/incontextMount the editor
Two things have to happen on a page: the editor has to be mounted, and the
strings the host renders have to carry a watermark so an ALT+click can resolve
them back to a key. mountIncontextEditor does the first; wrapT does the
second, against the registry the mount hands back.
import { createRegistry, mountIncontextEditor, wrapT } from '@transglot/incontext';
let locale = 'fr';
const registry = createRegistry();
const editor = mountIncontextEditor({
url: 'https://app.example.com',
publishableKey: 'taip_…',
registry,
locale,
// Editing mode is OFF until you ask for it. Ask only for a reviewer: see
// "Who should see this" below.
enterOnMount: currentUser.mayReviewTranslations,
// The SDK cannot re-render your tree, so it tells you when to.
onLocaleChange: (next) => {
locale = next;
render();
},
});
// Wrap YOUR translate function. Every string it returns is registered as a
// (key, locale) pair and suffixed with an invisible mark: the visible UI is
// unchanged, and ALT+click on any rendered string resolves back to its key.
const t = wrapT((key) => bundles[locale][key] ?? key, registry, () => editor.session.state().locale);
function render() {
document.querySelector('#title').textContent = t('home.title');
}
render();That is the entire integration. dev/vanilla.html is a runnable version of it
(open it after npm run build); dev/harness.html is the narrower overlay-only
harness.
Who should see this
The editor is for reviewers. Do not mount it for the public.
Editing mode is not a quiet developer tool. It paints a floating bar on the page carrying your project's per-language completion percentages, with the human-versus-machine texture behind each one, plus a link to the transglot console. ALT+click then opens any string on the page for editing. That is business information about your product plans, and on a public page anyone can read it.
The SDK cannot help you here: it has no way to tell a reviewer from a visitor, and no way to tell your production site from your staging one. Only your app knows. So mounting is inert by default, and turning editing mode on is something a host asks for, once, on purpose:
enterOnMount: truewhen the person looking at the page may review translations, or- leave it off and call
editor.session.enter()from your own control (an internal-only toolbar, a query flag your staff use, a keyboard shortcut behind your own permission check).
Better still, gate the mount itself, so a visitor's page never loads the editor
at all. getNonce is a separate decision about WRITING, not about seeing: a
session with no nonce is read-only, and its bar still reports your progress.
What the mount owns
- the ONE shared shadow root, with the single stylesheet installed into it, so the overlay, the bottom bar and the editing dialog cannot restyle each other or the host page,
- the ALT-hold highlight and ALT+click resolution, and opening the dialog for whatever was clicked,
- the dialog's write and suggest capability, kept in lock-step with the session as the scoped token resolves,
- the bar: locale switching, adding a language, and leaving editing mode,
- repainting the host's own light DOM as a backfill lands, and after a save is refused because someone else got there first,
- best-effort screenshot capture and upload before an AI suggestion,
- a teardown that stops the poll, releases every listener, and leaves the page as a non-editing visit would have left it.
Options
| option | required | meaning |
| --- | --- | --- |
| url + publishableKey | yes, unless client | the transglot app origin and the page's publishable key |
| client | no | a pre-built IncontextClient, instead of url + publishableKey |
| locale | yes | the locale being reviewed on this page |
| enterOnMount | no | enter editing mode once the session mints (default false, see "Who should see this") |
| registry | no | the registry your wrapT marks against; one is created when omitted |
| reviewer | no | label recorded on every write; accepts a getter for a value that changes |
| getNonce + writableKeys | no | how a session obtains WRITE (see below); read-only without them |
| theme | no | 'light' \| 'dark' \| 'auto' (default 'auto'), resolved once at mount |
| consoleUrl | no | where a reviewer who cannot add a language is pointed |
| onLocaleChange | no | re-render your own i18n after a switch |
| onRowsLanded | no | rows read on each backfill tick and on each locale switch, after the SDK has repainted what it can see |
| onReady | no | called with the session before any surface installs |
| onError | no | told about a failed session mint, a failed ALT+click read, or a locale list that would not load; supply it to route those into your own UI, omit it and the editor says so in its own chrome |
| captureElement | no | supply your own screenshot pipeline instead of the optional html2canvas peer |
| document | no | injectable document (tests, iframes) |
| fetchImpl | no | injected fetch (tests / non-browser) |
The returned handle exposes session, registry, client, shadowRoot() and
destroy(). session is how a host builds its own affordances:
editor.session.enter(); // turn editing mode on from your own button
editor.session.subscribe((state) => …); // active, locale, canWrite, backfill progress
await editor.session.setLocale('de');
editor.destroy(); // idempotentOne key per element, when a sentence needs markup
A watermark is an invisible run of characters appended to the string your t()
returned, so it only stays meaningful while that string renders as one text
node. A translation that carries inline markup does not:
<!-- One key. Parses into three nodes, and the mark lands in whichever one is last. -->
<p>Stamped with <strong>the carrier time</strong> and your local time.</p>The SDK will not update that string on the page. Both writers (the backfill
repaint and the re-mark after a save) check the element first, and decline it
when it holds more than one text node or any child element, because neither the
mark nor the DOM can say which node is "the string". Declining leaves a stale but
correct sentence; guessing would leave a mangled one. repaintLandedRows reports
those as skipped, and reWatermark returns a notice to put in front of the
reviewer: their save landed and the row is correct, the page simply did not move
under it.
Split the sentence into one key per element instead, and every part stays independently editable and live-updating:
<p><span>{t('ship.stampedWith')}</span><strong>{t('ship.carrierTime')}</strong><span>{t('ship.andLocal')}</span></p>Attribute strings (placeholder, title, alt, aria-label) have no text node
at all, so they never carry a mark and are not editable in place either.
Read-only by default, and why
The publishable key is public: it ships in your page source, so it grants
read and nothing else. To let a reviewer edit, your own backend calls
POST /v1/incontext/session-nonce with a project token that has the write
ability, naming the keys this session may change, and returns the nonce:
mountIncontextEditor({
url: 'https://app.example.com',
publishableKey: 'taip_…',
locale: 'fr',
writableKeys: ['home.title', 'home.subtitle'],
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),
});Omit getNonce and the editor is read-only, which is the right default for a
viewer. The dialog renders Save disabled with a stated reason rather than
offering a control that can only fail at submit time.
The client contract
createIncontextClient(options) returns the IncontextClient the mount uses.
Build one yourself only when you need to hold it (to share it, or to inject a
double in tests); otherwise pass url + publishableKey to the mount and let it
build one.
| option | required | meaning |
| --- | --- | --- |
| url | yes | origin of the transglot app, e.g. https://app.example.com |
| publishableKey | yes | the publishable in-context key, exchanged for a scoped session token |
| getNonce | no | supplies the single-use write nonce (see above) |
| writableKeys | no | the keys this session may write, passed to getNonce |
| fetchImpl | no | injected fetch (tests / non-browser) |
The session token is held in a closure variable only, never in
localStorage/sessionStorage.
import { createIncontextClient, mountIncontextEditor } from '@transglot/incontext';
const client = createIncontextClient({ url: 'https://app.example.com', publishableKey: 'taip_…' });
const editor = mountIncontextEditor({ client, locale: 'fr' });The parts
mountIncontextEditor is an assembly, not a wall: every part it uses is
exported and usable on its own. See src/index.ts for the full surface,
including encode/decode watermarking, createRegistry, wrapT,
forEachWatermarkedText, createOverlay, createDialog, createBar,
createEditingSession, repaintLandedRows, and the checkQa mirror.
