Skip to content

Instantly share code, notes, and snippets.

@bendytree
Created August 29, 2025 02:13
Show Gist options
  • Select an option

  • Save bendytree/cb5abb8551549e288c06df9351799345 to your computer and use it in GitHub Desktop.

Select an option

Save bendytree/cb5abb8551549e288c06df9351799345 to your computer and use it in GitHub Desktop.
import _ from 'lodash';
import { clientContext } from '../../context/client';
import { translateTextsClient } from './translate-texts.client';
import { isLocal } from '../../context';
const rxEmail = /^(([^<>()\[\]\.,;:\s@\"]+(\.[^<>()\[\]\.,;:\s@\"]+)*)|(\".+\"))@(([^<>()\.,;\s@\"]+\.{0,1})+([^<>()\.,;:\s@\"]{2,}|[\d\.]+))$/i;
interface IItem {
node: Node;
key: string;
orig: string;
hash: string;
knowns: WeakMap<Node, string>;
}
function getAncestors(node: Node):Node[] {
if (!node) return [];
const ancestors = [];
while (node) {
ancestors.push(node);
node = node.parentElement;
}
return ancestors;
}
export class TranslationWidget {
knownTitles = new WeakMap<Node, string>();
knownPlaceholders = new WeakMap<Node, string>();
knownContent = new WeakMap<Node, string>();
items:IItem[] = [];
checkSoon: () => Promise<void>;
langCode: string;
constructor() {
this.langCode = clientContext.user?.langCode;
if (!this.langCode || ['en', 'en-us'].includes(this.langCode.toLowerCase())) return;
this.checkSoon = _.throttle(
() => this.check(),
200,
{ leading: true, trailing: true },
);
this.scan(document);
this.checkSoon().then();
const observer = new MutationObserver(this.onMutate.bind(this));
observer.observe(document, {
characterData: true,
subtree: true,
childList: true,
attributes: true,
attributeFilter: ['placeholder', 'title'],
});
}
async check ():Promise<void> {
if (!this.items.length) return;
const items = this.items;
this.items = [];
await this.translateItems(items);
}
async translateItems(items:IItem[]):Promise<void> {
const cleanItems = items.map(item => {
const prefix = item.orig.match(/^\s*/)?.[0] || '';
const suffix = item.orig.match(/\s*$/)?.[0] || '';
const main = item.orig.substring(prefix.length, item.orig.length - suffix.length);
return { ...item, prefix, suffix, main };
});
const translations = await translateTextsClient({
langCode: this.langCode,
texts: cleanItems.map(i => i.main),
});
for (const [i, translation] of translations.entries()) {
const item = cleanItems[i];
const fullTranslation = [item.prefix, translation, item.suffix].filter(x => x).join('');
const newHash = fasthash(fullTranslation);
item.knowns.set(item.node, newHash);
item.node[item.key] = fullTranslation;
}
}
onMutate (mutations: MutationRecord[]):void {
for (const mutation of mutations) {
if (mutation.target) this.scan(mutation.target);
for (const node of mutation.addedNodes || []) {
const ancestors = getAncestors(node);
const ignore = !!ancestors.find(n => n.nodeName === 'HEAD' || (n instanceof Element && n.classList.contains('notranslate')));
if (ignore) continue;
this.scan(node);
}
}
this.checkSoon().then();
}
private checkItem ({ knowns, node, key }: { knowns: WeakMap<Node, string>, node:Node, key: string }) {
const orig = node[key] as string;
if (!orig || typeof orig !== 'string' || !/[a-z]/i.test(orig)) return;
if (rxEmail.test(orig)) return;
const knownHash = knowns.get(node);
const hash = fasthash(orig);
if (hash === knownHash) return;
this.items.push({ knowns, node, key, orig, hash });
}
scan (node:Node):void {
if (!node) return;
if (['SCRIPT', 'STYLE', 'HEAD', 'LINK', 'TEMPLATE'].includes(node.nodeName)) {
return;
}
if (node instanceof Element) {
if (node.classList.contains('notranslate')) {
return;
}
if (node.getAttribute('placeholder')) {
this.checkItem({ knowns: this.knownPlaceholders, node, key: 'placeholder' });
}
if (node.getAttribute('title')) {
this.checkItem({ knowns: this.knownTitles, node, key: 'title' });
}
}
if (node.nodeType === Node.TEXT_NODE) {
this.checkItem({ knowns: this.knownContent, node, key: 'textContent' });
}
for (const child of node.childNodes) {
this.scan(child);
}
}
}
function fasthash(s:string):string {
return [...s].reduce((hash, c) => (Math.imul(31, hash) + c.charCodeAt(0)) | 0, 0 ).toString();
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment