Skip to content
Comvi

In-context editor

View .md

With an i18n instance from the Quick start, a Comvi project from Connect to Comvi and an API key, let a translator click any rendered text and edit it in the browser. @comvi/plugin-in-context-editor injects invisible key markers into t() output and watches the DOM for them. Saves go to the Comvi platform over its API, so it needs a project and an API key; it has no offline mode.

Terminal window
npm install @comvi/plugin-in-context-editor
src/i18n.ts
import { createI18n } from '@comvi/core';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
import { inContextEditor } from '@comvi/plugin-in-context-editor';
export const i18n = createI18n({
locale: 'en',
apiKey: import.meta.env.DEV ? import.meta.env.VITE_COMVI_EDITOR_API_KEY : undefined,
})
.use(cdnLoader({ cdnUrl: import.meta.env.VITE_COMVI_CDN_URL }))
.use(inContextEditor());
await i18n.init();

Create an API key in the project’s settings with project read plus translation read and write; read alone loads the editor but every save fails. The editor reads the key off i18n.apiKey, the same option the fetch loader reads for API mode. Compose a loader or an inline messages catalog next to it: the editor can only edit text the app rendered through t().

An API key in a browser bundle is readable by every visitor, and this one can overwrite translations, so the editor key must never be in a production build or a public preview build. The import.meta.env.DEV test is what keeps it out: Vite replaces it with false in a build, and the bundler drops the key with the dead branch.

inContextEditor() takes the i18n instance, composes the devtools and plugin capabilities onto it, queues inContextEditorPlugin() and returns the instance with its type unchanged. The plugin runs inside init(): on the server it registers only the marker post-processor, so server-rendered HTML carries the markers the client expects; in the browser it also starts the DOM observer and the UI. Compose it before init().

Do this And
Hold Alt (Option on macOS) and hover translated elements light up with an overlay naming the key
Alt + click a highlighted element the edit modal opens on that key
Cmd or Ctrl + S in the modal saves without closing
Escape closes the modal, or asks about unsaved edits

Nothing highlights on hover alone. Release the modifier, or switch windows, and the overlay disappears. With no key configured the plugin starts in demo mode: the console says [@comvi/plugin-in-context-editor] Running in demo mode - API key not configured. Changes cannot be saved. once, and every click opens one panel naming the key, with no translation list.

src/i18n.ts
import { createI18n } from '@comvi/core';
import { cdnLoader } from '@comvi/plugin-fetch-loader';
import { inContextEditor } from '@comvi/plugin-in-context-editor';
const editorEnabled = import.meta.env.DEV;
const base = createI18n({
locale: 'en',
fallbackLocale: 'en',
apiKey: editorEnabled ? import.meta.env.VITE_COMVI_EDITOR_API_KEY : undefined,
}).use(cdnLoader({ cdnUrl: import.meta.env.VITE_COMVI_CDN_URL }));
export const i18n = editorEnabled ? base.use(inContextEditor()) : base;
await i18n.init();

Gating the key is required. Two things keep the editor code out of a production build: the package’s production export condition resolves to an entry whose installer is an identity function, with no editor bytes, and inContextEditorPlugin() returns a no-op when process.env.NODE_ENV is "production". Neither touches your own createI18n call, so an ungated apiKey still ships in dist/assets, where anyone can read it and overwrite translations with it. Put the key and the installer behind the same condition, and never widen that condition with a flag a deployed build can set: a preview deployment is a production build, so the editor would not run there and the key would still ship. Both branches of the ternary have the same type. The Comvi browser extension is the other way to run the editor: it injects the standalone editor runtime into the page and keeps the key in the extension.

Option Default Behaviour
targetElement document.body The DOM subtree to watch.
tagAttributes built-in map HTML tag name to the attributes treated as translated values. Replaces the built-in map.
debug false Verbose console logging.
highlightStyle { borderColor: '#D97706', backgroundColor: 'rgba(217, 119, 6, 0.12)', borderWidth: 2 } The overlay. Its z-index: 10000 is fixed.
collectContext true Passive context collection while the editor runs.
screenGroupResolver none () => '/users/:id', a PII-free route template shown instead of a URL digest; return null to keep the digest.
apiKeyOverride none Overrides i18n.apiKey for this plugin only; used by the extension.

Context collection sends the keys it sees, layout hints and screen groups to your project to improve suggestions. The plugin option reaches the editor you composed; the collectContext: false option on createI18n outranks it and also covers the extension. Collection is off in demo mode.

src/i18n.ts
import { createI18n } from '@comvi/core';
import { devtools } from '@comvi/core/devtools';
import { inContextEditor } from '@comvi/plugin-in-context-editor';
export const i18n = createI18n({ locale: 'en' })
.use(devtools({ instanceId: 'checkout' }))
.use(inContextEditor());

The Comvi browser extension finds an app through window.__COMVI__, which an i18n instance publishes only after composing @comvi/core/devtools. inContextEditor() composes it for you with a generated comvi-<n> id; to name the instance, compose devtools({ instanceId }) first, because a later call configures nothing. Composing devtools() alone is enough for extension visibility; the package is for the in-page UI. Discovery is removed on destroy().

The extension loads @comvi/plugin-in-context-editor/standalone, an IIFE that installs the hook and exposes window.ComviInContextEditor with activate(), deactivate(), isActive() and getStatus(). Its activate() takes EditorOptions plus apiKey, a transport proxy so the credential stays in the extension, instanceId, refreshMessages and onLifecycle, and returns null with a warning when a session is already running. Activation is announced on window as a credential-free comvi-in-context-editor:lifecycle CustomEvent.

src/chart.ts
import { i18n } from './i18n';
export function drawLabel(ctx: CanvasRenderingContext2D, x: number, y: number) {
ctx.fillText(i18n.t('chart.label', { markers: false }), x, y);
}

Pass markers: false when the invisible markers cause a problem for one call: a canvas or a font that draws them as boxes, a width-sensitive layout, a comparison or sort that sees the extra characters. The marker injector is the only reader of the option, so the text is otherwise the same t() result, and that one string is not editable in context. <T> takes the same option as a prop, markers={false} or :markers="false" in Vue. The plugin returns a cleanup function, so destroy() unhooks everything it registered.

  • Overview: installers, plugin factories and registration options.
  • Fetch loader: the loader the editor edits on top of.
  • Devtools API: devtools(), instanceId and exposeGlobal.