@shipi18n/core 2.6.1 → 2.8.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 +18 -0
- package/package.json +4 -2
- package/src/check.js +29 -22
- package/src/icu.js +135 -0
- package/src/index.js +1 -0
- package/src/reporters.js +2 -0
- package/src/tree.js +40 -20
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @shipi18n/core
|
|
2
2
|
|
|
3
|
+
## 2.8.0
|
|
4
|
+
|
|
5
|
+
- New: **ICU MessageFormat validation** (P6). When a source string is an ICU plural/select message,
|
|
6
|
+
it's now checked ICU-aware:
|
|
7
|
+
- `plural-category` (warning): the translation's ICU plural is missing a plural category the
|
|
8
|
+
target language needs for everyday counts under CLDR — e.g. a Russian plural with only
|
|
9
|
+
one/other, missing few/many. Categories come from the runtime's `Intl.PluralRules` (no data
|
|
10
|
+
dep); Spanish/French "many" (compact-notation only) is deliberately not flagged.
|
|
11
|
+
- `icu-invalid` (error): the source is valid ICU but the translation no longer parses.
|
|
12
|
+
- Fixes a real false positive: ICU select sub-messages (`{he}`/`{she}`) are no longer mistaken
|
|
13
|
+
for placeholders. Adds `@formatjs/icu-messageformat-parser`.
|
|
14
|
+
|
|
15
|
+
## 2.7.0
|
|
16
|
+
|
|
17
|
+
- New: **YAML locale files** (`.yaml` / `.yml`) are checked alongside JSON — flat and nested trees,
|
|
18
|
+
every existing check (placeholders, plurals, missing/orphan keys, glossary). The check logic was
|
|
19
|
+
already format-agnostic; this adds parsing + file discovery. Adds one dependency (`yaml`).
|
|
20
|
+
|
|
3
21
|
## 2.6.1
|
|
4
22
|
|
|
5
23
|
- Fix: the OpenAI adapter sent `max_tokens`, which current OpenAI models (gpt-5.x) reject with a
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipi18n/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.8.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,9 @@
|
|
|
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",
|
|
63
|
+
"@formatjs/icu-messageformat-parser": "^2.11.0"
|
|
62
64
|
},
|
|
63
65
|
"scripts": {
|
|
64
66
|
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
package/src/check.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import { flatten } from './translate.js'
|
|
9
9
|
import { validatePlaceholders } from './placeholders.js'
|
|
10
|
+
import { isICUControl, checkICU } from './icu.js'
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* vue-i18n expresses plurals as one pipe-separated string
|
|
@@ -121,28 +122,34 @@ export function checkTranslations({ source, target, targetLang = 'target', gloss
|
|
|
121
122
|
continue
|
|
122
123
|
}
|
|
123
124
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
125
|
+
// ICU MessageFormat (plural/select) is validated ICU-aware — the regex
|
|
126
|
+
// placeholder check would read its sub-messages as bogus placeholders.
|
|
127
|
+
if (isICUControl(s)) {
|
|
128
|
+
findings.push(...checkICU(s, t, targetLang, path))
|
|
129
|
+
} else {
|
|
130
|
+
const { missing, added } = validatePlaceholders(s, t)
|
|
131
|
+
if (missing.length) {
|
|
132
|
+
findings.push({
|
|
133
|
+
type: 'placeholder-missing',
|
|
134
|
+
severity: 'error',
|
|
135
|
+
path,
|
|
136
|
+
missing,
|
|
137
|
+
message: `dropped ${missing.join(', ')}`,
|
|
138
|
+
source: s,
|
|
139
|
+
translation: t,
|
|
140
|
+
})
|
|
141
|
+
}
|
|
142
|
+
if (added.length) {
|
|
143
|
+
findings.push({
|
|
144
|
+
type: 'placeholder-added',
|
|
145
|
+
severity: 'warning',
|
|
146
|
+
path,
|
|
147
|
+
added,
|
|
148
|
+
message: `unexpected ${added.join(', ')}`,
|
|
149
|
+
source: s,
|
|
150
|
+
translation: t,
|
|
151
|
+
})
|
|
152
|
+
}
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
const srcForms = pluralFormCount(s)
|
package/src/icu.js
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ICU MessageFormat validation (P6).
|
|
3
|
+
*
|
|
4
|
+
* Strings like `{count, plural, one {# item} other {# items}}` are ICU
|
|
5
|
+
* MessageFormat, not plain placeholders — the sub-messages `{# item}` are NOT
|
|
6
|
+
* interpolation tokens, so the ordinary placeholder check mis-reads them. When
|
|
7
|
+
* the source is an ICU control message we validate it ICU-aware instead:
|
|
8
|
+
*
|
|
9
|
+
* - the same ARGUMENTS survive translation (dropped/added → placeholder-*),
|
|
10
|
+
* - the translation still PARSES as ICU (malformed → icu-invalid),
|
|
11
|
+
* - each plural has the plural CATEGORIES its target locale requires per CLDR
|
|
12
|
+
* (missing → plural-category). CLDR categories come from the runtime's
|
|
13
|
+
* built-in Intl.PluralRules — no data dependency, always current.
|
|
14
|
+
*
|
|
15
|
+
* plural-category is a WARNING: some projects intentionally simplify plurals,
|
|
16
|
+
* and a QA gate that fails builds on that judgment call gets uninstalled.
|
|
17
|
+
*/
|
|
18
|
+
import { parse, TYPE } from '@formatjs/icu-messageformat-parser'
|
|
19
|
+
|
|
20
|
+
/** Does this string use ICU plural/select control syntax (vs. a plain {name})? */
|
|
21
|
+
export const isICUControl = (s) =>
|
|
22
|
+
typeof s === 'string' && /\{\s*[a-zA-Z0-9_]+\s*,\s*(?:plural|selectordinal|select)\s*,/.test(s)
|
|
23
|
+
|
|
24
|
+
/** Walk an ICU AST collecting argument names and plural nodes. */
|
|
25
|
+
function analyze(ast, acc) {
|
|
26
|
+
for (const n of ast) {
|
|
27
|
+
if (n.type === TYPE.argument) acc.args.add(n.value)
|
|
28
|
+
if (n.type === TYPE.select || n.type === TYPE.plural) {
|
|
29
|
+
acc.args.add(n.value)
|
|
30
|
+
if (n.type === TYPE.plural) {
|
|
31
|
+
acc.plurals.push({
|
|
32
|
+
arg: n.value,
|
|
33
|
+
ordinal: n.pluralType === 'ordinal',
|
|
34
|
+
categories: Object.keys(n.options).filter((k) => !k.startsWith('=')),
|
|
35
|
+
})
|
|
36
|
+
}
|
|
37
|
+
for (const opt of Object.values(n.options)) analyze(opt.value, acc)
|
|
38
|
+
}
|
|
39
|
+
if (n.type === TYPE.tag) analyze(n.children || [], acc)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const parseICU = (s) => {
|
|
44
|
+
const acc = { args: new Set(), plurals: [] }
|
|
45
|
+
analyze(parse(s), acc)
|
|
46
|
+
return acc
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Plural categories a locale needs for everyday INTEGER quantities, plus the
|
|
51
|
+
* always-mandatory `other`. Sampling integers (not resolvedOptions) is
|
|
52
|
+
* deliberate: Spanish/French declare a `many` category that only fires for
|
|
53
|
+
* compact notation (millions), never plain counts — flagging its absence would
|
|
54
|
+
* be pedantic noise. Integer sampling keeps the high-value cases (Russian/
|
|
55
|
+
* Arabic/Polish few·many, reachable at small counts) and drops that noise.
|
|
56
|
+
*/
|
|
57
|
+
function requiredCategories(lang, ordinal) {
|
|
58
|
+
try {
|
|
59
|
+
const pr = new Intl.PluralRules(lang, { type: ordinal ? 'ordinal' : 'cardinal' })
|
|
60
|
+
const cats = new Set(['other']) // ICU always requires `other`
|
|
61
|
+
for (let n = 0; n <= 200; n++) cats.add(pr.select(n))
|
|
62
|
+
return [...cats]
|
|
63
|
+
} catch {
|
|
64
|
+
return null // unknown/invalid locale tag — skip the category check
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Validate an ICU source/translation pair. Returns finding objects shaped like
|
|
70
|
+
* the rest of check.js (type/severity/path/message).
|
|
71
|
+
*/
|
|
72
|
+
export function checkICU(source, translation, targetLang, path) {
|
|
73
|
+
const findings = []
|
|
74
|
+
let src
|
|
75
|
+
try {
|
|
76
|
+
src = parseICU(source)
|
|
77
|
+
} catch {
|
|
78
|
+
return findings // malformed SOURCE is a usage error, not a translation defect
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let tr
|
|
82
|
+
try {
|
|
83
|
+
tr = parseICU(translation)
|
|
84
|
+
} catch (err) {
|
|
85
|
+
findings.push({
|
|
86
|
+
type: 'icu-invalid',
|
|
87
|
+
severity: 'error',
|
|
88
|
+
path,
|
|
89
|
+
message: `translation is not valid ICU MessageFormat: ${err.message}`,
|
|
90
|
+
source,
|
|
91
|
+
translation,
|
|
92
|
+
})
|
|
93
|
+
return findings // can't compare further against a string we can't parse
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const missing = [...src.args].filter((a) => !tr.args.has(a))
|
|
97
|
+
const added = [...tr.args].filter((a) => !src.args.has(a))
|
|
98
|
+
if (missing.length)
|
|
99
|
+
findings.push({
|
|
100
|
+
type: 'placeholder-missing',
|
|
101
|
+
severity: 'error',
|
|
102
|
+
path,
|
|
103
|
+
missing: missing.map((a) => `{${a}}`),
|
|
104
|
+
message: `dropped ${missing.map((a) => `{${a}}`).join(', ')}`,
|
|
105
|
+
source,
|
|
106
|
+
translation,
|
|
107
|
+
})
|
|
108
|
+
if (added.length)
|
|
109
|
+
findings.push({
|
|
110
|
+
type: 'placeholder-added',
|
|
111
|
+
severity: 'warning',
|
|
112
|
+
path,
|
|
113
|
+
added: added.map((a) => `{${a}}`),
|
|
114
|
+
message: `unexpected ${added.map((a) => `{${a}}`).join(', ')}`,
|
|
115
|
+
source,
|
|
116
|
+
translation,
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
for (const p of tr.plurals) {
|
|
120
|
+
const required = requiredCategories(targetLang, p.ordinal)
|
|
121
|
+
if (!required) continue
|
|
122
|
+
const missingCats = required.filter((c) => !p.categories.includes(c))
|
|
123
|
+
if (missingCats.length)
|
|
124
|
+
findings.push({
|
|
125
|
+
type: 'plural-category',
|
|
126
|
+
severity: 'warning',
|
|
127
|
+
path,
|
|
128
|
+
message: `plural {${p.arg}} is missing CLDR ${targetLang} categor${missingCats.length > 1 ? 'ies' : 'y'} ${missingCats.join(', ')} (has ${p.categories.join(', ') || 'none'})`,
|
|
129
|
+
source,
|
|
130
|
+
translation,
|
|
131
|
+
})
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
return findings
|
|
135
|
+
}
|
package/src/index.js
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
export { translateJSON, translateStrings, flatten, unflatten } from './translate.js'
|
|
14
14
|
export { extractPlaceholders, validatePlaceholders } from './placeholders.js'
|
|
15
|
+
export { isICUControl, checkICU } from './icu.js'
|
|
15
16
|
export { checkTranslations } from './check.js'
|
|
16
17
|
export { runCheck, runSemantic, discoverLayout, compileIgnores, statsFrom, aggregateLanguage, SEP } from './tree.js'
|
|
17
18
|
export { lockId, lockEntry, lockFinding, emptyLocks, normalizeLocks, LOCKS_VERSION } from './locks.js'
|
package/src/reporters.js
CHANGED
|
@@ -62,6 +62,8 @@ export const RULE_META = {
|
|
|
62
62
|
'placeholder-missing': 'A placeholder from the source string was dropped in the translation.',
|
|
63
63
|
'placeholder-added': 'The translation contains a placeholder the source does not have.',
|
|
64
64
|
'plural-forms': 'A pipe-separated plural lost one or more of its forms in translation.',
|
|
65
|
+
'plural-category': 'An ICU plural is missing a plural category the target language requires under CLDR.',
|
|
66
|
+
'icu-invalid': 'The source is valid ICU MessageFormat but the translation does not parse as ICU.',
|
|
65
67
|
'empty-value': 'The translation of a non-empty source string is empty.',
|
|
66
68
|
'untranslated': 'The translation is identical to a multi-word source string.',
|
|
67
69
|
'type-mismatch': 'Source and translation values have different JSON types.',
|
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
|
}
|