@shipi18n/core 2.6.1 → 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 CHANGED
@@ -1,5 +1,11 @@
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
+
3
9
  ## 2.6.1
4
10
 
5
11
  - 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.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/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
  }