@kiritan/deepl 0.0.0-reserved → 0.1.1
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 +13 -0
- package/README.ja.md +82 -0
- package/README.md +82 -0
- package/dist/index.cjs +138 -0
- package/dist/index.d.cts +45 -0
- package/dist/index.d.mts +45 -0
- package/dist/index.mjs +132 -0
- package/package.json +49 -13
- package/index.js +0 -1
package/LICENSE
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
|
2
|
+
Version 2, December 2004
|
|
3
|
+
|
|
4
|
+
Copyright (C) 2026 otoneko.
|
|
5
|
+
|
|
6
|
+
Everyone is permitted to copy and distribute verbatim or modified
|
|
7
|
+
copies of this license document, and changing it is allowed as long
|
|
8
|
+
as the name is changed.
|
|
9
|
+
|
|
10
|
+
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
|
|
11
|
+
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
|
12
|
+
|
|
13
|
+
0. You just DO WHAT THE FUCK YOU WANT TO.
|
package/README.ja.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<div align=center>
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
# @kiritan/deepl
|
|
6
|
+
|
|
7
|
+
[English](README.md) | **日本語**
|
|
8
|
+
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
> [Kiritan](https://www.npmjs.com/package/kiritan) 用の [DeepL](https://www.deepl.com/) 翻訳ミドルウェア。`translate.middlewares` に差し込むと、`kiritan translate` が未翻訳・staleな翻訳を埋める。
|
|
12
|
+
|
|
13
|
+
[](https://www.npmjs.com/package/@kiritan/deepl) [](https://github.com/otnc/kiritan/actions) [](https://github.com/otnc/kiritan/blob/main/LICENSE) [](https://www.npmjs.com/package/@kiritan/deepl)
|
|
14
|
+
|
|
15
|
+
## インストール
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install --save-dev @kiritan/deepl
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## 使い方
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
// .kiritanconfig
|
|
25
|
+
import { deepl } from "@kiritan/deepl";
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
locales: { default: "en", list: ["en", "ja"] },
|
|
29
|
+
sources: [{ glob: "base/README.base.md", strategy: "sidecar" }],
|
|
30
|
+
translate: {
|
|
31
|
+
auto: true,
|
|
32
|
+
middlewares: [deepl({ apiKey: process.env.DEEPL_API_KEY })],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
`sidecar` では、ドキュメント全体が1つのテキストとしてDeepLに送られ、翻訳結果がsidecarファイルになる。`catalog` では、ベースファイル内の `:::kiritan{#id}` ブロックごとに個別に翻訳される — ベースファイルにこのブロックが無ければ、翻訳するものが何も無い。
|
|
38
|
+
|
|
39
|
+
`catalog` のidが多い場合は、`deepl` の代わりに `deeplBatch` を使うと、1リクエストに多数のセグメント(DeepLの上限である50件・128 KiBまで)をまとめて送れるため、ずっと速い:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import { deeplBatch } from "@kiritan/deepl";
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
// ...
|
|
46
|
+
translate: {
|
|
47
|
+
auto: true,
|
|
48
|
+
middlewares: [deeplBatch({ apiKey: process.env.DEEPL_API_KEY })],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### オプション
|
|
54
|
+
|
|
55
|
+
| オプション | 説明 |
|
|
56
|
+
| ----------------- | ------------------------------------------------------------------------------------------------------------------- |
|
|
57
|
+
| `apiKey` | DeepLのAPIキー(必須)。`:fx` で終わるキーは無料API、それ以外はPro APIを使う。 |
|
|
58
|
+
| `baseUrl` | エンドポイントを上書きする。 |
|
|
59
|
+
| `targetLanguages` | DeepLの `target_lang` をロケールごとに上書きする。例: `{ en: "EN-GB" }`。既定では `en` は `EN-US`、`pt` は `PT-BR`。 |
|
|
60
|
+
| `extraParams` | リクエストボディにマージする追加フィールド。例: `{ formality: "prefer_less" }`。 |
|
|
61
|
+
| `retry` | 失敗したリクエスト(ネットワークエラー、408/409/425/429/5xx)を再試行する回数。既定: 2。 |
|
|
62
|
+
| `retryDelay` | 再試行までの待ち時間(ms)。既定: 500。 |
|
|
63
|
+
| `timeout` | リクエストごとのタイムアウト(ms)。既定: 30000。 |
|
|
64
|
+
| `fetch` | テストやプロキシ用の独自の `fetch`。 |
|
|
65
|
+
|
|
66
|
+
### 挙動
|
|
67
|
+
|
|
68
|
+
- そのまま保つべきものはすべて変更されずに戻る: コードブロック、インラインコード、URL、リンク先、HTML、front matter、`:::kiritan{...}` の行、`%{name}`。これらはテキストが手元を離れる前にトークンに置き換えられ、DeepLが触らないよう無視するよう指示したタグ(`tag_handling: xml`)で包まれ、後で元に戻される。DeepLがそのどれかを落とした場合は、リンクやコードが欠けた翻訳を書き込むのではなく実行が失敗する。([`@kiritan/middleware`](https://www.npmjs.com/package/@kiritan/middleware) の上に作られているため、`concurrency`・`minInterval`・`cache`・`protect`・`onError`・`onSkip` の各オプションも受け付ける。)
|
|
69
|
+
- DeepLの128 KiBを超えるテキスト(たとえば `sidecar` 翻訳で送るREADME全体)は、段落の境界で分割し、元の間隔のまま結合し直す。保護した部分の周りの文言はDeepL任せなので、公開前に機械翻訳をレビューすること — この方法で書き込まれた `catalog` のエントリは、レビューするまで `kiritan check` が `machine` として報告する。
|
|
70
|
+
- 失敗したリクエストは再試行(`retry` 参照)され、それでも失敗すれば例外を投げ、`kiritan translate` はDeepL自身のメッセージ(無効なキー、使い切ったクォータ等)とともに停止する。
|
|
71
|
+
|
|
72
|
+
## 動作環境
|
|
73
|
+
|
|
74
|
+
- Node.js >= 22.7(HTTP通信は [ofetch](https://github.com/unjs/ofetch) 経由)
|
|
75
|
+
|
|
76
|
+
## コントリビュート
|
|
77
|
+
|
|
78
|
+
コントリビューションを歓迎します。詳細は [CONTRIBUTING.md](https://github.com/otnc/kiritan/blob/main/CONTRIBUTING.md) を参照してください。
|
|
79
|
+
|
|
80
|
+
## ライセンス
|
|
81
|
+
|
|
82
|
+
[WTFPL License](https://github.com/otnc/kiritan/blob/main/LICENSE) の下で配布されています。
|
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<div align=center>
|
|
2
|
+
|
|
3
|
+

|
|
4
|
+
|
|
5
|
+
# @kiritan/deepl
|
|
6
|
+
|
|
7
|
+
**English** | [日本語](README.ja.md)
|
|
8
|
+
|
|
9
|
+
</div>
|
|
10
|
+
|
|
11
|
+
> A [DeepL](https://www.deepl.com/) translate middleware for [Kiritan](https://www.npmjs.com/package/kiritan). Plug it into `translate.middlewares` to have `kiritan translate` fill in missing and stale translations.
|
|
12
|
+
|
|
13
|
+
[](https://www.npmjs.com/package/@kiritan/deepl) [](https://github.com/otnc/kiritan/actions) [](https://github.com/otnc/kiritan/blob/main/LICENSE) [](https://www.npmjs.com/package/@kiritan/deepl)
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```sh
|
|
18
|
+
npm install --save-dev @kiritan/deepl
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Usage
|
|
22
|
+
|
|
23
|
+
```js
|
|
24
|
+
// .kiritanconfig
|
|
25
|
+
import { deepl } from "@kiritan/deepl";
|
|
26
|
+
|
|
27
|
+
export default {
|
|
28
|
+
locales: { default: "en", list: ["en", "ja"] },
|
|
29
|
+
sources: [{ glob: "base/README.base.md", strategy: "sidecar" }],
|
|
30
|
+
translate: {
|
|
31
|
+
auto: true,
|
|
32
|
+
middlewares: [deepl({ apiKey: process.env.DEEPL_API_KEY })],
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
With `sidecar`, the whole document goes to DeepL as one text and comes back as the translated sidecar file. With `catalog`, each `:::kiritan{#id}` block in the base file is translated separately — the base file needs those blocks, or there is nothing to translate.
|
|
38
|
+
|
|
39
|
+
Use `deeplBatch` instead of `deepl` to send many segments per request (up to DeepL's limits of 50 texts / 128 KiB), which is much faster for a `catalog` source with lots of ids:
|
|
40
|
+
|
|
41
|
+
```js
|
|
42
|
+
import { deeplBatch } from "@kiritan/deepl";
|
|
43
|
+
|
|
44
|
+
export default {
|
|
45
|
+
// ...
|
|
46
|
+
translate: {
|
|
47
|
+
auto: true,
|
|
48
|
+
middlewares: [deeplBatch({ apiKey: process.env.DEEPL_API_KEY })],
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
### Options
|
|
54
|
+
|
|
55
|
+
| Option | Description |
|
|
56
|
+
| ----------------- | ----------------------------------------------------------------------------------------------------------------- |
|
|
57
|
+
| `apiKey` | Your DeepL API key (required). A key ending in `:fx` uses the free API, any other the Pro API. |
|
|
58
|
+
| `baseUrl` | Overrides the endpoint. |
|
|
59
|
+
| `targetLanguages` | Per-locale overrides of the DeepL `target_lang`, e.g. `{ en: "EN-GB" }`. `en` defaults to `EN-US`, `pt` to `PT-BR`. |
|
|
60
|
+
| `extraParams` | Extra fields merged into the request body, e.g. `{ formality: "prefer_less" }`. |
|
|
61
|
+
| `retry` | How many times to retry a failed request (network errors, 408/409/425/429/5xx). Default: 2. |
|
|
62
|
+
| `retryDelay` | Delay between retries, in ms. Default: 500. |
|
|
63
|
+
| `timeout` | Per-request timeout, in ms. Default: 30000. |
|
|
64
|
+
| `fetch` | A custom `fetch`, for testing or a proxy. |
|
|
65
|
+
|
|
66
|
+
### Behavior
|
|
67
|
+
|
|
68
|
+
- Everything that must stay verbatim comes back untouched: code blocks, inline code, URLs, link destinations, HTML, front matter, `:::kiritan{...}` lines and `%{name}`. They are swapped for tokens before the text leaves your machine, wrapped in a tag DeepL is told to ignore (`tag_handling: xml`) so DeepL leaves them alone, and put back afterwards — and if DeepL drops one, the run fails instead of writing a translation that lost a link or a code span. (Built on [`@kiritan/middleware`](https://www.npmjs.com/package/@kiritan/middleware), so it also accepts its `concurrency`, `minInterval`, `cache`, `protect`, `onError` and `onSkip` options.)
|
|
69
|
+
- A text over DeepL's 128 KiB (say a whole README for a `sidecar` translation) is split at paragraph boundaries and joined back with the original spacing. The wording around protected spans is up to DeepL, so review machine translations before publishing them — `kiritan check` flags `catalog` entries written this way as `machine` until you do.
|
|
70
|
+
- A failed request is retried (see `retry`) and then throws, and `kiritan translate` stops with DeepL's own message (an invalid key, an exhausted quota, ...).
|
|
71
|
+
|
|
72
|
+
## Requirements
|
|
73
|
+
|
|
74
|
+
- Node.js >= 22.7 (HTTP goes through [ofetch](https://github.com/unjs/ofetch), its only dependency of note)
|
|
75
|
+
|
|
76
|
+
## Contributing
|
|
77
|
+
|
|
78
|
+
Contributions are welcome! See [CONTRIBUTING.md](https://github.com/otnc/kiritan/blob/main/CONTRIBUTING.md) for details.
|
|
79
|
+
|
|
80
|
+
## License
|
|
81
|
+
|
|
82
|
+
Distributed under the [WTFPL License](https://github.com/otnc/kiritan/blob/main/LICENSE).
|
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
let _kiritan_middleware = require("@kiritan/middleware");
|
|
3
|
+
let node_crypto = require("node:crypto");
|
|
4
|
+
let html_entities = require("html-entities");
|
|
5
|
+
let ofetch = require("ofetch");
|
|
6
|
+
//#region src/index.ts
|
|
7
|
+
const FREE_URL = "https://api-free.deepl.com";
|
|
8
|
+
const PRO_URL = "https://api.deepl.com";
|
|
9
|
+
/** DeepL accepts at most 50 texts per request, in a body of at most 128 KiB. */
|
|
10
|
+
const MAX_TEXTS_PER_REQUEST = 50;
|
|
11
|
+
/** One text and one request's texts, in bytes of the JSON that is actually sent; headroom under 128 KiB for the other fields. */
|
|
12
|
+
const MAX_TEXT_BYTES = 102400;
|
|
13
|
+
const MAX_BATCH_BYTES = 122880;
|
|
14
|
+
/**
|
|
15
|
+
* The DeepL `target_lang` for a Kiritan locale, parsed with `Intl.Locale` so any BCP 47 tag works (`ja`, `en-US`, `zh-Hant-TW`, `pt_BR`).
|
|
16
|
+
* DeepL has no bare `EN`/`PT` as a target, only a regional variant (British English if the locale says GB, else American; European Portuguese if PT, else Brazilian); Chinese is `ZH-HANS`/`ZH-HANT` by script; Norwegian is always `NB`; anything else is the upper-cased language. `overrides` (matched on the exact locale) win.
|
|
17
|
+
*/
|
|
18
|
+
function toDeepLTarget(locale, overrides = {}) {
|
|
19
|
+
if (Object.hasOwn(overrides, locale)) return overrides[locale];
|
|
20
|
+
const { language, region } = (0, _kiritan_middleware.parseLocale)(locale);
|
|
21
|
+
switch (language) {
|
|
22
|
+
case "en": return region === "GB" ? "EN-GB" : "EN-US";
|
|
23
|
+
case "pt": return region === "PT" ? "PT-PT" : "PT-BR";
|
|
24
|
+
case "zh": return (0, _kiritan_middleware.chineseScript)(locale) === "Hant" ? "ZH-HANT" : "ZH-HANS";
|
|
25
|
+
case "no":
|
|
26
|
+
case "nb":
|
|
27
|
+
case "nn": return "NB";
|
|
28
|
+
default: return language.toUpperCase();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** DeepL's `source_lang` takes no regional variant or script: `en-GB` -> `EN`, `zh-Hant` -> `ZH`, `no` -> `NB`. */
|
|
32
|
+
function toDeepLSource(locale) {
|
|
33
|
+
const { language } = (0, _kiritan_middleware.parseLocale)(locale);
|
|
34
|
+
return (language === "no" || language === "nn" ? "nb" : language).toUpperCase();
|
|
35
|
+
}
|
|
36
|
+
const KEEP_TAG = "x";
|
|
37
|
+
/** Escapes the text as XML and wraps every `[[N]]` token in the ignored tag. */
|
|
38
|
+
function encode(text) {
|
|
39
|
+
return (0, html_entities.encode)(text, { mode: "specialChars" }).replace(/\[\[(\d+)\]\]/g, `<${KEEP_TAG}>[[$1]]</${KEEP_TAG}>`);
|
|
40
|
+
}
|
|
41
|
+
/** The inverse of `encode`. */
|
|
42
|
+
function decode(text) {
|
|
43
|
+
return (0, html_entities.decode)(text.replace(new RegExp(`</?${KEEP_TAG}>`, "gi"), ""), { level: "xml" });
|
|
44
|
+
}
|
|
45
|
+
/** A failure the layer's retry logic can read, and that says what DeepL said. */
|
|
46
|
+
var DeepLError = class extends Error {
|
|
47
|
+
status;
|
|
48
|
+
retryAfter;
|
|
49
|
+
constructor(message, status, retryAfter, options) {
|
|
50
|
+
super(message, options);
|
|
51
|
+
this.status = status;
|
|
52
|
+
this.retryAfter = retryAfter;
|
|
53
|
+
this.name = "DeepLError";
|
|
54
|
+
}
|
|
55
|
+
};
|
|
56
|
+
function describeError(error) {
|
|
57
|
+
if (error instanceof ofetch.FetchError && error.status !== void 0) {
|
|
58
|
+
const data = error.data;
|
|
59
|
+
const detail = typeof data === "string" ? data : data ? JSON.stringify(data) : "";
|
|
60
|
+
return new DeepLError(`@kiritan/deepl: DeepL responded ${error.status}${detail ? `: ${detail}` : ""}`, error.status, (0, _kiritan_middleware.parseRetryAfter)(error.response?.headers.get("retry-after")), { cause: error });
|
|
61
|
+
}
|
|
62
|
+
return new DeepLError(`@kiritan/deepl: request to DeepL failed: ${error instanceof Error ? error.message : String(error)}`, void 0, void 0, { cause: error });
|
|
63
|
+
}
|
|
64
|
+
async function requestTranslations(options, texts, from, to) {
|
|
65
|
+
const baseUrl = options.baseUrl ?? (options.apiKey.endsWith(":fx") ? FREE_URL : PRO_URL);
|
|
66
|
+
const request = (0, ofetch.createFetch)({ fetch: options.fetch ?? globalThis.fetch });
|
|
67
|
+
let body;
|
|
68
|
+
try {
|
|
69
|
+
body = await request(`${baseUrl}/v2/translate`, {
|
|
70
|
+
method: "POST",
|
|
71
|
+
headers: { Authorization: `DeepL-Auth-Key ${options.apiKey}` },
|
|
72
|
+
retry: 0,
|
|
73
|
+
timeout: options.timeout ?? 3e4,
|
|
74
|
+
body: {
|
|
75
|
+
...options.extraParams,
|
|
76
|
+
text: texts,
|
|
77
|
+
source_lang: toDeepLSource(from),
|
|
78
|
+
target_lang: toDeepLTarget(to, options.targetLanguages),
|
|
79
|
+
tag_handling: "xml",
|
|
80
|
+
ignore_tags: [KEEP_TAG]
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
} catch (error) {
|
|
84
|
+
throw describeError(error);
|
|
85
|
+
}
|
|
86
|
+
if (body.translations?.length !== texts.length) throw new DeepLError(`@kiritan/deepl: expected ${texts.length} translation(s), got ${body.translations?.length ?? 0}`);
|
|
87
|
+
return body.translations.map((entry) => entry.text);
|
|
88
|
+
}
|
|
89
|
+
/** The size of a text as it will actually be sent: XML-wrapped and JSON-encoded, in UTF-8 bytes. */
|
|
90
|
+
const wireBytes = (text) => Buffer.byteLength(JSON.stringify(encode(text)), "utf8");
|
|
91
|
+
function create(options, batched) {
|
|
92
|
+
const common = {
|
|
93
|
+
name: `deepl:${(0, node_crypto.createHash)("sha1").update(JSON.stringify([options.extraParams ?? {}, options.targetLanguages ?? {}])).digest("hex").slice(0, 12)}`,
|
|
94
|
+
wire: {
|
|
95
|
+
encode,
|
|
96
|
+
decode
|
|
97
|
+
},
|
|
98
|
+
measure: wireBytes,
|
|
99
|
+
maxChars: MAX_TEXT_BYTES,
|
|
100
|
+
maxBatchChars: MAX_BATCH_BYTES,
|
|
101
|
+
maxBatchSize: batched ? MAX_TEXTS_PER_REQUEST : 1,
|
|
102
|
+
retry: {
|
|
103
|
+
retries: options.retry ?? 2,
|
|
104
|
+
delay: options.retryDelay ?? 500
|
|
105
|
+
},
|
|
106
|
+
concurrency: options.concurrency,
|
|
107
|
+
minInterval: options.minInterval,
|
|
108
|
+
cache: options.cache,
|
|
109
|
+
protect: options.protect,
|
|
110
|
+
onError: options.onError,
|
|
111
|
+
onSkip: options.onSkip,
|
|
112
|
+
async translateBatch(texts, { from, to }) {
|
|
113
|
+
return requestTranslations(options, texts, from, to);
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
for (const key of Object.keys(common)) if (common[key] === void 0) delete common[key];
|
|
117
|
+
return (0, _kiritan_middleware.createTranslator)(common);
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* A `translate.middlewares` entry that translates through DeepL, one request per text.
|
|
121
|
+
* Built on `@kiritan/middleware`, so code blocks, inline code, URLs, link destinations, HTML, front matter, `:::kiritan` lines and `%{name}` come back untouched (and a result that lost one is refused), a text over DeepL's size limit is split at paragraph boundaries and rejoined, and requests are retried on 429/5xx.
|
|
122
|
+
*/
|
|
123
|
+
function deepl(options) {
|
|
124
|
+
return create(options, false);
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* The same, but texts sharing a language pair go out together — up to DeepL's 50 texts / 128 KiB per request. Prefer this for a catalog with many segments.
|
|
128
|
+
*/
|
|
129
|
+
function deeplBatch(options) {
|
|
130
|
+
return create(options, true);
|
|
131
|
+
}
|
|
132
|
+
//#endregion
|
|
133
|
+
exports.decode = decode;
|
|
134
|
+
exports.deepl = deepl;
|
|
135
|
+
exports.deeplBatch = deeplBatch;
|
|
136
|
+
exports.encode = encode;
|
|
137
|
+
exports.toDeepLSource = toDeepLSource;
|
|
138
|
+
exports.toDeepLTarget = toDeepLTarget;
|
package/dist/index.d.cts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { TranslatorMiddleware, TranslatorOptions } from "@kiritan/middleware";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
type DeepLMiddleware = TranslatorMiddleware;
|
|
4
|
+
/** Tuning shared with every `@kiritan/middleware`-based provider. */
|
|
5
|
+
type Tuning = Pick<TranslatorOptions, "concurrency" | "minInterval" | "cache" | "protect" | "onError" | "onSkip">;
|
|
6
|
+
interface DeepLOptions extends Tuning {
|
|
7
|
+
/** Your DeepL API authentication key. */
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/** Overrides the endpoint. Default: the free API for a key ending in `:fx`, otherwise the Pro API. */
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
/** Per-locale overrides of the DeepL language code sent as `target_lang`, e.g. `{ en: "EN-GB" }`. */
|
|
12
|
+
targetLanguages?: Record<string, string>;
|
|
13
|
+
/** Extra fields merged into the request body, e.g. `{ formality: "prefer_less" }`. They can add fields but never override the ones this middleware depends on. */
|
|
14
|
+
extraParams?: Record<string, unknown>;
|
|
15
|
+
/** How many times to retry a failed request (network errors, 408/409/425/429/5xx) before giving up. Default: 2. */
|
|
16
|
+
retry?: number;
|
|
17
|
+
/** Delay before the first retry, in ms (doubled each time). Default: 500. */
|
|
18
|
+
retryDelay?: number;
|
|
19
|
+
/** Per-request timeout, in ms. Default: 30000. */
|
|
20
|
+
timeout?: number;
|
|
21
|
+
/** For testing or a custom transport. Default: the global `fetch`. */
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The DeepL `target_lang` for a Kiritan locale, parsed with `Intl.Locale` so any BCP 47 tag works (`ja`, `en-US`, `zh-Hant-TW`, `pt_BR`).
|
|
26
|
+
* DeepL has no bare `EN`/`PT` as a target, only a regional variant (British English if the locale says GB, else American; European Portuguese if PT, else Brazilian); Chinese is `ZH-HANS`/`ZH-HANT` by script; Norwegian is always `NB`; anything else is the upper-cased language. `overrides` (matched on the exact locale) win.
|
|
27
|
+
*/
|
|
28
|
+
declare function toDeepLTarget(locale: string, overrides?: Record<string, string>): string;
|
|
29
|
+
/** DeepL's `source_lang` takes no regional variant or script: `en-GB` -> `EN`, `zh-Hant` -> `ZH`, `no` -> `NB`. */
|
|
30
|
+
declare function toDeepLSource(locale: string): string;
|
|
31
|
+
/** Escapes the text as XML and wraps every `[[N]]` token in the ignored tag. */
|
|
32
|
+
declare function encode(text: string): string;
|
|
33
|
+
/** The inverse of `encode`. */
|
|
34
|
+
declare function decode(text: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* A `translate.middlewares` entry that translates through DeepL, one request per text.
|
|
37
|
+
* Built on `@kiritan/middleware`, so code blocks, inline code, URLs, link destinations, HTML, front matter, `:::kiritan` lines and `%{name}` come back untouched (and a result that lost one is refused), a text over DeepL's size limit is split at paragraph boundaries and rejoined, and requests are retried on 429/5xx.
|
|
38
|
+
*/
|
|
39
|
+
declare function deepl(options: DeepLOptions): DeepLMiddleware;
|
|
40
|
+
/**
|
|
41
|
+
* The same, but texts sharing a language pair go out together — up to DeepL's 50 texts / 128 KiB per request. Prefer this for a catalog with many segments.
|
|
42
|
+
*/
|
|
43
|
+
declare function deeplBatch(options: DeepLOptions): DeepLMiddleware;
|
|
44
|
+
//#endregion
|
|
45
|
+
export { DeepLMiddleware, DeepLOptions, decode, deepl, deeplBatch, encode, toDeepLSource, toDeepLTarget };
|
package/dist/index.d.mts
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { TranslatorMiddleware, TranslatorOptions } from "@kiritan/middleware";
|
|
2
|
+
//#region src/index.d.ts
|
|
3
|
+
type DeepLMiddleware = TranslatorMiddleware;
|
|
4
|
+
/** Tuning shared with every `@kiritan/middleware`-based provider. */
|
|
5
|
+
type Tuning = Pick<TranslatorOptions, "concurrency" | "minInterval" | "cache" | "protect" | "onError" | "onSkip">;
|
|
6
|
+
interface DeepLOptions extends Tuning {
|
|
7
|
+
/** Your DeepL API authentication key. */
|
|
8
|
+
apiKey: string;
|
|
9
|
+
/** Overrides the endpoint. Default: the free API for a key ending in `:fx`, otherwise the Pro API. */
|
|
10
|
+
baseUrl?: string;
|
|
11
|
+
/** Per-locale overrides of the DeepL language code sent as `target_lang`, e.g. `{ en: "EN-GB" }`. */
|
|
12
|
+
targetLanguages?: Record<string, string>;
|
|
13
|
+
/** Extra fields merged into the request body, e.g. `{ formality: "prefer_less" }`. They can add fields but never override the ones this middleware depends on. */
|
|
14
|
+
extraParams?: Record<string, unknown>;
|
|
15
|
+
/** How many times to retry a failed request (network errors, 408/409/425/429/5xx) before giving up. Default: 2. */
|
|
16
|
+
retry?: number;
|
|
17
|
+
/** Delay before the first retry, in ms (doubled each time). Default: 500. */
|
|
18
|
+
retryDelay?: number;
|
|
19
|
+
/** Per-request timeout, in ms. Default: 30000. */
|
|
20
|
+
timeout?: number;
|
|
21
|
+
/** For testing or a custom transport. Default: the global `fetch`. */
|
|
22
|
+
fetch?: typeof fetch;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The DeepL `target_lang` for a Kiritan locale, parsed with `Intl.Locale` so any BCP 47 tag works (`ja`, `en-US`, `zh-Hant-TW`, `pt_BR`).
|
|
26
|
+
* DeepL has no bare `EN`/`PT` as a target, only a regional variant (British English if the locale says GB, else American; European Portuguese if PT, else Brazilian); Chinese is `ZH-HANS`/`ZH-HANT` by script; Norwegian is always `NB`; anything else is the upper-cased language. `overrides` (matched on the exact locale) win.
|
|
27
|
+
*/
|
|
28
|
+
declare function toDeepLTarget(locale: string, overrides?: Record<string, string>): string;
|
|
29
|
+
/** DeepL's `source_lang` takes no regional variant or script: `en-GB` -> `EN`, `zh-Hant` -> `ZH`, `no` -> `NB`. */
|
|
30
|
+
declare function toDeepLSource(locale: string): string;
|
|
31
|
+
/** Escapes the text as XML and wraps every `[[N]]` token in the ignored tag. */
|
|
32
|
+
declare function encode(text: string): string;
|
|
33
|
+
/** The inverse of `encode`. */
|
|
34
|
+
declare function decode(text: string): string;
|
|
35
|
+
/**
|
|
36
|
+
* A `translate.middlewares` entry that translates through DeepL, one request per text.
|
|
37
|
+
* Built on `@kiritan/middleware`, so code blocks, inline code, URLs, link destinations, HTML, front matter, `:::kiritan` lines and `%{name}` come back untouched (and a result that lost one is refused), a text over DeepL's size limit is split at paragraph boundaries and rejoined, and requests are retried on 429/5xx.
|
|
38
|
+
*/
|
|
39
|
+
declare function deepl(options: DeepLOptions): DeepLMiddleware;
|
|
40
|
+
/**
|
|
41
|
+
* The same, but texts sharing a language pair go out together — up to DeepL's 50 texts / 128 KiB per request. Prefer this for a catalog with many segments.
|
|
42
|
+
*/
|
|
43
|
+
declare function deeplBatch(options: DeepLOptions): DeepLMiddleware;
|
|
44
|
+
//#endregion
|
|
45
|
+
export { DeepLMiddleware, DeepLOptions, decode, deepl, deeplBatch, encode, toDeepLSource, toDeepLTarget };
|
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { chineseScript, createTranslator, parseLocale, parseRetryAfter } from "@kiritan/middleware";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { decode as decode$1, encode as encode$1 } from "html-entities";
|
|
4
|
+
import { FetchError, createFetch } from "ofetch";
|
|
5
|
+
//#region src/index.ts
|
|
6
|
+
const FREE_URL = "https://api-free.deepl.com";
|
|
7
|
+
const PRO_URL = "https://api.deepl.com";
|
|
8
|
+
/** DeepL accepts at most 50 texts per request, in a body of at most 128 KiB. */
|
|
9
|
+
const MAX_TEXTS_PER_REQUEST = 50;
|
|
10
|
+
/** One text and one request's texts, in bytes of the JSON that is actually sent; headroom under 128 KiB for the other fields. */
|
|
11
|
+
const MAX_TEXT_BYTES = 102400;
|
|
12
|
+
const MAX_BATCH_BYTES = 122880;
|
|
13
|
+
/**
|
|
14
|
+
* The DeepL `target_lang` for a Kiritan locale, parsed with `Intl.Locale` so any BCP 47 tag works (`ja`, `en-US`, `zh-Hant-TW`, `pt_BR`).
|
|
15
|
+
* DeepL has no bare `EN`/`PT` as a target, only a regional variant (British English if the locale says GB, else American; European Portuguese if PT, else Brazilian); Chinese is `ZH-HANS`/`ZH-HANT` by script; Norwegian is always `NB`; anything else is the upper-cased language. `overrides` (matched on the exact locale) win.
|
|
16
|
+
*/
|
|
17
|
+
function toDeepLTarget(locale, overrides = {}) {
|
|
18
|
+
if (Object.hasOwn(overrides, locale)) return overrides[locale];
|
|
19
|
+
const { language, region } = parseLocale(locale);
|
|
20
|
+
switch (language) {
|
|
21
|
+
case "en": return region === "GB" ? "EN-GB" : "EN-US";
|
|
22
|
+
case "pt": return region === "PT" ? "PT-PT" : "PT-BR";
|
|
23
|
+
case "zh": return chineseScript(locale) === "Hant" ? "ZH-HANT" : "ZH-HANS";
|
|
24
|
+
case "no":
|
|
25
|
+
case "nb":
|
|
26
|
+
case "nn": return "NB";
|
|
27
|
+
default: return language.toUpperCase();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
/** DeepL's `source_lang` takes no regional variant or script: `en-GB` -> `EN`, `zh-Hant` -> `ZH`, `no` -> `NB`. */
|
|
31
|
+
function toDeepLSource(locale) {
|
|
32
|
+
const { language } = parseLocale(locale);
|
|
33
|
+
return (language === "no" || language === "nn" ? "nb" : language).toUpperCase();
|
|
34
|
+
}
|
|
35
|
+
const KEEP_TAG = "x";
|
|
36
|
+
/** Escapes the text as XML and wraps every `[[N]]` token in the ignored tag. */
|
|
37
|
+
function encode(text) {
|
|
38
|
+
return encode$1(text, { mode: "specialChars" }).replace(/\[\[(\d+)\]\]/g, `<${KEEP_TAG}>[[$1]]</${KEEP_TAG}>`);
|
|
39
|
+
}
|
|
40
|
+
/** The inverse of `encode`. */
|
|
41
|
+
function decode(text) {
|
|
42
|
+
return decode$1(text.replace(new RegExp(`</?${KEEP_TAG}>`, "gi"), ""), { level: "xml" });
|
|
43
|
+
}
|
|
44
|
+
/** A failure the layer's retry logic can read, and that says what DeepL said. */
|
|
45
|
+
var DeepLError = class extends Error {
|
|
46
|
+
status;
|
|
47
|
+
retryAfter;
|
|
48
|
+
constructor(message, status, retryAfter, options) {
|
|
49
|
+
super(message, options);
|
|
50
|
+
this.status = status;
|
|
51
|
+
this.retryAfter = retryAfter;
|
|
52
|
+
this.name = "DeepLError";
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
function describeError(error) {
|
|
56
|
+
if (error instanceof FetchError && error.status !== void 0) {
|
|
57
|
+
const data = error.data;
|
|
58
|
+
const detail = typeof data === "string" ? data : data ? JSON.stringify(data) : "";
|
|
59
|
+
return new DeepLError(`@kiritan/deepl: DeepL responded ${error.status}${detail ? `: ${detail}` : ""}`, error.status, parseRetryAfter(error.response?.headers.get("retry-after")), { cause: error });
|
|
60
|
+
}
|
|
61
|
+
return new DeepLError(`@kiritan/deepl: request to DeepL failed: ${error instanceof Error ? error.message : String(error)}`, void 0, void 0, { cause: error });
|
|
62
|
+
}
|
|
63
|
+
async function requestTranslations(options, texts, from, to) {
|
|
64
|
+
const baseUrl = options.baseUrl ?? (options.apiKey.endsWith(":fx") ? FREE_URL : PRO_URL);
|
|
65
|
+
const request = createFetch({ fetch: options.fetch ?? globalThis.fetch });
|
|
66
|
+
let body;
|
|
67
|
+
try {
|
|
68
|
+
body = await request(`${baseUrl}/v2/translate`, {
|
|
69
|
+
method: "POST",
|
|
70
|
+
headers: { Authorization: `DeepL-Auth-Key ${options.apiKey}` },
|
|
71
|
+
retry: 0,
|
|
72
|
+
timeout: options.timeout ?? 3e4,
|
|
73
|
+
body: {
|
|
74
|
+
...options.extraParams,
|
|
75
|
+
text: texts,
|
|
76
|
+
source_lang: toDeepLSource(from),
|
|
77
|
+
target_lang: toDeepLTarget(to, options.targetLanguages),
|
|
78
|
+
tag_handling: "xml",
|
|
79
|
+
ignore_tags: [KEEP_TAG]
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
} catch (error) {
|
|
83
|
+
throw describeError(error);
|
|
84
|
+
}
|
|
85
|
+
if (body.translations?.length !== texts.length) throw new DeepLError(`@kiritan/deepl: expected ${texts.length} translation(s), got ${body.translations?.length ?? 0}`);
|
|
86
|
+
return body.translations.map((entry) => entry.text);
|
|
87
|
+
}
|
|
88
|
+
/** The size of a text as it will actually be sent: XML-wrapped and JSON-encoded, in UTF-8 bytes. */
|
|
89
|
+
const wireBytes = (text) => Buffer.byteLength(JSON.stringify(encode(text)), "utf8");
|
|
90
|
+
function create(options, batched) {
|
|
91
|
+
const common = {
|
|
92
|
+
name: `deepl:${createHash("sha1").update(JSON.stringify([options.extraParams ?? {}, options.targetLanguages ?? {}])).digest("hex").slice(0, 12)}`,
|
|
93
|
+
wire: {
|
|
94
|
+
encode,
|
|
95
|
+
decode
|
|
96
|
+
},
|
|
97
|
+
measure: wireBytes,
|
|
98
|
+
maxChars: MAX_TEXT_BYTES,
|
|
99
|
+
maxBatchChars: MAX_BATCH_BYTES,
|
|
100
|
+
maxBatchSize: batched ? MAX_TEXTS_PER_REQUEST : 1,
|
|
101
|
+
retry: {
|
|
102
|
+
retries: options.retry ?? 2,
|
|
103
|
+
delay: options.retryDelay ?? 500
|
|
104
|
+
},
|
|
105
|
+
concurrency: options.concurrency,
|
|
106
|
+
minInterval: options.minInterval,
|
|
107
|
+
cache: options.cache,
|
|
108
|
+
protect: options.protect,
|
|
109
|
+
onError: options.onError,
|
|
110
|
+
onSkip: options.onSkip,
|
|
111
|
+
async translateBatch(texts, { from, to }) {
|
|
112
|
+
return requestTranslations(options, texts, from, to);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
for (const key of Object.keys(common)) if (common[key] === void 0) delete common[key];
|
|
116
|
+
return createTranslator(common);
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* A `translate.middlewares` entry that translates through DeepL, one request per text.
|
|
120
|
+
* Built on `@kiritan/middleware`, so code blocks, inline code, URLs, link destinations, HTML, front matter, `:::kiritan` lines and `%{name}` come back untouched (and a result that lost one is refused), a text over DeepL's size limit is split at paragraph boundaries and rejoined, and requests are retried on 429/5xx.
|
|
121
|
+
*/
|
|
122
|
+
function deepl(options) {
|
|
123
|
+
return create(options, false);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* The same, but texts sharing a language pair go out together — up to DeepL's 50 texts / 128 KiB per request. Prefer this for a catalog with many segments.
|
|
127
|
+
*/
|
|
128
|
+
function deeplBatch(options) {
|
|
129
|
+
return create(options, true);
|
|
130
|
+
}
|
|
131
|
+
//#endregion
|
|
132
|
+
export { decode, deepl, deeplBatch, encode, toDeepLSource, toDeepLTarget };
|
package/package.json
CHANGED
|
@@ -1,19 +1,55 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@kiritan/deepl",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
"
|
|
7
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A DeepL translate middleware for Kiritan / KiritanのDeepL翻訳ミドルウェア",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"deepl",
|
|
7
|
+
"translate",
|
|
8
|
+
"translation",
|
|
9
|
+
"machine-translation",
|
|
10
|
+
"middleware",
|
|
11
|
+
"i18n",
|
|
12
|
+
"kiritan"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://github.com/otnc/kiritan#readme",
|
|
15
|
+
"bugs": {
|
|
16
|
+
"url": "https://github.com/otnc/kiritan/issues"
|
|
17
|
+
},
|
|
18
|
+
"repository": {
|
|
19
|
+
"type": "git",
|
|
20
|
+
"url": "git+https://github.com/otnc/kiritan.git",
|
|
21
|
+
"directory": "packages/deepl"
|
|
22
|
+
},
|
|
23
|
+
"license": "WTFPL",
|
|
24
|
+
"author": "otoneko.",
|
|
25
|
+
"type": "module",
|
|
26
|
+
"main": "dist/index.cjs",
|
|
27
|
+
"module": "dist/index.mjs",
|
|
28
|
+
"types": "dist/index.d.mts",
|
|
29
|
+
"exports": {
|
|
30
|
+
".": {
|
|
31
|
+
"types": {
|
|
32
|
+
"import": "./dist/index.d.mts",
|
|
33
|
+
"require": "./dist/index.d.cts"
|
|
34
|
+
},
|
|
35
|
+
"import": "./dist/index.mjs",
|
|
36
|
+
"require": "./dist/index.cjs"
|
|
37
|
+
}
|
|
8
38
|
},
|
|
9
39
|
"files": [
|
|
10
|
-
"
|
|
40
|
+
"dist",
|
|
41
|
+
"README.ja.md"
|
|
11
42
|
],
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
"
|
|
16
|
-
"
|
|
17
|
-
"
|
|
43
|
+
"engines": {
|
|
44
|
+
"node": ">=22.7.0"
|
|
45
|
+
},
|
|
46
|
+
"scripts": {
|
|
47
|
+
"build": "tsdown",
|
|
48
|
+
"typecheck": "tsc --noEmit"
|
|
49
|
+
},
|
|
50
|
+
"dependencies": {
|
|
51
|
+
"@kiritan/middleware": "^0.1.0",
|
|
52
|
+
"html-entities": "^2.6.0",
|
|
53
|
+
"ofetch": "^1.5.1"
|
|
18
54
|
}
|
|
19
|
-
}
|
|
55
|
+
}
|
package/index.js
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
module.exports = console.log("@kiritan/deepl is reserved for otoneko.");
|