npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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

Readme

lingoweave

npm bundle types deps license

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 lingoweave
import { 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 key ships 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 accepts endpoint for 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 dictionary

Human 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 back

createWeaver(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 -A

npm 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/::after content are not translated
  • Machine translation is machine translation. Use overrides for 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