Files
Investidor-V2/resources/js/translation/page-translator.tsx
T
2026-11-03 15:31:25 +02:00

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;
}