Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions src/lib/translation/adapters.ts
Original file line number Diff line number Diff line change
@@ -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<TranslationResponse>;
}

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<TranslationResponse> {
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);
}
10 changes: 10 additions & 0 deletions src/lib/translation/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export type {
LibreTranslateAdapterOptions,
TranslationProvider,
TranslationRequest,
TranslationResponse,
} from "./adapters";
export {
createLibreTranslateAdapter,
LibreTranslateAdapter,
} from "./adapters";
121 changes: 121 additions & 0 deletions tests/lib/libreTranslateAdapter.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>, 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: "<b>hello</b>",
sourceLanguage: "en",
targetLanguage: "es",
});

expect(fetch).toHaveBeenCalledWith(
"https://translate.example.com/translate",
expect.objectContaining({
body: JSON.stringify({
q: "<b>hello</b>",
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.");
});
});