@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 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.6.1",
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
- const { missing, added } = validatePlaceholders(s, t)
125
- if (missing.length) {
126
- findings.push({
127
- type: 'placeholder-missing',
128
- severity: 'error',
129
- path,
130
- missing,
131
- message: `dropped ${missing.join(', ')}`,
132
- source: s,
133
- translation: t,
134
- })
135
- }
136
- if (added.length) {
137
- findings.push({
138
- type: 'placeholder-added',
139
- severity: 'warning',
140
- path,
141
- added,
142
- message: `unexpected ${added.join(', ')}`,
143
- source: s,
144
- translation: t,
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).replace(/\.json$/, '')
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 === `${sourceLang}.json`)) {
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
- const langs = readdirSync(dir)
67
- .filter((f) => f.endsWith('.json') && !f.startsWith('.'))
68
- .map((f) => f.replace(/\.json$/, ''))
69
- .filter((name) => LOCALE_NAME.test(name) || name === sourceLang)
70
- if (!langs.includes(sourceLang)) throw new Error(`source file not found: ${join(dir, sourceLang + '.json')}`)
71
- const files = (lang) => ({ translation: join(dir, `${lang}.json`) })
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: files(sourceLang),
77
- targets: langs.filter((l) => l !== sourceLang).map((lang) => ({ lang, files: files(lang) })),
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
- Object.fromEntries(
96
- readdirSync(join(dir, lang))
97
- .filter((f) => f.endsWith('.json'))
98
- .map((f) => [f.replace(/\.json$/, ''), join(dir, lang, f)])
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] = readJson(file) // broken source = usage error
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 = readJson(file)
223
+ data = readData(file)
204
224
  } catch (err) {
205
- const findings = [{ type: 'invalid-json', severity: 'error', path: ns, message: `invalid JSON: ${err.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
  }