@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 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
- Open-source, **bring-your-own-LLM** i18n translation engine. Translate locale JSON with your own
4
- OpenAI or Anthropic key no Shipi18n account, no hosted API, no per-word fees. Provider-agnostic and
5
- extensible.
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 @anthropic-ai/sdk # or: npm i @shipi18n/core openai
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.3.0",
4
- "description": "Open-source, bring-your-own-LLM i18n translation engine. Provider-agnostic (OpenAI, Anthropic, ...).",
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",
@@ -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 new Error(
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
- * @param {{ apiKey?: string, model?: string }} [config]
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(({ default: OpenAI }) => new OpenAI(config.apiKey ? { apiKey: config.apiKey } : {}))
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 new Error(
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
- * @returns aggregated judge stats { judged, cached, flagged, calls, parseFailures }
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)) continue
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(totals)) totals[k] += stats[k] ?? 0
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)