@shipi18n/core 2.3.0 → 2.5.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/CHANGELOG.md +20 -0
- package/README.md +26 -5
- package/package.json +6 -2
- package/src/adapters/index.js +41 -9
- package/src/review.js +2 -1
- package/src/translate.js +3 -2
- package/src/tree.js +13 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,25 @@
|
|
|
1
1
|
# @shipi18n/core
|
|
2
2
|
|
|
3
|
+
## 2.5.0
|
|
4
|
+
|
|
5
|
+
- New: the `openai` adapter accepts `baseURL`, pointing it at any OpenAI-compatible endpoint —
|
|
6
|
+
Ollama (no key needed, fully offline), Gemini's compatibility endpoint, Groq, Mistral, LM Studio,
|
|
7
|
+
vLLM, corporate gateways. `translateJSON`, `reviewTranslations` and `runSemantic` all take and
|
|
8
|
+
thread it through. When a `baseURL` is set and no key is given, a placeholder key is sent instead
|
|
9
|
+
of failing, since servers like Ollama accept anything.
|
|
10
|
+
|
|
11
|
+
## 2.4.0
|
|
12
|
+
|
|
13
|
+
- Fix: `runSemantic` now returns `excluded` — the number of pairs it skipped because the key already
|
|
14
|
+
carried a structural error. Without it a fully-broken tree reported `judged 0` and was
|
|
15
|
+
indistinguishable from a clean one, which reads as a dead feature rather than correct behaviour.
|
|
16
|
+
- Fix: the missing-SDK error names a fix that actually works. `npm i @anthropic-ai/sdk` does nothing
|
|
17
|
+
for the `npx @shipi18n/cli` path — that copy of the CLI resolves imports against npm's cache, not
|
|
18
|
+
your project — so the message now says to install the SDK next to the CLI and run `npx shipi18n`.
|
|
19
|
+
- Note: the npm description on this page was stale until this release. npm only refreshes it on
|
|
20
|
+
publish, so the registry still described a translation engine after the project had repositioned
|
|
21
|
+
around translation QA.
|
|
22
|
+
|
|
3
23
|
## 2.3.0
|
|
4
24
|
|
|
5
25
|
- New: manual-translation locks (`lockId`, `lockEntry`, `lockFinding`, `normalizeLocks`) — record
|
package/README.md
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
# @shipi18n/core
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
3
|
+
**The engine behind Shipi18n's translation QA** — placeholder and plural validation, key parity,
|
|
4
|
+
coverage and an LLM-as-judge semantic review — plus a structure-preserving translation engine. Open
|
|
5
|
+
source, **bring your own LLM**, no account and no hosted API.
|
|
6
6
|
|
|
7
7
|
```bash
|
|
8
|
-
npm i @shipi18n/core
|
|
8
|
+
npm i @shipi18n/core # checking needs nothing else
|
|
9
|
+
npm i @shipi18n/core @anthropic-ai/sdk # add a provider SDK to translate or judge
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
```js
|
|
13
|
+
import { runCheck } from '@shipi18n/core'
|
|
14
|
+
|
|
15
|
+
// deterministic, no model, no key
|
|
16
|
+
const { languages, totals } = runCheck({ input: './locales', source: 'en' })
|
|
9
17
|
```
|
|
10
18
|
|
|
11
19
|
## Quickstart
|
|
@@ -28,6 +36,19 @@ console.log(stats) // { translated, reused, placeholderWarnings }
|
|
|
28
36
|
The API key is resolved from `apiKey` or, if omitted, the provider's env var
|
|
29
37
|
(`ANTHROPIC_API_KEY` / `OPENAI_API_KEY`). Your key is used to call **your** LLM directly.
|
|
30
38
|
|
|
39
|
+
The `openai` provider also takes a `baseURL`, which points it at **any OpenAI-compatible
|
|
40
|
+
endpoint** — Ollama (`http://localhost:11434/v1`, no key needed, fully offline), Gemini's
|
|
41
|
+
compatibility endpoint, Groq, Mistral, LM Studio, vLLM, or a corporate gateway:
|
|
42
|
+
|
|
43
|
+
```js
|
|
44
|
+
await translateJSON({
|
|
45
|
+
content, from: 'en', to: 'es',
|
|
46
|
+
provider: 'openai',
|
|
47
|
+
baseURL: 'http://localhost:11434/v1', // Ollama — no apiKey required
|
|
48
|
+
model: 'llama3.2',
|
|
49
|
+
})
|
|
50
|
+
```
|
|
51
|
+
|
|
31
52
|
## What it does
|
|
32
53
|
|
|
33
54
|
- **Structure-preserving** — flattens/unflattens nested JSON; non-string leaves pass through untouched.
|
|
@@ -87,7 +108,7 @@ Format adapters for mobile catalogs are exported too: `parseArbBundle` (Flutter
|
|
|
87
108
|
|
|
88
109
|
## API
|
|
89
110
|
|
|
90
|
-
- `translateJSON({ content, from, to, provider, apiKey?, model?, existing? })` → `{ result, stats }`
|
|
111
|
+
- `translateJSON({ content, from, to, provider, apiKey?, model?, baseURL?, existing? })` → `{ result, stats }`
|
|
91
112
|
- `translateStrings(texts, { adapter, from, to, batchSize? })` → `string[]`
|
|
92
113
|
- `flatten(obj)` / `unflatten(flat)`
|
|
93
114
|
- `extractPlaceholders(str)` / `validatePlaceholders(source, translation)`
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipi18n/core",
|
|
3
|
-
"version": "2.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.5.0",
|
|
4
|
+
"description": "Translation QA for i18n locale files: placeholder and plural validation, key parity, coverage, and an LLM-as-judge semantic review. Also a structure-preserving translation engine — bring your own OpenAI or Anthropic key.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
7
7
|
"exports": {
|
|
@@ -20,6 +20,10 @@
|
|
|
20
20
|
"access": "public"
|
|
21
21
|
},
|
|
22
22
|
"keywords": [
|
|
23
|
+
"validation",
|
|
24
|
+
"lint",
|
|
25
|
+
"translation-quality",
|
|
26
|
+
"i18n-qa",
|
|
23
27
|
"i18n",
|
|
24
28
|
"translation",
|
|
25
29
|
"llm",
|
package/src/adapters/index.js
CHANGED
|
@@ -10,6 +10,26 @@
|
|
|
10
10
|
* @property {string} name
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The SDKs are optional peer deps, so a missing one is the single most common
|
|
15
|
+
* first-run failure. Naming `npm i <sdk>` alone is a trap for the npx path:
|
|
16
|
+
* `npx @shipi18n/cli` runs the CLI out of npm's throwaway cache, which resolves
|
|
17
|
+
* imports against itself and never sees the project's node_modules. The only
|
|
18
|
+
* fix that works there is installing both, then running the local binary.
|
|
19
|
+
* @param {string} provider
|
|
20
|
+
* @param {string} sdk
|
|
21
|
+
* @returns {Error}
|
|
22
|
+
*/
|
|
23
|
+
function missingSdkError(provider, sdk) {
|
|
24
|
+
return new Error(
|
|
25
|
+
`The '${provider}' provider requires the '${sdk}' package.\n` +
|
|
26
|
+
` Install it next to the CLI: npm i -D @shipi18n/cli ${sdk}\n` +
|
|
27
|
+
` then run: npx shipi18n <command>\n` +
|
|
28
|
+
` If you ran 'npx @shipi18n/cli', installing ${sdk} on its own will not help — ` +
|
|
29
|
+
`that copy of the CLI cannot see your project's node_modules.`
|
|
30
|
+
)
|
|
31
|
+
}
|
|
32
|
+
|
|
13
33
|
/**
|
|
14
34
|
* Anthropic Claude adapter. Requires the optional peer dep `@anthropic-ai/sdk`.
|
|
15
35
|
* Key resolved from opts.apiKey or the ANTHROPIC_API_KEY env var (SDK default).
|
|
@@ -24,9 +44,7 @@ export function anthropicAdapter(config = {}) {
|
|
|
24
44
|
clientPromise = import('@anthropic-ai/sdk')
|
|
25
45
|
.then(({ default: Anthropic }) => new Anthropic(config.apiKey ? { apiKey: config.apiKey } : {}))
|
|
26
46
|
.catch(() => {
|
|
27
|
-
throw
|
|
28
|
-
"The 'anthropic' provider requires the '@anthropic-ai/sdk' package. Install it with: npm i @anthropic-ai/sdk"
|
|
29
|
-
)
|
|
47
|
+
throw missingSdkError('anthropic', '@anthropic-ai/sdk')
|
|
30
48
|
})
|
|
31
49
|
}
|
|
32
50
|
return clientPromise
|
|
@@ -52,7 +70,13 @@ export function anthropicAdapter(config = {}) {
|
|
|
52
70
|
/**
|
|
53
71
|
* OpenAI adapter. Requires the optional peer dep `openai`.
|
|
54
72
|
* Key resolved from opts.apiKey or the OPENAI_API_KEY env var (SDK default).
|
|
55
|
-
*
|
|
73
|
+
*
|
|
74
|
+
* `baseURL` points the same adapter at any OpenAI-compatible endpoint —
|
|
75
|
+
* Ollama (http://localhost:11434/v1), Gemini's compatibility endpoint, Groq,
|
|
76
|
+
* Mistral, LM Studio, vLLM, a corporate gateway. Servers like Ollama accept
|
|
77
|
+
* any key, but the SDK refuses to construct without one, so when a baseURL is
|
|
78
|
+
* given and no key is, we pass a placeholder instead of failing the run.
|
|
79
|
+
* @param {{ apiKey?: string, model?: string, baseURL?: string }} [config]
|
|
56
80
|
* @returns {LLMAdapter}
|
|
57
81
|
*/
|
|
58
82
|
export function openaiAdapter(config = {}) {
|
|
@@ -61,11 +85,19 @@ export function openaiAdapter(config = {}) {
|
|
|
61
85
|
const getClient = async () => {
|
|
62
86
|
if (!clientPromise) {
|
|
63
87
|
clientPromise = import('openai')
|
|
64
|
-
.then(
|
|
88
|
+
.then(
|
|
89
|
+
({ default: OpenAI }) =>
|
|
90
|
+
new OpenAI({
|
|
91
|
+
...(config.apiKey
|
|
92
|
+
? { apiKey: config.apiKey }
|
|
93
|
+
: config.baseURL
|
|
94
|
+
? { apiKey: 'not-needed' } // local/keyless endpoints; real ones will 401
|
|
95
|
+
: {}), // no baseURL: keep SDK default (OPENAI_API_KEY env)
|
|
96
|
+
...(config.baseURL ? { baseURL: config.baseURL } : {}),
|
|
97
|
+
})
|
|
98
|
+
)
|
|
65
99
|
.catch(() => {
|
|
66
|
-
throw
|
|
67
|
-
"The 'openai' provider requires the 'openai' package. Install it with: npm i openai"
|
|
68
|
-
)
|
|
100
|
+
throw missingSdkError('openai', 'openai')
|
|
69
101
|
})
|
|
70
102
|
}
|
|
71
103
|
return clientPromise
|
|
@@ -90,7 +122,7 @@ export function openaiAdapter(config = {}) {
|
|
|
90
122
|
/**
|
|
91
123
|
* Resolve a provider name (+ config) to an adapter instance.
|
|
92
124
|
* @param {'anthropic'|'openai'|LLMAdapter} provider
|
|
93
|
-
* @param {{ apiKey?: string, model?: string }} [config]
|
|
125
|
+
* @param {{ apiKey?: string, model?: string, baseURL?: string }} [config]
|
|
94
126
|
* @returns {LLMAdapter}
|
|
95
127
|
*/
|
|
96
128
|
export function resolveAdapter(provider, config = {}) {
|
package/src/review.js
CHANGED
|
@@ -118,10 +118,11 @@ export async function reviewTranslations({
|
|
|
118
118
|
passes = 3,
|
|
119
119
|
glossary,
|
|
120
120
|
cache,
|
|
121
|
+
baseURL,
|
|
121
122
|
}) {
|
|
122
123
|
const judgeModel =
|
|
123
124
|
model ?? (typeof provider === 'string' ? DEFAULT_JUDGE_MODELS[provider] : undefined)
|
|
124
|
-
const adapter = resolveAdapter(provider, { apiKey, model: judgeModel })
|
|
125
|
+
const adapter = resolveAdapter(provider, { apiKey, model: judgeModel, baseURL })
|
|
125
126
|
|
|
126
127
|
const src = flatten(source)
|
|
127
128
|
const tgt = flatten(target)
|
package/src/translate.js
CHANGED
|
@@ -116,11 +116,12 @@ export async function translateStrings(texts, { adapter, from, to, batchSize = 4
|
|
|
116
116
|
* @param {'anthropic'|'openai'|object} params.provider provider name or a custom adapter
|
|
117
117
|
* @param {string} [params.apiKey] LLM API key (else provider env var)
|
|
118
118
|
* @param {string} [params.model] override the provider's default model
|
|
119
|
+
* @param {string} [params.baseURL] OpenAI-compatible endpoint override (Ollama, Gemini compat, ...)
|
|
119
120
|
* @param {Record<string,any>} [params.existing] prior translation → only re-translate changed/new keys (incremental)
|
|
120
121
|
* @returns {Promise<{ result: object, stats: { translated: number, reused: number, placeholderWarnings: Array }}>}
|
|
121
122
|
*/
|
|
122
|
-
export async function translateJSON({ content, from, to, provider, apiKey, model, existing }) {
|
|
123
|
-
const adapter = resolveAdapter(provider, { apiKey, model })
|
|
123
|
+
export async function translateJSON({ content, from, to, provider, apiKey, model, baseURL, existing }) {
|
|
124
|
+
const adapter = resolveAdapter(provider, { apiKey, model, baseURL })
|
|
124
125
|
const sourceFlat = flatten(content)
|
|
125
126
|
const existingFlat = existing ? flatten(existing) : {}
|
|
126
127
|
|
package/src/tree.js
CHANGED
|
@@ -313,11 +313,15 @@ export function runCheck({ input, source = 'en', ignoreKeys, glossary, locks } =
|
|
|
313
313
|
* placeholder. Semantic findings are WARNINGS unless `fail` is set; a noisy
|
|
314
314
|
* gate that blocks PRs gets uninstalled.
|
|
315
315
|
*
|
|
316
|
-
*
|
|
316
|
+
* `excluded` counts the pairs skipped for that reason. It exists so callers can
|
|
317
|
+
* tell "nothing was wrong" apart from "everything was too wrong to judge" — a
|
|
318
|
+
* fully-broken tree otherwise reports `judged 0` and reads like a dead feature.
|
|
319
|
+
*
|
|
320
|
+
* @returns aggregated judge stats { judged, cached, flagged, calls, parseFailures, excluded }
|
|
317
321
|
*/
|
|
318
322
|
|
|
319
|
-
export async function runSemantic(result, { provider, apiKey, model, passes, glossary, cache, fail = false }) {
|
|
320
|
-
const totals = { judged: 0, cached: 0, flagged: 0, calls: 0, parseFailures: 0 }
|
|
323
|
+
export async function runSemantic(result, { provider, apiKey, model, baseURL, passes, glossary, cache, fail = false }) {
|
|
324
|
+
const totals = { judged: 0, cached: 0, flagged: 0, calls: 0, parseFailures: 0, excluded: 0 }
|
|
321
325
|
|
|
322
326
|
for (const l of result.languages) {
|
|
323
327
|
const pairs = result.semanticPairs?.[l.lang]
|
|
@@ -331,7 +335,10 @@ export async function runSemantic(result, { provider, apiKey, model, passes, glo
|
|
|
331
335
|
const src = {}
|
|
332
336
|
const tgt = {}
|
|
333
337
|
for (const key of Object.keys(pairs.source)) {
|
|
334
|
-
if (errorPaths.has(key))
|
|
338
|
+
if (errorPaths.has(key)) {
|
|
339
|
+
totals.excluded++
|
|
340
|
+
continue
|
|
341
|
+
}
|
|
335
342
|
src[key] = pairs.source[key]
|
|
336
343
|
tgt[key] = pairs.target[key]
|
|
337
344
|
}
|
|
@@ -339,9 +346,9 @@ export async function runSemantic(result, { provider, apiKey, model, passes, glo
|
|
|
339
346
|
|
|
340
347
|
const { findings, stats } = await reviewTranslations({
|
|
341
348
|
source: src, target: tgt, from: result.source, to: l.lang,
|
|
342
|
-
provider, apiKey, model, passes, glossary, cache,
|
|
349
|
+
provider, apiKey, model, baseURL, passes, glossary, cache,
|
|
343
350
|
})
|
|
344
|
-
for (const k of Object.keys(
|
|
351
|
+
for (const k of Object.keys(stats)) totals[k] = (totals[k] ?? 0) + (stats[k] ?? 0)
|
|
345
352
|
|
|
346
353
|
for (const f of findings) {
|
|
347
354
|
const sepAt = f.path.indexOf(SEP)
|