diff --git a/app/Http/Controllers/Api/TranslationApiController.php b/app/Http/Controllers/Api/TranslationApiController.php
index 7f4c8a59..7056ce73 100644
--- a/app/Http/Controllers/Api/TranslationApiController.php
+++ b/app/Http/Controllers/Api/TranslationApiController.php
@@ -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) {
}
diff --git a/resources/css/tempo-header.css b/resources/css/tempo-header.css
index 8d57b441..ddb0fb79 100644
--- a/resources/css/tempo-header.css
+++ b/resources/css/tempo-header.css
@@ -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;
diff --git a/resources/js/layouts/portal-navigation.tsx b/resources/js/layouts/portal-navigation.tsx
index c31be9e1..2d4b2b14 100644
--- a/resources/js/layouts/portal-navigation.tsx
+++ b/resources/js/layouts/portal-navigation.tsx
@@ -127,7 +127,7 @@ function PortalNavigationContent({ children }: { children: ReactNode }) {
)}
{
-
+
}
diff --git a/resources/js/translation/page-translator.tsx b/resources/js/translation/page-translator.tsx
index e317d860..4915cdb7 100644
--- a/resources/js/translation/page-translator.tsx
+++ b/resources/js/translation/page-translator.tsx
@@ -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
>();
const cache = new Map>();
- const queue: Array<() => void> = [];
+ const queue: QueuedText[] = [];
let running = 0;
let timer: ReturnType | 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 =>
+ 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 => {
const previous = cache.get(value);
if (previous) return previous;
- const promise = new Promise((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();
diff --git a/resources/js/translation/service.ts b/resources/js/translation/service.ts
index cfede32a..d1b3d560 100644
--- a/resources/js/translation/service.ts
+++ b/resources/js/translation/service.ts
@@ -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 {
+ 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;
+}
diff --git a/tests/Feature/TranslationApiTest.php b/tests/Feature/TranslationApiTest.php
index 684a044d..34483ed8 100644
--- a/tests/Feature/TranslationApiTest.php
+++ b/tests/Feature/TranslationApiTest.php
@@ -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();
+ }
}