Merge remote-tracking branch 'origin/main' into entrega/directiva-20261110
tests / ci (push) Canceled after 0s

This commit is contained in:
2026-11-11 12:31:10 +02:00
6 changed files with 202 additions and 34 deletions
@@ -26,13 +26,27 @@ class TranslationApiController extends Controller
public function translate(Request $request): JsonResponse
{
$data = $request->validate([
'text' => 'required|string|max:10000',
'text' => is_array($request->input('text'))
? 'required|array|min:1|max:8'
: 'required|string|max:10000',
'text.*' => 'required|string|max:10000',
'source_lang' => 'required|string|max:20',
'target_lang' => 'required|string|max:20',
'is_html' => 'boolean',
]);
$isBatch = is_array($data['text']);
if ($isBatch && mb_strlen(implode('', $data['text'])) > 10000) {
return response()->json(['message' => 'O lote de tradução excede o limite permitido.'], 422);
}
if ($data['source_lang'] === $data['target_lang']) {
return response()->json(['original_text' => $data['text'], 'translated_text' => $data['text'], 'source_lang' => $data['source_lang'], 'target_lang' => $data['target_lang']])->header('Cache-Control', 'no-store');
return response()->json([
'original_text' => $data['text'],
'translated_text' => $data['text'],
'source_lang' => $data['source_lang'],
'target_lang' => $data['target_lang'],
])->header('Cache-Control', 'no-store');
}
$payload = ['q' => $data['text'], 'source' => $data['source_lang'], 'target' => $data['target_lang'], 'format' => ($data['is_html'] ?? false) ? 'html' : 'text'];
@@ -42,8 +56,19 @@ class TranslationApiController extends Controller
try {
$response = Http::connectTimeout(2)->timeout(config('services.translation.timeout'))->post($this->url().'/translate', $payload);
$translated = $response->json('translatedText');
if ($response->successful() && is_string($translated) && $translated !== '') {
return response()->json(['original_text' => $data['text'], 'translated_text' => $translated, 'source_lang' => $data['source_lang'], 'target_lang' => $data['target_lang']])->header('Cache-Control', 'no-store');
$validTranslation = $isBatch
? is_array($translated)
&& count($translated) === count($data['text'])
&& ! array_filter($translated, fn ($text) => ! is_string($text) || $text === '')
: is_string($translated) && $translated !== '';
if ($response->successful() && $validTranslation) {
return response()->json([
'original_text' => $data['text'],
'translated_text' => $translated,
'source_lang' => $data['source_lang'],
'target_lang' => $data['target_lang'],
])->header('Cache-Control', 'no-store');
}
} catch (Throwable) {
}
+2 -1
View File
@@ -1,6 +1,7 @@
/* Presentation choice for Tempo's 21/10/2026 request: one shared navigation. */
.tempo-header {
position: relative;
position: sticky;
top: 0;
z-index: 40;
background: #090909;
color: #fff;
+1 -1
View File
@@ -127,7 +127,7 @@ function PortalNavigationContent({ children }: { children: ReactNode }) {
</aside>
)}
{
<div className={printable ? 'print:hidden' : undefined}>
<div className={printable ? 'hidden' : 'contents'}>
<PortalMenu primary />
</div>
}
+102 -28
View File
@@ -1,12 +1,16 @@
import { useEffect } from 'react';
import { useTempoUi } from '@/components/tempo-brand';
import { translateText } from '@/translation/service';
import { TranslationRequestError, translateTexts } 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 };
type QueuedText = { value: string; resolve: (value: string) => void; reject: (error: unknown) => void };
const batchMaxItems = 8;
const batchMaxChars = 2200;
const requestMaxChars = 9000;
function read(target: Target): string | null {
return target.attribute ? (target.node as Element).getAttribute(target.attribute) : target.node.nodeValue;
@@ -21,48 +25,111 @@ export function PageTranslator() {
const { language } = useTempoUi();
useEffect(() => {
document.documentElement.lang = language;
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> = [];
const queue: QueuedText[] = [];
let running = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
let unavailable = false;
let failureReported = false;
const run = () => {
const schedule = (delay = 0) => {
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);
timer = setTimeout(() => {
timer = undefined;
void flush();
}, delay);
};
const rejectQueued = (error: unknown) => {
if (timer) clearTimeout(timer);
timer = undefined;
queue.splice(0).forEach((item) => item.reject(error));
};
const enqueue = (value: string): Promise<string> =>
new Promise((resolve, reject) => {
queue.push({ value, resolve, reject });
schedule();
});
const flush = async () => {
if (!active || unavailable || running >= 2 || !queue.length) return;
const batch: QueuedText[] = [];
let batchLength = 0;
while (queue.length && batch.length < batchMaxItems) {
const next = queue[0];
if (batch.length > 0 && batchLength + next.value.length > batchMaxChars) break;
batch.push(queue.shift()!);
batchLength += next.value.length;
}
running++;
schedule(650);
try {
const translated = await translateTexts(
batch.map((item) => item.value),
'auto',
language,
abort.signal,
);
batch.forEach((item, index) => item.resolve(translated[index]));
} catch (error) {
const isUnavailable = !abort.signal.aborted
&& error instanceof TranslationRequestError
&& (error.status === 503 || error.status === null);
if (isUnavailable) {
unavailable = true;
rejectQueued(error);
}
batch.forEach((item) => item.reject(error));
} finally {
running--;
schedule(650);
}
};
const splitForRequests = (value: string): string[] => {
const chunks: string[] = [];
let start = 0;
while (start < value.length) {
let end = Math.min(start + requestMaxChars, value.length);
if (end < value.length) {
const boundary = value.lastIndexOf(' ', end);
if (boundary > start) end = boundary + 1;
}
chunks.push(value.slice(start, end));
start = end;
}
return chunks;
};
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();
const protectedText = protectTranslationTokens(value);
const promise = Promise.all(
splitForRequests(protectedText.text).map(enqueue),
).then((translatedChunks) => {
const translatedText = translatedChunks.join('');
for (const token of protectedText.replacements.keys()) {
if (!translatedText.includes(token)) {
throw new Error('Translation lost a protected token');
}
}
return restoreTranslationTokens(translatedText, protectedText.replacements);
});
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;
@@ -70,7 +137,7 @@ export function PageTranslator() {
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;
if (!value || !/\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);
@@ -81,8 +148,14 @@ export function PageTranslator() {
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. */ });
}).catch((error) => {
if (!abort.signal.aborted && !failureReported) {
failureReported = true;
console.error('Page translation failed; original text was kept.', error);
}
});
};
const scan = (root: Node) => {
if (root instanceof Text) { consider(root); return; }
if (!(root instanceof Element)) return;
@@ -95,6 +168,7 @@ export function PageTranslator() {
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);
@@ -102,8 +176,8 @@ export function PageTranslator() {
else mutation.addedNodes.forEach(scan);
}
});
observer.observe(document.body, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
scan(document.body);
observer.observe(document.documentElement, { subtree: true, childList: true, characterData: true, attributes: true, attributeFilter: [...attributes] });
scan(document.documentElement);
return () => {
active = false;
abort.abort();
+42
View File
@@ -160,3 +160,45 @@ export async function translateText(
targetLanguage: payload.target_lang ?? request.targetLanguage,
};
}
export async function translateTexts(
texts: string[],
sourceLanguage: string,
targetLanguage: string,
signal?: AbortSignal,
): Promise<string[]> {
if (texts.length === 0) return [];
const payload = await fetchJson<{ translated_text?: unknown }>(
`${translationApiBaseUrl}/translate`,
{
method: "POST",
signal,
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
text: texts,
source_lang: sourceLanguage,
target_lang: targetLanguage,
}),
},
1,
1,
);
if (
!Array.isArray(payload.translated_text) ||
payload.translated_text.length !== texts.length ||
payload.translated_text.some(
(text) => typeof text !== "string" || text.trim() === "",
)
) {
throw new TranslationRequestError(
"Translation response does not contain all translated texts",
{ status: 200 },
);
}
return payload.translated_text;
}
+26
View File
@@ -3,6 +3,7 @@
namespace Tests\Feature;
use Illuminate\Support\Facades\Http;
use Illuminate\Http\Client\Request as ClientRequest;
use Tests\TestCase;
class TranslationApiTest extends TestCase
@@ -24,4 +25,29 @@ class TranslationApiTest extends TestCase
$this->postJson('/api/translate', $input)->assertOk()->assertJsonPath('translated_text', 'Hello');
$this->postJson('/api/translate', $input)->assertStatus(503);
}
public function test_it_translates_text_batches_for_full_page_translation(): void
{
config(['services.translation.url' => 'http://translator.test']);
Http::fake(['translator.test/translate' => Http::response(['translatedText' => ['Home', 'About us']])]);
$this->postJson('/api/translate', [
'text' => ['Início', 'Sobre nós'],
'source_lang' => 'auto',
'target_lang' => 'en',
])->assertOk()
->assertJsonPath('translated_text.0', 'Home')
->assertJsonPath('translated_text.1', 'About us');
Http::assertSent(fn (ClientRequest $request) => $request['q'] === ['Início', 'Sobre nós']);
}
public function test_it_rejects_translation_batches_that_exceed_the_request_limit(): void
{
$this->postJson('/api/translate', [
'text' => array_fill(0, 9, 'Texto'),
'source_lang' => 'auto',
'target_lang' => 'en',
])->assertUnprocessable();
}
}