@jwrunge/transmut
v1.0.2
Published
A client-side translation helper that watches the DOM, normalizes dynamic content, and populates localized text or attributes as translations become available. It is designed for single-page applications that need to translate asynchronously fetched conte
Readme
transmut Translation Observer
A client-side translation helper that watches the DOM, normalizes dynamic content, and populates localized text or attributes as translations become available. It is designed for single-page applications that need to translate asynchronously fetched content without re-rendering the entire view.
Installation
This package currently ships as source. Add it to your project as a workspace package or copy the src/observer folder into your bundle. Ensure your build pipeline includes the files in src/observer.
# via npm workspaces or a relative dependency
npm install @jwrunge/transmutVitest is configured for local testing (npm test).
Using in a TypeScript Project
You can import the sources directly without a publish step. Add a path mapping so the compiler resolves the .ts files shipped with this package:
// tsconfig.json
{
"compilerOptions": {
"paths": {
"@jwrunge/transmut": ["./node_modules/@jwrunge/transmut/src/index.ts"],
"@jwrunge/transmut/*": ["./node_modules/@jwrunge/transmut/src/*"]
}
}
}Then import the observer or the SQLite helpers straight from your code:
import { TranslationObserver, upsertTranslations } from "@jwrunge/transmut";
const observer = new TranslationObserver(/* ... */);
await upsertTranslations({
databasePath: "./translations.sqlite",
locale: "es-MX",
translations: { Cart: "Carrito" },
});Bundlers such as Vite/Esbuild can compile the .ts sources during your build. If you run Node directly, build the project first (tsc -p .) before executing the output.
Quick Start
import { TranslationObserver } from "@jwrunge/transmut";
const observer = new TranslationObserver(
"en-US", // default/source locale
"es-MX", // initial target locale (optional)
async ({ langCode, region }, keys, currentUrl) => {
// Fetch translations from your API
const response = await fetch(
`/api/translations?lang=${langCode}®ion=${region}`
);
return (await response.json()) as Record<string, string>;
},
24, // hours before cached entries are considered stale (optional)
async () => ["session.banner"], // invalidate cache keys (optional)
{
requireExplicitOptIn: true,
skipEditable: true,
}
);
await observer.changeLocale("es", "MX");Place data-transmut="include" (or other directives) on elements that provide opt-in. The observer will automatically translate matching text nodes and opted-in attributes.
SQLite Backend (Optional)
The module in src/backend/sqlite-translations.ts persists translations in a local SQLite database using sql.js. It exposes helpers that can run in a Node environment (or be bundled to a standalone binary with tools such as npx pkg, bun build --compile, or deno compile --unstable when the accompanying sql-wasm.wasm file is copied next to the executable).
import {
createSqliteTranslationProvider,
upsertTranslations,
} from "./src/backend/sqlite-translations";
// Persist or update translation pairs
await upsertTranslations({
databasePath: "/var/app/data/translations.sqlite",
locale: { langCode: "es", region: "MX" },
translations: {
"Hello, world!": "¡Hola, mundo!",
Checkout: "Pagar",
},
});
// Hook the database up to TranslationObserver on the server
const sqliteProvider = createSqliteTranslationProvider(
"/var/app/data/translations.sqlite"
);
// Example inside an HTTP handler
app.get("/api/translations", async (req, res) => {
const { lang, region, keys } = req.query;
const map = await sqliteProvider(
{ langCode: String(lang), region: String(region ?? "") },
Array.isArray(keys) ? keys : String(keys).split(",")
);
res.json(map);
});Additional utilities are available:
loadTranslations— fetch a subset of keys directly from disk.listTranslations— inspect all entries for a locale (useful for admin tools or exporting).SqliteTranslationProviderOptions— control locale fallback behaviour.
CLI / Binary Workflow
The src/backend/cli.ts entry provides a small command-line tool for managing translation databases. You can execute it with Node after compiling:
npm run build:pkg:prepare
node dist/backend/cli.js list --db ./translations.sqlite --locale esTo distribute a standalone binary, install pkg globally (npm install -g pkg) or run it via npx, then build:
npm run build:pkgThis produces dist/transmut (Linux x64 by default) together with dist/sql-wasm.wasm. Ship both files and ensure SQLJS_WASM_PATH points to the WASM if you relocate it.
The CLI supports three commands:
upsert --db <file> --locale <lang[-REGION]> --input <json or ->— merge JSON translations into the database (STDIN with-).list --db <file> --locale <lang[-REGION]>— dump stored entries for inspection/export.load --db <file> --locale <lang[-REGION]> --keys key1,key2— fetch a subset, honouring base-locale fallback unless--no-fallbackis passed.
Constructor Signature
new TranslationObserver(
defaultLangCode?: string,
initialLocale?: string,
getTranslations: GetTransMapFn,
expiryHours?: number,
invalidateFn?: InvalidateFn,
options?: TranslationObserverOptions
);- defaultLangCode: BCP 47 tag representing the source language (defaults to
en). - initialLocale: Target locale to translate into. If omitted, the browser
navigator.languageis used. - getTranslations: Required async (or sync) function that returns a map of translation strings keyed by normalized source phrases. Returning a JSON string is also supported.
- expiryHours: Number of hours before cached translations are considered stale. Set to
0or omit to disable staleness checks. - invalidateFn: Optional callback invoked on startup to return translation keys that should be removed from IndexedDB before use.
- options: Optional configuration (detailed below).
Lifecycle Helpers
changeLocale(langCode?: string, region?: string): Promise<void>— updates the target locale, reapplies language metadata, and re-translates tracked nodes/attributes.observeShadowRoot(root: ShadowRoot): void— explicitly opt a shadow root into observation.disconnect(): void— stop observing mutations and close caches. Call this when tearing down your app.
HTML Integration
The observer uses data-transmut-* directives to decide what to translate.
| Attribute | Purpose | Notes |
| ---------------------------------------- | ------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| data-transmut="include" | Opt-in a node (and descendants) for translation. | Required when requireExplicitOptIn is true. |
| data-transmut="skip" | Prevent translation for the node and its subtree. | Equivalent to adding data-transmut-skip. |
| data-transmut-skip | When present (any truthy value), skips translation for the subtree. | Empty string counts as true. |
| data-transmut-attrs="title,aria-label" | Comma-separated list of attribute names to translate. | Attributes must exist on the element. |
| data-transmut-locale | Override locale for a section. Values such as inherit, auto, or empty fall back to observer locale. | When set to another locale the subtree is skipped. |
| data-transmut-dir | Force text direction (ltr or rtl). | Applied alongside locale metadata. |
| data-transmut-{variable} | Supply values for dynamic placeholders (see below). | The {variable} name is derived from placeholders. |
Dynamic Content Placeholders
Source strings that contain placeholders are normalized before being sent to getTranslations. By default, ${variable} tokens and numbers are replaced with {} in the translation key.
By default, protected token patterns are also normalized to {} so unique values are preserved during reconstruction:
- Email addresses (e.g.
[email protected]) - UUIDs (e.g.
550e8400-e29b-41d4-a716-446655440000) - HTTP(S) URLs (e.g.
https://example.com/help)
Example:
<p data-transmut="include" data-transmut-count="5">
You have ${count} unread messages.
</p>- The observer requests a translation for
"You have {} unread messages.". - After receiving the translation (e.g.,
"Tienes {} mensajes sin leer."), the observer reconstructs the sentence and replaces the placeholder with the value fromdata-transmut-count.
You can customise placeholder detection via the variablePattern and variableNameGroup options if your templates use different syntax.
Attribute Translation
Attributes listed in data-transmut-attrs or matched by the default list (title, aria-label, aria-description, placeholder, alt) are translated alongside text. Opt in using directives or selectors when requireExplicitOptIn is enabled.
Svelte Patterns
In Svelte, {value} expressions are resolved before the observer sees the DOM. For predictable translation keys and safe reconstruction, prefer literal placeholders in text plus data-transmut-* value attributes.
<p
data-transmut="include"
data-transmut-myfavoritefood={myFavoriteFood}
>
I love ${myFavoriteFood}!
</p>This yields the translation key I love {}! and reinserts the runtime value from data-transmut-myfavoritefood.
For sensitive/dynamic values such as emails, UUIDs, or URLs:
- They are protected by default and normalized to
{}in lookup keys. - Keep
requireExplicitOptIn: trueso only marked content is translated. - Use
data-transmut-skipon subtrees that should never be translated.
Example with mixed translated/static-sensitive content:
<p data-transmut="include">
My email address is <span data-transmut-skip>{myEmail}</span>.
</p>Options Reference
TranslationObserverOptions control how the observer targets nodes and handles directionality.
| Option | Type | Default | Description |
| ----------------------- | -------------------------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| requireExplicitOptIn | boolean | false | When true, only nodes matching textSelector or with data-transmut="include" are processed. |
| textSelector | string \| null | requireExplicitOptIn ? "[data-transmut]" : null | Additional CSS selector that opts elements in for text translation. |
| attributeSelector | string \| null | "[data-transmut-attrs]" | CSS selector used to detect attribute translation candidates. |
| attributeNames | string[] | See defaults above | Attribute names automatically considered for translation. |
| skipEditable | boolean | true | When true, editable controls (input, textarea, contentEditable) are ignored. |
| setLanguageAttributes | boolean | true | Apply lang, dir, and data-transmut-* metadata to observed roots and the document element. |
| direction | 'ltr' \| 'rtl' \| 'auto' | 'auto' | auto infers direction from locale using defaults plus overrides. |
| directionOverrides | Record<string, 'ltr' \| 'rtl'> | DEFAULT_DIRECTION_OVERRIDES | Extend or override the built-in map of locale → direction. Keys should be lowercase BCP 47 tags. |
| variablePattern | RegExp | /\${\s*([^}]+?)\s*}/g | Pattern used to detect variable placeholders. Must be global (g). |
| variableNameGroup | number | 1 | Capture group index that contains the placeholder name. |
| protectedPatterns | RegExp[] | email, UUID, and URL defaults | Additional token patterns replaced with {} during lookup and restored after translation. |
Translation Cache
An IndexedDB-backed cache stores translations per locale.
- Database names follow
transmut.<lang>.<region>(regiondefaults todefault). - Cached entries include
updatedAttimestamps. WhenexpiryHoursis provided, stale keys are re-fetched. invalidateFnruns once on startup. Returning an array of keys deletes them across all known locales before translation begins.
If IndexedDB is unavailable (e.g., SSR or non-browser contexts), the cache gracefully no-ops.
Environment Requirements
- Runs in browsers with
MutationObserverand (optionally)indexedDB. - For unit testing, the project uses Vitest with a
jsdomenvironment (npm test). - Ensure your bundler supports modern ES modules (
targetisES2022).
Development Workflow
npm installnpm test
Tips and Patterns
- Wrap
getTranslationswith your own batching logic or memoization to reduce network chatter. - Use
observer.observeShadowRoot(shadowRoot)for web components. - Apply
data-transmut-skipto sections that should stay in the source language (e.g., brand names). - Consider emitting analytics or logs inside
getTranslationsto monitor missing keys.
License
MIT
