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 +21 -0
- package/README.md +191 -0
- package/dist/index.cjs +453 -0
- package/dist/index.d.cts +68 -0
- package/dist/index.d.ts +68 -0
- package/dist/index.js +415 -0
- package/dist/t.js +1 -0
- package/package.json +61 -0
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
|
+
});
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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,"&").replace(/</g,"<").replace(/>/g,">")},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
|
+
}
|