@lionrapid/cli 0.5.0 → 0.6.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
@@ -3,6 +3,21 @@
3
3
  All notable changes to `@lionrapid/cli`. Format based on
4
4
  [Keep a Changelog](https://keepachangelog.com/); this package follows semver.
5
5
 
6
+ ## 0.6.0 (2026-11-04)
7
+
8
+ ### Changed
9
+
10
+ - **Node.js 22 or newer is required** (`engines.node` is now `>=22`, was
11
+ `>=18.0.0`). Node 18 and 20 are past end of life; the package is tested on
12
+ Node 22, 24 and 26.
13
+
14
+ ### Fixed
15
+
16
+ - **`pull` keeps the source file's shape.** A source with flat dotted keys
17
+ (`"cta.start": …`) no longer comes back nested (`"cta": {"start": …}`) in
18
+ the pulled locale files, and a nested source stays nested. JSON and YAML.
19
+ `files[].flat` in `lionrapid.yml` still decides when it is set.
20
+
6
21
  ## 0.5.0
7
22
 
8
23
  ### Changed — behaviour
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 LionRapid
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -7,6 +7,8 @@ import type { FormatAdapter, SerializeOptions, TranslationEntry } from './types.
7
7
  export declare class JsonAdapter implements FormatAdapter {
8
8
  readonly format: "json";
9
9
  parse(content: string): TranslationEntry[];
10
+ isFlat(content: string): boolean | undefined;
11
+ private root;
10
12
  serialize(entries: TranslationEntry[], options?: SerializeOptions): string;
11
13
  }
12
14
  //# sourceMappingURL=json.d.ts.map
@@ -3,13 +3,25 @@
3
3
  * node backends). Nested objects flatten to dot-notation keys (`home.cta`) and
4
4
  * unflatten back on serialize, preserving insertion order.
5
5
  */
6
- import { flatEntriesObject, flattenObject, unflattenEntries } from './nested.js';
6
+ import { flatEntriesObject, flattenObject, treeIsFlat, unflattenEntries } from './nested.js';
7
7
  export class JsonAdapter {
8
8
  format = 'json';
9
9
  parse(content) {
10
+ const data = this.root(content);
11
+ if (data === null)
12
+ return [];
13
+ const out = [];
14
+ flattenObject(data, '', out);
15
+ return out;
16
+ }
17
+ isFlat(content) {
18
+ const data = this.root(content);
19
+ return data === null ? undefined : treeIsFlat(data);
20
+ }
21
+ root(content) {
10
22
  const trimmed = content.trim();
11
23
  if (trimmed === '')
12
- return [];
24
+ return null;
13
25
  let data;
14
26
  try {
15
27
  data = JSON.parse(trimmed);
@@ -20,9 +32,7 @@ export class JsonAdapter {
20
32
  if (data === null || typeof data !== 'object' || Array.isArray(data)) {
21
33
  throw new Error('Expected a JSON object of translation keys.');
22
34
  }
23
- const out = [];
24
- flattenObject(data, '', out);
25
- return out;
35
+ return data;
26
36
  }
27
37
  serialize(entries, options) {
28
38
  const tree = options?.flat ? flatEntriesObject(entries) : unflattenEntries(entries);
@@ -10,6 +10,13 @@ export declare function flattenObject(obj: Record<string, unknown>, prefix: stri
10
10
  * Built with Object.fromEntries so a hostile key like `__proto__` lands as a
11
11
  * plain own property instead of touching the prototype.
12
12
  */
13
+ /**
14
+ * The shape a key file is written in: `true` for flat dotted keys
15
+ * (`"cta.start": …`), `false` when any value is a nested object
16
+ * (`"cta": {"start": …}`), `undefined` when it says neither (no dots, no
17
+ * nesting), where both shapes write the same file.
18
+ */
19
+ export declare function treeIsFlat(tree: Record<string, unknown>): boolean | undefined;
13
20
  export declare function flatEntriesObject(entries: TranslationEntry[]): Record<string, unknown>;
14
21
  export declare function unflattenEntries(entries: TranslationEntry[], onSkippedKey?: (key: string) => void): Record<string, unknown>;
15
22
  //# sourceMappingURL=nested.d.ts.map
@@ -23,6 +23,20 @@ export function flattenObject(obj, prefix, out) {
23
23
  * Built with Object.fromEntries so a hostile key like `__proto__` lands as a
24
24
  * plain own property instead of touching the prototype.
25
25
  */
26
+ /**
27
+ * The shape a key file is written in: `true` for flat dotted keys
28
+ * (`"cta.start": …`), `false` when any value is a nested object
29
+ * (`"cta": {"start": …}`), `undefined` when it says neither (no dots, no
30
+ * nesting), where both shapes write the same file.
31
+ */
32
+ export function treeIsFlat(tree) {
33
+ const values = Object.values(tree);
34
+ if (values.some((v) => v !== null && typeof v === 'object' && !Array.isArray(v)))
35
+ return false;
36
+ if (Object.keys(tree).some((k) => k.includes('.')))
37
+ return true;
38
+ return undefined;
39
+ }
26
40
  export function flatEntriesObject(entries) {
27
41
  return Object.fromEntries(entries.map((e) => [e.key, fromContentUnit(e.value)]));
28
42
  }
@@ -37,6 +37,12 @@ export interface FormatAdapter {
37
37
  readonly format: FileFormat;
38
38
  parse(content: string, options?: ParseOptions): TranslationEntry[];
39
39
  serialize(entries: TranslationEntry[], options?: SerializeOptions): string;
40
+ /**
41
+ * The shape of an existing file (JSON/YAML), so `pull` writes targets the
42
+ * way the source is written: `true` flat dotted keys, `false` nested,
43
+ * `undefined` when it cannot tell (see treeIsFlat).
44
+ */
45
+ isFlat?(content: string, options?: ParseOptions): boolean | undefined;
40
46
  /** True for single-file, multi-locale catalogs (e.g. .xcstrings). */
41
47
  readonly multiLocale?: boolean;
42
48
  /**
@@ -11,6 +11,8 @@ import type { FormatAdapter, ParseOptions, SerializeOptions, TranslationEntry }
11
11
  export declare class YamlAdapter implements FormatAdapter {
12
12
  readonly format: "yaml";
13
13
  parse(content: string, options?: ParseOptions): TranslationEntry[];
14
+ isFlat(content: string, options?: ParseOptions): boolean | undefined;
15
+ private root;
14
16
  serialize(entries: TranslationEntry[], options?: SerializeOptions): string;
15
17
  }
16
18
  //# sourceMappingURL=yaml.d.ts.map
@@ -8,13 +8,25 @@
8
8
  * JSON adapter; this adapter assumes the Rails/locale-rooted shape.)
9
9
  */
10
10
  import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
11
- import { flatEntriesObject, flattenObject, unflattenEntries } from './nested.js';
11
+ import { flatEntriesObject, flattenObject, treeIsFlat, unflattenEntries } from './nested.js';
12
12
  function isObject(v) {
13
13
  return v !== null && typeof v === 'object' && !Array.isArray(v);
14
14
  }
15
15
  export class YamlAdapter {
16
16
  format = 'yaml';
17
17
  parse(content, options) {
18
+ const root = this.root(content, options);
19
+ if (root === null)
20
+ return [];
21
+ const out = [];
22
+ flattenObject(root, '', out);
23
+ return out;
24
+ }
25
+ isFlat(content, options) {
26
+ const root = this.root(content, options);
27
+ return root === null ? undefined : treeIsFlat(root);
28
+ }
29
+ root(content, options) {
18
30
  let data;
19
31
  try {
20
32
  data = parseYaml(content);
@@ -23,7 +35,7 @@ export class YamlAdapter {
23
35
  throw new Error(`Invalid YAML: ${err instanceof Error ? err.message : String(err)}`);
24
36
  }
25
37
  if (data === null || data === undefined)
26
- return [];
38
+ return null;
27
39
  if (!isObject(data))
28
40
  throw new Error('Expected a YAML mapping of translation keys.');
29
41
  // Descend into the locale root: prefer the requested locale, else a single
@@ -38,9 +50,7 @@ export class YamlAdapter {
38
50
  root = data[keys[0]];
39
51
  }
40
52
  }
41
- const out = [];
42
- flattenObject(root, '', out);
43
- return out;
53
+ return root;
44
54
  }
45
55
  serialize(entries, options) {
46
56
  const tree = options?.flat ? flatEntriesObject(entries) : unflattenEntries(entries);
@@ -170,6 +170,29 @@ export async function runPull(config, client, opts) {
170
170
  // namespace fetched on an earlier run must not be erased by this one.
171
171
  const lock = readLock(opts.cwd);
172
172
  const pulledAt = new Date().toISOString();
173
+ // Write each target in its source's shape: flat dotted keys stay flat,
174
+ // nested stays nested. `flat` in the config still decides when set.
175
+ // (2026-11-04: a flat `"cta.start"` source pulled into a nested fi.json.)
176
+ const shapes = new Map();
177
+ const flatFor = (file) => {
178
+ if (file.flat !== undefined)
179
+ return file.flat;
180
+ if (!shapes.has(file)) {
181
+ const sourceAbs = resolve(opts.cwd, file.source);
182
+ let shape;
183
+ try {
184
+ const adapter = getAdapter(file.format);
185
+ shape = existsSync(sourceAbs)
186
+ ? adapter.isFlat?.(readFileSync(sourceAbs, 'utf8'), { locale: config.sourceLocale })
187
+ : undefined;
188
+ }
189
+ catch {
190
+ shape = undefined; // an unreadable source: keep the adapter's default
191
+ }
192
+ shapes.set(file, shape);
193
+ }
194
+ return shapes.get(file);
195
+ };
173
196
  const locales = [];
174
197
  for (const locale of config.targetLocales) {
175
198
  const fileResults = [];
@@ -226,7 +249,7 @@ export async function runPull(config, client, opts) {
226
249
  writeFileSync(absPath, adapter.serialize(toWrite, {
227
250
  locale,
228
251
  sourceLocale: config.sourceLocale,
229
- flat: file.flat,
252
+ flat: flatFor(file),
230
253
  }));
231
254
  }
232
255
  // Record what the SERVER just said. `pull` is the only writer of
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lionrapid/cli",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "LionRapid CLI — sync translation files (iOS, Android, Flutter, JSON, YAML, PO) with the LionRapid backend",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,7 +34,7 @@
34
34
  "yaml": "^2.4.0"
35
35
  },
36
36
  "devDependencies": {
37
- "@types/node": "^20.11.0",
37
+ "@types/node": "^26.0.0",
38
38
  "@vitest/coverage-v8": "^1.6.1",
39
39
  "dotenv-cli": "^7.3.0",
40
40
  "vitest": "^1.6.1"
@@ -46,7 +46,7 @@
46
46
  "CHANGELOG.md"
47
47
  ],
48
48
  "engines": {
49
- "node": ">=18.0.0"
49
+ "node": ">=22"
50
50
  },
51
51
  "keywords": [
52
52
  "i18n",
@@ -67,7 +67,10 @@
67
67
  },
68
68
  "repository": {
69
69
  "type": "git",
70
- "url": "git+https://github.com/Hatamoto/tm-integration.git",
71
- "directory": "packages/cli"
70
+ "url": "git+https://github.com/LionRapid/lionrapid-sdk.git"
71
+ },
72
+ "homepage": "https://docs.lionrapid.com",
73
+ "bugs": {
74
+ "url": "https://github.com/LionRapid/lionrapid-sdk/issues"
72
75
  }
73
76
  }