xcstrings-cli 1.0.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.
@@ -0,0 +1,38 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { add, remove } from '../src/commands/index';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { setupTempFile, cleanupTempFiles } from './utils/testFileHelper';
5
+
6
+ afterEach(async () => await cleanupTempFiles());
7
+
8
+ describe('commands', () => {
9
+ it('add: should add a string to no-strings.xcstrings', async () => {
10
+ const tempFile = await setupTempFile('no-strings.xcstrings');
11
+
12
+ await add(tempFile, 'greeting', 'Hello World', {
13
+ en: 'Hello',
14
+ ja: 'こんにちは'
15
+ });
16
+
17
+ const contentString = await readFile(tempFile, 'utf-8');
18
+ const content = JSON.parse(contentString);
19
+ expect(content.strings).toHaveProperty('greeting');
20
+ expect(content.strings.greeting.comment).toBe('Hello World');
21
+ expect(content.strings.greeting.localizations.en.stringUnit.value).toBe('Hello');
22
+ expect(content.strings.greeting.localizations.ja.stringUnit.value).toBe('こんにちは');
23
+ expect(content.strings.greeting.extractionState).toBe('manual');
24
+ });
25
+
26
+ it('remove: should remove a string from manual-comment-3langs.xcstrings', async () => {
27
+ const tempFile = await setupTempFile('manual-comment-3langs.xcstrings');
28
+
29
+ let content = JSON.parse(await readFile(tempFile, 'utf-8'));
30
+ expect(content.strings).toHaveProperty('closeAction');
31
+
32
+ await remove(tempFile, 'closeAction');
33
+
34
+ content = JSON.parse(await readFile(tempFile, 'utf-8'));
35
+ expect(content.strings).not.toHaveProperty('closeAction');
36
+ expect(content).toHaveProperty('sourceLanguage', 'en');
37
+ });
38
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+ import { loadConfig } from '../src/utils/config';
3
+ import { resolve } from 'path';
4
+ import fs from 'fs/promises';
5
+
6
+ describe('loadConfig', () => {
7
+ const cwd = process.cwd();
8
+ const configJsonPath = resolve(cwd, 'xcstrings-cli.json');
9
+ const configJson5Path = resolve(cwd, 'xcstrings-cli.json5');
10
+
11
+ beforeEach(async () => {
12
+ try { await fs.unlink(configJsonPath); } catch { }
13
+ try { await fs.unlink(configJson5Path); } catch { }
14
+ });
15
+
16
+ afterEach(async () => {
17
+ try { await fs.unlink(configJsonPath); } catch { }
18
+ try { await fs.unlink(configJson5Path); } catch { }
19
+ });
20
+
21
+ it('should load config from xcstrings-cli.json', async () => {
22
+ const config = { xcstringsPaths: ['path/to/Localizable.xcstrings'] };
23
+ await fs.writeFile(configJsonPath, JSON.stringify(config));
24
+
25
+ const result = await loadConfig();
26
+ expect(result).toEqual(config);
27
+ });
28
+
29
+ it('should load config from xcstrings-cli.json5', async () => {
30
+ const configContent = "{ xcstringsPaths: ['path/to/Localizable.xcstrings'] }";
31
+ await fs.writeFile(configJson5Path, configContent);
32
+
33
+ const result = await loadConfig();
34
+ expect(result).toEqual({ xcstringsPaths: ['path/to/Localizable.xcstrings'] });
35
+ });
36
+
37
+ it('should prefer explicit path', async () => {
38
+ const explicitPath = resolve(cwd, 'custom-config.json');
39
+ const config = { xcstringsPaths: ['custom/path'] };
40
+ await fs.writeFile(explicitPath, JSON.stringify(config));
41
+
42
+ try {
43
+ const result = await loadConfig(explicitPath);
44
+ expect(result).toEqual(config);
45
+ } finally {
46
+ await fs.unlink(explicitPath);
47
+ }
48
+ });
49
+
50
+ it('should return null if no config found', async () => {
51
+ const result = await loadConfig();
52
+ expect(result).toBeNull();
53
+ });
54
+ });
@@ -0,0 +1,32 @@
1
+ {
2
+ "sourceLanguage" : "en",
3
+ "strings" : {
4
+ "nonTranslatableString" : {
5
+ "shouldTranslate" : false
6
+ },
7
+ "closeAction" : {
8
+ "comment" : "Button title of closing a dialog, etc.",
9
+ "extractionState" : "manual",
10
+ "localizations" : {
11
+ "en" : {
12
+ "stringUnit" : {
13
+ "state" : "translated",
14
+ "value" : "Close"
15
+ }
16
+ },
17
+ "ja" : {
18
+ "stringUnit" : {
19
+ "state" : "translated",
20
+ "value" : "閉じる"
21
+ }
22
+ },
23
+ "zh-Hans" : {
24
+ "stringUnit" : {
25
+ "state" : "translated",
26
+ "value" : "关闭"
27
+ }
28
+ }
29
+ }
30
+ }
31
+ }
32
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "sourceLanguage" : "en",
3
+ "strings" : {
4
+ }
5
+ }
@@ -0,0 +1,86 @@
1
+ // !$*UTF8*$!
2
+ {
3
+ archiveVersion = 1;
4
+ classes = {
5
+ };
6
+ objectVersion = 56;
7
+ objects = {
8
+
9
+ /* Begin PBXGroup section */
10
+ 13B07FAE1A68108700A75B9A = {
11
+ isa = PBXGroup;
12
+ children = (
13
+ );
14
+ name = TestApp;
15
+ sourceTree = "<group>";
16
+ };
17
+ 83CBB9F61A601CBA00E9B192 = {
18
+ isa = PBXGroup;
19
+ children = (
20
+ 13B07FAE1A68108700A75B9A /* TestApp */,
21
+ );
22
+ indentWidth = 2;
23
+ sourceTree = "<group>";
24
+ tabWidth = 2;
25
+ usesTabs = 0;
26
+ };
27
+ /* End PBXGroup section */
28
+
29
+ /* Begin PBXProject section */
30
+ 83CBB9F71A601CBA00E9B192 /* Project object */ = {
31
+ isa = PBXProject;
32
+ attributes = {
33
+ LastSwiftUpdateCheck = 1340;
34
+ LastUpgradeCheck = 1130;
35
+ TargetAttributes = {
36
+ };
37
+ };
38
+ buildConfigurationList = 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "TestApp" */;
39
+ compatibilityVersion = "Xcode 12.0";
40
+ developmentRegion = en;
41
+ hasScannedForEncodings = 0;
42
+ knownRegions = (
43
+ en,
44
+ Base,
45
+ ja,
46
+ de,
47
+ );
48
+ mainGroup = 83CBB9F61A601CBA00E9B192;
49
+ projectDirPath = "";
50
+ projectRoot = "";
51
+ targets = (
52
+ );
53
+ };
54
+ /* End PBXProject section */
55
+
56
+ /* Begin XCBuildConfiguration section */
57
+ 83CBBA201A601CBA00E9B192 /* Debug */ = {
58
+ isa = XCBuildConfiguration;
59
+ buildSettings = {
60
+ ALWAYS_SEARCH_USER_PATHS = NO;
61
+ };
62
+ name = Debug;
63
+ };
64
+ 83CBBA211A601CBA00E9B192 /* Release */ = {
65
+ isa = XCBuildConfiguration;
66
+ buildSettings = {
67
+ ALWAYS_SEARCH_USER_PATHS = NO;
68
+ };
69
+ name = Release;
70
+ };
71
+ /* End XCBuildConfiguration section */
72
+
73
+ /* Begin XCConfigurationList section */
74
+ 83CBB9FA1A601CBA00E9B192 /* Build configuration list for PBXProject "TestApp" */ = {
75
+ isa = XCConfigurationList;
76
+ buildConfigurations = (
77
+ 83CBBA201A601CBA00E9B192 /* Debug */,
78
+ 83CBBA211A601CBA00E9B192 /* Release */,
79
+ );
80
+ defaultConfigurationIsVisible = 0;
81
+ defaultConfigurationName = Release;
82
+ };
83
+ /* End XCConfigurationList section */
84
+ };
85
+ rootObject = 83CBB9F71A601CBA00E9B192 /* Project object */;
86
+ }
@@ -0,0 +1,72 @@
1
+
2
+ import { describe, it, expect } from 'vitest';
3
+ import { formatXCStrings } from '../src/commands/index';
4
+
5
+ describe('formatXCStrings', () => {
6
+ it('should add space before colon in simple objects', () => {
7
+ const input = JSON.stringify({ key: "value" }, null, 2);
8
+ const expected = input.replace('":', '" :');
9
+ expect(formatXCStrings(input)).toBe(expected);
10
+ expect(formatXCStrings(input)).toContain('"key" : "value"');
11
+ });
12
+
13
+ it('should handle nested objects', () => {
14
+ const obj = {
15
+ nested: {
16
+ child: "grandchild"
17
+ }
18
+ };
19
+ const input = JSON.stringify(obj, null, 2);
20
+ const output = formatXCStrings(input);
21
+ expect(output).toContain('"nested" : {');
22
+ expect(output).toContain('"child" : "grandchild"');
23
+ });
24
+
25
+ it('should not affect colons inside string values', () => {
26
+ const obj = {
27
+ "key:with:colons": "value:with:colons"
28
+ };
29
+ const input = JSON.stringify(obj, null, 2);
30
+ const output = formatXCStrings(input);
31
+ expect(output).toContain('"key:with:colons" : "value:with:colons"');
32
+ });
33
+
34
+ it('should handle escaped quotes inside strings', () => {
35
+ const obj = {
36
+ tricky: 'value has ": sequence inside'
37
+ };
38
+ const input = JSON.stringify(obj, null, 2);
39
+ const output = formatXCStrings(input);
40
+ expect(output).toContain('"tricky" : "value has \\": sequence inside"');
41
+ });
42
+
43
+ it('should handle backslashes correctly', () => {
44
+ const obj = {
45
+ path: 'C:\\Windows\\System32'
46
+ };
47
+ const input = JSON.stringify(obj, null, 2);
48
+ const output = formatXCStrings(input);
49
+ expect(output).toContain('"path" : "C:\\\\Windows\\\\System32"');
50
+ });
51
+
52
+ it('should maintain data integrity when parsed back', () => {
53
+ const obj = {
54
+ "normalKey": "normalValue",
55
+ "key with spaces": "value with spaces",
56
+ "key:with:colons": "value:with:colons",
57
+ "key\"with\"quotes": "value\"with\"quotes",
58
+ "key\\with\\backslashes": "value\\with\\backslashes",
59
+ "nested": {
60
+ "child": "grandchild"
61
+ },
62
+ "empty": {},
63
+ "tricky": "value has \": sequence inside",
64
+ "tricky2": "value ending in quote\"",
65
+ "tricky3": "value ending in backslash\\"
66
+ };
67
+ const input = JSON.stringify(obj, null, 2);
68
+ const output = formatXCStrings(input);
69
+ const parsed = JSON.parse(output);
70
+ expect(parsed).toEqual(obj);
71
+ });
72
+ });
@@ -0,0 +1,21 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { getLanguagesFromXcodeproj, getLanguagesFromXCStrings } from '../src/commands/languages';
3
+ import { resolve } from 'node:path';
4
+ import { FIXTURES_DIR } from './utils/resources';
5
+
6
+ describe('languages', () => {
7
+ it('should extract knownRegions from xcodeproj', () => {
8
+ const xcodeprojPath = resolve(FIXTURES_DIR, 'test.xcodeproj');
9
+ const languages = getLanguagesFromXcodeproj(xcodeprojPath);
10
+
11
+ expect(languages).toEqual(['en', 'Base', 'ja', 'de']);
12
+ });
13
+
14
+ it('should extract languages from xcstrings file', async () => {
15
+ const xcstringsPath = resolve(FIXTURES_DIR, 'manual-comment-3langs.xcstrings');
16
+ const languages = await getLanguagesFromXCStrings(xcstringsPath);
17
+ expect(languages).toContain('ja');
18
+ expect(languages).toContain('en');
19
+ expect(languages).toContain('zh-Hans');
20
+ });
21
+ });
@@ -0,0 +1,4 @@
1
+ import { resolve } from "path";
2
+
3
+ export const FIXTURES_DIR = resolve(__dirname, '..', 'fixtures');
4
+ export const TEMP_DIR = resolve(__dirname, '..', 'temp');
@@ -0,0 +1,30 @@
1
+ import { resolve, basename, extname } from 'node:path';
2
+ import { copyFile, mkdir, unlink } from 'node:fs/promises';
3
+ import { existsSync } from 'node:fs';
4
+ import { FIXTURES_DIR, TEMP_DIR } from './resources';
5
+
6
+ export const createdFiles: string[] = [];
7
+
8
+ export async function setupTempFile(fileName: string): Promise<string> {
9
+ if (!existsSync(TEMP_DIR)) {
10
+ await mkdir(TEMP_DIR);
11
+ }
12
+ const source = resolve(FIXTURES_DIR, fileName);
13
+ const base = basename(fileName, extname(fileName));
14
+ const unique = `${base}-${Date.now()}-${Math.random().toString(36).slice(2)}${extname(fileName)}`;
15
+ const dest = resolve(TEMP_DIR, unique);
16
+ await copyFile(source, dest);
17
+ createdFiles.push(dest);
18
+ return dest;
19
+ }
20
+
21
+ export async function cleanupTempFiles(): Promise<void> {
22
+ for (const f of createdFiles) {
23
+ try {
24
+ await unlink(f);
25
+ } catch {
26
+ // ignore: best effort cleanup
27
+ }
28
+ }
29
+ createdFiles.length = 0;
30
+ }
package/tsconfig.json ADDED
@@ -0,0 +1,19 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ESNext",
4
+ "module": "ESNext",
5
+ "moduleResolution": "bundler",
6
+ "strict": true,
7
+ "esModuleInterop": true,
8
+ "skipLibCheck": true,
9
+ "forceConsistentCasingInFileNames": true,
10
+ "outDir": "./dist",
11
+ "declaration": true
12
+ },
13
+ "include": [
14
+ "src/**/*"
15
+ ],
16
+ "exclude": [
17
+ "node_modules"
18
+ ]
19
+ }
package/vite.config.ts ADDED
@@ -0,0 +1,32 @@
1
+ import { defineConfig } from 'vite';
2
+ import { resolve } from 'path';
3
+
4
+ export default defineConfig({
5
+ build: {
6
+ lib: {
7
+ entry: resolve(__dirname, 'src/index.ts'),
8
+ fileName: 'index',
9
+ formats: ['es'],
10
+ },
11
+ rollupOptions: {
12
+ external: [
13
+ /^node:.*/,
14
+ 'fs',
15
+ 'path',
16
+ 'os',
17
+ 'crypto',
18
+ 'assert',
19
+ 'util',
20
+ 'yargs',
21
+ 'yargs/helpers',
22
+ 'cosmiconfig',
23
+ 'json5',
24
+ '@inquirer/prompts',
25
+ '@bacons/xcode',
26
+ 'chalk',
27
+ ],
28
+ },
29
+ target: 'node18',
30
+ emptyOutDir: true,
31
+ },
32
+ });