canopy-i18n 0.8.2 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -226,6 +226,141 @@ const {
226
226
  - `createI18nReact` itself has no built-in persistence. Use a built-in wrapper (`createHash/Search/Pathname/Storage/CookieI18nReact`) for common sources, or pass your own `useLocaleSource` / `onLocaleChange` for anything else.
227
227
  - React is a `peerDependency` (`>=18`). Non-React users can ignore the `/react` subpath entirely.
228
228
 
229
+ ## AI Translation
230
+
231
+ The `canopy-i18n/ai` subpath provides a runtime translator built around a pluggable adapter. Bring any AI backend by implementing a single `translate` function.
232
+
233
+ ```ts
234
+ import { createAITranslator, memoryCache } from 'canopy-i18n/ai';
235
+
236
+ const translator = createAITranslator({
237
+ // Implement AIAdapter with any provider (OpenAI, local model, ...)
238
+ adapter: {
239
+ async translate({ texts, from, to }) {
240
+ const translated = await callYourAI(texts, from, to);
241
+ return translated; // same order and length as texts
242
+ },
243
+ },
244
+ sourceLocale: 'ja',
245
+ cache: memoryCache(), // optional; implement TranslationCache for DB persistence
246
+ });
247
+ ```
248
+
249
+ ### Built-in adapters
250
+
251
+ Ready-made adapters for OpenAI, Anthropic (Claude), and Google Gemini. All are fetch-based with no SDK dependency. `model` and `apiKey` are required; `instructions` and `baseURL` are optional.
252
+
253
+ ```ts
254
+ import {
255
+ createAITranslator,
256
+ memoryCache,
257
+ openAIAdapter,
258
+ anthropicAdapter,
259
+ geminiAdapter,
260
+ } from 'canopy-i18n/ai';
261
+
262
+ const adapter = openAIAdapter({
263
+ model: 'gpt-4o-mini',
264
+ apiKey: process.env.OPENAI_API_KEY!,
265
+ // baseURL: 'http://localhost:11434/v1', // any OpenAI-compatible API (Ollama, etc.)
266
+ // instructions: 'Do not translate the product name "Canopy".',
267
+ });
268
+
269
+ // or
270
+ anthropicAdapter({
271
+ model: 'claude-haiku-4-5',
272
+ apiKey: process.env.ANTHROPIC_API_KEY!,
273
+ // maxTokens: 8192, // the Anthropic API requires max_tokens; this is the default
274
+ });
275
+
276
+ // or
277
+ geminiAdapter({
278
+ model: 'gemini-2.0-flash',
279
+ apiKey: process.env.GEMINI_API_KEY!,
280
+ });
281
+
282
+ const translator = createAITranslator({
283
+ adapter,
284
+ sourceLocale: 'ja',
285
+ cache: memoryCache(),
286
+ });
287
+ ```
288
+
289
+ ### Prompt helpers
290
+
291
+ The prompt logic used by the built-in adapter is exported, so custom adapters can reuse it instead of writing their own:
292
+
293
+ ```ts
294
+ import { buildTranslatePrompt, parseTranslatedTexts } from 'canopy-i18n/ai';
295
+
296
+ const adapter = {
297
+ async translate(request) {
298
+ const prompt = buildTranslatePrompt(request, { instructions: 'Keep it casual.' });
299
+ const raw = await callYourAI(prompt);
300
+ // strips code fences / surrounding text, validates order & length
301
+ return parseTranslatedTexts(raw, request.texts.length);
302
+ },
303
+ };
304
+ ```
305
+
306
+ ### Translating dynamic texts (e.g. user input)
307
+
308
+ ```ts
309
+ const translated = await translator.translate(userComment, { to: 'en' });
310
+ const many = await translator.translateMany(['こんにちは', 'さようなら'], { to: 'en' });
311
+
312
+ // Source language unknown? Omit both sourceLocale and `from` —
313
+ // the adapter receives `from: undefined` and should auto-detect.
314
+ const detected = await translator.translate(anyLanguageInput, { to: 'ja' });
315
+ ```
316
+
317
+ - `from` falls back to `sourceLocale`; if neither is set, the adapter auto-detects.
318
+ - Results are cached (`cache` option), so the same text is translated only once.
319
+ - Concurrent requests for the same text are deduplicated into a single adapter call.
320
+ - `translateMany` batches all texts into one adapter call.
321
+ - On adapter failure the original text is returned by default (`onError: 'fallback'`). Set `onError: 'throw'` to propagate errors.
322
+
323
+ ### Completing message entries
324
+
325
+ Write only the source locale and let AI fill in the rest. The result is a complete entries object for `ChainBuilder.add()`:
326
+
327
+ ```ts
328
+ const entries = await translator.completeEntries(['ja', 'en', 'fr'] as const, {
329
+ title: { ja: 'タイトル' }, // en & fr are AI-translated
330
+ greeting: { ja: 'こんにちは', en: 'Hello' }, // existing en is kept, fr is AI-translated
331
+ });
332
+
333
+ const messages = createI18n(['ja', 'en', 'fr'] as const)
334
+ .add(entries)
335
+ .build('en');
336
+ ```
337
+
338
+ Only static string entries are supported; template functions are out of scope for AI translation.
339
+
340
+ ### Interfaces
341
+
342
+ ```ts
343
+ interface AIAdapter {
344
+ // `from` is undefined when the source language is unknown — auto-detect it
345
+ translate(request: { texts: string[]; from?: string; to: string }): Promise<string[]>;
346
+ }
347
+
348
+ interface TranslationCache {
349
+ get(key: string): Promise<string | undefined> | string | undefined;
350
+ set(key: string, value: string): Promise<void> | void;
351
+ }
352
+ ```
353
+
354
+ ```ts
355
+ createAITranslator({
356
+ adapter, // AIAdapter (required)
357
+ sourceLocale: 'ja', // default `from` (optional; required for completeEntries)
358
+ cache, // TranslationCache (optional)
359
+ onError: 'fallback', // 'fallback' (default) | 'throw'
360
+ cacheKey, // (text, from, to) => string (optional)
361
+ });
362
+ ```
363
+
229
364
  ## API
230
365
 
231
366
  ### `createI18n(locales)`
@@ -0,0 +1,19 @@
1
+ import type { TranslatePromptOptions } from "./prompt.js";
2
+ import type { AIAdapter } from "./types.js";
3
+ export interface AnthropicAdapterOptions extends TranslatePromptOptions {
4
+ /** Model name, e.g. "claude-haiku-4-5". Required: model lineups change too often to default. */
5
+ model: string;
6
+ /** API key. */
7
+ apiKey: string;
8
+ /** Max output tokens (the Anthropic API requires this). Defaults to 8192. */
9
+ maxTokens?: number;
10
+ /** Defaults to "https://api.anthropic.com/v1". */
11
+ baseURL?: string;
12
+ /** Custom fetch implementation (testing, proxies). Defaults to globalThis.fetch. */
13
+ fetch?: typeof fetch;
14
+ }
15
+ /**
16
+ * Built-in adapter for the Anthropic (Claude) Messages API.
17
+ * Uses fetch directly — no SDK dependency.
18
+ */
19
+ export declare function anthropicAdapter(options: AnthropicAdapterOptions): AIAdapter;
@@ -0,0 +1,36 @@
1
+ import { buildTranslatePrompt, parseTranslatedTexts } from "./prompt.js";
2
+ /**
3
+ * Built-in adapter for the Anthropic (Claude) Messages API.
4
+ * Uses fetch directly — no SDK dependency.
5
+ */
6
+ export function anthropicAdapter(options) {
7
+ const baseURL = (options.baseURL ?? "https://api.anthropic.com/v1").replace(/\/+$/, "");
8
+ const fetchFn = options.fetch ?? fetch;
9
+ return {
10
+ async translate(request) {
11
+ const res = await fetchFn(`${baseURL}/messages`, {
12
+ method: "POST",
13
+ headers: {
14
+ "x-api-key": options.apiKey,
15
+ "anthropic-version": "2023-06-01",
16
+ "Content-Type": "application/json",
17
+ },
18
+ body: JSON.stringify({
19
+ model: options.model,
20
+ max_tokens: options.maxTokens ?? 8192,
21
+ messages: [{ role: "user", content: buildTranslatePrompt(request, options) }],
22
+ }),
23
+ });
24
+ if (!res.ok) {
25
+ const body = await res.text().catch(() => "");
26
+ throw new Error(`Anthropic API error ${res.status}: ${body}`);
27
+ }
28
+ const json = (await res.json());
29
+ const text = json.content?.find((block) => block.type === "text")?.text;
30
+ if (typeof text !== "string") {
31
+ throw new Error("Anthropic API returned no text content");
32
+ }
33
+ return parseTranslatedTexts(text, request.texts.length);
34
+ },
35
+ };
36
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { anthropicAdapter } from "./anthropic.js";
3
+ function fakeFetch(handler) {
4
+ const requests = [];
5
+ const fetchFn = (async (input, init) => {
6
+ const url = String(input);
7
+ requests.push({ url, init });
8
+ return handler(url, init);
9
+ });
10
+ return { fetchFn, requests };
11
+ }
12
+ function messagesResponse(text) {
13
+ return new Response(JSON.stringify({ content: [{ type: "text", text }] }), {
14
+ status: 200,
15
+ });
16
+ }
17
+ describe("anthropicAdapter", () => {
18
+ it("calls the Messages API and parses the result", async () => {
19
+ const { fetchFn, requests } = fakeFetch(() => messagesResponse('["Hello"]'));
20
+ const adapter = anthropicAdapter({
21
+ model: "test-model",
22
+ apiKey: "sk-ant-test",
23
+ fetch: fetchFn,
24
+ });
25
+ const result = await adapter.translate({ texts: ["こんにちは"], from: "ja", to: "en" });
26
+ expect(result).toEqual(["Hello"]);
27
+ expect(requests[0]?.url).toBe("https://api.anthropic.com/v1/messages");
28
+ const headers = requests[0]?.init?.headers;
29
+ expect(headers["x-api-key"]).toBe("sk-ant-test");
30
+ expect(headers["anthropic-version"]).toBe("2023-06-01");
31
+ const body = JSON.parse(requests[0]?.init?.body);
32
+ expect(body.model).toBe("test-model");
33
+ expect(body.max_tokens).toBe(8192);
34
+ expect(body.messages[0].content).toContain('from "ja" to "en"');
35
+ });
36
+ it("respects maxTokens and baseURL", async () => {
37
+ const { fetchFn, requests } = fakeFetch(() => messagesResponse('["Hello"]'));
38
+ const adapter = anthropicAdapter({
39
+ model: "test-model",
40
+ apiKey: "sk-ant-test",
41
+ maxTokens: 1024,
42
+ baseURL: "http://localhost:8080/v1/",
43
+ fetch: fetchFn,
44
+ });
45
+ await adapter.translate({ texts: ["a"], from: "ja", to: "en" });
46
+ expect(requests[0]?.url).toBe("http://localhost:8080/v1/messages");
47
+ expect(JSON.parse(requests[0]?.init?.body).max_tokens).toBe(1024);
48
+ });
49
+ it("throws with status and body on API errors", async () => {
50
+ const { fetchFn } = fakeFetch(() => new Response("overloaded", { status: 529 }));
51
+ const adapter = anthropicAdapter({
52
+ model: "test-model",
53
+ apiKey: "sk-ant-test",
54
+ fetch: fetchFn,
55
+ });
56
+ await expect(adapter.translate({ texts: ["a"], from: "ja", to: "en" })).rejects.toThrow("Anthropic API error 529: overloaded");
57
+ });
58
+ it("throws when the response has no text content", async () => {
59
+ const { fetchFn } = fakeFetch(() => new Response(JSON.stringify({ content: [] }), { status: 200 }));
60
+ const adapter = anthropicAdapter({
61
+ model: "test-model",
62
+ apiKey: "sk-ant-test",
63
+ fetch: fetchFn,
64
+ });
65
+ await expect(adapter.translate({ texts: ["a"], from: "ja", to: "en" })).rejects.toThrow("no text content");
66
+ });
67
+ });
@@ -0,0 +1,5 @@
1
+ import type { TranslationCache } from "./types.js";
2
+ /**
3
+ * Built-in in-memory cache. For persistence, implement TranslationCache yourself.
4
+ */
5
+ export declare function memoryCache(): TranslationCache;
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Built-in in-memory cache. For persistence, implement TranslationCache yourself.
3
+ */
4
+ export function memoryCache() {
5
+ const store = new Map();
6
+ return {
7
+ get: (key) => store.get(key),
8
+ set: (key, value) => {
9
+ store.set(key, value);
10
+ },
11
+ };
12
+ }
@@ -0,0 +1,17 @@
1
+ import type { TranslatePromptOptions } from "./prompt.js";
2
+ import type { AIAdapter } from "./types.js";
3
+ export interface GeminiAdapterOptions extends TranslatePromptOptions {
4
+ /** Model name, e.g. "gemini-2.0-flash". Required: model lineups change too often to default. */
5
+ model: string;
6
+ /** API key. */
7
+ apiKey: string;
8
+ /** Defaults to "https://generativelanguage.googleapis.com/v1beta". */
9
+ baseURL?: string;
10
+ /** Custom fetch implementation (testing, proxies). Defaults to globalThis.fetch. */
11
+ fetch?: typeof fetch;
12
+ }
13
+ /**
14
+ * Built-in adapter for the Google Gemini API (generateContent).
15
+ * Uses fetch directly — no SDK dependency.
16
+ */
17
+ export declare function geminiAdapter(options: GeminiAdapterOptions): AIAdapter;
@@ -0,0 +1,35 @@
1
+ import { buildTranslatePrompt, parseTranslatedTexts } from "./prompt.js";
2
+ /**
3
+ * Built-in adapter for the Google Gemini API (generateContent).
4
+ * Uses fetch directly — no SDK dependency.
5
+ */
6
+ export function geminiAdapter(options) {
7
+ const baseURL = (options.baseURL ?? "https://generativelanguage.googleapis.com/v1beta").replace(/\/+$/, "");
8
+ const fetchFn = options.fetch ?? fetch;
9
+ return {
10
+ async translate(request) {
11
+ const res = await fetchFn(`${baseURL}/models/${options.model}:generateContent`, {
12
+ method: "POST",
13
+ headers: {
14
+ "x-goog-api-key": options.apiKey,
15
+ "Content-Type": "application/json",
16
+ },
17
+ body: JSON.stringify({
18
+ contents: [{ parts: [{ text: buildTranslatePrompt(request, options) }] }],
19
+ }),
20
+ });
21
+ if (!res.ok) {
22
+ const body = await res.text().catch(() => "");
23
+ throw new Error(`Gemini API error ${res.status}: ${body}`);
24
+ }
25
+ const json = (await res.json());
26
+ const text = json.candidates?.[0]?.content?.parts
27
+ ?.map((part) => part.text ?? "")
28
+ .join("");
29
+ if (!text) {
30
+ throw new Error("Gemini API returned no text content");
31
+ }
32
+ return parseTranslatedTexts(text, request.texts.length);
33
+ },
34
+ };
35
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,72 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { geminiAdapter } from "./gemini.js";
3
+ function fakeFetch(handler) {
4
+ const requests = [];
5
+ const fetchFn = (async (input, init) => {
6
+ const url = String(input);
7
+ requests.push({ url, init });
8
+ return handler(url, init);
9
+ });
10
+ return { fetchFn, requests };
11
+ }
12
+ function generateContentResponse(text) {
13
+ return new Response(JSON.stringify({ candidates: [{ content: { parts: [{ text }] } }] }), { status: 200 });
14
+ }
15
+ describe("geminiAdapter", () => {
16
+ it("calls the generateContent API and parses the result", async () => {
17
+ const { fetchFn, requests } = fakeFetch(() => generateContentResponse('["Hello"]'));
18
+ const adapter = geminiAdapter({
19
+ model: "test-model",
20
+ apiKey: "AIza-test",
21
+ fetch: fetchFn,
22
+ });
23
+ const result = await adapter.translate({ texts: ["こんにちは"], from: "ja", to: "en" });
24
+ expect(result).toEqual(["Hello"]);
25
+ expect(requests[0]?.url).toBe("https://generativelanguage.googleapis.com/v1beta/models/test-model:generateContent");
26
+ const headers = requests[0]?.init?.headers;
27
+ expect(headers["x-goog-api-key"]).toBe("AIza-test");
28
+ const body = JSON.parse(requests[0]?.init?.body);
29
+ expect(body.contents[0].parts[0].text).toContain('from "ja" to "en"');
30
+ });
31
+ it("joins multiple response parts", async () => {
32
+ const { fetchFn } = fakeFetch(() => new Response(JSON.stringify({
33
+ candidates: [{ content: { parts: [{ text: '["Hel' }, { text: 'lo"]' }] } }],
34
+ }), { status: 200 }));
35
+ const adapter = geminiAdapter({
36
+ model: "test-model",
37
+ apiKey: "AIza-test",
38
+ fetch: fetchFn,
39
+ });
40
+ const result = await adapter.translate({ texts: ["a"], from: "ja", to: "en" });
41
+ expect(result).toEqual(["Hello"]);
42
+ });
43
+ it("respects a custom baseURL", async () => {
44
+ const { fetchFn, requests } = fakeFetch(() => generateContentResponse('["Hello"]'));
45
+ const adapter = geminiAdapter({
46
+ model: "test-model",
47
+ apiKey: "AIza-test",
48
+ baseURL: "http://localhost:8080/v1beta/",
49
+ fetch: fetchFn,
50
+ });
51
+ await adapter.translate({ texts: ["a"], from: "ja", to: "en" });
52
+ expect(requests[0]?.url).toBe("http://localhost:8080/v1beta/models/test-model:generateContent");
53
+ });
54
+ it("throws with status and body on API errors", async () => {
55
+ const { fetchFn } = fakeFetch(() => new Response("quota exceeded", { status: 429 }));
56
+ const adapter = geminiAdapter({
57
+ model: "test-model",
58
+ apiKey: "AIza-test",
59
+ fetch: fetchFn,
60
+ });
61
+ await expect(adapter.translate({ texts: ["a"], from: "ja", to: "en" })).rejects.toThrow("Gemini API error 429: quota exceeded");
62
+ });
63
+ it("throws when the response has no text content", async () => {
64
+ const { fetchFn } = fakeFetch(() => new Response(JSON.stringify({ candidates: [] }), { status: 200 }));
65
+ const adapter = geminiAdapter({
66
+ model: "test-model",
67
+ apiKey: "AIza-test",
68
+ fetch: fetchFn,
69
+ });
70
+ await expect(adapter.translate({ texts: ["a"], from: "ja", to: "en" })).rejects.toThrow("no text content");
71
+ });
72
+ });
@@ -0,0 +1,12 @@
1
+ export { anthropicAdapter } from "./anthropic.js";
2
+ export type { AnthropicAdapterOptions } from "./anthropic.js";
3
+ export { memoryCache } from "./cache.js";
4
+ export { geminiAdapter } from "./gemini.js";
5
+ export type { GeminiAdapterOptions } from "./gemini.js";
6
+ export { openAIAdapter } from "./openai.js";
7
+ export type { OpenAIAdapterOptions } from "./openai.js";
8
+ export { buildTranslatePrompt, parseTranslatedTexts } from "./prompt.js";
9
+ export type { TranslatePromptOptions } from "./prompt.js";
10
+ export { AITranslator, createAITranslator } from "./translator.js";
11
+ export type { AITranslatorOptions, TranslateOptions } from "./translator.js";
12
+ export type { AIAdapter, AITranslateRequest, TranslationCache } from "./types.js";
@@ -0,0 +1,6 @@
1
+ export { anthropicAdapter } from "./anthropic.js";
2
+ export { memoryCache } from "./cache.js";
3
+ export { geminiAdapter } from "./gemini.js";
4
+ export { openAIAdapter } from "./openai.js";
5
+ export { buildTranslatePrompt, parseTranslatedTexts } from "./prompt.js";
6
+ export { AITranslator, createAITranslator } from "./translator.js";
@@ -0,0 +1,17 @@
1
+ import type { TranslatePromptOptions } from "./prompt.js";
2
+ import type { AIAdapter } from "./types.js";
3
+ export interface OpenAIAdapterOptions extends TranslatePromptOptions {
4
+ /** Model name, e.g. "gpt-4o-mini". Required: model lineups change too often to default. */
5
+ model: string;
6
+ /** API key. */
7
+ apiKey: string;
8
+ /** Defaults to "https://api.openai.com/v1". Point this at any OpenAI-compatible API. */
9
+ baseURL?: string;
10
+ /** Custom fetch implementation (testing, proxies). Defaults to globalThis.fetch. */
11
+ fetch?: typeof fetch;
12
+ }
13
+ /**
14
+ * Built-in adapter for the OpenAI Chat Completions API (and compatible APIs).
15
+ * Uses fetch directly — no SDK dependency.
16
+ */
17
+ export declare function openAIAdapter(options: OpenAIAdapterOptions): AIAdapter;
@@ -0,0 +1,35 @@
1
+ import { buildTranslatePrompt, parseTranslatedTexts } from "./prompt.js";
2
+ /**
3
+ * Built-in adapter for the OpenAI Chat Completions API (and compatible APIs).
4
+ * Uses fetch directly — no SDK dependency.
5
+ */
6
+ export function openAIAdapter(options) {
7
+ const baseURL = (options.baseURL ?? "https://api.openai.com/v1").replace(/\/+$/, "");
8
+ const fetchFn = options.fetch ?? fetch;
9
+ return {
10
+ async translate(request) {
11
+ const { apiKey } = options;
12
+ const res = await fetchFn(`${baseURL}/chat/completions`, {
13
+ method: "POST",
14
+ headers: {
15
+ Authorization: `Bearer ${apiKey}`,
16
+ "Content-Type": "application/json",
17
+ },
18
+ body: JSON.stringify({
19
+ model: options.model,
20
+ messages: [{ role: "user", content: buildTranslatePrompt(request, options) }],
21
+ }),
22
+ });
23
+ if (!res.ok) {
24
+ const body = await res.text().catch(() => "");
25
+ throw new Error(`OpenAI API error ${res.status}: ${body}`);
26
+ }
27
+ const json = (await res.json());
28
+ const content = json.choices?.[0]?.message?.content;
29
+ if (typeof content !== "string") {
30
+ throw new Error("OpenAI API returned no message content");
31
+ }
32
+ return parseTranslatedTexts(content, request.texts.length);
33
+ },
34
+ };
35
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,65 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { openAIAdapter } from "./openai.js";
3
+ function fakeFetch(handler) {
4
+ const requests = [];
5
+ const fetchFn = (async (input, init) => {
6
+ const url = String(input);
7
+ requests.push({ url, init });
8
+ return handler(url, init);
9
+ });
10
+ return { fetchFn, requests };
11
+ }
12
+ function chatResponse(content) {
13
+ return new Response(JSON.stringify({ choices: [{ message: { content } }] }), {
14
+ status: 200,
15
+ });
16
+ }
17
+ describe("openAIAdapter", () => {
18
+ it("calls the Chat Completions API and parses the result", async () => {
19
+ const { fetchFn, requests } = fakeFetch(() => chatResponse('["Hello"]'));
20
+ const adapter = openAIAdapter({ model: "test-model", apiKey: "sk-test", fetch: fetchFn });
21
+ const result = await adapter.translate({ texts: ["こんにちは"], from: "ja", to: "en" });
22
+ expect(result).toEqual(["Hello"]);
23
+ expect(requests).toHaveLength(1);
24
+ expect(requests[0]?.url).toBe("https://api.openai.com/v1/chat/completions");
25
+ const init = requests[0]?.init;
26
+ expect((init?.headers).Authorization).toBe("Bearer sk-test");
27
+ const body = JSON.parse(init?.body);
28
+ expect(body.model).toBe("test-model");
29
+ expect(body.messages[0].content).toContain('from "ja" to "en"');
30
+ expect(body.messages[0].content).toContain('["こんにちは"]');
31
+ });
32
+ it("respects a custom baseURL (OpenAI-compatible APIs)", async () => {
33
+ const { fetchFn, requests } = fakeFetch(() => chatResponse('["Hello"]'));
34
+ const adapter = openAIAdapter({
35
+ model: "local-model",
36
+ apiKey: "sk-test",
37
+ baseURL: "http://localhost:11434/v1/",
38
+ fetch: fetchFn,
39
+ });
40
+ await adapter.translate({ texts: ["a"], from: "ja", to: "en" });
41
+ expect(requests[0]?.url).toBe("http://localhost:11434/v1/chat/completions");
42
+ });
43
+ it("passes instructions into the prompt", async () => {
44
+ const { fetchFn, requests } = fakeFetch(() => chatResponse('["Hello"]'));
45
+ const adapter = openAIAdapter({
46
+ model: "test-model",
47
+ apiKey: "sk-test",
48
+ instructions: "Keep it casual.",
49
+ fetch: fetchFn,
50
+ });
51
+ await adapter.translate({ texts: ["a"], from: "ja", to: "en" });
52
+ const body = JSON.parse(requests[0]?.init?.body);
53
+ expect(body.messages[0].content).toContain("Keep it casual.");
54
+ });
55
+ it("throws with status and body on API errors", async () => {
56
+ const { fetchFn } = fakeFetch(() => new Response("rate limited", { status: 429 }));
57
+ const adapter = openAIAdapter({ model: "test-model", apiKey: "sk-test", fetch: fetchFn });
58
+ await expect(adapter.translate({ texts: ["a"], from: "ja", to: "en" })).rejects.toThrow("OpenAI API error 429: rate limited");
59
+ });
60
+ it("throws when the response has no message content", async () => {
61
+ const { fetchFn } = fakeFetch(() => new Response(JSON.stringify({ choices: [] }), { status: 200 }));
62
+ const adapter = openAIAdapter({ model: "test-model", apiKey: "sk-test", fetch: fetchFn });
63
+ await expect(adapter.translate({ texts: ["a"], from: "ja", to: "en" })).rejects.toThrow("no message content");
64
+ });
65
+ });
@@ -0,0 +1,15 @@
1
+ import type { AITranslateRequest } from "./types.js";
2
+ export interface TranslatePromptOptions {
3
+ /** Extra instructions appended to the prompt (tone, glossary, terms to keep, ...) */
4
+ instructions?: string;
5
+ }
6
+ /**
7
+ * Build a provider-neutral translation prompt.
8
+ * Built-in adapters use this; custom adapters can too.
9
+ */
10
+ export declare function buildTranslatePrompt(request: AITranslateRequest, options?: TranslatePromptOptions): string;
11
+ /**
12
+ * Parse an AI response into translated texts.
13
+ * Tolerates code fences and surrounding text, and validates the result.
14
+ */
15
+ export declare function parseTranslatedTexts(raw: string, expected: number): string[];
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Build a provider-neutral translation prompt.
3
+ * Built-in adapters use this; custom adapters can too.
4
+ */
5
+ export function buildTranslatePrompt(request, options = {}) {
6
+ const { texts, from, to } = request;
7
+ const lines = [
8
+ from === undefined
9
+ ? `Detect the language of each text and translate it to "${to}".`
10
+ : `Translate each text from "${from}" to "${to}".`,
11
+ "Rules:",
12
+ "- Return ONLY a JSON array of strings, in the same order and length as the input.",
13
+ "- Do not add explanations, code fences, or any text outside the JSON array.",
14
+ "- Keep placeholders (e.g. {name}), code, URLs, and proper nouns as they are.",
15
+ "- Preserve the tone and formatting of each text.",
16
+ ];
17
+ if (options.instructions) {
18
+ lines.push(`Additional instructions: ${options.instructions}`);
19
+ }
20
+ lines.push("", "Input:", JSON.stringify(texts));
21
+ return lines.join("\n");
22
+ }
23
+ /**
24
+ * Parse an AI response into translated texts.
25
+ * Tolerates code fences and surrounding text, and validates the result.
26
+ */
27
+ export function parseTranslatedTexts(raw, expected) {
28
+ const start = raw.indexOf("[");
29
+ const end = raw.lastIndexOf("]");
30
+ if (start === -1 || end < start) {
31
+ throw new Error("AI response does not contain a JSON array");
32
+ }
33
+ let parsed;
34
+ try {
35
+ parsed = JSON.parse(raw.slice(start, end + 1));
36
+ }
37
+ catch {
38
+ throw new Error("AI response contains an invalid JSON array");
39
+ }
40
+ if (!Array.isArray(parsed) || !parsed.every((v) => typeof v === "string")) {
41
+ throw new Error("AI response is not a JSON array of strings");
42
+ }
43
+ if (parsed.length !== expected) {
44
+ throw new Error(`AI returned ${parsed.length} texts, expected ${expected}`);
45
+ }
46
+ return parsed;
47
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,36 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { buildTranslatePrompt, parseTranslatedTexts } from "./prompt.js";
3
+ describe("buildTranslatePrompt", () => {
4
+ it("includes from, to and the texts as JSON", () => {
5
+ const prompt = buildTranslatePrompt({ texts: ["こんにちは"], from: "ja", to: "en" });
6
+ expect(prompt).toContain('from "ja" to "en"');
7
+ expect(prompt).toContain('["こんにちは"]');
8
+ });
9
+ it("asks for language detection when from is omitted", () => {
10
+ const prompt = buildTranslatePrompt({ texts: ["hola"], to: "ja" });
11
+ expect(prompt).toContain("Detect the language");
12
+ expect(prompt).toContain('to "ja"');
13
+ });
14
+ it("appends custom instructions", () => {
15
+ const prompt = buildTranslatePrompt({ texts: ["a"], from: "ja", to: "en" }, { instructions: 'Do not translate "Canopy".' });
16
+ expect(prompt).toContain('Additional instructions: Do not translate "Canopy".');
17
+ });
18
+ });
19
+ describe("parseTranslatedTexts", () => {
20
+ it("parses a plain JSON array", () => {
21
+ expect(parseTranslatedTexts('["Hello", "Goodbye"]', 2)).toEqual(["Hello", "Goodbye"]);
22
+ });
23
+ it("tolerates code fences and surrounding text", () => {
24
+ const raw = 'Here is the translation:\n```json\n["Hello"]\n```\n';
25
+ expect(parseTranslatedTexts(raw, 1)).toEqual(["Hello"]);
26
+ });
27
+ it("throws when no JSON array is found", () => {
28
+ expect(() => parseTranslatedTexts("sorry, I cannot do that", 1)).toThrow("does not contain a JSON array");
29
+ });
30
+ it("throws on a length mismatch", () => {
31
+ expect(() => parseTranslatedTexts('["a"]', 2)).toThrow("expected 2");
32
+ });
33
+ it("throws when items are not strings", () => {
34
+ expect(() => parseTranslatedTexts("[1, 2]", 2)).toThrow("not a JSON array of strings");
35
+ });
36
+ });
@@ -0,0 +1,53 @@
1
+ import type { AIAdapter, TranslationCache } from "./types.js";
2
+ export interface AITranslatorOptions<S extends string = string> {
3
+ /** Translation backend. Implement AIAdapter to use any provider. */
4
+ adapter: AIAdapter;
5
+ /**
6
+ * Default locale of the original texts, used when `from` is omitted.
7
+ * When neither is given, the adapter receives `from: undefined` and
8
+ * should detect the source language (required for completeEntries).
9
+ */
10
+ sourceLocale?: S;
11
+ /** Optional cache to avoid repeated adapter calls. */
12
+ cache?: TranslationCache;
13
+ /**
14
+ * What to do when the adapter fails.
15
+ * - "fallback" (default): return the original text
16
+ * - "throw": rethrow the error
17
+ */
18
+ onError?: "fallback" | "throw";
19
+ /** Customize cache keys. Default: `${from ?? "auto"}:${to}:${text}` */
20
+ cacheKey?: (text: string, from: string | undefined, to: string) => string;
21
+ }
22
+ export interface TranslateOptions {
23
+ to: string;
24
+ from?: string;
25
+ }
26
+ export declare class AITranslator<S extends string = string> {
27
+ private readonly adapter;
28
+ private readonly sourceLocale?;
29
+ private readonly cache?;
30
+ private readonly onError;
31
+ private readonly makeKey;
32
+ /** Deduplicates concurrent requests for the same text */
33
+ private readonly inflight;
34
+ constructor(options: AITranslatorOptions<S>);
35
+ /**
36
+ * Translate a single text (e.g. user input) at runtime.
37
+ */
38
+ translate(text: string, options: TranslateOptions): Promise<string>;
39
+ /**
40
+ * Translate multiple texts in a single adapter call.
41
+ * Results are cached and concurrent requests for the same text are deduplicated.
42
+ */
43
+ translateMany(texts: string[], options: TranslateOptions): Promise<string[]>;
44
+ /**
45
+ * Fill in missing locales of static message entries.
46
+ * Existing translations are kept; only missing ones are translated
47
+ * from the source locale. The result can be passed to ChainBuilder.add().
48
+ */
49
+ completeEntries<const Ls extends readonly string[], E extends Record<string, Partial<Record<Ls[number], string>> & Record<S, string>>>(locales: Ls, entries: E): Promise<{
50
+ [K in keyof E]: Record<Ls[number], string>;
51
+ }>;
52
+ }
53
+ export declare function createAITranslator<S extends string = string>(options: AITranslatorOptions<S>): AITranslator<S>;
@@ -0,0 +1,153 @@
1
+ function createDeferred() {
2
+ let resolve;
3
+ let reject;
4
+ const promise = new Promise((res, rej) => {
5
+ resolve = res;
6
+ reject = rej;
7
+ });
8
+ promise.catch(() => { }); // avoid unhandled rejection
9
+ return { promise, resolve, reject };
10
+ }
11
+ export class AITranslator {
12
+ adapter;
13
+ sourceLocale;
14
+ cache;
15
+ onError;
16
+ makeKey;
17
+ /** Deduplicates concurrent requests for the same text */
18
+ inflight = new Map();
19
+ constructor(options) {
20
+ this.adapter = options.adapter;
21
+ this.sourceLocale = options.sourceLocale;
22
+ this.cache = options.cache;
23
+ this.onError = options.onError ?? "fallback";
24
+ this.makeKey = options.cacheKey ?? ((text, from, to) => `${from ?? "auto"}:${to}:${text}`);
25
+ }
26
+ /**
27
+ * Translate a single text (e.g. user input) at runtime.
28
+ */
29
+ async translate(text, options) {
30
+ const results = await this.translateMany([text], options);
31
+ return results[0];
32
+ }
33
+ /**
34
+ * Translate multiple texts in a single adapter call.
35
+ * Results are cached and concurrent requests for the same text are deduplicated.
36
+ */
37
+ async translateMany(texts, options) {
38
+ const from = options.from ?? this.sourceLocale;
39
+ const to = options.to;
40
+ if (from !== undefined && from === to)
41
+ return [...texts];
42
+ const keys = texts.map((text) => this.makeKey(text, from, to));
43
+ const textByKey = new Map();
44
+ // Keys this call resolves itself vs. keys another call is already resolving.
45
+ // Claim ownership synchronously so concurrent calls dedup reliably.
46
+ const owned = new Map();
47
+ const waiting = new Map();
48
+ for (const [i, key] of keys.entries()) {
49
+ if (textByKey.has(key))
50
+ continue;
51
+ const text = texts[i];
52
+ textByKey.set(key, text);
53
+ const inflight = this.inflight.get(key);
54
+ if (inflight) {
55
+ waiting.set(key, inflight);
56
+ }
57
+ else {
58
+ const deferred = createDeferred();
59
+ this.inflight.set(key, deferred.promise);
60
+ owned.set(key, { text, deferred });
61
+ }
62
+ }
63
+ const resolved = new Map();
64
+ // Resolve owned keys from the cache; the rest need the adapter
65
+ const misses = [];
66
+ await Promise.all(Array.from(owned, async ([key, { text, deferred }]) => {
67
+ const cached = this.cache ? await this.cache.get(key) : undefined;
68
+ if (cached !== undefined) {
69
+ resolved.set(key, cached);
70
+ deferred.resolve(cached);
71
+ this.inflight.delete(key);
72
+ }
73
+ else {
74
+ misses.push({ key, text, deferred });
75
+ }
76
+ }));
77
+ if (misses.length > 0) {
78
+ try {
79
+ const out = await this.adapter.translate({
80
+ texts: misses.map((miss) => miss.text),
81
+ from,
82
+ to,
83
+ });
84
+ if (out.length !== misses.length) {
85
+ throw new Error(`AIAdapter returned ${out.length} texts, expected ${misses.length}`);
86
+ }
87
+ await Promise.all(misses.map(async (miss, i) => {
88
+ const translated = out[i];
89
+ resolved.set(miss.key, translated);
90
+ miss.deferred.resolve(translated);
91
+ await this.cache?.set(miss.key, translated);
92
+ }));
93
+ }
94
+ catch (err) {
95
+ for (const miss of misses)
96
+ miss.deferred.reject(err);
97
+ if (this.onError === "throw")
98
+ throw err;
99
+ for (const miss of misses)
100
+ resolved.set(miss.key, miss.text);
101
+ }
102
+ finally {
103
+ for (const miss of misses)
104
+ this.inflight.delete(miss.key);
105
+ }
106
+ }
107
+ // Wait for keys owned by other concurrent calls
108
+ await Promise.all(Array.from(waiting, async ([key, promise]) => {
109
+ try {
110
+ resolved.set(key, await promise);
111
+ }
112
+ catch (err) {
113
+ if (this.onError === "throw")
114
+ throw err;
115
+ resolved.set(key, textByKey.get(key));
116
+ }
117
+ }));
118
+ return keys.map((key) => resolved.get(key));
119
+ }
120
+ /**
121
+ * Fill in missing locales of static message entries.
122
+ * Existing translations are kept; only missing ones are translated
123
+ * from the source locale. The result can be passed to ChainBuilder.add().
124
+ */
125
+ async completeEntries(locales, entries) {
126
+ const source = this.sourceLocale;
127
+ if (source === undefined) {
128
+ throw new Error("completeEntries requires the sourceLocale option");
129
+ }
130
+ const result = {};
131
+ for (const [key, entry] of Object.entries(entries)) {
132
+ if (typeof entry[source] !== "string") {
133
+ throw new Error(`Entry "${key}" is missing source locale "${source}"`);
134
+ }
135
+ result[key] = { ...entry };
136
+ }
137
+ await Promise.all(locales
138
+ .filter((locale) => locale !== source)
139
+ .map(async (locale) => {
140
+ const missing = Object.values(result).filter((entry) => entry[locale] === undefined);
141
+ if (missing.length === 0)
142
+ return;
143
+ const translated = await this.translateMany(missing.map((entry) => entry[source]), { to: locale, from: source });
144
+ for (const [i, entry] of missing.entries()) {
145
+ entry[locale] = translated[i];
146
+ }
147
+ }));
148
+ return result;
149
+ }
150
+ }
151
+ export function createAITranslator(options) {
152
+ return new AITranslator(options);
153
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,154 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { memoryCache } from "./cache.js";
3
+ import { createAITranslator } from "./translator.js";
4
+ function fakeAdapter() {
5
+ const calls = [];
6
+ const adapter = {
7
+ async translate(request) {
8
+ calls.push(request);
9
+ return request.texts.map((text) => `${text}[${request.to}]`);
10
+ },
11
+ };
12
+ return { adapter, calls };
13
+ }
14
+ describe("AITranslator", () => {
15
+ it("translates a single text via the adapter", async () => {
16
+ const { adapter, calls } = fakeAdapter();
17
+ const translator = createAITranslator({ adapter, sourceLocale: "ja" });
18
+ const result = await translator.translate("こんにちは", { to: "en" });
19
+ expect(result).toBe("こんにちは[en]");
20
+ expect(calls).toEqual([{ texts: ["こんにちは"], from: "ja", to: "en" }]);
21
+ });
22
+ it("passes from: undefined to the adapter when the source language is unknown", async () => {
23
+ const { adapter, calls } = fakeAdapter();
24
+ const translator = createAITranslator({ adapter });
25
+ const result = await translator.translate("こんにちは", { to: "en" });
26
+ expect(result).toBe("こんにちは[en]");
27
+ expect(calls).toEqual([{ texts: ["こんにちは"], from: undefined, to: "en" }]);
28
+ });
29
+ it("calls the adapter even when to may equal the unknown source language", async () => {
30
+ const { adapter, calls } = fakeAdapter();
31
+ const translator = createAITranslator({ adapter });
32
+ await translator.translate("こんにちは", { to: "ja" });
33
+ expect(calls).toHaveLength(1);
34
+ });
35
+ it("rejects completeEntries without sourceLocale", async () => {
36
+ const { adapter } = fakeAdapter();
37
+ const translator = createAITranslator({ adapter });
38
+ await expect(translator.completeEntries(["ja", "en"], {
39
+ title: { ja: "タイトル" },
40
+ })).rejects.toThrow("requires the sourceLocale option");
41
+ });
42
+ it("returns texts as-is when from equals to", async () => {
43
+ const { adapter, calls } = fakeAdapter();
44
+ const translator = createAITranslator({ adapter, sourceLocale: "ja" });
45
+ const result = await translator.translate("こんにちは", { to: "ja" });
46
+ expect(result).toBe("こんにちは");
47
+ expect(calls).toHaveLength(0);
48
+ });
49
+ it("batches multiple texts into one adapter call", async () => {
50
+ const { adapter, calls } = fakeAdapter();
51
+ const translator = createAITranslator({ adapter, sourceLocale: "ja" });
52
+ const result = await translator.translateMany(["a", "b"], { to: "en" });
53
+ expect(result).toEqual(["a[en]", "b[en]"]);
54
+ expect(calls).toHaveLength(1);
55
+ });
56
+ it("deduplicates the same text within a batch", async () => {
57
+ const { adapter, calls } = fakeAdapter();
58
+ const translator = createAITranslator({ adapter, sourceLocale: "ja" });
59
+ const result = await translator.translateMany(["a", "a", "b"], { to: "en" });
60
+ expect(result).toEqual(["a[en]", "a[en]", "b[en]"]);
61
+ expect(calls[0]?.texts).toEqual(["a", "b"]);
62
+ });
63
+ it("uses the cache to avoid repeated adapter calls", async () => {
64
+ const { adapter, calls } = fakeAdapter();
65
+ const translator = createAITranslator({
66
+ adapter,
67
+ sourceLocale: "ja",
68
+ cache: memoryCache(),
69
+ });
70
+ await translator.translate("a", { to: "en" });
71
+ const result = await translator.translate("a", { to: "en" });
72
+ expect(result).toBe("a[en]");
73
+ expect(calls).toHaveLength(1);
74
+ });
75
+ it("deduplicates concurrent requests for the same text", async () => {
76
+ const calls = [];
77
+ let release;
78
+ const gate = new Promise((resolve) => {
79
+ release = resolve;
80
+ });
81
+ const adapter = {
82
+ async translate(request) {
83
+ calls.push(request);
84
+ await gate;
85
+ return request.texts.map((text) => `${text}[${request.to}]`);
86
+ },
87
+ };
88
+ const translator = createAITranslator({ adapter, sourceLocale: "ja" });
89
+ const first = translator.translate("a", { to: "en" });
90
+ const second = translator.translate("a", { to: "en" });
91
+ await Promise.resolve();
92
+ release();
93
+ expect(await first).toBe("a[en]");
94
+ expect(await second).toBe("a[en]");
95
+ expect(calls).toHaveLength(1);
96
+ });
97
+ it("falls back to the original text on adapter error by default", async () => {
98
+ const adapter = {
99
+ async translate() {
100
+ throw new Error("api down");
101
+ },
102
+ };
103
+ const translator = createAITranslator({ adapter, sourceLocale: "ja" });
104
+ const result = await translator.translate("こんにちは", { to: "en" });
105
+ expect(result).toBe("こんにちは");
106
+ });
107
+ it("throws on adapter error when onError is 'throw'", async () => {
108
+ const adapter = {
109
+ async translate() {
110
+ throw new Error("api down");
111
+ },
112
+ };
113
+ const translator = createAITranslator({
114
+ adapter,
115
+ sourceLocale: "ja",
116
+ onError: "throw",
117
+ });
118
+ await expect(translator.translate("こんにちは", { to: "en" })).rejects.toThrow("api down");
119
+ });
120
+ it("throws when the adapter returns a wrong number of texts", async () => {
121
+ const adapter = {
122
+ async translate() {
123
+ return [];
124
+ },
125
+ };
126
+ const translator = createAITranslator({
127
+ adapter,
128
+ sourceLocale: "ja",
129
+ onError: "throw",
130
+ });
131
+ await expect(translator.translate("a", { to: "en" })).rejects.toThrow("expected 1");
132
+ });
133
+ it("completes missing locales of entries, keeping existing translations", async () => {
134
+ const { adapter, calls } = fakeAdapter();
135
+ const translator = createAITranslator({ adapter, sourceLocale: "ja" });
136
+ const entries = await translator.completeEntries(["ja", "en", "fr"], {
137
+ title: { ja: "タイトル" },
138
+ greeting: { ja: "こんにちは", en: "Hello" },
139
+ });
140
+ expect(entries).toEqual({
141
+ title: { ja: "タイトル", en: "タイトル[en]", fr: "タイトル[fr]" },
142
+ greeting: { ja: "こんにちは", en: "Hello", fr: "こんにちは[fr]" },
143
+ });
144
+ // en: title のみ / fr: title と greeting
145
+ expect(calls).toHaveLength(2);
146
+ });
147
+ it("throws when an entry is missing the source locale", async () => {
148
+ const { adapter } = fakeAdapter();
149
+ const translator = createAITranslator({ adapter, sourceLocale: "ja" });
150
+ await expect(translator.completeEntries(["ja", "en"], {
151
+ title: { en: "Title" },
152
+ })).rejects.toThrow('missing source locale "ja"');
153
+ });
154
+ });
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Minimal interface for an AI translation backend.
3
+ * Implement this to plug in any provider (OpenAI, local model, etc.).
4
+ */
5
+ export interface AIAdapter {
6
+ /**
7
+ * Translate texts from one locale to another.
8
+ * Must return translations in the same order and length as `texts`.
9
+ */
10
+ translate(request: AITranslateRequest): Promise<string[]>;
11
+ }
12
+ export interface AITranslateRequest {
13
+ texts: string[];
14
+ /** Omitted when the source language is unknown — detect it from the texts. */
15
+ from?: string;
16
+ to: string;
17
+ }
18
+ /**
19
+ * Cache for translated texts.
20
+ * Implement this to persist translations anywhere (memory, DB, file, etc.).
21
+ */
22
+ export interface TranslationCache {
23
+ get(key: string): Promise<string | undefined> | string | undefined;
24
+ set(key: string, value: string): Promise<void> | void;
25
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -21,7 +21,7 @@ export interface ClientLocaleProviderProps {
21
21
  useLocaleSource?: UseLocaleSource;
22
22
  pathPrefix?: string;
23
23
  }
24
- export declare function ClientLocaleProvider({ children, locales, fallbackLocale, paramKey, useLocaleSource, pathPrefix, }: ClientLocaleProviderProps): import("react/jsx-runtime").JSX.Element;
24
+ export declare function ClientLocaleProvider({ children, locales, fallbackLocale, paramKey, useLocaleSource, pathPrefix, }: ClientLocaleProviderProps): import("react").JSX.Element;
25
25
  export declare function useLocaleClient(): LocaleContextValue;
26
26
  export declare function useBindLocaleClient<T extends object>(messages: T): unknown;
27
27
  export interface ClientLocaleLinkProps extends Omit<ComponentProps<typeof Link>, "href" | "locale"> {
@@ -29,4 +29,4 @@ export interface ClientLocaleLinkProps extends Omit<ComponentProps<typeof Link>,
29
29
  href?: string;
30
30
  children?: ReactNode;
31
31
  }
32
- export declare function ClientLocaleLink({ locale, href, children, ...rest }: ClientLocaleLinkProps): import("react/jsx-runtime").JSX.Element;
32
+ export declare function ClientLocaleLink({ locale, href, children, ...rest }: ClientLocaleLinkProps): import("react").JSX.Element;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-i18n",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "The Type-Safe i18n library that your IDE will Love",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -20,6 +20,11 @@
20
20
  "types": "./dist/adaptors/next/index.d.ts",
21
21
  "import": "./dist/adaptors/next/index.js",
22
22
  "default": "./dist/adaptors/next/index.js"
23
+ },
24
+ "./ai": {
25
+ "types": "./dist/adaptors/ai/index.d.ts",
26
+ "import": "./dist/adaptors/ai/index.js",
27
+ "default": "./dist/adaptors/ai/index.js"
23
28
  }
24
29
  },
25
30
  "peerDependencies": {
@@ -54,12 +59,12 @@
54
59
  "devDependencies": {
55
60
  "@tsconfig/node20": "^20.1.9",
56
61
  "@types/node": "^25.9.1",
57
- "@types/react": "^19.2.15",
62
+ "@types/react": "^19.2.16",
58
63
  "next": "16",
59
- "react": "^19.2.6",
60
- "release-it": "^19.2.4",
64
+ "react": "^19.2.7",
65
+ "release-it": "^20.2.0",
61
66
  "typescript": "^6.0.3",
62
- "vitest": "^4.1.7"
67
+ "vitest": "^4.1.8"
63
68
  },
64
69
  "keywords": [
65
70
  "i18n",
package/skills/SKILL.md CHANGED
@@ -225,6 +225,41 @@ React is a `peerDependency` (`>=18`).
225
225
 
226
226
  ---
227
227
 
228
+ ## AI Translation
229
+
230
+ `canopy-i18n/ai` provides a runtime translator with a pluggable adapter (bring any AI backend). Static strings only — template functions are not translated. See README for details.
231
+
232
+ ```ts
233
+ import { createAITranslator, memoryCache, openAIAdapter } from 'canopy-i18n/ai';
234
+
235
+ const translator = createAITranslator({
236
+ // Built-in adapters: openAIAdapter / anthropicAdapter / geminiAdapter
237
+ // (fetch-based; model & apiKey required; baseURL/instructions optional).
238
+ // Or implement AIAdapter yourself: { async translate({ texts, from, to }) {...} }
239
+ // (`from` is undefined when the source language is unknown — auto-detect it)
240
+ adapter: openAIAdapter({ model: 'gpt-4o-mini', apiKey: process.env.OPENAI_API_KEY! }),
241
+ sourceLocale: 'ja', // default `from` (optional; required for completeEntries)
242
+ cache: memoryCache(), // optional; custom { get, set } for DB persistence
243
+ // onError: 'fallback' // default: return original text on failure ('throw' to propagate)
244
+ });
245
+
246
+ // Custom adapters can reuse the built-in prompt logic:
247
+ // buildTranslatePrompt(request, { instructions }) / parseTranslatedTexts(raw, expected)
248
+
249
+ // Dynamic texts (e.g. user input) — cached, deduplicated, batched
250
+ await translator.translate(userInput, { to: 'en' }); // from = sourceLocale
251
+ await translator.translate(userInput, { to: 'en', from: 'fr' });
252
+ // Without sourceLocale and `from`, the adapter auto-detects the source language
253
+
254
+ // Fill missing locales of entries, then pass to ChainBuilder.add()
255
+ const entries = await translator.completeEntries(['ja', 'en'] as const, {
256
+ title: { ja: 'タイトル' }, // en is AI-translated; existing values are kept
257
+ });
258
+ const messages = createI18n(['ja', 'en'] as const).add(entries).build('en');
259
+ ```
260
+
261
+ ---
262
+
228
263
  ## Exports
229
264
 
230
265
  ```ts
@@ -242,4 +277,10 @@ export {
242
277
  createStorageI18nReact,
243
278
  createCookieI18nReact,
244
279
  } from 'canopy-i18n/react';
280
+
281
+ // AI subpath
282
+ export { createAITranslator, AITranslator, memoryCache } from 'canopy-i18n/ai';
283
+ export { openAIAdapter, anthropicAdapter, geminiAdapter } from 'canopy-i18n/ai';
284
+ export { buildTranslatePrompt, parseTranslatedTexts } from 'canopy-i18n/ai';
285
+ export type { AIAdapter, TranslationCache } from 'canopy-i18n/ai';
245
286
  ```