From 4b1422290e2bf85c60093e87291c80ff17410ade Mon Sep 17 00:00:00 2001 From: Temporary Committer Date: Wed, 22 Apr 2026 09:16:42 -0600 Subject: [PATCH] Add LibreTranslate adapter boundary --- src/lib/translation/adapters.ts | 99 +++++++++++++++++++ src/lib/translation/index.ts | 10 ++ tests/lib/libreTranslateAdapter.test.ts | 121 ++++++++++++++++++++++++ 3 files changed, 230 insertions(+) create mode 100644 src/lib/translation/adapters.ts create mode 100644 src/lib/translation/index.ts create mode 100644 tests/lib/libreTranslateAdapter.test.ts diff --git a/src/lib/translation/adapters.ts b/src/lib/translation/adapters.ts new file mode 100644 index 00000000..78dfee45 --- /dev/null +++ b/src/lib/translation/adapters.ts @@ -0,0 +1,99 @@ +export interface TranslationRequest { + text: string; + sourceLanguage: string; + targetLanguage: string; + signal?: AbortSignal; +} + +export interface TranslationResponse { + translatedText: string; + detectedLanguage?: string; + provider: string; +} + +export interface TranslationProvider { + id: string; + label: string; + translate(request: TranslationRequest): Promise; +} + +export interface LibreTranslateAdapterOptions { + endpoint: string; + apiKey?: string; + format?: "text" | "html"; +} + +interface LibreTranslateSuccessResponse { + translatedText?: string; + detectedLanguage?: { + language?: string; + }; + error?: string; +} + +function normalizeEndpoint(endpoint: string): string { + return endpoint.replace(/\/+$/, ""); +} + +export class LibreTranslateAdapter implements TranslationProvider { + readonly id = "libretranslate"; + readonly label = "LibreTranslate"; + + private readonly endpoint: string; + private readonly apiKey?: string; + private readonly format: "text" | "html"; + + constructor({ + endpoint, + apiKey, + format = "text", + }: LibreTranslateAdapterOptions) { + this.endpoint = normalizeEndpoint(endpoint); + this.apiKey = apiKey; + this.format = format; + } + + async translate({ + text, + sourceLanguage, + targetLanguage, + signal, + }: TranslationRequest): Promise { + const response = await fetch(`${this.endpoint}/translate`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + q: text, + source: sourceLanguage, + target: targetLanguage, + format: this.format, + api_key: this.apiKey, + }), + signal, + }); + + const payload = (await response.json()) as LibreTranslateSuccessResponse; + + if (!response.ok) { + throw new Error(payload.error || "LibreTranslate request failed."); + } + + if (!payload.translatedText) { + throw new Error("LibreTranslate returned no translated text."); + } + + return { + translatedText: payload.translatedText, + detectedLanguage: payload.detectedLanguage?.language, + provider: this.id, + }; + } +} + +export function createLibreTranslateAdapter( + options: LibreTranslateAdapterOptions, +): TranslationProvider { + return new LibreTranslateAdapter(options); +} diff --git a/src/lib/translation/index.ts b/src/lib/translation/index.ts new file mode 100644 index 00000000..c30a0387 --- /dev/null +++ b/src/lib/translation/index.ts @@ -0,0 +1,10 @@ +export type { + LibreTranslateAdapterOptions, + TranslationProvider, + TranslationRequest, + TranslationResponse, +} from "./adapters"; +export { + createLibreTranslateAdapter, + LibreTranslateAdapter, +} from "./adapters"; diff --git a/tests/lib/libreTranslateAdapter.test.ts b/tests/lib/libreTranslateAdapter.test.ts new file mode 100644 index 00000000..e338738b --- /dev/null +++ b/tests/lib/libreTranslateAdapter.test.ts @@ -0,0 +1,121 @@ +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + createLibreTranslateAdapter, + LibreTranslateAdapter, +} from "../../src/lib/translation/adapters"; + +function makeResponse(body: Record, ok = true): Response { + return { + ok, + json: vi.fn().mockResolvedValue(body), + } as unknown as Response; +} + +beforeEach(() => { + vi.stubGlobal("fetch", vi.fn()); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +describe("LibreTranslateAdapter", () => { + test("posts translation requests to /translate", async () => { + vi.mocked(fetch).mockResolvedValueOnce( + makeResponse({ + translatedText: "hola mundo", + detectedLanguage: { language: "en" }, + }), + ); + + const adapter = new LibreTranslateAdapter({ + endpoint: "https://translate.example.com/", + }); + + await expect( + adapter.translate({ + text: "hello world", + sourceLanguage: "en", + targetLanguage: "es", + }), + ).resolves.toEqual({ + translatedText: "hola mundo", + detectedLanguage: "en", + provider: "libretranslate", + }); + + expect(fetch).toHaveBeenCalledWith( + "https://translate.example.com/translate", + expect.objectContaining({ + method: "POST", + headers: { "Content-Type": "application/json" }, + }), + ); + }); + + test("includes api key and html format when configured", async () => { + vi.mocked(fetch).mockResolvedValueOnce( + makeResponse({ translatedText: "hola" }), + ); + + const adapter = createLibreTranslateAdapter({ + endpoint: "https://translate.example.com", + apiKey: "secret", + format: "html", + }); + + await adapter.translate({ + text: "hello", + sourceLanguage: "en", + targetLanguage: "es", + }); + + expect(fetch).toHaveBeenCalledWith( + "https://translate.example.com/translate", + expect.objectContaining({ + body: JSON.stringify({ + q: "hello", + source: "en", + target: "es", + format: "html", + api_key: "secret", + }), + }), + ); + }); + + test("surfaces backend errors", async () => { + vi.mocked(fetch).mockResolvedValueOnce( + makeResponse({ error: "rate limited" }, false), + ); + + const adapter = new LibreTranslateAdapter({ + endpoint: "https://translate.example.com", + }); + + await expect( + adapter.translate({ + text: "hello", + sourceLanguage: "en", + targetLanguage: "es", + }), + ).rejects.toThrow("rate limited"); + }); + + test("fails when translatedText is missing", async () => { + vi.mocked(fetch).mockResolvedValueOnce(makeResponse({})); + + const adapter = new LibreTranslateAdapter({ + endpoint: "https://translate.example.com", + }); + + await expect( + adapter.translate({ + text: "hello", + sourceLanguage: "en", + targetLanguage: "es", + }), + ).rejects.toThrow("LibreTranslate returned no translated text."); + }); +});