@guttools/translation-tool 0.0.0-stage → 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/dist/index.cjs +756 -0
- package/dist/index.d.cts +93 -0
- package/dist/index.d.ts +93 -0
- package/dist/index.js +735 -0
- package/package.json +38 -4
- package/README.md +0 -3
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,756 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
"use strict";
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
7
|
+
var __export = (target, all) => {
|
|
8
|
+
for (var name in all)
|
|
9
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
10
|
+
};
|
|
11
|
+
var __copyProps = (to, from, except, desc) => {
|
|
12
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
13
|
+
for (let key of __getOwnPropNames(from))
|
|
14
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
15
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
16
|
+
}
|
|
17
|
+
return to;
|
|
18
|
+
};
|
|
19
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
20
|
+
|
|
21
|
+
// src/index.ts
|
|
22
|
+
var index_exports = {};
|
|
23
|
+
__export(index_exports, {
|
|
24
|
+
LANGUAGES: () => LANGUAGES,
|
|
25
|
+
LanguageSelect: () => LanguageSelect,
|
|
26
|
+
TranslateProvider: () => TranslateProvider,
|
|
27
|
+
TranslationEngine: () => TranslationEngine,
|
|
28
|
+
useTranslate: () => useTranslate
|
|
29
|
+
});
|
|
30
|
+
module.exports = __toCommonJS(index_exports);
|
|
31
|
+
|
|
32
|
+
// src/provider.tsx
|
|
33
|
+
var import_react = require("react");
|
|
34
|
+
|
|
35
|
+
// src/config.ts
|
|
36
|
+
var DEFAULT_API_URL = "https://YOUR-DEPLOYMENT.vercel.app";
|
|
37
|
+
|
|
38
|
+
// src/cache.ts
|
|
39
|
+
var DB_NAME = "translationtool";
|
|
40
|
+
var STORE = "dicts";
|
|
41
|
+
var dbPromise = null;
|
|
42
|
+
function openDb() {
|
|
43
|
+
if (dbPromise) return dbPromise;
|
|
44
|
+
dbPromise = new Promise((resolve) => {
|
|
45
|
+
try {
|
|
46
|
+
if (typeof indexedDB === "undefined") return resolve(null);
|
|
47
|
+
const req = indexedDB.open(DB_NAME, 1);
|
|
48
|
+
req.onupgradeneeded = () => req.result.createObjectStore(STORE);
|
|
49
|
+
req.onsuccess = () => resolve(req.result);
|
|
50
|
+
req.onerror = () => resolve(null);
|
|
51
|
+
req.onblocked = () => resolve(null);
|
|
52
|
+
} catch {
|
|
53
|
+
resolve(null);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
return dbPromise;
|
|
57
|
+
}
|
|
58
|
+
async function loadDict(key) {
|
|
59
|
+
const db = await openDb();
|
|
60
|
+
if (!db) return {};
|
|
61
|
+
return new Promise((resolve) => {
|
|
62
|
+
try {
|
|
63
|
+
const req = db.transaction(STORE, "readonly").objectStore(STORE).get(key);
|
|
64
|
+
req.onsuccess = () => resolve(req.result ?? {});
|
|
65
|
+
req.onerror = () => resolve({});
|
|
66
|
+
} catch {
|
|
67
|
+
resolve({});
|
|
68
|
+
}
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
async function saveDict(key, dict) {
|
|
72
|
+
const db = await openDb();
|
|
73
|
+
if (!db) return;
|
|
74
|
+
try {
|
|
75
|
+
db.transaction(STORE, "readwrite").objectStore(STORE).put(dict, key);
|
|
76
|
+
} catch {
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
var LANG_KEY = "translationtool:lang";
|
|
80
|
+
function readStoredLang() {
|
|
81
|
+
try {
|
|
82
|
+
return localStorage.getItem(LANG_KEY);
|
|
83
|
+
} catch {
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function writeStoredLang(lang) {
|
|
88
|
+
try {
|
|
89
|
+
localStorage.setItem(LANG_KEY, lang);
|
|
90
|
+
} catch {
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// src/hash.ts
|
|
95
|
+
function normalizeText(text) {
|
|
96
|
+
return text.normalize("NFC").replace(/\s+/g, " ").trim();
|
|
97
|
+
}
|
|
98
|
+
function hashText(text) {
|
|
99
|
+
let h1 = 3735928559;
|
|
100
|
+
let h2 = 1103547991;
|
|
101
|
+
for (let i = 0; i < text.length; i++) {
|
|
102
|
+
const ch = text.charCodeAt(i);
|
|
103
|
+
h1 = Math.imul(h1 ^ ch, 2654435761);
|
|
104
|
+
h2 = Math.imul(h2 ^ ch, 1597334677);
|
|
105
|
+
}
|
|
106
|
+
h1 = Math.imul(h1 ^ h1 >>> 16, 2246822507) ^ Math.imul(h2 ^ h2 >>> 13, 3266489909);
|
|
107
|
+
h2 = Math.imul(h2 ^ h2 >>> 16, 2246822507) ^ Math.imul(h1 ^ h1 >>> 13, 3266489909);
|
|
108
|
+
return (4294967296 * (2097151 & h2) + (h1 >>> 0)).toString(36);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// src/template.ts
|
|
112
|
+
var NUMBER = /\d+(?:[.,:]\d+)*/g;
|
|
113
|
+
var PLACEHOLDER = /\{#(\d+)\}/g;
|
|
114
|
+
function toTemplate(text) {
|
|
115
|
+
const values = [];
|
|
116
|
+
const template = text.replace(NUMBER, (n) => `{#${values.push(n) - 1}}`);
|
|
117
|
+
return { template, values };
|
|
118
|
+
}
|
|
119
|
+
function fromTemplate(translated, values) {
|
|
120
|
+
const seen = /* @__PURE__ */ new Set();
|
|
121
|
+
let broken = false;
|
|
122
|
+
const out = translated.replace(PLACEHOLDER, (_, i) => {
|
|
123
|
+
const idx = Number(i);
|
|
124
|
+
if (idx >= values.length) {
|
|
125
|
+
broken = true;
|
|
126
|
+
return "";
|
|
127
|
+
}
|
|
128
|
+
seen.add(idx);
|
|
129
|
+
return values[idx];
|
|
130
|
+
});
|
|
131
|
+
return broken || seen.size !== values.length ? null : out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// src/engine.ts
|
|
135
|
+
var SKIP_TAGS = /* @__PURE__ */ new Set([
|
|
136
|
+
"SCRIPT",
|
|
137
|
+
"STYLE",
|
|
138
|
+
"NOSCRIPT",
|
|
139
|
+
"TEMPLATE",
|
|
140
|
+
"CODE",
|
|
141
|
+
"PRE",
|
|
142
|
+
"KBD",
|
|
143
|
+
"SAMP",
|
|
144
|
+
"VAR",
|
|
145
|
+
"TEXTAREA",
|
|
146
|
+
"IFRAME",
|
|
147
|
+
"CANVAS",
|
|
148
|
+
"SVG",
|
|
149
|
+
"MATH",
|
|
150
|
+
"META",
|
|
151
|
+
"LINK"
|
|
152
|
+
]);
|
|
153
|
+
var TRANSLATABLE_ATTRS = ["placeholder", "title", "alt", "aria-label"];
|
|
154
|
+
var BUTTON_INPUT_TYPES = /* @__PURE__ */ new Set(["button", "submit", "reset"]);
|
|
155
|
+
var HAS_LETTER = /\p{L}/u;
|
|
156
|
+
var MAX_STRING_LENGTH = 1e4;
|
|
157
|
+
var BATCH_SIZE = 50;
|
|
158
|
+
var PARALLEL_BATCHES = 4;
|
|
159
|
+
var FLUSH_DELAY_MS = 120;
|
|
160
|
+
function currentPath() {
|
|
161
|
+
const p = location.pathname.replace(/\/+$/, "");
|
|
162
|
+
return p === "" ? "/" : p;
|
|
163
|
+
}
|
|
164
|
+
function isSkippedElement(el) {
|
|
165
|
+
return SKIP_TAGS.has(el.tagName.toUpperCase()) || el.hasAttribute("data-no-translate") || el.getAttribute("translate") === "no" || el.isContentEditable === true;
|
|
166
|
+
}
|
|
167
|
+
function isInsideSkipped(node) {
|
|
168
|
+
for (let el = node.parentElement; el; el = el.parentElement) {
|
|
169
|
+
if (isSkippedElement(el)) return true;
|
|
170
|
+
}
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
function attrsFor(el) {
|
|
174
|
+
if (el.tagName === "INPUT" && BUTTON_INPUT_TYPES.has(el.type)) {
|
|
175
|
+
return [...TRANSLATABLE_ATTRS, "value"];
|
|
176
|
+
}
|
|
177
|
+
return TRANSLATABLE_ATTRS;
|
|
178
|
+
}
|
|
179
|
+
function withOriginalWhitespace(original, translated) {
|
|
180
|
+
const lead = /^\s*/.exec(original)[0];
|
|
181
|
+
const trail = /\s*$/.exec(original)[0];
|
|
182
|
+
return lead + translated + trail;
|
|
183
|
+
}
|
|
184
|
+
var TranslationEngine = class {
|
|
185
|
+
constructor(opts) {
|
|
186
|
+
this.dicts = /* @__PURE__ */ new Map();
|
|
187
|
+
this.dictLoads = /* @__PURE__ */ new Map();
|
|
188
|
+
this.bundlesFetched = /* @__PURE__ */ new Set();
|
|
189
|
+
this.bundleWait = Promise.resolve();
|
|
190
|
+
this.textSlots = /* @__PURE__ */ new Map();
|
|
191
|
+
this.attrSlots = /* @__PURE__ */ new Map();
|
|
192
|
+
this.pending = /* @__PURE__ */ new Map();
|
|
193
|
+
// hash -> normalized source
|
|
194
|
+
this.inflight = /* @__PURE__ */ new Set();
|
|
195
|
+
this.failed = /* @__PURE__ */ new Set();
|
|
196
|
+
this.flushTimer = null;
|
|
197
|
+
this.saveTimer = null;
|
|
198
|
+
this.observer = null;
|
|
199
|
+
this.path = "";
|
|
200
|
+
this.generation = 0;
|
|
201
|
+
this.opts = opts;
|
|
202
|
+
this.lang = opts.sourceLang;
|
|
203
|
+
}
|
|
204
|
+
get language() {
|
|
205
|
+
return this.lang;
|
|
206
|
+
}
|
|
207
|
+
/** Switch the page to `lang`. Resolves once cached strings are applied. */
|
|
208
|
+
async setLanguage(lang) {
|
|
209
|
+
if (lang === this.lang && (lang === this.opts.sourceLang || this.observer)) return;
|
|
210
|
+
const gen = ++this.generation;
|
|
211
|
+
this.restoreAll();
|
|
212
|
+
this.lang = lang;
|
|
213
|
+
this.pending.clear();
|
|
214
|
+
this.failed.clear();
|
|
215
|
+
const root = document.documentElement;
|
|
216
|
+
if (lang === this.opts.sourceLang) {
|
|
217
|
+
this.disconnect();
|
|
218
|
+
root.removeAttribute("dir");
|
|
219
|
+
root.setAttribute("lang", this.opts.sourceLang);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
root.setAttribute("lang", lang);
|
|
223
|
+
root.setAttribute("dir", isRtl(lang) ? "rtl" : "ltr");
|
|
224
|
+
await this.ensureDict(lang);
|
|
225
|
+
if (gen !== this.generation) return;
|
|
226
|
+
this.path = currentPath();
|
|
227
|
+
this.bundleWait = this.fetchBundle(lang, this.path);
|
|
228
|
+
this.scan(root);
|
|
229
|
+
this.connect();
|
|
230
|
+
if (this.pending.size > 0) {
|
|
231
|
+
await this.bundleWait;
|
|
232
|
+
if (gen !== this.generation) return;
|
|
233
|
+
this.reapply();
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
/** Called on client-side navigation so the next page's bundle is fetched. */
|
|
237
|
+
onNavigate() {
|
|
238
|
+
if (this.lang === this.opts.sourceLang) return;
|
|
239
|
+
const p = currentPath();
|
|
240
|
+
if (p === this.path) return;
|
|
241
|
+
this.path = p;
|
|
242
|
+
this.bundleWait = this.fetchBundle(this.lang, p).then(() => this.reapply());
|
|
243
|
+
}
|
|
244
|
+
stop() {
|
|
245
|
+
this.generation++;
|
|
246
|
+
this.disconnect();
|
|
247
|
+
this.restoreAll();
|
|
248
|
+
if (this.flushTimer) clearTimeout(this.flushTimer);
|
|
249
|
+
this.flushTimer = null;
|
|
250
|
+
if (this.lang !== this.opts.sourceLang) {
|
|
251
|
+
document.documentElement.removeAttribute("dir");
|
|
252
|
+
document.documentElement.setAttribute("lang", this.opts.sourceLang);
|
|
253
|
+
}
|
|
254
|
+
this.lang = this.opts.sourceLang;
|
|
255
|
+
}
|
|
256
|
+
// ---- DOM ----------------------------------------------------------------
|
|
257
|
+
connect() {
|
|
258
|
+
if (this.observer) return;
|
|
259
|
+
this.observer = new MutationObserver((records) => this.onMutations(records));
|
|
260
|
+
this.observer.observe(document.documentElement, {
|
|
261
|
+
subtree: true,
|
|
262
|
+
childList: true,
|
|
263
|
+
characterData: true,
|
|
264
|
+
attributes: true,
|
|
265
|
+
attributeFilter: [...TRANSLATABLE_ATTRS, "value"]
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
disconnect() {
|
|
269
|
+
this.observer?.disconnect();
|
|
270
|
+
this.observer = null;
|
|
271
|
+
}
|
|
272
|
+
onMutations(records) {
|
|
273
|
+
for (const r of records) {
|
|
274
|
+
if (r.type === "childList") {
|
|
275
|
+
r.addedNodes.forEach((n) => {
|
|
276
|
+
if (n.isConnected && !isInsideSkipped(n)) this.scan(n);
|
|
277
|
+
});
|
|
278
|
+
} else if (r.type === "characterData") {
|
|
279
|
+
const t = r.target;
|
|
280
|
+
if (t.isConnected && !isInsideSkipped(t)) this.visitText(t);
|
|
281
|
+
} else if (r.type === "attributes" && r.attributeName) {
|
|
282
|
+
const el = r.target;
|
|
283
|
+
if (!isSkippedElement(el) && !isInsideSkipped(el) && attrsFor(el).includes(r.attributeName)) {
|
|
284
|
+
this.visitAttr(el, r.attributeName);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
if (records.length > 0 && Math.random() < 0.05) this.prune();
|
|
289
|
+
}
|
|
290
|
+
scan(root) {
|
|
291
|
+
if (root.nodeType === Node.TEXT_NODE) {
|
|
292
|
+
this.visitText(root);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
if (root.nodeType !== Node.ELEMENT_NODE) return;
|
|
296
|
+
const rootEl = root;
|
|
297
|
+
if (isSkippedElement(rootEl)) return;
|
|
298
|
+
this.visitAttrs(rootEl);
|
|
299
|
+
const walker = document.createTreeWalker(rootEl, NodeFilter.SHOW_ELEMENT | NodeFilter.SHOW_TEXT, {
|
|
300
|
+
acceptNode: (n) => n.nodeType === Node.ELEMENT_NODE && isSkippedElement(n) ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT
|
|
301
|
+
});
|
|
302
|
+
for (let n = walker.nextNode(); n; n = walker.nextNode()) {
|
|
303
|
+
if (n.nodeType === Node.TEXT_NODE) this.visitText(n);
|
|
304
|
+
else this.visitAttrs(n);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
visitText(node) {
|
|
308
|
+
const value = node.nodeValue ?? "";
|
|
309
|
+
let slot = this.textSlots.get(node);
|
|
310
|
+
if (slot && value === slot.applied) return;
|
|
311
|
+
if (!slot) {
|
|
312
|
+
slot = { original: value, applied: null };
|
|
313
|
+
this.textSlots.set(node, slot);
|
|
314
|
+
} else {
|
|
315
|
+
slot.original = value;
|
|
316
|
+
slot.applied = null;
|
|
317
|
+
}
|
|
318
|
+
this.applySlot(slot, (v) => {
|
|
319
|
+
node.nodeValue = v;
|
|
320
|
+
});
|
|
321
|
+
}
|
|
322
|
+
visitAttrs(el) {
|
|
323
|
+
for (const name of attrsFor(el)) {
|
|
324
|
+
if (el.hasAttribute(name)) this.visitAttr(el, name);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
visitAttr(el, name) {
|
|
328
|
+
const value = el.getAttribute(name);
|
|
329
|
+
if (value == null) return;
|
|
330
|
+
let slots = this.attrSlots.get(el);
|
|
331
|
+
if (!slots) {
|
|
332
|
+
slots = /* @__PURE__ */ new Map();
|
|
333
|
+
this.attrSlots.set(el, slots);
|
|
334
|
+
}
|
|
335
|
+
let slot = slots.get(name);
|
|
336
|
+
if (slot && value === slot.applied) return;
|
|
337
|
+
if (!slot) {
|
|
338
|
+
slot = { original: value, applied: null };
|
|
339
|
+
slots.set(name, slot);
|
|
340
|
+
} else {
|
|
341
|
+
slot.original = value;
|
|
342
|
+
slot.applied = null;
|
|
343
|
+
}
|
|
344
|
+
this.applySlot(slot, (v) => el.setAttribute(name, v));
|
|
345
|
+
}
|
|
346
|
+
applySlot(slot, write) {
|
|
347
|
+
const norm = normalizeText(slot.original);
|
|
348
|
+
if (!norm || norm.length > MAX_STRING_LENGTH || !HAS_LETTER.test(norm)) return;
|
|
349
|
+
const { template, values } = toTemplate(norm);
|
|
350
|
+
const hash = hashText(template);
|
|
351
|
+
const translated = this.dicts.get(this.lang)?.get(hash);
|
|
352
|
+
if (translated === void 0) {
|
|
353
|
+
if (!this.inflight.has(hash) && !this.failed.has(hash)) {
|
|
354
|
+
this.pending.set(hash, template);
|
|
355
|
+
this.scheduleFlush();
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
const filled = fromTemplate(translated, values);
|
|
360
|
+
if (filled === null) return;
|
|
361
|
+
const next = withOriginalWhitespace(slot.original, filled);
|
|
362
|
+
slot.applied = next;
|
|
363
|
+
write(next);
|
|
364
|
+
}
|
|
365
|
+
/** Re-run lookups for every slot still showing source text. */
|
|
366
|
+
reapply() {
|
|
367
|
+
this.textSlots.forEach((slot, node) => {
|
|
368
|
+
if (slot.applied === null && node.isConnected) {
|
|
369
|
+
this.applySlot(slot, (v) => {
|
|
370
|
+
node.nodeValue = v;
|
|
371
|
+
});
|
|
372
|
+
}
|
|
373
|
+
});
|
|
374
|
+
this.attrSlots.forEach((slots, el) => {
|
|
375
|
+
if (!el.isConnected) return;
|
|
376
|
+
slots.forEach((slot, name) => {
|
|
377
|
+
if (slot.applied === null) this.applySlot(slot, (v) => el.setAttribute(name, v));
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
}
|
|
381
|
+
restoreAll() {
|
|
382
|
+
const wasObserving = !!this.observer;
|
|
383
|
+
this.disconnect();
|
|
384
|
+
this.textSlots.forEach((slot, node) => {
|
|
385
|
+
if (slot.applied !== null && node.nodeValue === slot.applied) node.nodeValue = slot.original;
|
|
386
|
+
});
|
|
387
|
+
this.attrSlots.forEach((slots, el) => {
|
|
388
|
+
slots.forEach((slot, name) => {
|
|
389
|
+
if (slot.applied !== null && el.getAttribute(name) === slot.applied) {
|
|
390
|
+
el.setAttribute(name, slot.original);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
this.textSlots.clear();
|
|
395
|
+
this.attrSlots.clear();
|
|
396
|
+
if (wasObserving && this.lang !== this.opts.sourceLang) this.connect();
|
|
397
|
+
}
|
|
398
|
+
prune() {
|
|
399
|
+
this.textSlots.forEach((_, node) => {
|
|
400
|
+
if (!node.isConnected) this.textSlots.delete(node);
|
|
401
|
+
});
|
|
402
|
+
this.attrSlots.forEach((_, el) => {
|
|
403
|
+
if (!el.isConnected) this.attrSlots.delete(el);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
// ---- Network ------------------------------------------------------------
|
|
407
|
+
dictKey(lang) {
|
|
408
|
+
return `${this.opts.projectKey}:${lang}`;
|
|
409
|
+
}
|
|
410
|
+
ensureDict(lang) {
|
|
411
|
+
let p = this.dictLoads.get(lang);
|
|
412
|
+
if (!p) {
|
|
413
|
+
const dict = /* @__PURE__ */ new Map();
|
|
414
|
+
this.dicts.set(lang, dict);
|
|
415
|
+
p = loadDict(this.dictKey(lang)).then((stored) => {
|
|
416
|
+
for (const [h, t] of Object.entries(stored)) if (!dict.has(h)) dict.set(h, t);
|
|
417
|
+
});
|
|
418
|
+
this.dictLoads.set(lang, p);
|
|
419
|
+
}
|
|
420
|
+
return p;
|
|
421
|
+
}
|
|
422
|
+
merge(lang, entries) {
|
|
423
|
+
const dict = this.dicts.get(lang);
|
|
424
|
+
if (!dict) return;
|
|
425
|
+
let changed = false;
|
|
426
|
+
for (const [h, t] of Object.entries(entries)) {
|
|
427
|
+
if (dict.get(h) !== t) {
|
|
428
|
+
dict.set(h, t);
|
|
429
|
+
changed = true;
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
if (!changed) return;
|
|
433
|
+
if (this.saveTimer) clearTimeout(this.saveTimer);
|
|
434
|
+
this.saveTimer = setTimeout(() => {
|
|
435
|
+
void saveDict(this.dictKey(lang), Object.fromEntries(dict));
|
|
436
|
+
}, 1e3);
|
|
437
|
+
}
|
|
438
|
+
async fetchBundle(lang, path) {
|
|
439
|
+
const id = `${lang}|${path}`;
|
|
440
|
+
if (this.bundlesFetched.has(id)) return;
|
|
441
|
+
this.bundlesFetched.add(id);
|
|
442
|
+
try {
|
|
443
|
+
const url = new URL("/api/v1/bundle", this.opts.apiUrl);
|
|
444
|
+
url.searchParams.set("key", this.opts.projectKey);
|
|
445
|
+
url.searchParams.set("lang", lang);
|
|
446
|
+
url.searchParams.set("path", path);
|
|
447
|
+
const res = await fetch(url.toString());
|
|
448
|
+
if (!res.ok) return;
|
|
449
|
+
const body = await res.json();
|
|
450
|
+
if (body.translations) this.merge(lang, body.translations);
|
|
451
|
+
} catch {
|
|
452
|
+
this.bundlesFetched.delete(id);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
scheduleFlush() {
|
|
456
|
+
if (this.flushTimer) return;
|
|
457
|
+
this.flushTimer = setTimeout(() => {
|
|
458
|
+
this.flushTimer = null;
|
|
459
|
+
void this.flush();
|
|
460
|
+
}, FLUSH_DELAY_MS);
|
|
461
|
+
}
|
|
462
|
+
async flush() {
|
|
463
|
+
await this.bundleWait;
|
|
464
|
+
const lang = this.lang;
|
|
465
|
+
if (lang === this.opts.sourceLang) return;
|
|
466
|
+
const dict = this.dicts.get(lang);
|
|
467
|
+
const batch = [];
|
|
468
|
+
this.pending.forEach((text, hash) => {
|
|
469
|
+
if (!dict?.has(hash)) batch.push([hash, text]);
|
|
470
|
+
});
|
|
471
|
+
this.pending.clear();
|
|
472
|
+
if (batch.length === 0) {
|
|
473
|
+
this.reapply();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
const chunks = [];
|
|
477
|
+
for (let i = 0; i < batch.length; i += BATCH_SIZE) chunks.push(batch.slice(i, i + BATCH_SIZE));
|
|
478
|
+
for (let i = 0; i < chunks.length; i += PARALLEL_BATCHES) {
|
|
479
|
+
await Promise.all(chunks.slice(i, i + PARALLEL_BATCHES).map((chunk) => this.sendBatch(lang, chunk)));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
async sendBatch(lang, chunk) {
|
|
483
|
+
chunk.forEach(([h]) => this.inflight.add(h));
|
|
484
|
+
try {
|
|
485
|
+
const res = await fetch(new URL("/api/v1/translate", this.opts.apiUrl).toString(), {
|
|
486
|
+
method: "POST",
|
|
487
|
+
headers: { "content-type": "application/json" },
|
|
488
|
+
body: JSON.stringify({
|
|
489
|
+
key: this.opts.projectKey,
|
|
490
|
+
lang,
|
|
491
|
+
path: this.path || currentPath(),
|
|
492
|
+
strings: chunk.map(([, t]) => t)
|
|
493
|
+
})
|
|
494
|
+
});
|
|
495
|
+
if (!res.ok) throw new Error(`translate failed: ${res.status}`);
|
|
496
|
+
const body = await res.json();
|
|
497
|
+
this.merge(lang, body.translations);
|
|
498
|
+
chunk.forEach(([h]) => {
|
|
499
|
+
if (!(h in body.translations)) this.failed.add(h);
|
|
500
|
+
});
|
|
501
|
+
} catch {
|
|
502
|
+
chunk.forEach(([h]) => this.failed.add(h));
|
|
503
|
+
} finally {
|
|
504
|
+
chunk.forEach(([h]) => this.inflight.delete(h));
|
|
505
|
+
}
|
|
506
|
+
if (lang === this.lang) this.reapply();
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
var RTL = /* @__PURE__ */ new Set(["ar", "he", "fa", "ur", "ps", "sd", "yi", "dv", "ug"]);
|
|
510
|
+
function isRtl(lang) {
|
|
511
|
+
return RTL.has(lang.split("-")[0]);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// src/languages.ts
|
|
515
|
+
var LANGUAGES = [
|
|
516
|
+
{ code: "en", name: "English", native: "English" },
|
|
517
|
+
{ code: "es", name: "Spanish", native: "Espa\xF1ol" },
|
|
518
|
+
{ code: "fr", name: "French", native: "Fran\xE7ais" },
|
|
519
|
+
{ code: "de", name: "German", native: "Deutsch" },
|
|
520
|
+
{ code: "it", name: "Italian", native: "Italiano" },
|
|
521
|
+
{ code: "pt", name: "Portuguese", native: "Portugu\xEAs" },
|
|
522
|
+
{ code: "pt-BR", name: "Brazilian Portuguese", native: "Portugu\xEAs (Brasil)" },
|
|
523
|
+
{ code: "nl", name: "Dutch", native: "Nederlands" },
|
|
524
|
+
{ code: "sv", name: "Swedish", native: "Svenska" },
|
|
525
|
+
{ code: "no", name: "Norwegian", native: "Norsk" },
|
|
526
|
+
{ code: "da", name: "Danish", native: "Dansk" },
|
|
527
|
+
{ code: "fi", name: "Finnish", native: "Suomi" },
|
|
528
|
+
{ code: "pl", name: "Polish", native: "Polski" },
|
|
529
|
+
{ code: "cs", name: "Czech", native: "\u010Ce\u0161tina" },
|
|
530
|
+
{ code: "sk", name: "Slovak", native: "Sloven\u010Dina" },
|
|
531
|
+
{ code: "hu", name: "Hungarian", native: "Magyar" },
|
|
532
|
+
{ code: "ro", name: "Romanian", native: "Rom\xE2n\u0103" },
|
|
533
|
+
{ code: "bg", name: "Bulgarian", native: "\u0411\u044A\u043B\u0433\u0430\u0440\u0441\u043A\u0438" },
|
|
534
|
+
{ code: "el", name: "Greek", native: "\u0395\u03BB\u03BB\u03B7\u03BD\u03B9\u03BA\u03AC" },
|
|
535
|
+
{ code: "tr", name: "Turkish", native: "T\xFCrk\xE7e" },
|
|
536
|
+
{ code: "ru", name: "Russian", native: "\u0420\u0443\u0441\u0441\u043A\u0438\u0439" },
|
|
537
|
+
{ code: "uk", name: "Ukrainian", native: "\u0423\u043A\u0440\u0430\u0457\u043D\u0441\u044C\u043A\u0430" },
|
|
538
|
+
{ code: "ar", name: "Arabic", native: "\u0627\u0644\u0639\u0631\u0628\u064A\u0629", rtl: true },
|
|
539
|
+
{ code: "he", name: "Hebrew", native: "\u05E2\u05D1\u05E8\u05D9\u05EA", rtl: true },
|
|
540
|
+
{ code: "fa", name: "Persian", native: "\u0641\u0627\u0631\u0633\u06CC", rtl: true },
|
|
541
|
+
{ code: "ur", name: "Urdu", native: "\u0627\u0631\u062F\u0648", rtl: true },
|
|
542
|
+
{ code: "yi", name: "Yiddish", native: "\u05D9\u05D9\u05B4\u05D3\u05D9\u05E9", rtl: true },
|
|
543
|
+
{ code: "hi", name: "Hindi", native: "\u0939\u093F\u0928\u094D\u0926\u0940" },
|
|
544
|
+
{ code: "bn", name: "Bengali", native: "\u09AC\u09BE\u0982\u09B2\u09BE" },
|
|
545
|
+
{ code: "ta", name: "Tamil", native: "\u0BA4\u0BAE\u0BBF\u0BB4\u0BCD" },
|
|
546
|
+
{ code: "th", name: "Thai", native: "\u0E44\u0E17\u0E22" },
|
|
547
|
+
{ code: "vi", name: "Vietnamese", native: "Ti\u1EBFng Vi\u1EC7t" },
|
|
548
|
+
{ code: "id", name: "Indonesian", native: "Bahasa Indonesia" },
|
|
549
|
+
{ code: "ms", name: "Malay", native: "Bahasa Melayu" },
|
|
550
|
+
{ code: "tl", name: "Filipino", native: "Filipino" },
|
|
551
|
+
{ code: "zh-CN", name: "Simplified Chinese", native: "\u7B80\u4F53\u4E2D\u6587" },
|
|
552
|
+
{ code: "zh-TW", name: "Traditional Chinese", native: "\u7E41\u9AD4\u4E2D\u6587" },
|
|
553
|
+
{ code: "ja", name: "Japanese", native: "\u65E5\u672C\u8A9E" },
|
|
554
|
+
{ code: "ko", name: "Korean", native: "\uD55C\uAD6D\uC5B4" },
|
|
555
|
+
{ code: "sw", name: "Swahili", native: "Kiswahili" }
|
|
556
|
+
];
|
|
557
|
+
function findLanguage(code) {
|
|
558
|
+
return LANGUAGES.find((l) => l.code === code);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// src/usage.ts
|
|
562
|
+
var FLUSH_INTERVAL_MS = 15e3;
|
|
563
|
+
var NAVIGATE_EVENT = "translationtool:navigate";
|
|
564
|
+
var UsageTracker = class {
|
|
565
|
+
constructor(apiUrl, projectKey, getLang) {
|
|
566
|
+
this.apiUrl = apiUrl;
|
|
567
|
+
this.projectKey = projectKey;
|
|
568
|
+
this.getLang = getLang;
|
|
569
|
+
this.counts = /* @__PURE__ */ new Map();
|
|
570
|
+
this.timer = null;
|
|
571
|
+
this.lastPath = "";
|
|
572
|
+
this.onHide = () => {
|
|
573
|
+
if (document.visibilityState === "hidden") this.flush();
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
start() {
|
|
577
|
+
this.view();
|
|
578
|
+
this.timer = setInterval(() => this.flush(), FLUSH_INTERVAL_MS);
|
|
579
|
+
document.addEventListener("visibilitychange", this.onHide);
|
|
580
|
+
}
|
|
581
|
+
stop() {
|
|
582
|
+
this.flush();
|
|
583
|
+
if (this.timer) clearInterval(this.timer);
|
|
584
|
+
document.removeEventListener("visibilitychange", this.onHide);
|
|
585
|
+
}
|
|
586
|
+
/** Record a view of the current page, ignoring repeats of the same path. */
|
|
587
|
+
view(force = false) {
|
|
588
|
+
const path = currentPath();
|
|
589
|
+
if (!force && path === this.lastPath) return;
|
|
590
|
+
this.lastPath = path;
|
|
591
|
+
const id = `${path}
|
|
592
|
+
${this.getLang()}`;
|
|
593
|
+
this.counts.set(id, (this.counts.get(id) ?? 0) + 1);
|
|
594
|
+
}
|
|
595
|
+
flush() {
|
|
596
|
+
if (this.counts.size === 0) return;
|
|
597
|
+
const views = [...this.counts].map(([id, count]) => {
|
|
598
|
+
const [path, lang] = id.split("\n");
|
|
599
|
+
return { path, lang, count };
|
|
600
|
+
});
|
|
601
|
+
this.counts.clear();
|
|
602
|
+
const body = JSON.stringify({ key: this.projectKey, views });
|
|
603
|
+
const url = new URL("/api/v1/events", this.apiUrl).toString();
|
|
604
|
+
try {
|
|
605
|
+
if (navigator.sendBeacon?.(url, new Blob([body], { type: "text/plain" }))) return;
|
|
606
|
+
} catch {
|
|
607
|
+
}
|
|
608
|
+
void fetch(url, { method: "POST", body, keepalive: true, headers: { "content-type": "text/plain" } }).catch(
|
|
609
|
+
() => {
|
|
610
|
+
}
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
};
|
|
614
|
+
var historyPatched = false;
|
|
615
|
+
function onNavigate(cb) {
|
|
616
|
+
if (!historyPatched) {
|
|
617
|
+
historyPatched = true;
|
|
618
|
+
for (const method of ["pushState", "replaceState"]) {
|
|
619
|
+
const original = history[method];
|
|
620
|
+
history[method] = function(...args) {
|
|
621
|
+
const result = original.apply(this, args);
|
|
622
|
+
window.dispatchEvent(new Event(NAVIGATE_EVENT));
|
|
623
|
+
return result;
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
window.addEventListener("popstate", () => window.dispatchEvent(new Event(NAVIGATE_EVENT)));
|
|
627
|
+
}
|
|
628
|
+
window.addEventListener(NAVIGATE_EVENT, cb);
|
|
629
|
+
return () => window.removeEventListener(NAVIGATE_EVENT, cb);
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
// src/provider.tsx
|
|
633
|
+
var import_jsx_runtime = require("react/jsx-runtime");
|
|
634
|
+
var TranslateContext = (0, import_react.createContext)(null);
|
|
635
|
+
var useIsomorphicLayoutEffect = typeof window === "undefined" ? import_react.useEffect : import_react.useLayoutEffect;
|
|
636
|
+
var LOADING_ATTR = "data-translationtool-loading";
|
|
637
|
+
var MAX_HIDE_MS = 1500;
|
|
638
|
+
function hidePage() {
|
|
639
|
+
if (!document.getElementById("translationtool-style")) {
|
|
640
|
+
const style = document.createElement("style");
|
|
641
|
+
style.id = "translationtool-style";
|
|
642
|
+
style.textContent = `html[${LOADING_ATTR}] body{opacity:0 !important}`;
|
|
643
|
+
document.head.appendChild(style);
|
|
644
|
+
}
|
|
645
|
+
document.documentElement.setAttribute(LOADING_ATTR, "");
|
|
646
|
+
const show = () => document.documentElement.removeAttribute(LOADING_ATTR);
|
|
647
|
+
const t = setTimeout(show, MAX_HIDE_MS);
|
|
648
|
+
return () => {
|
|
649
|
+
clearTimeout(t);
|
|
650
|
+
show();
|
|
651
|
+
};
|
|
652
|
+
}
|
|
653
|
+
function TranslateProvider({
|
|
654
|
+
projectKey,
|
|
655
|
+
sourceLang = "en",
|
|
656
|
+
languages,
|
|
657
|
+
detectBrowserLanguage = false,
|
|
658
|
+
apiUrl = DEFAULT_API_URL,
|
|
659
|
+
children
|
|
660
|
+
}) {
|
|
661
|
+
const [lang, setLangState] = (0, import_react.useState)(sourceLang);
|
|
662
|
+
const [loading, setLoading] = (0, import_react.useState)(false);
|
|
663
|
+
const engineRef = (0, import_react.useRef)(null);
|
|
664
|
+
const langRef = (0, import_react.useRef)(lang);
|
|
665
|
+
langRef.current = lang;
|
|
666
|
+
const available = (0, import_react.useMemo)(() => {
|
|
667
|
+
const codes = languages ?? LANGUAGES.map((l) => l.code);
|
|
668
|
+
const list = codes.map((c) => findLanguage(c) ?? { code: c, name: c, native: c });
|
|
669
|
+
if (!list.some((l) => l.code === sourceLang)) {
|
|
670
|
+
list.unshift(findLanguage(sourceLang) ?? { code: sourceLang, name: sourceLang, native: sourceLang });
|
|
671
|
+
}
|
|
672
|
+
return list;
|
|
673
|
+
}, [languages, sourceLang]);
|
|
674
|
+
useIsomorphicLayoutEffect(() => {
|
|
675
|
+
const engine = new TranslationEngine({ projectKey, apiUrl, sourceLang });
|
|
676
|
+
engineRef.current = engine;
|
|
677
|
+
const tracker = new UsageTracker(apiUrl, projectKey, () => langRef.current);
|
|
678
|
+
tracker.start();
|
|
679
|
+
const off = onNavigate(() => {
|
|
680
|
+
tracker.view();
|
|
681
|
+
engine.onNavigate();
|
|
682
|
+
});
|
|
683
|
+
return () => {
|
|
684
|
+
off();
|
|
685
|
+
tracker.stop();
|
|
686
|
+
engine.stop();
|
|
687
|
+
engineRef.current = null;
|
|
688
|
+
};
|
|
689
|
+
}, [projectKey, apiUrl, sourceLang]);
|
|
690
|
+
useIsomorphicLayoutEffect(() => {
|
|
691
|
+
let initial = readStoredLang();
|
|
692
|
+
if (!initial && detectBrowserLanguage) {
|
|
693
|
+
const nav = navigator.language;
|
|
694
|
+
initial = available.find((l) => l.code === nav)?.code ?? available.find((l) => l.code === nav.split("-")[0])?.code ?? null;
|
|
695
|
+
}
|
|
696
|
+
if (initial && initial !== sourceLang && available.some((l) => l.code === initial)) {
|
|
697
|
+
setLangState(initial);
|
|
698
|
+
}
|
|
699
|
+
}, []);
|
|
700
|
+
useIsomorphicLayoutEffect(() => {
|
|
701
|
+
const engine = engineRef.current;
|
|
702
|
+
if (!engine || engine.language === lang) return;
|
|
703
|
+
const show = lang !== sourceLang ? hidePage() : () => {
|
|
704
|
+
};
|
|
705
|
+
setLoading(true);
|
|
706
|
+
let cancelled = false;
|
|
707
|
+
engine.setLanguage(lang).finally(() => {
|
|
708
|
+
show();
|
|
709
|
+
if (!cancelled) setLoading(false);
|
|
710
|
+
});
|
|
711
|
+
return () => {
|
|
712
|
+
cancelled = true;
|
|
713
|
+
show();
|
|
714
|
+
};
|
|
715
|
+
}, [lang, sourceLang, projectKey, apiUrl]);
|
|
716
|
+
const setLang = (0, import_react.useCallback)((code) => {
|
|
717
|
+
writeStoredLang(code);
|
|
718
|
+
setLangState(code);
|
|
719
|
+
}, []);
|
|
720
|
+
const value = (0, import_react.useMemo)(
|
|
721
|
+
() => ({ lang, sourceLang, languages: available, setLang, loading }),
|
|
722
|
+
[lang, sourceLang, available, setLang, loading]
|
|
723
|
+
);
|
|
724
|
+
return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TranslateContext.Provider, { value, children });
|
|
725
|
+
}
|
|
726
|
+
function useTranslate() {
|
|
727
|
+
const ctx = (0, import_react.useContext)(TranslateContext);
|
|
728
|
+
if (!ctx) throw new Error("useTranslate must be used inside <TranslateProvider>");
|
|
729
|
+
return ctx;
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
// src/LanguageSelect.tsx
|
|
733
|
+
var import_jsx_runtime2 = require("react/jsx-runtime");
|
|
734
|
+
function LanguageSelect({ className, style, "aria-label": ariaLabel = "Language" }) {
|
|
735
|
+
const { lang, languages, setLang } = useTranslate();
|
|
736
|
+
return /* @__PURE__ */ (0, import_jsx_runtime2.jsx)(
|
|
737
|
+
"select",
|
|
738
|
+
{
|
|
739
|
+
"data-no-translate": true,
|
|
740
|
+
className,
|
|
741
|
+
style,
|
|
742
|
+
"aria-label": ariaLabel,
|
|
743
|
+
value: lang,
|
|
744
|
+
onChange: (e) => setLang(e.target.value),
|
|
745
|
+
children: languages.map((l) => /* @__PURE__ */ (0, import_jsx_runtime2.jsx)("option", { value: l.code, children: l.native }, l.code))
|
|
746
|
+
}
|
|
747
|
+
);
|
|
748
|
+
}
|
|
749
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
750
|
+
0 && (module.exports = {
|
|
751
|
+
LANGUAGES,
|
|
752
|
+
LanguageSelect,
|
|
753
|
+
TranslateProvider,
|
|
754
|
+
TranslationEngine,
|
|
755
|
+
useTranslate
|
|
756
|
+
});
|