open-tongues 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 80x24
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,191 @@
1
+ # open-tongues
2
+
3
+ Zero-config website translation. One script tag, any language — powered by Claude AI.
4
+
5
+ ## How it works
6
+
7
+ ```
8
+ Request → Memory Cache (L1) → SQLite (L2) → Claude API (L3) → Cache & Return
9
+ ```
10
+
11
+ 1. Add a script tag to your site
12
+ 2. tongues scans the page for translatable text
13
+ 3. Translations are cached at three levels for instant subsequent loads
14
+ 4. New content is automatically detected via MutationObserver
15
+
16
+ ## Install
17
+
18
+ ### As an npm package
19
+
20
+ ```bash
21
+ npm install open-tongues
22
+ ```
23
+
24
+ ```ts
25
+ import { Hono } from 'hono'
26
+ import { createHandler } from 'open-tongues'
27
+
28
+ const app = new Hono()
29
+ app.route('/tongues', createHandler({
30
+ apiKey: process.env.ANTHROPIC_API_KEY!,
31
+ }))
32
+
33
+ export default app
34
+ ```
35
+
36
+ Then add the client script to your HTML:
37
+
38
+ ```html
39
+ <script src="/tongues/t.js" defer></script>
40
+ ```
41
+
42
+ ### As a standalone server
43
+
44
+ ```bash
45
+ git clone https://github.com/80x24/open-tongues.git
46
+ cd open-tongues
47
+ bun install
48
+
49
+ cp .env.example .env
50
+ # Edit .env — add your ANTHROPIC_API_KEY
51
+
52
+ bun dev
53
+ ```
54
+
55
+ ```html
56
+ <script src="http://localhost:3000/t.js" defer></script>
57
+ ```
58
+
59
+ ## API
60
+
61
+ ### `createHandler(config)`
62
+
63
+ Factory that returns a Hono app you can mount as a sub-router.
64
+
65
+ ```ts
66
+ import { createHandler } from 'open-tongues'
67
+
68
+ app.route('/tongues', createHandler({
69
+ apiKey: 'sk-ant-...', // required
70
+ dbPath: './tongues.db', // default: ./tongues.db
71
+ model: 'claude-haiku-4-5-20251001', // default
72
+ cacheSize: 10_000, // L1 max entries (default: 10000)
73
+ cacheTTL: 86_400_000, // L1 TTL in ms (default: 24h)
74
+ rateLimit: 100, // per domain per minute (default: 100, 0 = disabled)
75
+ corsOrigin: '*', // CORS origin (default: *)
76
+ }))
77
+ ```
78
+
79
+ ### `createTranslator(config)`
80
+
81
+ Standalone translation engine — use without Hono if you only need the translation logic.
82
+
83
+ ```ts
84
+ import { createTranslator } from 'open-tongues'
85
+
86
+ const translator = createTranslator({
87
+ apiKey: process.env.ANTHROPIC_API_KEY!,
88
+ })
89
+
90
+ const result = await translator.translateTexts(
91
+ ['Hello', 'Welcome'],
92
+ 'ko',
93
+ 'example.com'
94
+ )
95
+ // { "Hello": "안녕하세요", "Welcome": "환영합니다" }
96
+ ```
97
+
98
+ ### REST Endpoints
99
+
100
+ When mounted via `createHandler()`, the following endpoints are available:
101
+
102
+ #### `POST /api/translate`
103
+
104
+ ```json
105
+ {
106
+ "texts": ["Hello", "Welcome to our site"],
107
+ "to": "ko",
108
+ "domain": "example.com",
109
+ "pageTitle": "My Site",
110
+ "preprompt": "This is a food menu"
111
+ }
112
+ ```
113
+
114
+ Response:
115
+
116
+ ```json
117
+ {
118
+ "translations": {
119
+ "Hello": "안녕하세요",
120
+ "Welcome to our site": "저희 사이트에 오신 것을 환영합니다"
121
+ }
122
+ }
123
+ ```
124
+
125
+ #### `GET /api/seo/render?url=...&lang=...`
126
+
127
+ Server-side rendered translation for SEO crawlers.
128
+
129
+ #### `POST /api/purge/:domain/:lang`
130
+
131
+ Clear cached translations for a domain and language.
132
+
133
+ #### `GET /health`
134
+
135
+ Health check with cache statistics.
136
+
137
+ ## Client
138
+
139
+ ### Script tag options
140
+
141
+ ```html
142
+ <!-- Auto-translate on load (default) -->
143
+ <script src="https://YOUR_HOST/t.js" defer></script>
144
+
145
+ <!-- Manual mode — call window.t.setLocale("ko") to start -->
146
+ <script src="https://YOUR_HOST/t.js" data-manual defer></script>
147
+
148
+ <!-- Custom context for better translations -->
149
+ <script src="https://YOUR_HOST/t.js" data-preprompt="This is a food menu" defer></script>
150
+ ```
151
+
152
+ ### Client API (`window.t`)
153
+
154
+ - `t.setLocale("ko")` — translate to a language
155
+ - `t.restore()` — revert to original text
156
+ - `t.translateEl(".selector")` — translate specific elements
157
+ - `t.locale` — current locale (read-only)
158
+
159
+ ### Exclude from translation
160
+
161
+ ```html
162
+ <span translate="no">Brand Name</span>
163
+ <span class="notranslate">Keep Original</span>
164
+ ```
165
+
166
+ ### Importing the client bundle
167
+
168
+ If you're bundling the client yourself:
169
+
170
+ ```js
171
+ import 'open-tongues/client'
172
+ ```
173
+
174
+ ## Docker
175
+
176
+ ```bash
177
+ docker build -t tongues .
178
+ docker run -p 3000:3000 -e ANTHROPIC_API_KEY=sk-ant-... tongues
179
+ ```
180
+
181
+ ## Environment Variables (standalone mode)
182
+
183
+ | Variable | Required | Default | Description |
184
+ |----------|----------|---------|-------------|
185
+ | `ANTHROPIC_API_KEY` | Yes | — | Claude API key |
186
+ | `PORT` | No | `3000` | Server port |
187
+ | `DB_PATH` | No | `./tongues.db` | SQLite database path |
188
+
189
+ ## License
190
+
191
+ MIT
package/dist/index.cjs ADDED
@@ -0,0 +1,453 @@
1
+ "use strict";
2
+ var __create = Object.create;
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __getProtoOf = Object.getPrototypeOf;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __export = (target, all) => {
9
+ for (var name in all)
10
+ __defProp(target, name, { get: all[name], enumerable: true });
11
+ };
12
+ var __copyProps = (to, from, except, desc) => {
13
+ if (from && typeof from === "object" || typeof from === "function") {
14
+ for (let key of __getOwnPropNames(from))
15
+ if (!__hasOwnProp.call(to, key) && key !== except)
16
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
17
+ }
18
+ return to;
19
+ };
20
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
21
+ // If the importer is in node compatibility mode or this is not an ESM
22
+ // file that has been converted to a CommonJS file using a Babel-
23
+ // compatible transform (i.e. "__esModule" has not been set), then set
24
+ // "default" to the CommonJS "module.exports" for node compatibility.
25
+ isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
26
+ mod
27
+ ));
28
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
29
+
30
+ // src/index.ts
31
+ var index_exports = {};
32
+ __export(index_exports, {
33
+ createHandler: () => createHandler,
34
+ createTranslator: () => createTranslator
35
+ });
36
+ module.exports = __toCommonJS(index_exports);
37
+
38
+ // src/server/handler.ts
39
+ var import_hono = require("hono");
40
+ var import_cors = require("hono/cors");
41
+
42
+ // src/lib/validation.ts
43
+ var import_zod = require("zod");
44
+ var LOCALE_PATTERN = /^[a-zA-Z]{2,8}(-[a-zA-Z0-9]{1,8})*$/;
45
+ var langCodeSchema = import_zod.z.string().max(35, "Language code too long").regex(LOCALE_PATTERN, "Invalid locale format");
46
+ var translateBodySchema = import_zod.z.object({
47
+ texts: import_zod.z.array(import_zod.z.string().max(5e3)).min(1).max(100),
48
+ to: langCodeSchema,
49
+ domain: import_zod.z.string().max(253),
50
+ from: langCodeSchema.optional(),
51
+ pageTitle: import_zod.z.string().max(200).optional(),
52
+ pageDescription: import_zod.z.string().max(1e3).optional(),
53
+ preprompt: import_zod.z.string().trim().max(30).optional()
54
+ });
55
+
56
+ // src/lib/translator.ts
57
+ var import_sdk = __toESM(require("@anthropic-ai/sdk"), 1);
58
+
59
+ // src/lib/db.ts
60
+ var import_bun_sqlite = require("bun:sqlite");
61
+ function createDB(dbPath) {
62
+ let db = null;
63
+ try {
64
+ db = new import_bun_sqlite.Database(dbPath, { create: true });
65
+ db.exec("PRAGMA journal_mode=WAL");
66
+ db.exec("PRAGMA synchronous=NORMAL");
67
+ db.exec(`
68
+ CREATE TABLE IF NOT EXISTS translations (
69
+ domain TEXT NOT NULL DEFAULT '',
70
+ lang TEXT NOT NULL,
71
+ original TEXT NOT NULL,
72
+ translated TEXT NOT NULL,
73
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
74
+ PRIMARY KEY (domain, lang, original)
75
+ )
76
+ `);
77
+ console.log(`[db] SQLite initialized at ${dbPath}`);
78
+ } catch (err) {
79
+ console.error(`[db] SQLite init failed: ${err.message}`);
80
+ db = null;
81
+ }
82
+ return {
83
+ getTranslations(domain, lang, originals) {
84
+ if (!db || originals.length === 0) return originals.map(() => null);
85
+ try {
86
+ const stmt = db.prepare(
87
+ "SELECT translated FROM translations WHERE domain = ? AND lang = ? AND original = ?"
88
+ );
89
+ return originals.map((text) => {
90
+ const row = stmt.get(domain, lang, text);
91
+ return row?.translated ?? null;
92
+ });
93
+ } catch {
94
+ return originals.map(() => null);
95
+ }
96
+ },
97
+ setTranslations(entries) {
98
+ if (!db || entries.length === 0) return;
99
+ try {
100
+ const stmt = db.prepare(
101
+ "INSERT OR REPLACE INTO translations (domain, lang, original, translated) VALUES (?, ?, ?, ?)"
102
+ );
103
+ const tx = db.transaction(() => {
104
+ for (const e of entries) {
105
+ stmt.run(e.domain, e.lang, e.original, e.translated);
106
+ }
107
+ });
108
+ tx();
109
+ } catch (err) {
110
+ console.error(`[db] write translations failed: ${err.message}`);
111
+ }
112
+ },
113
+ deleteTranslationsByDomainLang(domain, lang) {
114
+ if (!db) return 0;
115
+ try {
116
+ const result = db.prepare("DELETE FROM translations WHERE domain = ? AND lang = ?").run(domain, lang);
117
+ return result.changes;
118
+ } catch {
119
+ return 0;
120
+ }
121
+ },
122
+ deleteTranslationsByDomain(domain) {
123
+ if (!db) return 0;
124
+ try {
125
+ const result = db.prepare("DELETE FROM translations WHERE domain = ?").run(domain);
126
+ return result.changes;
127
+ } catch {
128
+ return 0;
129
+ }
130
+ },
131
+ isReady() {
132
+ return db !== null;
133
+ }
134
+ };
135
+ }
136
+
137
+ // src/lib/translator.ts
138
+ function createTranslator(config) {
139
+ const client = new import_sdk.default({ apiKey: config.apiKey });
140
+ const model = config.model ?? "claude-haiku-4-5-20251001";
141
+ const MAX_CACHE = config.cacheSize ?? 1e4;
142
+ const TTL_MS = config.cacheTTL ?? 24 * 60 * 60 * 1e3;
143
+ const cache = /* @__PURE__ */ new Map();
144
+ const db = createDB(config.dbPath ?? "./tongues.db");
145
+ let cacheHits = 0;
146
+ let cacheMisses = 0;
147
+ let sqliteHits = 0;
148
+ let apiCalls = 0;
149
+ let textsTranslated = 0;
150
+ function getCacheStats() {
151
+ return {
152
+ size: cache.size,
153
+ maxSize: MAX_CACHE,
154
+ hits: cacheHits,
155
+ sqliteHits,
156
+ misses: cacheMisses,
157
+ hitRate: cacheHits + sqliteHits + cacheMisses > 0 ? Math.round((cacheHits + sqliteHits) / (cacheHits + sqliteHits + cacheMisses) * 100) : 0,
158
+ apiCalls,
159
+ textsTranslated,
160
+ sqliteReady: db.isReady()
161
+ };
162
+ }
163
+ function cacheKey(domain, to, text) {
164
+ return `${domain}:${to}:${text}`;
165
+ }
166
+ function storeCache(domain, to, original, translated) {
167
+ const key = cacheKey(domain, to, original);
168
+ cache.delete(key);
169
+ if (cache.size >= MAX_CACHE) {
170
+ const firstKey = cache.keys().next().value;
171
+ if (firstKey) cache.delete(firstKey);
172
+ }
173
+ cache.set(key, { value: translated, expiresAt: Date.now() + TTL_MS });
174
+ }
175
+ function getCache(domain, to, text) {
176
+ const key = cacheKey(domain, to, text);
177
+ const entry = cache.get(key);
178
+ if (!entry) return null;
179
+ if (Date.now() > entry.expiresAt) {
180
+ cache.delete(key);
181
+ return null;
182
+ }
183
+ cache.delete(key);
184
+ cache.set(key, entry);
185
+ return entry.value;
186
+ }
187
+ async function claudeTranslate(texts, to, context) {
188
+ const result = {};
189
+ const prompt = texts.map((t, i) => `[${i}] ${t}`).join("\n");
190
+ const targetLang = langName(to);
191
+ const response = await client.messages.create({
192
+ model,
193
+ max_tokens: 4096,
194
+ messages: [
195
+ {
196
+ role: "user",
197
+ content: `Translate every numbered text below into ${targetLang}. The source texts may be in any language \u2014 detect each one individually and translate it to ${targetLang}. Keep the [N] numbering. Output only the translated lines, no explanations.
198
+
199
+ IMPORTANT \u2014 Placeholder tags like <0>, </0>, <1>, </1>, <2>, </2> are NOT HTML. They are opaque tokens that MUST appear in your output exactly as written. Never rename, rewrite, or spell out the numbers (e.g. do NOT change <0> to <zero>).
200
+ CRITICAL \u2014 If a source text contains NO placeholder tags, your translation MUST also contain NO placeholder tags. Never invent or add tags that do not exist in the input.
201
+ Example with tags:
202
+ Input: [0] <0>Email address:</0> Provided during registration.
203
+ Output: [0] <0>\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9\uFF1A</0> \u767B\u9332\u6642\u306B\u63D0\u4F9B\u3055\u308C\u307E\u3059\u3002
204
+ Example without tags:
205
+ Input: [1] Read-only. Current translation locale (e.g. "en", "ja").
206
+ Output: [1] \uC77D\uAE30 \uC804\uC6A9\uC785\uB2C8\uB2E4. \uD604\uC7AC \uBC88\uC5ED \uC5B8\uC5B4\uC785\uB2C8\uB2E4 (\uC608: "en", "ja").
207
+
208
+ ${prompt}`
209
+ }
210
+ ],
211
+ system: `You are a website translator. Target: ${targetLang} (${to}).${context?.from ? ` Likely source: ${langName(context.from)}.` : ""} Rules: 1) Every text MUST be translated to ${targetLang} \u2014 never return the source text unchanged unless it is already valid ${targetLang}. 2) Texts may come from different source languages in one batch. 3) Only preserve brand names, product names, and technical terms (URLs, code, variable names) in their original form. 4) Preserve numbered placeholder tags (<0>...</0>, <1/>, etc.) exactly \u2014 but NEVER add placeholder tags to text that has none. 5) Keep translations concise and natural for web UI.${context?.pageTitle ? ` Page: "${context.pageTitle}${context?.pageDescription ? ` \u2014 ${context.pageDescription}` : ""}".` : ""}${context?.preprompt ? ` Note: ${context.preprompt}` : ""}`
212
+ });
213
+ const responseText = response.content[0].type === "text" ? response.content[0].text : "";
214
+ const blocks = responseText.split(/(?=^\[\d+\])/m);
215
+ for (const block of blocks) {
216
+ const match = block.match(/^\[(\d+)\]\s*([\s\S]+)$/);
217
+ if (match) {
218
+ const idx = parseInt(match[1]);
219
+ const fixed = fixPlaceholderTags(match[2].trim());
220
+ if (idx < texts.length) {
221
+ result[texts[idx]] = stripPhantomTags(texts[idx], fixed);
222
+ }
223
+ }
224
+ }
225
+ for (const text of texts) {
226
+ if (!result[text]) result[text] = text;
227
+ }
228
+ return result;
229
+ }
230
+ async function translateTexts(texts, to, domain, context) {
231
+ const result = {};
232
+ const l1Missed = [];
233
+ for (const text of texts) {
234
+ const cached = getCache(domain, to, text);
235
+ if (cached) {
236
+ cacheHits++;
237
+ result[text] = cached;
238
+ } else {
239
+ l1Missed.push(text);
240
+ }
241
+ }
242
+ if (l1Missed.length === 0) return result;
243
+ const sqliteResults = db.getTranslations(domain, to, l1Missed);
244
+ const uncached = [];
245
+ for (let i = 0; i < l1Missed.length; i++) {
246
+ const text = l1Missed[i];
247
+ const sqliteVal = sqliteResults[i];
248
+ if (sqliteVal) {
249
+ sqliteHits++;
250
+ result[text] = sqliteVal;
251
+ storeCache(domain, to, text, sqliteVal);
252
+ } else {
253
+ cacheMisses++;
254
+ uncached.push(text);
255
+ }
256
+ }
257
+ if (uncached.length === 0) return result;
258
+ apiCalls++;
259
+ textsTranslated += uncached.length;
260
+ const translations = await claudeTranslate(uncached, to, context);
261
+ const dbEntries = [];
262
+ for (const [original, translated] of Object.entries(translations)) {
263
+ result[original] = translated;
264
+ storeCache(domain, to, original, translated);
265
+ dbEntries.push({ domain, lang: to, original, translated });
266
+ }
267
+ db.setTranslations(dbEntries);
268
+ return result;
269
+ }
270
+ async function purgeTranslations(domain, lang) {
271
+ let l1Purged = 0;
272
+ const prefix = `${domain}:${lang}:`;
273
+ for (const key of [...cache.keys()]) {
274
+ if (key.startsWith(prefix)) {
275
+ cache.delete(key);
276
+ l1Purged++;
277
+ }
278
+ }
279
+ const dbPurged = db.deleteTranslationsByDomainLang(domain, lang);
280
+ return { l1Purged, dbPurged };
281
+ }
282
+ async function purgeDomainTranslations(domain) {
283
+ let l1Purged = 0;
284
+ const prefix = `${domain}:`;
285
+ for (const key of [...cache.keys()]) {
286
+ if (key.startsWith(prefix)) {
287
+ cache.delete(key);
288
+ l1Purged++;
289
+ }
290
+ }
291
+ const dbPurged = db.deleteTranslationsByDomain(domain);
292
+ return { l1Purged, dbPurged };
293
+ }
294
+ return {
295
+ translateTexts,
296
+ purgeTranslations,
297
+ purgeDomainTranslations,
298
+ getCacheStats
299
+ };
300
+ }
301
+ var LANG_NAMES = {
302
+ ko: "Korean",
303
+ en: "English",
304
+ ja: "Japanese",
305
+ zh: "Chinese",
306
+ es: "Spanish",
307
+ fr: "French",
308
+ de: "German",
309
+ pt: "Portuguese",
310
+ it: "Italian",
311
+ ru: "Russian",
312
+ ar: "Arabic",
313
+ hi: "Hindi",
314
+ th: "Thai",
315
+ vi: "Vietnamese",
316
+ id: "Indonesian",
317
+ ms: "Malay",
318
+ tr: "Turkish",
319
+ nl: "Dutch",
320
+ pl: "Polish",
321
+ sv: "Swedish",
322
+ da: "Danish",
323
+ no: "Norwegian",
324
+ fi: "Finnish",
325
+ cs: "Czech",
326
+ uk: "Ukrainian",
327
+ ro: "Romanian",
328
+ hu: "Hungarian",
329
+ el: "Greek",
330
+ he: "Hebrew",
331
+ bn: "Bengali",
332
+ ta: "Tamil",
333
+ te: "Telugu"
334
+ };
335
+ function langName(code) {
336
+ return LANG_NAMES[code] || code;
337
+ }
338
+ var PH_TAG_RE = /<\/?(\d+)\s*\/?>/;
339
+ function stripPhantomTags(original, translated) {
340
+ if (PH_TAG_RE.test(original)) return translated;
341
+ return translated.replace(/<\/?(\d+)\s*\/?>/g, "");
342
+ }
343
+ var WORD_TO_DIGIT = {
344
+ zero: "0",
345
+ one: "1",
346
+ two: "2",
347
+ three: "3",
348
+ four: "4",
349
+ five: "5",
350
+ six: "6",
351
+ seven: "7",
352
+ eight: "8",
353
+ nine: "9",
354
+ ten: "10",
355
+ eleven: "11",
356
+ twelve: "12",
357
+ thirteen: "13",
358
+ fourteen: "14",
359
+ fifteen: "15"
360
+ };
361
+ var TAG_FIX_RE = new RegExp(
362
+ `<(/?)(${Object.keys(WORD_TO_DIGIT).join("|")})(/)?>`,
363
+ "gi"
364
+ );
365
+ function fixPlaceholderTags(text) {
366
+ return text.replace(TAG_FIX_RE, (_, slash1, word, slash2) => {
367
+ const digit = WORD_TO_DIGIT[word.toLowerCase()];
368
+ if (!digit) return _;
369
+ return `<${slash1 || ""}${digit}${slash2 || ""}>`;
370
+ });
371
+ }
372
+
373
+ // src/server/handler.ts
374
+ function createHandler(config) {
375
+ if (!config.apiKey) {
376
+ throw new Error("open-tongues: apiKey is required");
377
+ }
378
+ const app = new import_hono.Hono();
379
+ const maxRate = config.rateLimit ?? 100;
380
+ app.use("/*", (0, import_cors.cors)({ origin: config.corsOrigin ?? "*" }));
381
+ const rateLimits = /* @__PURE__ */ new Map();
382
+ if (maxRate > 0) {
383
+ setInterval(() => {
384
+ const now = Date.now();
385
+ for (const [k, v] of rateLimits) {
386
+ if (now > v.resetAt) rateLimits.delete(k);
387
+ }
388
+ }, 6e4);
389
+ }
390
+ function checkRate(domain) {
391
+ if (maxRate <= 0) return true;
392
+ const now = Date.now();
393
+ const entry = rateLimits.get(domain);
394
+ if (!entry || now > entry.resetAt) {
395
+ rateLimits.set(domain, { count: 1, resetAt: now + 6e4 });
396
+ return true;
397
+ }
398
+ if (entry.count >= maxRate) return false;
399
+ entry.count++;
400
+ return true;
401
+ }
402
+ const translator = createTranslator({
403
+ apiKey: config.apiKey,
404
+ dbPath: config.dbPath ?? "./tongues.db",
405
+ model: config.model ?? "claude-haiku-4-5-20251001",
406
+ cacheSize: config.cacheSize ?? 1e4,
407
+ cacheTTL: config.cacheTTL ?? 24 * 60 * 60 * 1e3
408
+ });
409
+ app.post("/api/translate", async (c) => {
410
+ const raw = await c.req.json();
411
+ const parsed = translateBodySchema.safeParse(raw);
412
+ if (!parsed.success) {
413
+ const msg = parsed.error.issues.map((i) => i.message).join(", ");
414
+ return c.json({ error: msg }, 400);
415
+ }
416
+ const body = parsed.data;
417
+ if (!checkRate(body.domain)) {
418
+ return c.json({ error: "Rate limit exceeded" }, 429);
419
+ }
420
+ try {
421
+ const translations = await translator.translateTexts(body.texts, body.to, body.domain, {
422
+ pageTitle: body.pageTitle,
423
+ pageDescription: body.pageDescription,
424
+ from: body.from,
425
+ preprompt: body.preprompt
426
+ });
427
+ return c.json({ translations });
428
+ } catch (e) {
429
+ console.error("[tongues] translation error:", e);
430
+ return c.json({ error: "Translation failed" }, 500);
431
+ }
432
+ });
433
+ app.post("/api/purge/:domain/:lang", async (c) => {
434
+ const domain = c.req.param("domain");
435
+ const lang = c.req.param("lang");
436
+ const langCheck = langCodeSchema.safeParse(lang);
437
+ if (!langCheck.success) return c.json({ error: "Invalid language code" }, 400);
438
+ const result = await translator.purgeTranslations(domain, langCheck.data);
439
+ return c.json({ ok: true, domain, lang, ...result });
440
+ });
441
+ app.post("/api/purge/:domain", async (c) => {
442
+ const domain = c.req.param("domain");
443
+ const result = await translator.purgeDomainTranslations(domain);
444
+ return c.json({ ok: true, domain, ...result });
445
+ });
446
+ app.get("/health", (c) => c.json({ status: "ok", cache: translator.getCacheStats() }));
447
+ return app;
448
+ }
449
+ // Annotate the CommonJS export names for ESM import in node:
450
+ 0 && (module.exports = {
451
+ createHandler,
452
+ createTranslator
453
+ });
@@ -0,0 +1,68 @@
1
+ import { Hono } from 'hono';
2
+
3
+ /**
4
+ * createHandler — factory function for mounting tongues as middleware.
5
+ *
6
+ * Usage:
7
+ * import { createHandler } from 'open-tongues/server'
8
+ * const app = new Hono()
9
+ * app.route('/tongues', createHandler({ apiKey: process.env.ANTHROPIC_API_KEY! }))
10
+ */
11
+
12
+ interface TonguesConfig {
13
+ /** Anthropic API key (required) */
14
+ apiKey: string;
15
+ /** Path to SQLite database file (default: "./tongues.db") */
16
+ dbPath?: string;
17
+ /** Claude model to use (default: "claude-haiku-4-5-20251001") */
18
+ model?: string;
19
+ /** Max in-memory cache entries (default: 10000) */
20
+ cacheSize?: number;
21
+ /** In-memory cache TTL in ms (default: 86400000 = 24h) */
22
+ cacheTTL?: number;
23
+ /** Rate limit per domain per minute (default: 100, 0 = disabled) */
24
+ rateLimit?: number;
25
+ /** CORS origin (default: "*") */
26
+ corsOrigin?: string;
27
+ }
28
+ declare function createHandler(config: TonguesConfig): Hono;
29
+
30
+ interface TranslatorConfig {
31
+ apiKey: string;
32
+ dbPath?: string;
33
+ model?: string;
34
+ cacheSize?: number;
35
+ cacheTTL?: number;
36
+ }
37
+ interface TranslateContext {
38
+ pageTitle?: string;
39
+ pageDescription?: string;
40
+ from?: string;
41
+ preprompt?: string;
42
+ }
43
+ interface Translator {
44
+ translateTexts(texts: string[], to: string, domain: string, context?: TranslateContext): Promise<Record<string, string>>;
45
+ purgeTranslations(domain: string, lang: string): Promise<{
46
+ l1Purged: number;
47
+ dbPurged: number;
48
+ }>;
49
+ purgeDomainTranslations(domain: string): Promise<{
50
+ l1Purged: number;
51
+ dbPurged: number;
52
+ }>;
53
+ getCacheStats(): CacheStats;
54
+ }
55
+ interface CacheStats {
56
+ size: number;
57
+ maxSize: number;
58
+ hits: number;
59
+ sqliteHits: number;
60
+ misses: number;
61
+ hitRate: number;
62
+ apiCalls: number;
63
+ textsTranslated: number;
64
+ sqliteReady: boolean;
65
+ }
66
+ declare function createTranslator(config: TranslatorConfig): Translator;
67
+
68
+ export { type CacheStats, type TonguesConfig, type TranslateContext, type Translator, type TranslatorConfig, createHandler, createTranslator };
@@ -0,0 +1,68 @@
1
+ import { Hono } from 'hono';
2
+
3
+ /**
4
+ * createHandler — factory function for mounting tongues as middleware.
5
+ *
6
+ * Usage:
7
+ * import { createHandler } from 'open-tongues/server'
8
+ * const app = new Hono()
9
+ * app.route('/tongues', createHandler({ apiKey: process.env.ANTHROPIC_API_KEY! }))
10
+ */
11
+
12
+ interface TonguesConfig {
13
+ /** Anthropic API key (required) */
14
+ apiKey: string;
15
+ /** Path to SQLite database file (default: "./tongues.db") */
16
+ dbPath?: string;
17
+ /** Claude model to use (default: "claude-haiku-4-5-20251001") */
18
+ model?: string;
19
+ /** Max in-memory cache entries (default: 10000) */
20
+ cacheSize?: number;
21
+ /** In-memory cache TTL in ms (default: 86400000 = 24h) */
22
+ cacheTTL?: number;
23
+ /** Rate limit per domain per minute (default: 100, 0 = disabled) */
24
+ rateLimit?: number;
25
+ /** CORS origin (default: "*") */
26
+ corsOrigin?: string;
27
+ }
28
+ declare function createHandler(config: TonguesConfig): Hono;
29
+
30
+ interface TranslatorConfig {
31
+ apiKey: string;
32
+ dbPath?: string;
33
+ model?: string;
34
+ cacheSize?: number;
35
+ cacheTTL?: number;
36
+ }
37
+ interface TranslateContext {
38
+ pageTitle?: string;
39
+ pageDescription?: string;
40
+ from?: string;
41
+ preprompt?: string;
42
+ }
43
+ interface Translator {
44
+ translateTexts(texts: string[], to: string, domain: string, context?: TranslateContext): Promise<Record<string, string>>;
45
+ purgeTranslations(domain: string, lang: string): Promise<{
46
+ l1Purged: number;
47
+ dbPurged: number;
48
+ }>;
49
+ purgeDomainTranslations(domain: string): Promise<{
50
+ l1Purged: number;
51
+ dbPurged: number;
52
+ }>;
53
+ getCacheStats(): CacheStats;
54
+ }
55
+ interface CacheStats {
56
+ size: number;
57
+ maxSize: number;
58
+ hits: number;
59
+ sqliteHits: number;
60
+ misses: number;
61
+ hitRate: number;
62
+ apiCalls: number;
63
+ textsTranslated: number;
64
+ sqliteReady: boolean;
65
+ }
66
+ declare function createTranslator(config: TranslatorConfig): Translator;
67
+
68
+ export { type CacheStats, type TonguesConfig, type TranslateContext, type Translator, type TranslatorConfig, createHandler, createTranslator };
package/dist/index.js ADDED
@@ -0,0 +1,415 @@
1
+ // src/server/handler.ts
2
+ import { Hono } from "hono";
3
+ import { cors } from "hono/cors";
4
+
5
+ // src/lib/validation.ts
6
+ import { z } from "zod";
7
+ var LOCALE_PATTERN = /^[a-zA-Z]{2,8}(-[a-zA-Z0-9]{1,8})*$/;
8
+ var langCodeSchema = z.string().max(35, "Language code too long").regex(LOCALE_PATTERN, "Invalid locale format");
9
+ var translateBodySchema = z.object({
10
+ texts: z.array(z.string().max(5e3)).min(1).max(100),
11
+ to: langCodeSchema,
12
+ domain: z.string().max(253),
13
+ from: langCodeSchema.optional(),
14
+ pageTitle: z.string().max(200).optional(),
15
+ pageDescription: z.string().max(1e3).optional(),
16
+ preprompt: z.string().trim().max(30).optional()
17
+ });
18
+
19
+ // src/lib/translator.ts
20
+ import Anthropic from "@anthropic-ai/sdk";
21
+
22
+ // src/lib/db.ts
23
+ import { Database } from "bun:sqlite";
24
+ function createDB(dbPath) {
25
+ let db = null;
26
+ try {
27
+ db = new Database(dbPath, { create: true });
28
+ db.exec("PRAGMA journal_mode=WAL");
29
+ db.exec("PRAGMA synchronous=NORMAL");
30
+ db.exec(`
31
+ CREATE TABLE IF NOT EXISTS translations (
32
+ domain TEXT NOT NULL DEFAULT '',
33
+ lang TEXT NOT NULL,
34
+ original TEXT NOT NULL,
35
+ translated TEXT NOT NULL,
36
+ created_at INTEGER NOT NULL DEFAULT (unixepoch()),
37
+ PRIMARY KEY (domain, lang, original)
38
+ )
39
+ `);
40
+ console.log(`[db] SQLite initialized at ${dbPath}`);
41
+ } catch (err) {
42
+ console.error(`[db] SQLite init failed: ${err.message}`);
43
+ db = null;
44
+ }
45
+ return {
46
+ getTranslations(domain, lang, originals) {
47
+ if (!db || originals.length === 0) return originals.map(() => null);
48
+ try {
49
+ const stmt = db.prepare(
50
+ "SELECT translated FROM translations WHERE domain = ? AND lang = ? AND original = ?"
51
+ );
52
+ return originals.map((text) => {
53
+ const row = stmt.get(domain, lang, text);
54
+ return row?.translated ?? null;
55
+ });
56
+ } catch {
57
+ return originals.map(() => null);
58
+ }
59
+ },
60
+ setTranslations(entries) {
61
+ if (!db || entries.length === 0) return;
62
+ try {
63
+ const stmt = db.prepare(
64
+ "INSERT OR REPLACE INTO translations (domain, lang, original, translated) VALUES (?, ?, ?, ?)"
65
+ );
66
+ const tx = db.transaction(() => {
67
+ for (const e of entries) {
68
+ stmt.run(e.domain, e.lang, e.original, e.translated);
69
+ }
70
+ });
71
+ tx();
72
+ } catch (err) {
73
+ console.error(`[db] write translations failed: ${err.message}`);
74
+ }
75
+ },
76
+ deleteTranslationsByDomainLang(domain, lang) {
77
+ if (!db) return 0;
78
+ try {
79
+ const result = db.prepare("DELETE FROM translations WHERE domain = ? AND lang = ?").run(domain, lang);
80
+ return result.changes;
81
+ } catch {
82
+ return 0;
83
+ }
84
+ },
85
+ deleteTranslationsByDomain(domain) {
86
+ if (!db) return 0;
87
+ try {
88
+ const result = db.prepare("DELETE FROM translations WHERE domain = ?").run(domain);
89
+ return result.changes;
90
+ } catch {
91
+ return 0;
92
+ }
93
+ },
94
+ isReady() {
95
+ return db !== null;
96
+ }
97
+ };
98
+ }
99
+
100
+ // src/lib/translator.ts
101
+ function createTranslator(config) {
102
+ const client = new Anthropic({ apiKey: config.apiKey });
103
+ const model = config.model ?? "claude-haiku-4-5-20251001";
104
+ const MAX_CACHE = config.cacheSize ?? 1e4;
105
+ const TTL_MS = config.cacheTTL ?? 24 * 60 * 60 * 1e3;
106
+ const cache = /* @__PURE__ */ new Map();
107
+ const db = createDB(config.dbPath ?? "./tongues.db");
108
+ let cacheHits = 0;
109
+ let cacheMisses = 0;
110
+ let sqliteHits = 0;
111
+ let apiCalls = 0;
112
+ let textsTranslated = 0;
113
+ function getCacheStats() {
114
+ return {
115
+ size: cache.size,
116
+ maxSize: MAX_CACHE,
117
+ hits: cacheHits,
118
+ sqliteHits,
119
+ misses: cacheMisses,
120
+ hitRate: cacheHits + sqliteHits + cacheMisses > 0 ? Math.round((cacheHits + sqliteHits) / (cacheHits + sqliteHits + cacheMisses) * 100) : 0,
121
+ apiCalls,
122
+ textsTranslated,
123
+ sqliteReady: db.isReady()
124
+ };
125
+ }
126
+ function cacheKey(domain, to, text) {
127
+ return `${domain}:${to}:${text}`;
128
+ }
129
+ function storeCache(domain, to, original, translated) {
130
+ const key = cacheKey(domain, to, original);
131
+ cache.delete(key);
132
+ if (cache.size >= MAX_CACHE) {
133
+ const firstKey = cache.keys().next().value;
134
+ if (firstKey) cache.delete(firstKey);
135
+ }
136
+ cache.set(key, { value: translated, expiresAt: Date.now() + TTL_MS });
137
+ }
138
+ function getCache(domain, to, text) {
139
+ const key = cacheKey(domain, to, text);
140
+ const entry = cache.get(key);
141
+ if (!entry) return null;
142
+ if (Date.now() > entry.expiresAt) {
143
+ cache.delete(key);
144
+ return null;
145
+ }
146
+ cache.delete(key);
147
+ cache.set(key, entry);
148
+ return entry.value;
149
+ }
150
+ async function claudeTranslate(texts, to, context) {
151
+ const result = {};
152
+ const prompt = texts.map((t, i) => `[${i}] ${t}`).join("\n");
153
+ const targetLang = langName(to);
154
+ const response = await client.messages.create({
155
+ model,
156
+ max_tokens: 4096,
157
+ messages: [
158
+ {
159
+ role: "user",
160
+ content: `Translate every numbered text below into ${targetLang}. The source texts may be in any language \u2014 detect each one individually and translate it to ${targetLang}. Keep the [N] numbering. Output only the translated lines, no explanations.
161
+
162
+ IMPORTANT \u2014 Placeholder tags like <0>, </0>, <1>, </1>, <2>, </2> are NOT HTML. They are opaque tokens that MUST appear in your output exactly as written. Never rename, rewrite, or spell out the numbers (e.g. do NOT change <0> to <zero>).
163
+ CRITICAL \u2014 If a source text contains NO placeholder tags, your translation MUST also contain NO placeholder tags. Never invent or add tags that do not exist in the input.
164
+ Example with tags:
165
+ Input: [0] <0>Email address:</0> Provided during registration.
166
+ Output: [0] <0>\u30E1\u30FC\u30EB\u30A2\u30C9\u30EC\u30B9\uFF1A</0> \u767B\u9332\u6642\u306B\u63D0\u4F9B\u3055\u308C\u307E\u3059\u3002
167
+ Example without tags:
168
+ Input: [1] Read-only. Current translation locale (e.g. "en", "ja").
169
+ Output: [1] \uC77D\uAE30 \uC804\uC6A9\uC785\uB2C8\uB2E4. \uD604\uC7AC \uBC88\uC5ED \uC5B8\uC5B4\uC785\uB2C8\uB2E4 (\uC608: "en", "ja").
170
+
171
+ ${prompt}`
172
+ }
173
+ ],
174
+ system: `You are a website translator. Target: ${targetLang} (${to}).${context?.from ? ` Likely source: ${langName(context.from)}.` : ""} Rules: 1) Every text MUST be translated to ${targetLang} \u2014 never return the source text unchanged unless it is already valid ${targetLang}. 2) Texts may come from different source languages in one batch. 3) Only preserve brand names, product names, and technical terms (URLs, code, variable names) in their original form. 4) Preserve numbered placeholder tags (<0>...</0>, <1/>, etc.) exactly \u2014 but NEVER add placeholder tags to text that has none. 5) Keep translations concise and natural for web UI.${context?.pageTitle ? ` Page: "${context.pageTitle}${context?.pageDescription ? ` \u2014 ${context.pageDescription}` : ""}".` : ""}${context?.preprompt ? ` Note: ${context.preprompt}` : ""}`
175
+ });
176
+ const responseText = response.content[0].type === "text" ? response.content[0].text : "";
177
+ const blocks = responseText.split(/(?=^\[\d+\])/m);
178
+ for (const block of blocks) {
179
+ const match = block.match(/^\[(\d+)\]\s*([\s\S]+)$/);
180
+ if (match) {
181
+ const idx = parseInt(match[1]);
182
+ const fixed = fixPlaceholderTags(match[2].trim());
183
+ if (idx < texts.length) {
184
+ result[texts[idx]] = stripPhantomTags(texts[idx], fixed);
185
+ }
186
+ }
187
+ }
188
+ for (const text of texts) {
189
+ if (!result[text]) result[text] = text;
190
+ }
191
+ return result;
192
+ }
193
+ async function translateTexts(texts, to, domain, context) {
194
+ const result = {};
195
+ const l1Missed = [];
196
+ for (const text of texts) {
197
+ const cached = getCache(domain, to, text);
198
+ if (cached) {
199
+ cacheHits++;
200
+ result[text] = cached;
201
+ } else {
202
+ l1Missed.push(text);
203
+ }
204
+ }
205
+ if (l1Missed.length === 0) return result;
206
+ const sqliteResults = db.getTranslations(domain, to, l1Missed);
207
+ const uncached = [];
208
+ for (let i = 0; i < l1Missed.length; i++) {
209
+ const text = l1Missed[i];
210
+ const sqliteVal = sqliteResults[i];
211
+ if (sqliteVal) {
212
+ sqliteHits++;
213
+ result[text] = sqliteVal;
214
+ storeCache(domain, to, text, sqliteVal);
215
+ } else {
216
+ cacheMisses++;
217
+ uncached.push(text);
218
+ }
219
+ }
220
+ if (uncached.length === 0) return result;
221
+ apiCalls++;
222
+ textsTranslated += uncached.length;
223
+ const translations = await claudeTranslate(uncached, to, context);
224
+ const dbEntries = [];
225
+ for (const [original, translated] of Object.entries(translations)) {
226
+ result[original] = translated;
227
+ storeCache(domain, to, original, translated);
228
+ dbEntries.push({ domain, lang: to, original, translated });
229
+ }
230
+ db.setTranslations(dbEntries);
231
+ return result;
232
+ }
233
+ async function purgeTranslations(domain, lang) {
234
+ let l1Purged = 0;
235
+ const prefix = `${domain}:${lang}:`;
236
+ for (const key of [...cache.keys()]) {
237
+ if (key.startsWith(prefix)) {
238
+ cache.delete(key);
239
+ l1Purged++;
240
+ }
241
+ }
242
+ const dbPurged = db.deleteTranslationsByDomainLang(domain, lang);
243
+ return { l1Purged, dbPurged };
244
+ }
245
+ async function purgeDomainTranslations(domain) {
246
+ let l1Purged = 0;
247
+ const prefix = `${domain}:`;
248
+ for (const key of [...cache.keys()]) {
249
+ if (key.startsWith(prefix)) {
250
+ cache.delete(key);
251
+ l1Purged++;
252
+ }
253
+ }
254
+ const dbPurged = db.deleteTranslationsByDomain(domain);
255
+ return { l1Purged, dbPurged };
256
+ }
257
+ return {
258
+ translateTexts,
259
+ purgeTranslations,
260
+ purgeDomainTranslations,
261
+ getCacheStats
262
+ };
263
+ }
264
+ var LANG_NAMES = {
265
+ ko: "Korean",
266
+ en: "English",
267
+ ja: "Japanese",
268
+ zh: "Chinese",
269
+ es: "Spanish",
270
+ fr: "French",
271
+ de: "German",
272
+ pt: "Portuguese",
273
+ it: "Italian",
274
+ ru: "Russian",
275
+ ar: "Arabic",
276
+ hi: "Hindi",
277
+ th: "Thai",
278
+ vi: "Vietnamese",
279
+ id: "Indonesian",
280
+ ms: "Malay",
281
+ tr: "Turkish",
282
+ nl: "Dutch",
283
+ pl: "Polish",
284
+ sv: "Swedish",
285
+ da: "Danish",
286
+ no: "Norwegian",
287
+ fi: "Finnish",
288
+ cs: "Czech",
289
+ uk: "Ukrainian",
290
+ ro: "Romanian",
291
+ hu: "Hungarian",
292
+ el: "Greek",
293
+ he: "Hebrew",
294
+ bn: "Bengali",
295
+ ta: "Tamil",
296
+ te: "Telugu"
297
+ };
298
+ function langName(code) {
299
+ return LANG_NAMES[code] || code;
300
+ }
301
+ var PH_TAG_RE = /<\/?(\d+)\s*\/?>/;
302
+ function stripPhantomTags(original, translated) {
303
+ if (PH_TAG_RE.test(original)) return translated;
304
+ return translated.replace(/<\/?(\d+)\s*\/?>/g, "");
305
+ }
306
+ var WORD_TO_DIGIT = {
307
+ zero: "0",
308
+ one: "1",
309
+ two: "2",
310
+ three: "3",
311
+ four: "4",
312
+ five: "5",
313
+ six: "6",
314
+ seven: "7",
315
+ eight: "8",
316
+ nine: "9",
317
+ ten: "10",
318
+ eleven: "11",
319
+ twelve: "12",
320
+ thirteen: "13",
321
+ fourteen: "14",
322
+ fifteen: "15"
323
+ };
324
+ var TAG_FIX_RE = new RegExp(
325
+ `<(/?)(${Object.keys(WORD_TO_DIGIT).join("|")})(/)?>`,
326
+ "gi"
327
+ );
328
+ function fixPlaceholderTags(text) {
329
+ return text.replace(TAG_FIX_RE, (_, slash1, word, slash2) => {
330
+ const digit = WORD_TO_DIGIT[word.toLowerCase()];
331
+ if (!digit) return _;
332
+ return `<${slash1 || ""}${digit}${slash2 || ""}>`;
333
+ });
334
+ }
335
+
336
+ // src/server/handler.ts
337
+ function createHandler(config) {
338
+ if (!config.apiKey) {
339
+ throw new Error("open-tongues: apiKey is required");
340
+ }
341
+ const app = new Hono();
342
+ const maxRate = config.rateLimit ?? 100;
343
+ app.use("/*", cors({ origin: config.corsOrigin ?? "*" }));
344
+ const rateLimits = /* @__PURE__ */ new Map();
345
+ if (maxRate > 0) {
346
+ setInterval(() => {
347
+ const now = Date.now();
348
+ for (const [k, v] of rateLimits) {
349
+ if (now > v.resetAt) rateLimits.delete(k);
350
+ }
351
+ }, 6e4);
352
+ }
353
+ function checkRate(domain) {
354
+ if (maxRate <= 0) return true;
355
+ const now = Date.now();
356
+ const entry = rateLimits.get(domain);
357
+ if (!entry || now > entry.resetAt) {
358
+ rateLimits.set(domain, { count: 1, resetAt: now + 6e4 });
359
+ return true;
360
+ }
361
+ if (entry.count >= maxRate) return false;
362
+ entry.count++;
363
+ return true;
364
+ }
365
+ const translator = createTranslator({
366
+ apiKey: config.apiKey,
367
+ dbPath: config.dbPath ?? "./tongues.db",
368
+ model: config.model ?? "claude-haiku-4-5-20251001",
369
+ cacheSize: config.cacheSize ?? 1e4,
370
+ cacheTTL: config.cacheTTL ?? 24 * 60 * 60 * 1e3
371
+ });
372
+ app.post("/api/translate", async (c) => {
373
+ const raw = await c.req.json();
374
+ const parsed = translateBodySchema.safeParse(raw);
375
+ if (!parsed.success) {
376
+ const msg = parsed.error.issues.map((i) => i.message).join(", ");
377
+ return c.json({ error: msg }, 400);
378
+ }
379
+ const body = parsed.data;
380
+ if (!checkRate(body.domain)) {
381
+ return c.json({ error: "Rate limit exceeded" }, 429);
382
+ }
383
+ try {
384
+ const translations = await translator.translateTexts(body.texts, body.to, body.domain, {
385
+ pageTitle: body.pageTitle,
386
+ pageDescription: body.pageDescription,
387
+ from: body.from,
388
+ preprompt: body.preprompt
389
+ });
390
+ return c.json({ translations });
391
+ } catch (e) {
392
+ console.error("[tongues] translation error:", e);
393
+ return c.json({ error: "Translation failed" }, 500);
394
+ }
395
+ });
396
+ app.post("/api/purge/:domain/:lang", async (c) => {
397
+ const domain = c.req.param("domain");
398
+ const lang = c.req.param("lang");
399
+ const langCheck = langCodeSchema.safeParse(lang);
400
+ if (!langCheck.success) return c.json({ error: "Invalid language code" }, 400);
401
+ const result = await translator.purgeTranslations(domain, langCheck.data);
402
+ return c.json({ ok: true, domain, lang, ...result });
403
+ });
404
+ app.post("/api/purge/:domain", async (c) => {
405
+ const domain = c.req.param("domain");
406
+ const result = await translator.purgeDomainTranslations(domain);
407
+ return c.json({ ok: true, domain, ...result });
408
+ });
409
+ app.get("/health", (c) => c.json({ status: "ok", cache: translator.getCacheStats() }));
410
+ return app;
411
+ }
412
+ export {
413
+ createHandler,
414
+ createTranslator
415
+ };
package/dist/t.js ADDED
@@ -0,0 +1 @@
1
+ if(!window.__tongues){let u=function(){let q=document.currentScript||document.querySelector("script[src*='t.js']");if(!q)return!1;f=(q.src||"").replace(/\/t\.js.*$/,""),N=location.hostname,v=_=(navigator.language||"en").split("-")[0],R=q.hasAttribute("data-manual"),S=(q.getAttribute("data-preprompt")||"").trim().slice(0,30);let z=document.createElement("style");return z.textContent=".t-ing{animation:t-p 1.5s ease-in-out infinite}@keyframes t-p{0%,100%{opacity:1}50%{opacity:.4}}",document.head.appendChild(z),!0},d=function(q){return q.replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;")},g=function(q){let z=new Map,G=0;function J(W){if(W.nodeType===3)return d(W.nodeValue||"");if(W.nodeType!==1)return"";let F=W,M=F.tagName;if(b.has(M))return z.set(G,[F.outerHTML,""]),`<${G++}/>`;if(k.has(M)){let B=G++,U=F.outerHTML.match(/^<[^>]+>/);z.set(B,[U?U[0]:`<${M.toLowerCase()}>`,`</${M.toLowerCase()}>`]);let Q="";for(let Z of F.childNodes)Q+=J(Z);return`<${B}>${Q}</${B}>`}let H="";for(let B of F.childNodes)H+=J(B);return H}let Y="";for(let W of q.childNodes)Y+=J(W);return{t:Y.trim(),m:z,h:z.size>0}},h=function(q,z){let G=q.replace(/<(\d+)\/>/g,(Y,W)=>z.get(+W)?.[0]||""),J=!0;while(J)J=!1,G=G.replace(/<(\d+)>(.*?)<\/\1>/gs,(Y,W,F)=>{J=!0;let M=z.get(+W);return M?M[0]+F+M[1]:F});return G=G.replace(/<\/?(\d+)\s*\/?>/g,""),G},p=function(q,z){let G=new Map,J=new Map;A=new WeakMap;let Y=new WeakSet,W=document.createTreeWalker(z||document.body,NodeFilter.SHOW_ELEMENT,{acceptNode(H){let B=H,U=B.closest(L);if(y.has(B.tagName)||U&&U!==z)return 2;if(B.isContentEditable)return 2;if(B.parentElement&&Y.has(B.parentElement))return 3;if(q&&B.hasAttribute("data-t")){if(!B.hasAttribute("data-th"))return 2;if(B.innerHTML===B.getAttribute("data-th")||B.innerHTML===B.getAttribute("data-tt"))return 2}let Q=B.textContent?.trim();if(!Q||Q.length<2)return 3;if(B.children.length>0){for(let Z of B.children){if(!k.has(Z.tagName)&&!b.has(Z.tagName))return 3;for(let X of r)if(Z.hasAttribute(X))return 3}return Y.add(B),1}return 1}}),F;while(F=W.nextNode()){let H=F,B;if(Y.has(H)){let U=g(H);if(B=U.t,U.h)A.set(H,U.m)}else B=H.textContent.trim();if(B&&B.length>=2){let U=G.get(B)||[];U.push(H),G.set(B,U)}}let M=z||document.body;for(let H of M.querySelectorAll("[placeholder],[title],[alt],[aria-label]")){let B=H.closest(L);if(B&&B!==z||H.isContentEditable||y.has(H.tagName))continue;for(let U of I){let Q=H.getAttribute(U)?.trim();if(!Q||Q.length<2||q&&H.hasAttribute(`data-ta-${U}`))continue;let Z=J.get(Q)||[];Z.push({e:H,a:U}),J.set(Q,Z)}}return{txt:G,atr:J}},m=function(q){q.classList.remove("t-ing");let z=q.style;z.transition="none",z.opacity="0.3",q.offsetHeight,z.transition="opacity .4s ease-in",z.opacity="1",q.addEventListener("transitionend",()=>{z.opacity="",z.transition=""},{once:!0})},w=function(q,z,G){x();try{for(let[J,Y]of G){if(J===Y){for(let F of q.get(J)||[]){if(!F.hasAttribute("data-t"))F.setAttribute("data-t",J);F.classList.remove("t-ing")}for(let{e:F,a:M}of z.get(J)||[])if(!F.hasAttribute(`data-ta-${M}`))F.setAttribute(`data-ta-${M}`,J);continue}let W=q.get(J);if(W)for(let F of W){let M=A.get(F);if(!F.hasAttribute("data-t")){if(F.setAttribute("data-t",J),M?.size)F.setAttribute("data-th",F.innerHTML)}if(M?.size){let H=h(Y,M);F.innerHTML=H,F.setAttribute("data-tt",H)}else{let H=document.createElement("font");H.setAttribute("data-tf","1"),H.textContent=Y,F.replaceChildren(H)}m(F)}for(let{e:F,a:M}of z.get(J)||[]){if(!F.hasAttribute(`data-ta-${M}`))F.setAttribute(`data-ta-${M}`,J);F.setAttribute(M,Y)}}}finally{D()}},P=function(){x();try{K(".t-ing").forEach((q)=>{q.classList.remove("t-ing");let z=q.style;z.opacity="",z.transition=""}),K("[data-th]").forEach((q)=>{q.innerHTML=q.getAttribute("data-th"),q.removeAttribute("data-th"),q.removeAttribute("data-tt"),q.removeAttribute("data-t")}),K("[data-t]").forEach((q)=>{q.textContent=q.getAttribute("data-t"),q.removeAttribute("data-t")});for(let q of I){let z=`data-ta-${q}`;K(`[${z}]`).forEach((G)=>{G.setAttribute(q,G.getAttribute(z)),G.removeAttribute(z)})}}finally{D()}},T=function(){return`t:${N}:${_}`},E=function(){try{let q=localStorage.getItem(T());return q?new Map(JSON.parse(q)):new Map}catch{return new Map}},i=function(q){try{let z=E();for(let[G,J]of q)z.set(G,J);localStorage.setItem(T(),JSON.stringify([...z]))}catch{}},x=function(){$?.disconnect()},D=function(){$?.observe(document.body,{childList:!0,subtree:!0,characterData:!0,attributes:!0,attributeFilter:I})},n=function(){$=new MutationObserver((q)=>{if(R)return;for(let z of q){let G=z.target instanceof Element?z.target:z.target.parentElement;if(!G||G.isContentEditable)continue;if(G.closest(L))continue;if(!G.hasAttribute("data-t")){if(O)clearTimeout(O);O=setTimeout(()=>{if(!C)j(V)},300);return}}}),D()};zq=u,Jq=d,Gq=g,t=h,Mq=p,l=m,Fq=w,o=P,Hq=T,Qq=E,qq=i,e=x,Uq=D,Bq=n,window.__tongues=!0;let y=new Set("SCRIPT,STYLE,NOSCRIPT,SVG,TEMPLATE,CODE,PRE,KBD,SAMP,VAR,CANVAS,VIDEO,AUDIO,IFRAME,MATH".split(",")),k=new Set("STRONG,EM,B,I,U,S,CODE,A,SPAN,MARK,SUB,SUP,SMALL,ABBR,CITE,DFN,TIME,Q".split(",")),b=new Set("BR,IMG,WBR".split(",")),I=["placeholder","title","alt","aria-label"],r="x-text,x-html,v-text,v-html,:textContent,:innerHTML".split(","),L='.notranslate,[translate="no"]',K=(q)=>document.querySelectorAll(q),s=/^[a-zA-Z]{2,8}(-[a-zA-Z0-9]{1,8})*$/,f="",N="",_="",v="",C=!1,V=!1,R=!1,S="",A=new WeakMap,$=null,O=null;async function j(q=!1,z){if(C)return;if(C=!0,!q&&!z)P(),V=!1;let{txt:G,atr:J}=p(q,z),Y=[...new Set([...G.keys(),...J.keys()])];if(!Y.length){C=!1;return}for(let H of G.values())for(let B of H)B.classList.add("t-ing");let W=E(),F=new Map,M=[];for(let H of Y){let B=W.get(H);if(B!==void 0)F.set(H,B);else M.push(H)}if(F.size)w(G,J,F);if(M.length){let H=document.querySelector('meta[name="description"]')?.getAttribute("content")||"",B=async(Q)=>{for(let Z=0;Z<3;Z++)try{let X=await fetch(`${f}/api/translate`,{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({texts:Q,to:_,domain:N,pageTitle:document.title,pageDescription:H,...S&&{preprompt:S}})});if(!X.ok)throw 0;let a=new Map(Object.entries((await X.json()).translations));w(G,J,a),i(a);return}catch{if(Z<2)await new Promise((X)=>setTimeout(X,300*(Z+1)))}},U=[];for(let Q=0;Q<M.length;Q+=17)U.push(M.slice(Q,Q+17));for(let Q=0;Q<U.length;Q+=10)await Promise.all(U.slice(Q,Q+10).map(B))}if(!z)V=!0;C=!1,D()}async function c(){if(!u())return;if(n(),window.t={version:"1.2.0",get locale(){return _},async setLocale(q){if(q===_||!q||q.length>35||!s.test(q))return;_=q,await j()},restore(){if(O)clearTimeout(O),O=null;P(),V=!1,_=v},async translateEl(q){let z=typeof q==="string"?[...document.querySelectorAll(q)]:Array.isArray(q)?q:[q];for(let G of z)if(G instanceof Element)await j(!0,G)}},!R)await j()}if(document.readyState==="loading")document.addEventListener("DOMContentLoaded",c);else c()}var zq,Jq,Gq,t,Mq,l,Fq,o,Hq,Qq,qq,e,Uq,Bq;
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "open-tongues",
3
+ "version": "0.1.0",
4
+ "description": "Zero-config website translation server. One script tag, any language.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/80x24/open-tongues"
10
+ },
11
+ "keywords": ["translation", "i18n", "l10n", "website", "claude", "ai", "hono"],
12
+ "exports": {
13
+ ".": {
14
+ "types": "./dist/index.d.ts",
15
+ "import": "./dist/index.js",
16
+ "require": "./dist/index.cjs"
17
+ },
18
+ "./server": {
19
+ "types": "./dist/index.d.ts",
20
+ "import": "./dist/index.js",
21
+ "require": "./dist/index.cjs"
22
+ },
23
+ "./client": "./dist/t.js"
24
+ },
25
+ "main": "./dist/index.cjs",
26
+ "module": "./dist/index.js",
27
+ "types": "./dist/index.d.ts",
28
+ "files": [
29
+ "dist",
30
+ "LICENSE",
31
+ "README.md"
32
+ ],
33
+ "scripts": {
34
+ "dev": "bun run --watch src/server/index.ts",
35
+ "start": "bun run src/server/index.ts",
36
+ "build": "bun run build:server && bun run build:client",
37
+ "build:server": "tsup",
38
+ "build:client": "bun build src/client/t.ts --outdir dist --minify",
39
+ "test": "bun test test/*.test.ts",
40
+ "prepublishOnly": "bun run build && bun run test"
41
+ },
42
+ "dependencies": {
43
+ "@anthropic-ai/sdk": "^0.39.0",
44
+ "hono": "^4.7.0",
45
+ "zod": "^4.3.6"
46
+ },
47
+ "devDependencies": {
48
+ "@types/bun": "latest",
49
+ "linkedom": "^0.18.12",
50
+ "tsup": "^8.4.0",
51
+ "typescript": "^5.7.0"
52
+ },
53
+ "peerDependencies": {
54
+ "hono": ">=4.0.0"
55
+ },
56
+ "peerDependenciesMeta": {
57
+ "hono": {
58
+ "optional": false
59
+ }
60
+ }
61
+ }