@shipi18n/core 2.6.0 → 2.7.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 +15 -0
- package/package.json +3 -2
- package/src/adapters/index.js +25 -5
- package/src/tree.js +40 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# @shipi18n/core
|
|
2
2
|
|
|
3
|
+
## 2.7.0
|
|
4
|
+
|
|
5
|
+
- New: **YAML locale files** (`.yaml` / `.yml`) are checked alongside JSON — flat and nested trees,
|
|
6
|
+
every existing check (placeholders, plurals, missing/orphan keys, glossary). The check logic was
|
|
7
|
+
already format-agnostic; this adds parsing + file discovery. Adds one dependency (`yaml`).
|
|
8
|
+
|
|
9
|
+
## 2.6.1
|
|
10
|
+
|
|
11
|
+
- Fix: the OpenAI adapter sent `max_tokens`, which current OpenAI models (gpt-5.x) reject with a
|
|
12
|
+
400 — `check --semantic -p openai` and `translate -p openai` failed against api.openai.com. The
|
|
13
|
+
adapter now sends `max_completion_tokens` to api.openai.com and keeps `max_tokens` for
|
|
14
|
+
OpenAI-compatible endpoints (Ollama, Gemini compat, LM Studio, vLLM), with a one-shot fallback
|
|
15
|
+
on the telltale 400 in either direction. Found by running the UIStringBench leaderboard against
|
|
16
|
+
live GPT judges.
|
|
17
|
+
|
|
3
18
|
## 2.6.0
|
|
4
19
|
|
|
5
20
|
- New: reporters (`humanReport`, `jsonReport`, `sarifReport`, `junitReport`, `REPORTERS`,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipi18n/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.0",
|
|
4
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",
|
|
@@ -58,7 +58,8 @@
|
|
|
58
58
|
"jest": "^29.7.0"
|
|
59
59
|
},
|
|
60
60
|
"dependencies": {
|
|
61
|
-
"chalk": "^5.3.0"
|
|
61
|
+
"chalk": "^5.3.0",
|
|
62
|
+
"yaml": "^2.5.0"
|
|
62
63
|
},
|
|
63
64
|
"scripts": {
|
|
64
65
|
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
package/src/adapters/index.js
CHANGED
|
@@ -103,15 +103,35 @@ export function openaiAdapter(config = {}) {
|
|
|
103
103
|
return clientPromise
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
+
// Current OpenAI models (gpt-5.x era) reject `max_tokens` and require
|
|
107
|
+
// `max_completion_tokens`; OpenAI-compatible endpoints (Ollama, Gemini
|
|
108
|
+
// compat, LM Studio, vLLM) mostly still speak `max_tokens`. Try the modern
|
|
109
|
+
// name against api.openai.com and the legacy name against custom baseURLs,
|
|
110
|
+
// and fall back once on the telltale 400 either way.
|
|
111
|
+
let tokenParam = config.baseURL ? 'max_tokens' : 'max_completion_tokens'
|
|
106
112
|
return {
|
|
107
113
|
name: 'openai',
|
|
108
114
|
async complete(prompt, opts = {}) {
|
|
109
115
|
const client = await getClient()
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
116
|
+
const request = (param) =>
|
|
117
|
+
client.chat.completions.create({
|
|
118
|
+
model,
|
|
119
|
+
[param]: opts.maxTokens || 4096,
|
|
120
|
+
messages: [{ role: 'user', content: prompt }],
|
|
121
|
+
})
|
|
122
|
+
let res
|
|
123
|
+
try {
|
|
124
|
+
res = await request(tokenParam)
|
|
125
|
+
} catch (err) {
|
|
126
|
+
const msg = String(err?.message || '')
|
|
127
|
+
const other = tokenParam === 'max_tokens' ? 'max_completion_tokens' : 'max_tokens'
|
|
128
|
+
if (err?.status === 400 && msg.includes(other)) {
|
|
129
|
+
tokenParam = other // remember for subsequent calls
|
|
130
|
+
res = await request(other)
|
|
131
|
+
} else {
|
|
132
|
+
throw err
|
|
133
|
+
}
|
|
134
|
+
}
|
|
115
135
|
const content = res.choices?.[0]?.message?.content
|
|
116
136
|
if (!content) throw new Error('OpenAI response contained no content')
|
|
117
137
|
return content.trim()
|
package/src/tree.js
CHANGED
|
@@ -11,8 +11,17 @@
|
|
|
11
11
|
import { readFileSync, readdirSync, existsSync, statSync } from 'node:fs'
|
|
12
12
|
import { join, basename, resolve, relative, dirname } from 'node:path'
|
|
13
13
|
import { checkTranslations } from './check.js'
|
|
14
|
+
import { parse as parseYaml } from 'yaml'
|
|
14
15
|
import { parseArbBundle } from './formats/arb.js'
|
|
15
16
|
import { parseXcstrings } from './formats/xcstrings.js'
|
|
17
|
+
|
|
18
|
+
// Locale files are JSON or YAML. The check logic is format-agnostic once the
|
|
19
|
+
// file is parsed to an object, so support is entirely a parse + discovery
|
|
20
|
+
// concern. `.json` is matched first so it stays the default when both exist.
|
|
21
|
+
const LOCALE_EXT = /\.(json|ya?ml)$/i
|
|
22
|
+
const stripExt = (name) => name.replace(LOCALE_EXT, '')
|
|
23
|
+
const readData = (file) =>
|
|
24
|
+
/\.ya?ml$/i.test(file) ? parseYaml(readFileSync(file, 'utf8')) : JSON.parse(readFileSync(file, 'utf8'))
|
|
16
25
|
import { flatten } from './translate.js'
|
|
17
26
|
import { lockId, lockFinding } from './locks.js'
|
|
18
27
|
import { reviewTranslations } from './review.js'
|
|
@@ -37,19 +46,19 @@ export function discoverLayout(inputPath, sourceLang) {
|
|
|
37
46
|
|
|
38
47
|
if (statSync(path).isFile()) {
|
|
39
48
|
const dir = resolve(path, '..')
|
|
40
|
-
const lang = basename(path)
|
|
49
|
+
const lang = stripExt(basename(path))
|
|
41
50
|
return flatLayout(dir, lang)
|
|
42
51
|
}
|
|
43
52
|
|
|
44
53
|
const entries = readdirSync(path, { withFileTypes: true })
|
|
45
|
-
if (entries.some((e) => e.isFile() && e.name ===
|
|
54
|
+
if (entries.some((e) => e.isFile() && LOCALE_EXT.test(e.name) && stripExt(e.name) === sourceLang)) {
|
|
46
55
|
return flatLayout(path, sourceLang)
|
|
47
56
|
}
|
|
48
57
|
if (entries.some((e) => e.isDirectory() && e.name === sourceLang)) {
|
|
49
58
|
return nestedLayout(path, sourceLang)
|
|
50
59
|
}
|
|
51
60
|
throw new Error(
|
|
52
|
-
`no source locale found: expected ${join(inputPath, sourceLang + '.json')} or ${join(inputPath, sourceLang)}/`
|
|
61
|
+
`no source locale found: expected ${join(inputPath, sourceLang + '.{json,yaml}')} or ${join(inputPath, sourceLang)}/`
|
|
53
62
|
)
|
|
54
63
|
}
|
|
55
64
|
|
|
@@ -63,18 +72,26 @@ export function discoverLayout(inputPath, sourceLang) {
|
|
|
63
72
|
const LOCALE_NAME = /^[a-z]{2,3}(?:-[A-Za-z0-9]{2,8})*$/
|
|
64
73
|
|
|
65
74
|
export function flatLayout(dir, sourceLang) {
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
75
|
+
// lang → actual file path (extension resolved, since a tree may be .json or
|
|
76
|
+
// .yaml — .json wins if both somehow exist for the same lang).
|
|
77
|
+
const pathByLang = {}
|
|
78
|
+
for (const f of readdirSync(dir).sort()) {
|
|
79
|
+
if (f.startsWith('.') || !LOCALE_EXT.test(f)) continue
|
|
80
|
+
const name = stripExt(f)
|
|
81
|
+
if (!(LOCALE_NAME.test(name) || name === sourceLang)) continue
|
|
82
|
+
if (!pathByLang[name] || f.endsWith('.json')) pathByLang[name] = join(dir, f)
|
|
83
|
+
}
|
|
84
|
+
const langs = Object.keys(pathByLang)
|
|
85
|
+
if (!langs.includes(sourceLang))
|
|
86
|
+
throw new Error(`source file not found: ${join(dir, sourceLang + '.{json,yaml}')}`)
|
|
72
87
|
return {
|
|
73
88
|
layout: 'flat',
|
|
74
89
|
dir,
|
|
75
90
|
sourceLang,
|
|
76
|
-
source:
|
|
77
|
-
targets: langs
|
|
91
|
+
source: { translation: pathByLang[sourceLang] },
|
|
92
|
+
targets: langs
|
|
93
|
+
.filter((l) => l !== sourceLang)
|
|
94
|
+
.map((lang) => ({ lang, files: { translation: pathByLang[lang] } })),
|
|
78
95
|
}
|
|
79
96
|
}
|
|
80
97
|
|
|
@@ -91,12 +108,15 @@ export function nestedLayout(dir, sourceLang) {
|
|
|
91
108
|
(LOCALE_NAME.test(e.name) || e.name === sourceLang)
|
|
92
109
|
)
|
|
93
110
|
.map((e) => e.name)
|
|
94
|
-
const nsFiles = (lang) =>
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
111
|
+
const nsFiles = (lang) => {
|
|
112
|
+
const out = {}
|
|
113
|
+
for (const f of readdirSync(join(dir, lang)).sort()) {
|
|
114
|
+
if (!LOCALE_EXT.test(f)) continue
|
|
115
|
+
const ns = stripExt(f)
|
|
116
|
+
if (!out[ns] || f.endsWith('.json')) out[ns] = join(dir, lang, f)
|
|
117
|
+
}
|
|
118
|
+
return out
|
|
119
|
+
}
|
|
100
120
|
return {
|
|
101
121
|
layout: 'nested',
|
|
102
122
|
dir,
|
|
@@ -182,7 +202,7 @@ export function jsonMode({ input, source, isIgnored, glossary, locks }) {
|
|
|
182
202
|
const layout = discoverLayout(input, source)
|
|
183
203
|
|
|
184
204
|
const sourceData = {}
|
|
185
|
-
for (const [ns, file] of Object.entries(layout.source)) sourceData[ns] =
|
|
205
|
+
for (const [ns, file] of Object.entries(layout.source)) sourceData[ns] = readData(file) // broken source = usage error
|
|
186
206
|
|
|
187
207
|
const perLang = {}
|
|
188
208
|
const languages = []
|
|
@@ -200,9 +220,9 @@ export function jsonMode({ input, source, isIgnored, glossary, locks }) {
|
|
|
200
220
|
}
|
|
201
221
|
let data
|
|
202
222
|
try {
|
|
203
|
-
data =
|
|
223
|
+
data = readData(file)
|
|
204
224
|
} catch (err) {
|
|
205
|
-
const findings = [{ type: 'invalid-json', severity: 'error', path: ns, message: `
|
|
225
|
+
const findings = [{ type: 'invalid-json', severity: 'error', path: ns, message: `could not parse ${rel(file)}: ${err.message}` }]
|
|
206
226
|
namespaces.push({ ns, file: rel(file), findings, stats: statsFrom(findings, srcKeys, 0) })
|
|
207
227
|
continue
|
|
208
228
|
}
|