120 lines
6.1 KiB
TypeScript
120 lines
6.1 KiB
TypeScript
import { useEffect } from 'react';
|
|
import { useTempoUi } from '@/components/tempo-brand';
|
|
import { translateText } from '@/translation/service';
|
|
import { protectTranslationTokens, restoreTranslationTokens } from '@/translation/utils';
|
|
|
|
// Never change the structure of React's DOM or the value of a form control.
|
|
const excluded = 'script,style,noscript,code,pre,textarea,[contenteditable]:not([contenteditable="false"]),[translate="no"],[data-no-translate],.no-translate';
|
|
const attributes = ['placeholder', 'title', 'aria-label', 'alt'] as const;
|
|
type Target = { node: Text | Element; attribute?: string; original: string; translated?: string };
|
|
|
|
function read(target: Target): string | null {
|
|
return target.attribute ? (target.node as Element).getAttribute(target.attribute) : target.node.nodeValue;
|
|
}
|
|
|
|
function write(target: Target, value: string): void {
|
|
if (target.attribute) (target.node as Element).setAttribute(target.attribute, value);
|
|
else target.node.nodeValue = value;
|
|
}
|
|
|
|
export function PageTranslator() {
|
|
const { language } = useTempoUi();
|
|
|
|
useEffect(() => {
|
|
if (language === 'pt') return;
|
|
let active = true;
|
|
const abort = new AbortController();
|
|
const targets = new Map<Node, Map<string, Target>>();
|
|
const cache = new Map<string, Promise<string>>();
|
|
const queue: Array<() => void> = [];
|
|
let running = 0;
|
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
let unavailable = false;
|
|
|
|
const run = () => {
|
|
if (!active || unavailable || timer || running >= 2 || !queue.length) return;
|
|
running++;
|
|
queue.shift()?.();
|
|
// Stay below the API's per-minute limit on large pages.
|
|
timer = setTimeout(() => { timer = undefined; run(); }, 650);
|
|
};
|
|
const translate = (value: string): Promise<string> => {
|
|
const previous = cache.get(value);
|
|
if (previous) return previous;
|
|
const promise = new Promise<string>((resolve, reject) => {
|
|
queue.push(() => {
|
|
const protectedText = protectTranslationTokens(value);
|
|
translateText({ text: protectedText.text, sourceLanguage: 'auto', targetLanguage: language }, abort.signal)
|
|
.then(({ translatedText }) => {
|
|
for (const token of protectedText.replacements.keys()) {
|
|
if (!translatedText.includes(token)) throw new Error('Translation lost a protected token');
|
|
}
|
|
resolve(restoreTranslationTokens(translatedText, protectedText.replacements));
|
|
})
|
|
.catch((error) => {
|
|
if (!abort.signal.aborted && (error?.status === 503 || error?.status === null)) unavailable = true;
|
|
reject(error);
|
|
})
|
|
.finally(() => { running--; run(); });
|
|
});
|
|
run();
|
|
});
|
|
cache.set(value, promise);
|
|
promise.catch(() => cache.delete(value));
|
|
return promise;
|
|
};
|
|
const consider = (node: Text | Element, attribute?: string) => {
|
|
const element = node instanceof Element ? node : node.parentElement;
|
|
if (!element || element.closest(excluded)) return;
|
|
// Without an explicit value, translating an option changes the submitted value.
|
|
if (element.closest('option:not([value])')) return;
|
|
const key = attribute ?? '';
|
|
const value = attribute ? (node as Element).getAttribute(attribute) : node.nodeValue;
|
|
if (!value || value.length > 10000 || !/\p{L}/u.test(value)) return;
|
|
let entries = targets.get(node);
|
|
if (!entries) { entries = new Map(); targets.set(node, entries); }
|
|
const old = entries.get(key);
|
|
if (old && (value === old.original || value === old.translated)) return;
|
|
const target: Target = { node, attribute, original: value };
|
|
entries.set(key, target);
|
|
translate(value).then((translated) => {
|
|
if (!active || !node.isConnected || entries?.get(key) !== target || read(target) !== value) return;
|
|
target.translated = translated;
|
|
write(target, translated);
|
|
}).catch(() => { /* Keep the original visible when translation fails. */ });
|
|
};
|
|
const scan = (root: Node) => {
|
|
if (root instanceof Text) { consider(root); return; }
|
|
if (!(root instanceof Element)) return;
|
|
if (root.closest(excluded)) return;
|
|
for (const name of attributes) if (root.hasAttribute(name)) consider(root, name);
|
|
const walker = document.createTreeWalker(root, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT);
|
|
while (walker.nextNode()) {
|
|
const node = walker.currentNode;
|
|
if (node instanceof Text) consider(node);
|
|
else if (node instanceof Element) for (const name of attributes) if (node.hasAttribute(name)) consider(node, name);
|
|
}
|
|
};
|
|
const observer = new MutationObserver((mutations) => {
|
|
for (const mutation of mutations) {
|
|
if (mutation.type === 'characterData') scan(mutation.target);
|
|
else if (mutation.type === 'attributes' && mutation.target instanceof Element && mutation.attributeName) consider(mutation.target, mutation.attributeName);
|
|
else mutation.addedNodes.forEach(scan);
|
|
}
|
|
});
|
|
observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
|
|
scan(document.body);
|
|
return () => {
|
|
active = false;
|
|
abort.abort();
|
|
if (timer) clearTimeout(timer);
|
|
observer.disconnect();
|
|
for (const entries of targets.values()) for (const target of entries.values()) {
|
|
if (target.translated !== undefined && read(target) === target.translated) write(target, target.original);
|
|
}
|
|
};
|
|
}, [language]);
|
|
|
|
return null;
|
|
}
|