kTranslationHelper

A dependency-free JavaScript plugin that lets visitors of a multi-language website flag and fix translations: correct a mistake in the language they're reading, or add a missing translation for any language you support.

Overview

kTranslationHelper is for sites that are already translated. Each translatable element carries its translation key in an attribute. When the helper is active, those elements are highlighted; the visitor holds Shift and clicks one, edits the text in a dialog, and the suggestion is sent to a URL (or function) you provide.

The plugin handles: highlighting, the trigger, the editor dialog, language selection, placeholder checks, submitting, and remembering state between pages. Your app handles:

Quick start

1. Include the script

Use the classic build (adds window.kTranslationHelper) or the ES module. No build step or dependencies.

<script src="dist/ktranslationhelper.js"></script>
<!-- or -->
<script type="module">
  import kTranslationHelper from './dist/ktranslationhelper.mjs';
</script>

2. Mark up translatable text

Most i18n setups already output something like this. Use whichever attribute name you like.

<html lang="es">
…
<h1 data-i18n="home.title">Bienvenido</h1>
<input type="search" data-i18n-attr="placeholder:search.placeholder" placeholder="Buscar…">

3. Initialise and give users a way to turn it on

Call init() anywhere — in <head>, at the end of the page or from a module. Its interface is added to <body> as soon as that exists.

const helper = kTranslationHelper.init({
  keyAttribute: 'data-i18n',          // required
  defaultLanguage: 'en',              // required
  languages: ['en', 'es', 'de', { code: 'ar', name: 'العربية', dir: 'rtl' }],
  submitUrl: '/api/translation-suggestions',
});

document.querySelector('#suggest-translations')
  .addEventListener('click', () => helper.toggle());

That's it. Once a visitor turns it on, it stays on as they move between pages until they press Exit on the badge (or your app calls helper.deactivate()).

How it works

  1. The visitor turns on translation mode.your app
  2. Every translatable element is outlined, a badge appears in the corner, and a short instruction message is shown (once per session by default). Help on the badge explains the colours and the steps below, worded for your configuration.plugin
  3. The visitor holds Shift and clicks some text. Normal clicks still work, so links and buttons keep working while the mode is on. On touch screens, a long press does the same.plugin
  4. A dialog shows the current text, the original (source-language) text, and a Translate to list that remembers the last language chosen. If that differs from the page's language a clear notice says so, with a one-click switch back. The proposal is pre-filled with the current text.plugin
  5. On submit the suggestion is POSTed to submitUrl (or passed to your submit function), and the result message is shown. If the edit was in the page's language, the text on the page is updated straight away (updateInPlace). When the string was a missing translation and others remain, a Next missing button takes the translator to the next one.plugin
  6. Your backend stores or applies the suggestion.your app

The two everyday uses both follow this flow: correcting a mistake (leave the language as it is and edit), and adding a missing translation — for an existing language or one you're just starting (choose the language, then type).

Marking up translatable text

Attributes

OptionExample markupPurpose
keyAttributerequired<p data-i18n="cart.empty">The element's text is translatable, and this is its key.
attrMapAttribute<input data-i18n-attr="placeholder:form.email; title:form.email.tip">Translatable attributes — placeholders, tooltips, alt text, aria-label … Pairs of attribute:key separated by ;.
missingAttribute<p data-i18n="cart.empty" data-i18n-missing>Set this when your app rendered a fallback because the string isn't translated yet. The element is outlined in red and the editor says so.
staleAttribute<p data-i18n="cart.empty" data-i18n-stale>Set this when the string is translated but its original has changed since. The element gets its own solid outline and the editor asks the visitor to check the translation. See Outdated text.
langAttribute<section data-locale="de">Where to read an element's language from, if not the standard lang attribute. Ancestors are searched.

Attributes whose value is code are never editable, whatever the mapping says: event handlers (onclick …), style, srcdoc, formaction and is. URL attributes such as href and src are left out too unless you set allowUrlAttributes: true — most translators are not developers and shouldn't be asked to edit them — and even then a javascript:, data: or vbscript: value is not applied to the page. Your backend must still validate every attribute value it stores.

When an element has more than one string — a button with a label and a tooltip, or translatable text inside a translatable parent — the editor shows a Text to translate picker. Text that can't be clicked (<option>s, collapsed sections, the page <title>) is reachable from All text on the badge, which lists every string on the page and lets you filter.

Languages

The language of the clicked text is resolved in this order:

  1. the nearest element with langAttribute, if you set that option;
  2. the nearest standard lang attribute (usually <html lang>), unless useLangAttribute: false;
  3. defaultLanguage.

So a quote in <blockquote lang="es"> on an English page is correctly treated as Spanish. languages is the list offered in Translate to; leave it out and only the page's language can be edited (corrections only). Each entry is a code or an object:

languages: [
  'en',                                     // name from Intl.DisplayNames ("English"), dir detected
  { code: 'zh-Hans', name: '简体中文' },
  { code: 'ar', name: 'العربية', dir: 'rtl' }, // the text box switches to right-to-left
]

The page's language is always offered as well, first in the list, so a mistake can be corrected where it is read. A host that decides per visitor which languages they may translate into sets includePageLanguage: false: Translate to is then exactly languages. The editor starts on the remembered language if it is listed, else on the page language if it is listed, else on the first one; with a single language there is no list at all; and when the page language isn't offered, the "You are translating into …" notice has no Switch to button. Use prefill: 'target' with it, so the text box starts from the existing translation rather than the page's text.

sourceLanguage (defaults to defaultLanguage) is the language your strings are written in; when getSource is set its text is shown as Original for reference.

HTML strings

By default everything is plain text: the plugin reads textContent and never inserts markup. Set allowHtml to let visitors edit markup:

allowHtml: true                                   // every string
allowHtml: (el, key) => el.hasAttribute('data-i18n-html')  // per element

For HTML strings the editor shows and edits innerHTML, and the payload has html: true.

Your backend must sanitise HTML suggestions. Anyone who can open the editor can submit anything. Allow a short list of tags and attributes and check link URLs — see docs/backend/lib.php for an example. Note that with the default updateInPlace: 'always' the visitor's raw HTML is written into their own page before your backend answers (it affects nobody else); return the sanitised text in the response and the plugin shows that instead, or use 'success'.

Options

Pass these to kTranslationHelper.init(). kTranslationHelper.defaults holds the defaults.

What is translatable

OptionDefaultDescription
keyAttributerequired—Attribute holding the translation key.
attrMapAttributenullAttribute mapping element attributes to keys. See Attributes.
missingAttributenullAttribute your app sets on fallback text. Also enables the missing count on the badge, the Missing only filter and Next missing.
staleAttributenullAttribute your app sets on translated text whose original has changed since. Own outline (--kth-stale), an outdated count on the badge, an Outdated only filter and Next outdated. See Outdated text.
ignoreSelectornullElements matching this selector, or inside one, are left alone: no highlighting, no editor, not listed. E.g. '[data-i18n-ignore]'.
canEditnull(element, key, attribute) => boolean to exclude individual strings. CSS can't call it, so the outline stays; use ignoreSelector when the text should not be highlighted either.
allowUrlAttributesfalseOffer URL attributes (href, src, action …) mapped with attrMapAttribute for editing, e.g. links to localised pages. Off by default: they are confusing for non-developers, and a wrong value breaks the page. See Attributes.

Languages

OptionDefaultDescription
defaultLanguagerequired—Language present when nothing else says otherwise.
languagesnullLanguages offered in Translate to.
includePageLanguagetrueThe page's language is offered in Translate to even when it isn't in languages. false: the list is exactly languages. See Languages. No effect without languages.
langAttributenullCustom attribute that holds an element's language.
useLangAttributetrueFall back to the nearest lang attribute.
sourceLanguagedefaultLanguageLanguage shown as Original.
rememberLanguagetruePre-select the last language chosen in Translate to, even if that suggestion wasn't sent. Stored in localStorage, whatever persist is.

Interaction

OptionDefaultDescription
trigger'shift+click'Modifier keys plus click: 'alt+click', 'ctrl+shift+click', 'mod+click' (Cmd on Mac, Ctrl elsewhere), 'click', or a function (event) => boolean. Avoid ctrl alone on Mac (it's a right-click) and alt alone on links (it downloads them).
hotkey'shift+f2'Keyboard shortcut that opens the editor for the focused translatable text, or the text under the mouse. Same modifier names as trigger plus a key ('mod+shift+e'); false disables it. See Keyboard.
longPresstruePress and hold on touch screens opens the editor.
longPressDelay550Milliseconds to hold.
messagenullReplace the instruction message (otherwise it's generated from the trigger and strings).
showMessage'session'When to show the instruction: 'always', 'session' (once per browser session, and whenever the visitor turns the mode on), 'once' (the first time ever, remembered in localStorage), or false.

Editor

OptionDefaultDescription
prefill'current'What goes in the text box: 'current' (the text as it is now), 'blank', 'target' (the existing translation in the chosen language, if any), or (context) => string. With getSuggestion, a suggestion your app already holds takes the place of the three built-in choices; a function gets it as context.suggestion.
allowHtmlfalsetrue, false or (element, key) => boolean. See HTML strings.
showKeyfalseShow the translation key in the editor and list. Handy for developers; most translators don't need it.
reviewtrueWhether suggestions go to a reviewer. Set false when your backend applies them directly: the comment box disappears, the button reads Save instead of Submit for review (Apply in local mode), and the help text says "saved" rather than "sent for review". The labels are the submit, save and apply strings.
showCommenttrueShow an optional comment box for the reviewer. Only shown when review is true and there is a backend.
placeholderPattern/\{\{?\s*[\w.-]+\s*\}\}?|%(?:\d+\$)?[sdif]/gPlaceholders to look for ({name}, {{name}}, %s, %1$s …). The editor warns if the proposal drops one. null turns the check off.
maxLengthnullShows a character counter under the text box and refuses longer suggestions. Counted in code points, like PHP's mb_strlen.
getSourcenullasync (key, lang) => string | null. See Raw strings.
editorNotenull({ key, attribute, element, pageLang, targetLang }) => string | null (a promise is fine). A notice shown directly above the editor's buttons, e.g. "This text is only shown after the page has been reloaded". Plain text; it stays while the visitor types, is asked for again when the string or the target language changes, and is read out by screen readers with the text box and the submit button.

Submitting

OptionDefaultDescription
submitUrlnullURL that receives suggestions. With neither this nor submit the plugin runs in local mode.
submitnullasync (payload) => result. Used instead of the built-in fetch.
method'POST'HTTP method. It must allow a request body: init() refuses GET and HEAD.
encoding'json''json' or 'form' (application/x-www-form-urlencoded, handy for PHP's $_POST).
headersnullObject, or async (payload) => object — e.g. a CSRF token read at submit time.
credentials'same-origin'Passed to fetch.
buildPayloadnull(payload, { element }) => payload — add or change fields, e.g. a user id or a translation namespace read from the element.
updateInPlace'always'Change the page after an edit in the page's language, updating every element with that key (using the text your backend returned, if it did — see Response). 'always' does it whatever the backend answers, so the visitor sees their change even when nothing was saved; 'success' only when it answers ok; false never. Strings containing placeholders are skipped, since only your app can fill them in. A function ({ elements, key, attribute, text, html, lang, pageLang, ok, result, original, element }) => void | false does it itself: it is called after every submit and for every language (elements is empty when lang isn't the page's), and can return false to say it applied nothing. original is the string before the edit (the payload's original) and element the element the editor was opened on, whatever the language — null when the key isn't on the page.
suggestedAttribute'data-kth-suggested'Set on text the visitor has already suggested a translation for during this browser session (green outline, suggested tag in the list, a note in the editor). null disables it.
getSuggestionnull(key, lang, attribute) => string | null, sync or async: the visitor's pending suggestion, from your app. The marks then come from your app instead of the browser session, and the editor starts from that text. See Suggestions your app remembers.
closeDelay1200How long the success message shows before the dialog closes. 0 keeps it open.
closeOnSuccessnullWhen the editor closes after a success: true (or 0) at once, false never, or after a number of milliseconds. null leaves it to closeDelay. A string opened from onSubmit is not closed.
stayOpenForNexttrueKeep the editor open after a success while Next missing or Next outdated is offered. false: it closes as closeOnSuccess / closeDelay say.
showSuccesstruefalse: no success toast and no success line in the editor, for apps that confirm in onSubmit themselves. Errors are still shown. In local mode this also hides "Applied to this page only".

Persistence

OptionDefaultDescription
persist'localStorage'Where "translation mode is on" is remembered: 'localStorage', 'sessionStorage', 'cookie' or false. (rememberLanguage and showMessage: 'once' always use localStorage.)
storageKey'kTranslationHelper'Prefix for storage keys (and the cookie name).
cookieDaysnullCookie lifetime; null is a session cookie.
autoActivatetrueTurn on at page load if it was on before.

Interface

OptionDefaultDescription
showBadgetrueThe floating badge with Pick text / All text / Help / Exit. The instruction message and the "sent" toast are still shown. If you hide the badge, make sure your app offers another way out.
showPicktrueThe Pick text button.
showListtrueThe All text button.
showHelptrueThe Help button: a legend of the outline colours and a short "how it works", both put together from this configuration (trigger keys, hotkey, touch, languages, placeholders, HTML, drafts, local mode or backend, review, persistence).
strings{}Interface text overrides. See Interface text.
uiLanguage'en'Language of the interface text (BCP 47). Set it with strings from a locale file so screen readers pronounce the plugin's own text correctly.
theme'auto''auto' follows the page's own color-scheme (so a site's light/dark switch is respected), falling back to the operating system setting; 'light' or 'dark' force one.
zIndex2147483000z-index of the badge. (Dialogs use the browser's top layer.)
noncenullNonce for the plugin's <style> elements on pages with a strict Content Security Policy. Defaults to the nonce of the <script> that loaded the classic build. See Content Security Policy.

Raw strings (getSource)

What's on the page isn't always the string you store. "Hello {name}, you have {count} messages" is rendered as "Hello Ben, you have 3 messages"; if the editor used that, a translator would save "Ben" and "3" into the translation. Give the plugin a way to fetch the raw string and it uses it everywhere instead:

getSource: async (key, lang) => {
  // Return the stored string, or null if there is none (not translated yet).
  return myI18n.getRaw(key, lang) ?? null;
},

It's called for the page's language (the Current text), the sourceLanguage (the Original), and the chosen Translate to language (Existing translation). Results are cached per page; call helper.clearCache() if your translations reload. Without getSource the plugin falls back to the element's text and the Original and Existing fields aren't shown.

Submitting

Either give a URL, or a function. A function is useful for GraphQL, custom auth, queuing offline, or — as the demo does — a static host with no server at all:

// A static JSON file can't receive a POST, so GET it instead.
submit: async (payload) => {
  const res = await fetch('static-backend/response.json');
  return res.json();  // { "ok": false, "message": "The demo page does not have a backend …" }
},

Local mode (no backend)

Leave out both submitUrl and submit and nothing is sent: submitting applies the text to the page and shows "Applied to this page only. It will be lost when the page is reloaded." Useful for trying wording, for a static demo, or while the backend is still being written. onSubmit still fires, with result.local === true.

With a backend, updateInPlace: 'always' (the default) gives visitors the same instant feedback even when it answers ok: false; their change is only in their browser until it is reloaded.

Payload

FieldExampleDescription
key"cart.empty"Translation key.
lang"fr"Language the text is in — the one chosen in Translate to.
text"Votre panier est vide"The suggested text (trimmed).
attributenull / "placeholder"Set when the string came from an attribute mapping.
htmlfalseWhether the string was edited as HTML.
pageLang"es"Language of the text that was clicked, as the page declares it. lang is one of your languages codes when one matches, so compare primary subtags: on an en-GB page with languages: ['en'], a correction arrives as lang: "en", pageLang: "en-GB". Without languages (or when none matches) lang is the page's own code, "en-GB".
sourceLang"en"The configured source language.
original"Tu cesta está vacía"The text before editing, in pageLang (the raw string, if getSource is set). It is not the sourceLang text.
comment""The visitor's optional comment.
url"https://…/cart"Page the suggestion was made on — useful context for reviewers. The fragment (#…) is left out, since it can carry tokens; add location.hash in buildPayload if your app routes with it.

Response

Reply with JSON:

{ "ok": true,  "message": "Thanks! A reviewer will look at it." }
{ "ok": true,  "message": "Saved.", "text": "Votre panier est <strong>vide</strong>" }
{ "ok": false, "message": "That language isn't open for suggestions yet." }

The request counts as successful when the HTTP status is 2xx and ok isn't false. If you changed the text before storing it (sanitised HTML, trimmed whitespace …), return it as text: the plugin then shows that on the page rather than what was typed. message is shown to the visitor (a default is used if it's missing), so write it in their language if you can — payload.pageLang tells you which. A submit function can return the same object, true/undefined (success), false (failure) or a string (a success message); throwing counts as failure.

Writing a backend

The backend is yours to write, and it should match how your translations are stored. Some guidance:

Queue suggestions for review rather than applying them directly. The editor can be opened by anyone who can turn translation mode on. Store suggestions with who made them and when, and let a trusted person accept them. Apply directly only for trusted, signed-in translators.

Checklist

Minimal example (PHP, review queue)

<?php
// POST /api/translation-suggestions — stores suggestions for review.
session_start();
header('Content-Type: application/json');

$in = json_decode(file_get_contents('php://input'), true) ?: [];
$languages = ['en', 'es', 'de', 'ar'];

// CSRF: only our own pages may post here (or check a token sent with the `headers` option).
if (strtolower($_SERVER['HTTP_SEC_FETCH_SITE'] ?? 'same-origin') !== 'same-origin') { http_response_code(403); exit(json_encode(['ok' => false, 'message' => 'Forbidden.'])); }
if (empty($_SESSION['user_id'])) { http_response_code(401); exit(json_encode(['ok' => false, 'message' => 'Please sign in.'])); }
if (!in_array($in['lang'] ?? '', $languages, true) || !is_string($in['key'] ?? null) || !is_string($in['text'] ?? null)
    || mb_strlen($in['text']) > 2000) {
    http_response_code(422); exit(json_encode(['ok' => false, 'message' => 'Invalid suggestion.']));
}

$pdo->prepare('INSERT INTO translation_suggestions (tkey, lang, text, original, page_url, comment, user_id, created_at)
               VALUES (?, ?, ?, ?, ?, ?, ?, NOW())')
    ->execute([$in['key'], $in['lang'], trim($in['text']), $in['original'] ?? null, $in['url'] ?? null,
               $in['comment'] ?? null, $_SESSION['user_id']]);

echo json_encode(['ok' => true, 'message' => 'Thanks! A reviewer will look at your suggestion.']);

The demo ships two backends you can read: docs/static-backend/ (always answers that nothing was saved) and docs/backend/ (PHP, writes straight into the JSON files, for local use only).

With the default updateInPlace: 'always', the page shows the new text as soon as it is submitted. If suggestions go to a review queue, you may prefer 'success' or false so visitors don't think the change is already live. The suggested mark (suggestedAttribute) tells them which strings they have already sent.

Activation & persistence

Turning the mode on is up to your app — you decide who sees the button:

button.addEventListener('click', () => helper.toggle());

// Optional keyboard shortcut
addEventListener('keydown', (e) => {
  if (e.altKey && e.code === 'KeyT') { e.preventDefault(); helper.toggle(); }
});

// Keep your button in sync, including when it's re-enabled on page load
kTranslationHelper.init({ …, onActivate: () => setPressed(true), onDeactivate: () => setPressed(false) });

While active, the state is saved (in localStorage by default), so the helper turns itself back on as the visitor moves around the site. They leave with Exit on the badge, your button, or helper.deactivate(). Use persist: 'cookie' if your server needs to know (for example to render extra attributes or skip caching), or persist: false to switch persistence off.

While active, <html> gets the class kth-active (plus kth-mod while the trigger keys are held, and kth-picking in pick mode), which you can use in your own CSS.

Touch screens

Touch screens have no Shift key, so there are two alternatives, both on by default:

Keyboard

Visitors who don't use a mouse can press the hotkey (Shift + F2 by default) while a translatable element has focus — a link, a button, a form field with a translatable placeholder — to open the editor for it. With the mouse over some text and nothing translatable focused, the hotkey opens that text instead. The instruction message mentions it. Set hotkey: false to turn it off, or choose another combination such as 'mod+shift+e'.

Inside the editor, Ctrl/Cmd + Enter submits and Esc closes. Everything else — All text, the filters, Next missing — is an ordinary button.

Drafts & suggested marks

Closing the editor with an unsent edit keeps it as a draft for that string and language until the page is reloaded. Opening the same string again restores it, with a Discard link. There is no "are you sure?" dialog.

Once a suggestion has been sent (accepted by the backend, shown on the page by updateInPlace, or applied in local mode), the plugin sets suggestedAttribute on the elements that show it (a green outline), tags it suggested in All text, and says so at the top of the editor if it is opened again. This is remembered for the browser session (sessionStorage), so it survives moving between pages.

With missingAttribute set, the badge shows how many strings on the page are still fallbacks, All text has a Missing only filter, and after filling in a gap the editor stays open with a Next missing button that scrolls to and opens the next one. "Missing" means the page language's gaps; gaps in other languages need getSource and are not counted.

Suggestions your app remembers

The session-only marks are forgotten on another device, and never withdrawn when a suggestion is accepted or rejected. An app with a real review queue passes getSuggestion(key, lang, attribute), returning the visitor's pending text or null (a promise is fine). Your app is then the only source: the plugin asks about every string on the page when translation mode is turned on and when you call helper.refresh(), sets suggestedAttribute where there is a suggestion and removes it where there is none, and in the editor shows the "already suggested" note and starts from that text (an unsent draft still comes first). A suggestion just sent is marked at once, until the next answer from your app.

That is one call per string on each activation — on every page load while the mode is on — so answer from data you already have rather than with a request per string. Marks describe the page's language; with includePageLanguage: false and a page language that isn't offered, they describe the first listed language.

const pending = new Map(myQueue.map((s) => [`${s.lang}|${s.key}|${s.attribute || ''}`, s.text]));
const helper = kTranslationHelper.init({
  …,
  getSuggestion: (key, lang, attribute) => pending.get(`${lang}|${key}|${attribute || ''}`) ?? null,
});
// later, after the queue changed:
await helper.refresh();

Outdated text

staleAttribute is for text that is translated but whose original has changed since. It has its own solid outline, an outdated count on the badge, a tag and an Outdated only filter in All text, a line in the editor asking the visitor to check the translation, and Next outdated to work through them. Sending such a string back unchanged is accepted: it confirms the translation. The plugin removes the attribute when it applies an edit in the page's language; an updateInPlace function removes it itself (that is what its element is for). An element with both attributes counts as missing.

Interface text

The plugin's own interface is in English by default. Override any string with strings; ready-made translations are in dist/locales/ (es, zh-Hans, ar, sv, plus en.json as a template for new ones):

const strings = await fetch(`/js/ktranslationhelper/locales/${lang}.json`).then((r) => r.json());
kTranslationHelper.init({ …, strings, uiLanguage: lang });

Strings can contain {placeholders}: {keys} in message and messageHotkey, {count} and {max} in the counters, {lang}, {target}, {page} and {list} elsewhere. Language names in {lang}, {target} and {page} are given in the page's language ("Alemán" on a Spanish page), and the Translate to list shows both that and the language's own name ("Alemán (Deutsch)"). kTranslationHelper.strings lists every key. New locale files are very welcome as contributions.

Theming

The badge and dialogs live in a Shadow DOM, so your site's CSS can't break them and theirs can't leak out. Adjust them with CSS custom properties on :root. Both light and dark defaults are built in; which one is used follows the theme option (by default the page's color-scheme, then the OS setting).

:root {
  /* Highlighting on the page */
  --kth-highlight: #cf6a00;       /* outline of translatable text */
  --kth-highlight-ring: rgba(207, 106, 0, .22);
  --kth-missing: #e11d48;         /* outline of text with missingAttribute */
  --kth-stale: #8b5cf6;           /* outline of text with staleAttribute */
  --kth-suggested: #16a34a;       /* outline of text already suggested this session */
  --kth-focus: #1d4ed8;           /* keyboard focus ring on highlighted text */
  --kth-focus-gap: #fff;

  /* Badge and dialogs */
  --kth-accent: #2563eb;  --kth-accent-text: #fff;
  --kth-bg: #fff;  --kth-text: #1f2328;  --kth-muted: #59636e;
  --kth-border: #d1d9e0;  --kth-soft: #f6f8fa;
  --kth-warn-bg: #fff8c5;  --kth-warn-text: #6b4f00;
  --kth-error: #cf222e;  --kth-success: #1a7f37;
  --kth-font: system-ui, sans-serif;
}

The defaults meet WCAG 2.1 AA: text at 4.5:1 and outlines at 3:1 on light and dark backgrounds. If you change them, keep those ratios. The states also differ in outline style, not just colour: translatable text is dashed, missing text dotted, outdated text solid and suggested text double, so they can be told apart without colour vision. Keyboard focus on highlighted text gets its own ring (--kth-focus) so it stays visible under the highlight.

The badge sits at the inline end of the viewport (bottom-right on left-to-right pages, bottom-left on right-to-left ones) and the dialogs follow the page's direction.

When a string is chosen from All text or with Next missing / Next outdated, the page scrolls to it and its outline pulses for a second (class kth-flash).

Highlighting uses outline, so it doesn't shift your layout, and CSS attribute selectors, so content added after page load is highlighted automatically and frameworks like React or Vue can re-render freely.

Content Security Policy

The plugin adds two <style> elements: one to <head> for the highlighting and one inside its Shadow DOM. A policy with style-src but no 'unsafe-inline' blocks both, and the plugin then shows no highlighting and an unstyled badge. Give it your nonce: the classic build picks it up from its own <script nonce="…"> tag automatically; the ES module (or a script loaded another way) needs nonce: '…' in the options. Nothing else needs an exception — the plugin uses no inline scripts, no eval and no inline style attributes.

API & events

kTranslationHelper

init(options)Create a helper and return it. Throws if a required option is missing.
versionPlugin version.
defaultsDefault options.
stringsDefault (English) interface text.

Instance methods

activate()Turn translation mode on.
deactivate()Turn it off and forget the saved state.
toggle(force?)Switch on/off, or set with a boolean.
isActive()true while on.
pick()Arm pick mode (activates if needed).
list()Open the list of all translatable text.
help()Open the help dialog (legend and how it works).
open(target)Open the editor for an element, or for { key, attribute?, element? } — e.g. from your own "report a translation problem" link.
close()Close the editor.
clearCache()Forget strings cached from getSource.
refresh()Ask getSuggestion again and redo the suggested marks and the missing / outdated counts, e.g. after your review queue changed or your app re-rendered. Returns a promise for the helper.
destroy()Deactivate and remove everything the plugin added.

Events

Pass these as options. this is the helper instance.

onActivate({ auto })Turned on. auto is true when re-enabled on page load.
onDeactivate()Turned off.
onOpen({ key, attribute, element, lang })Editor opened.
onClose()Editor closed.
onBeforeSubmit(payload)Return false to cancel.
onSubmit(result, payload)Suggestion accepted by the backend (or applied in local mode: result.local). A good place to refresh your own translation cache; result.text is the stored text when the backend returned one.
onError(errorOrResult, payload)Network error, non-2xx response, or ok: false.

Running the demo

The demo is a small bakery site with three pages — Home (eleven numbered examples), About and Contact — in English, Spanish, Simplified Chinese and Arabic (right-to-left), plus French with an empty file so you can try building a language from scratch. Two Spanish strings are deliberately missing. Translations are plain JSON files in docs/lang/.

Turn translation mode on and move between the pages: it stays on, the instruction message isn't repeated, and the editor keeps the language you last translated into. The About page shares its image's alt-text key with the home page, so fixing it on one page fixes both.

The demo lives in the repository's docs/ folder. Pick the backend in docs/config.js:

window.DEMO_CONFIG = {
  backend: 'static',   // 'static' — any static host; nothing is saved
                       // 'php'    — writes suggestions into docs/lang/*.json
  showKey: false,
};

Static (no backend)

Upload the docs/ folder to any static host (it is what GitHub Pages serves) and open it. Suggestions get the reply "The demo page does not have a backend, so updates are not permanently saved and will disappear on the next page load".

PHP (full demo)

# PHP 8.1+ with the json and dom extensions (both usually built in)
cd ktranslationhelper
php -S localhost:8000 -t docs
# open http://localhost:8000/ — after setting backend: 'php'

Suggestions are written straight into the JSON files. Reset translations on the page (or php docs/backend/reset.php) restores them from docs/lang/original/.

Tests

npm install --no-save playwright axe-core
node tests/demo.test.mjs      # starts its own php -S, runs both backends, restores docs/lang/
node tests/a11y.test.mjs      # WCAG 2.1 AA audit of the demo and docs with axe-core

The PHP backend is for trying things out on your own machine. It only answers requests from localhost or private network addresses (a check that any reverse proxy defeats, since it sees the proxy's address) and from its own pages (Sec-Fetch-Site / Origin, so another website open in your browser can't write to the files), and must not be deployed on a public server.

Limits

Browser support

Current versions of Chrome, Edge, Firefox and Safari (desktop and mobile). The plugin uses <dialog>, Shadow DOM, :is() and ES2020, and has no dependencies. TypeScript declarations ship in dist/ktranslationhelper.d.ts. Building dist/ from src/ needs Node 18+ (node build.mjs); using the plugin needs nothing.