lingoweave
v0.1.1
Published
Drop-in automatic translation for your whole website. Translates every element — dropdowns, submenus, modals, tooltips and anything injected later — pierces Shadow DOM, and never breaks React.
Downloads
0
Maintainers
Readme
lingoweave
Drop-in automatic translation for your whole website. One line, and every string on the page is translated — including the ones every other library misses.
npm install lingoweaveimport { weave } from 'lingoweave'
await weave({ to: 'es' })That's it. No account, no API key to get started, no JSON dictionaries to maintain, no wrapping your text in <T> components. Zero dependencies, 8.2 kB.
What it actually translates
Not just text nodes. The long tail is where "translate my site" normally falls apart:
| | |
|---|---|
| Text | every text node, at any depth |
| Closed dropdowns | display: none content is translated before it's opened |
| Submenus | including nested, including injected on click |
| Modals & toasts | unopened dialogs, portals, notifications |
| Form labels | placeholder, aria-label, aria-description, label |
| Images | alt, title |
| Selects | <option>, <optgroup label> |
| Buttons | value on submit/reset/button inputs |
| Browser tab | <title> |
| Social previews | description, og:*, twitter:* |
| SVG | <text>, <tspan>, <title>, <desc> |
| Shadow DOM | open shadow roots, nested, each with its own observer |
| Anything added later | infinite scroll, route changes, async content |
And it deliberately leaves alone: <code>, <pre>, <script>, <style>, textarea contents, contenteditable, user-typed input values, machine-facing meta tags, translate="no", .notranslate, and [data-lw-ignore].
Why this exists
Google discontinued its free Website Translator widget for commercial sites in 2019. Nothing open-source properly replaced it. The libraries that do exist share four problems.
1. They crash React
Google Translate and every wrapper-based translator replace a text node with a <font> element. React still holds a reference to the original node, so its next parent.removeChild(node) throws Failed to execute 'removeChild' on 'Node'. That's facebook/react#11538, open since 2017.
lingoweave never replaces, wraps, or moves a node. It assigns to nodeValue and calls setAttribute, and nothing else. Node identity survives, so every framework's stored references stay valid.
2. They lose to re-renders
When a framework re-renders a translated node it writes the original string straight back. Naive translators either give up (text reverts permanently) or re-translate over the network (flicker, and another charge).
lingoweave records both the source and exactly what it wrote, so it tells three cases apart:
- its own write echoing back → ignore it. This is what stops the translate-observe-translate loop
- a re-render → re-apply from cache in the same task: no network, no flicker, no second charge
- genuinely new text → translate it
3. They can't see into Shadow DOM
TreeWalker doesn't cross shadow boundaries — Firefox's own translator has this open, and Google Translate fails too. Every Lit / Ionic / web-component UI goes untranslated.
lingoweave walks each shadow root as its own tree, with its own observer.
4. They translate fragments, not sentences
<p>Only <b>signed-in</b> users can post</p> is three text nodes. Send three requests and you get three fragments translated with no knowledge of each other — gender, case and word order each decided blindly. In inflected languages the result is wrong, not merely clumsy.
lingoweave sends one string and puts the reply back:
sent: Only <0>signed-in</0> users can post
ja reply: <0>ログイン済み</0>のユーザーのみ投稿できます
rendered: <b>ログイン済み</b>のユーザーのみ投稿できますNote the bold moved to the front — Japanese word order differs — and it still reads correctly, because each node is pinned to a position in the sentence, not just to an inline container. If a provider mangles the placeholders, it falls back to per-node rather than corrupting your markup.
Providers
Zero config uses Chrome's built-in on-device translator (Chrome 138+): free, private, works offline, costs nothing at any volume. On other browsers a keyless endpoint covers localhost only, so your first run works immediately.
In production with no provider configured it warns once and leaves the page in its source language — rather than quietly depending on an endpoint that will rate-limit you in front of real users.
import { weave } from 'lingoweave'
import { chromeBuiltIn, proxy } from 'lingoweave/providers'
await weave({
to: 'auto', // from navigator.languages
providers: [
chromeBuiltIn(), // free, on-device, offline
proxy('/api/translate'), // your server; your key stays secret
],
})| Provider | Notes |
|---|---|
| chromeBuiltIn() | Free, on-device, offline, private. Desktop Chromium only |
| proxy(url) | Your own route. The right production default |
| microsoft({ key }) | Cheapest managed API — 2M chars/month free, permanently |
| deepl({ key }) | Best quality for European languages |
| google({ key }) | Widest language coverage |
| libretranslate({ url }) | Self-hosted; nothing leaves your infrastructure |
| llm({ key, instructions }) | Any OpenAI-compatible endpoint, for tone and brand voice |
| custom(fn) | Anything else |
| chain([...]) | Compose with fallback |
A key in client-side code is public
Anything you pass as
keyships to every visitor and is readable in DevTools. There is no way around that — it's client-side code.That's fine for a referrer-restricted key, or self-hosted LibreTranslate where there's nothing to steal. It is not fine for an unrestricted paid key. Use
proxy('/api/translate')and keep the real key on your server. Every provider also acceptsendpointfor this.Your proxy route receives
POST { texts, from, to }and returns{ translations: string[] }in the same order.
Cost control
- Deduplication — a nav label in a desktop menu, a mobile menu and a footer is one billed string
- Persistent cache — IndexedDB, loaded into memory up front so lookups are synchronous. A returning visitor's page is translated before first paint, with zero network requests
- Dictionaries — commit translations to JSON and pay nothing, ever
- A cost meter — characters sent, characters saved, cache hit rate, estimated USD
const weaver = await weave({
to: 'es',
dictionaries: { es: await import('./locales/es.json') }, // 0 API calls
})
weaver.stats()
// { chars: 0, charsSaved: 4210, requests: 0, cacheHitRate: 1, estimatedCost: 0, ... }
weaver.export() // runtime translations, ready to commit as a dictionaryHuman control over the machine
await weave({
to: 'es',
overrides: { es: { 'Sign in': 'Iniciar sesión' } }, // beats the machine
glossary: { Acme: 'Acme' }, // never translated
ignore: ['.chart-labels', '#code-sample'],
})For a brand name inside a sentence, use the standard translate="no" — lingoweave honours it and falls back to per-node so the term survives intact.
Scripts and writing systems
Switching to Arabic, Hebrew, Persian or Urdu sets dir="rtl" and <html lang> automatically. Translating the words but leaving the layout LTR gives you correct text that's unreadable.
CJK is a first-class case, not an afterthought. Japanese and Chinese have no spaces between words, so a sentence rebuilt from fragments must not invent any. Sentences end in 。, which a regex looking for . never finds — Intl.Segmenter does. Full-width digits and CJK punctuation (。 、 ・ ¥1,200) are skipped as non-prose, while a lone kanji like 円 is not.
API
const weaver = await weave({ to: 'es' })
await weaver.setLanguage('ar') // reuses everything already cached
weaver.stats() // cost and coverage
weaver.export() // runtime translations as a dictionary
await weaver.whenIdle() // resolves once the page has settled
weaver.retranslate(element) // force a re-scan
weaver.pause(); weaver.resume()
await weaver.destroy() // puts every original string backcreateWeaver(options) builds an instance without starting it, for when you need the reference first.
| Option | Default | |
|---|---|---|
| to | required | target language, or 'auto' for navigator.languages |
| from | 'auto' | source; auto reads <html lang>, then detects on-device |
| providers | 'auto' | provider array, or the default chain |
| root | documentElement | subtree to translate |
| cache | 'indexeddb' | 'memory', 'indexeddb' or false |
| dictionaries | — | pre-translated strings, per language |
| shadowDom | true | walk into open shadow roots |
| attributes | — | extra attributes, added to the defaults |
| ignore | — | CSS selectors to skip |
| glossary | — | terms never translated |
| overrides | — | hand-written translations that win |
| rtl | true | flip dir for RTL languages |
| persistChoice | true | remember the visitor's language |
| onProgress | — | coalesced progress callback |
| onError | — | (error, { provider, texts }) |
| debug | false | log decisions to the console |
onProgress and the DOM. If your handler writes into the translated page, that write is new content, which fires progress again. lingoweave rate-limits this rather than hanging, but mark such elements translate="no" or keep them outside root.
Plain HTML, no build step
Works on any site, including the ones the discontinued Google widget left with nothing.
<script src="https://unpkg.com/[email protected]/dist/lingoweave.global.js"
integrity="sha384-/WGnoV0JFGYMtnv1q2IN8ySmzJBwHjceQRMILp5DipUb3y72GolpmyRP6njJ1wEF"
crossorigin="anonymous"></script>
<script>lingoweave.weave({ to: 'es' })</script>Copy that as-is — the hash is real and verified against what unpkg serves.
The integrity attribute is what makes this safe to paste into a production page. Without it the browser runs whatever the CDN hands over; with it, a substituted file is refused and the page breaks loudly instead of leaking quietly. That also means the version must be pinned: @latest and SRI cannot coexist, because the file changes underneath the hash.
To verify it yourself, or after any version bump:
curl -s https://unpkg.com/[email protected]/dist/lingoweave.global.js \
| openssl dgst -sha384 -binary | openssl base64 -Anpm install users need none of this — npm already verifies the tarball hash from your lockfile.
Everything is exposed on window.lingoweave: weave, createWeaver, providers, isRtl.
Honest limitations
- SEO — this is client-side, so crawlers mostly index your source language. Server-rendered translated HTML is the only real fix;
export()is the building block for it - On-device is desktop Chromium only — no Safari, no Firefox, no mobile. Configure a provider for production
<canvas>text, cross-origin iframes, closed shadow roots, and CSS::before/::aftercontent are not translated- Machine translation is machine translation. Use
overridesfor the strings that matter
Status
v0.1.0 — the core is complete and tested: 194 tests, zero dependencies, 8.2 kB brotlied, typechecked with TypeScript 7.
Verified in a real browser against a page that puts every hard case together — closed dropdown, nested submenu, unopened modal, shadow root, SVG labels, meta tags, exclusions — cycling ja → ko → ja → ar → es → en and confirming the DOM returns byte-for-byte to its original.
Planned for 0.2: a React adapter, a <lingo-switcher> element, closed-shadow-root support via a preload script, same-origin iframes, viewport-priority lazy mode, and a CLI that extracts dictionaries from a running site.
License
MIT
