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.
- package/.changeset/README.md +8 -0
- package/.changeset/config.json +11 -0
- package/.github/workflows/release.yml +56 -0
- package/.markdownlint.json +3 -0
- package/.prettierrc +7 -0
- package/LICENSE +19 -0
- package/README.md +120 -0
- package/dist/index.js +659 -0
- package/package.json +51 -0
- package/src/commands/_shared.ts +63 -0
- package/src/commands/add.ts +38 -0
- package/src/commands/index.ts +5 -0
- package/src/commands/init.ts +159 -0
- package/src/commands/languages.ts +43 -0
- package/src/commands/remove.ts +9 -0
- package/src/index.ts +126 -0
- package/src/utils/cli.ts +60 -0
- package/src/utils/config.ts +32 -0
- package/src/utils/logger.ts +7 -0
- package/tests/cli.heredoc.test.ts +44 -0
- package/tests/cli.stdin.test.ts +61 -0
- package/tests/cli.unknown.test.ts +24 -0
- package/tests/commands.test.ts +38 -0
- package/tests/config.test.ts +54 -0
- package/tests/fixtures/manual-comment-3langs.xcstrings +32 -0
- package/tests/fixtures/no-strings.xcstrings +5 -0
- package/tests/fixtures/test.xcodeproj/project.pbxproj +86 -0
- package/tests/formatting.test.ts +72 -0
- package/tests/languages.test.ts +21 -0
- package/tests/utils/resources.ts +4 -0
- package/tests/utils/testFileHelper.ts +30 -0
- package/tsconfig.json +19 -0
- package/vite.config.ts +32 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { readFile, writeFile } from 'node:fs/promises';
|
|
2
|
+
|
|
3
|
+
export interface XCStrings {
|
|
4
|
+
sourceLanguage: string;
|
|
5
|
+
strings: Record<string, XCStringUnit>;
|
|
6
|
+
version?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface XCStringUnit {
|
|
10
|
+
comment?: string;
|
|
11
|
+
extractionState?: 'manual' | 'migrated' | 'stale' | 'ucheck';
|
|
12
|
+
localizations?: Record<string, XCStringLocalization>;
|
|
13
|
+
shouldTranslate?: boolean;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface XCStringLocalization {
|
|
17
|
+
stringUnit: {
|
|
18
|
+
state: 'translated' | 'needs_review' | 'new';
|
|
19
|
+
value: string;
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function readXCStrings(path: string): Promise<XCStrings> {
|
|
24
|
+
const content = await readFile(path, 'utf-8');
|
|
25
|
+
return JSON.parse(content) as XCStrings;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export async function writeXCStrings(path: string, data: XCStrings): Promise<void> {
|
|
29
|
+
const json = JSON.stringify(data, null, 2);
|
|
30
|
+
const formatted = formatXCStrings(json);
|
|
31
|
+
await writeFile(path, formatted + '\n', 'utf-8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function formatXCStrings(json: string): string {
|
|
35
|
+
let result = '';
|
|
36
|
+
let inString = false;
|
|
37
|
+
let escape = false;
|
|
38
|
+
|
|
39
|
+
for (let i = 0; i < json.length; i++) {
|
|
40
|
+
const char = json[i];
|
|
41
|
+
if (inString && char === '\\' && !escape) {
|
|
42
|
+
escape = true;
|
|
43
|
+
result += char;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (escape) {
|
|
47
|
+
escape = false;
|
|
48
|
+
result += char;
|
|
49
|
+
continue;
|
|
50
|
+
}
|
|
51
|
+
if (char === '"') {
|
|
52
|
+
inString = !inString;
|
|
53
|
+
result += char;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (!inString && char === ':') {
|
|
57
|
+
result += ' :';
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
result += char;
|
|
61
|
+
}
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { readXCStrings, writeXCStrings, XCStringUnit } from './_shared';
|
|
2
|
+
|
|
3
|
+
export async function add(
|
|
4
|
+
path: string,
|
|
5
|
+
key: string,
|
|
6
|
+
comment: string | undefined,
|
|
7
|
+
strings: Record<string, string> | undefined,
|
|
8
|
+
): Promise<void> {
|
|
9
|
+
const data = await readXCStrings(path);
|
|
10
|
+
|
|
11
|
+
if (!data.strings) {
|
|
12
|
+
data.strings = {};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const unit: XCStringUnit = {
|
|
16
|
+
...data.strings[key],
|
|
17
|
+
extractionState: 'manual',
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
if (comment) {
|
|
21
|
+
unit.comment = comment;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (strings) {
|
|
25
|
+
unit.localizations = unit.localizations || {};
|
|
26
|
+
for (const [lang, value] of Object.entries(strings)) {
|
|
27
|
+
unit.localizations[lang] = {
|
|
28
|
+
stringUnit: {
|
|
29
|
+
state: 'translated',
|
|
30
|
+
value: value,
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
data.strings[key] = unit;
|
|
37
|
+
await writeXCStrings(path, data);
|
|
38
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
import { writeFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { resolve, relative } from 'node:path';
|
|
3
|
+
import chalk from 'chalk';
|
|
4
|
+
import { checkbox, confirm } from '@inquirer/prompts';
|
|
5
|
+
|
|
6
|
+
const INIT_FILE_NAME = 'xcstrings-cli.json5';
|
|
7
|
+
|
|
8
|
+
async function findXCStringsFiles(dir: string): Promise<string[]> {
|
|
9
|
+
const results: string[] = [];
|
|
10
|
+
|
|
11
|
+
async function walk(currentDir: string): Promise<void> {
|
|
12
|
+
try {
|
|
13
|
+
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
14
|
+
for (const entry of entries) {
|
|
15
|
+
const fullPath = resolve(currentDir, entry.name);
|
|
16
|
+
if (entry.isDirectory()) {
|
|
17
|
+
// Skip node_modules, .git, etc.
|
|
18
|
+
if (!entry.name.startsWith('.') && entry.name !== 'node_modules') {
|
|
19
|
+
await walk(fullPath);
|
|
20
|
+
}
|
|
21
|
+
} else if (entry.name.endsWith('.xcstrings')) {
|
|
22
|
+
results.push(fullPath);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} catch {
|
|
26
|
+
// Ignore permission errors
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
await walk(dir);
|
|
31
|
+
return results;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function findXcodeprojDirs(startDir: string): Promise<string[]> {
|
|
35
|
+
const results: string[] = [];
|
|
36
|
+
let currentDir = startDir;
|
|
37
|
+
try {
|
|
38
|
+
const entries = await readdir(currentDir, { withFileTypes: true });
|
|
39
|
+
for (const entry of entries) {
|
|
40
|
+
if (entry.isDirectory() && entry.name.endsWith('.xcodeproj')) {
|
|
41
|
+
results.push(resolve(currentDir, entry.name));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
} catch {
|
|
45
|
+
// Ignore permission errors
|
|
46
|
+
}
|
|
47
|
+
return results;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function init(): Promise<void> {
|
|
51
|
+
const cwd = process.cwd();
|
|
52
|
+
|
|
53
|
+
console.log();
|
|
54
|
+
console.log(chalk.bold.cyan('🚀 xcstrings-cli Configuration Setup'));
|
|
55
|
+
console.log(chalk.dim('─'.repeat(40)));
|
|
56
|
+
console.log();
|
|
57
|
+
|
|
58
|
+
console.log(chalk.yellow('🔍 Searching for .xcstrings files...'));
|
|
59
|
+
const xcstringsFiles = await findXCStringsFiles(cwd);
|
|
60
|
+
|
|
61
|
+
console.log(chalk.yellow('🔍 Searching for .xcodeproj directories...'));
|
|
62
|
+
const xcodeprojDirs = await findXcodeprojDirs(cwd);
|
|
63
|
+
|
|
64
|
+
console.log();
|
|
65
|
+
|
|
66
|
+
let selectedXCStrings: string[] = [];
|
|
67
|
+
if (xcstringsFiles.length > 0) {
|
|
68
|
+
console.log(chalk.green(`✓ Found ${xcstringsFiles.length} .xcstrings file(s)`));
|
|
69
|
+
|
|
70
|
+
const choices = xcstringsFiles.map((file) => ({
|
|
71
|
+
name: chalk.white(relative(cwd, file)) + chalk.dim(` (${file})`),
|
|
72
|
+
value: relative(cwd, file),
|
|
73
|
+
checked: true,
|
|
74
|
+
}));
|
|
75
|
+
|
|
76
|
+
selectedXCStrings = await checkbox({
|
|
77
|
+
message: chalk.bold('Select .xcstrings files to manage:'),
|
|
78
|
+
choices,
|
|
79
|
+
});
|
|
80
|
+
} else {
|
|
81
|
+
console.log(chalk.dim(' No .xcstrings files found in current directory'));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
console.log();
|
|
85
|
+
|
|
86
|
+
let selectedXcodeproj: string[] = [];
|
|
87
|
+
if (xcodeprojDirs.length > 0) {
|
|
88
|
+
console.log(chalk.green(`✓ Found ${xcodeprojDirs.length} .xcodeproj director${xcodeprojDirs.length === 1 ? 'y' : 'ies'}`));
|
|
89
|
+
|
|
90
|
+
const choices = xcodeprojDirs.map((dir) => ({
|
|
91
|
+
name: chalk.white(relative(cwd, dir) || dir) + chalk.dim(` (${dir})`),
|
|
92
|
+
value: relative(cwd, dir) || dir,
|
|
93
|
+
checked: true,
|
|
94
|
+
}));
|
|
95
|
+
|
|
96
|
+
selectedXcodeproj = await checkbox({
|
|
97
|
+
message: chalk.bold('Select .xcodeproj directories for language detection:'),
|
|
98
|
+
choices,
|
|
99
|
+
});
|
|
100
|
+
} else {
|
|
101
|
+
console.log(chalk.dim(' No .xcodeproj directories found'));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
console.log();
|
|
105
|
+
console.log(chalk.dim('─'.repeat(40)));
|
|
106
|
+
console.log();
|
|
107
|
+
|
|
108
|
+
console.log(chalk.bold('📋 Configuration Summary:'));
|
|
109
|
+
console.log();
|
|
110
|
+
|
|
111
|
+
console.log(chalk.cyan(' xcstringsPaths:'));
|
|
112
|
+
if (selectedXCStrings.length > 0) {
|
|
113
|
+
selectedXCStrings.forEach((p) => console.log(chalk.white(` • ${p}`)));
|
|
114
|
+
} else {
|
|
115
|
+
console.log(chalk.dim(' (none)'));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
console.log();
|
|
119
|
+
console.log(chalk.cyan(' xcodeprojPaths:'));
|
|
120
|
+
if (selectedXcodeproj.length > 0) {
|
|
121
|
+
selectedXcodeproj.forEach((p) => console.log(chalk.white(` • ${p}`)));
|
|
122
|
+
} else {
|
|
123
|
+
console.log(chalk.dim(' (none)'));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
console.log();
|
|
127
|
+
|
|
128
|
+
const shouldWrite = await confirm({
|
|
129
|
+
message: chalk.bold(`Create ${chalk.yellow(INIT_FILE_NAME)}?`),
|
|
130
|
+
default: true,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
if (!shouldWrite) {
|
|
134
|
+
console.log(chalk.dim(' Configuration cancelled.'));
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const xcstringsArray = selectedXCStrings.map((p) => ` "${p}"`).join(',\n');
|
|
139
|
+
const xcodeprojArray = selectedXcodeproj.map((p) => ` "${p}"`).join(',\n');
|
|
140
|
+
|
|
141
|
+
const config = `{
|
|
142
|
+
// Array of paths to .xcstrings files to manage. Specify relative or absolute paths.
|
|
143
|
+
xcstringsPaths: [
|
|
144
|
+
${xcstringsArray}
|
|
145
|
+
],
|
|
146
|
+
// Array of paths to .xcodeproj directories. Used for discovering supported languages.
|
|
147
|
+
xcodeprojPaths: [
|
|
148
|
+
${xcodeprojArray}
|
|
149
|
+
]
|
|
150
|
+
}
|
|
151
|
+
`;
|
|
152
|
+
|
|
153
|
+
await writeFile(INIT_FILE_NAME, config, 'utf-8');
|
|
154
|
+
|
|
155
|
+
console.log();
|
|
156
|
+
console.log(chalk.bold.green(`✓ Created ${INIT_FILE_NAME}`));
|
|
157
|
+
console.log(chalk.dim(` Run ${chalk.cyan('xcstrings --help')} to see available commands.`));
|
|
158
|
+
console.log();
|
|
159
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { XcodeProject } from '@bacons/xcode';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { loadConfig } from '../utils/config';
|
|
4
|
+
import { readXCStrings } from './_shared';
|
|
5
|
+
|
|
6
|
+
export function getLanguagesFromXcodeproj(xcodeprojPath: string): string[] {
|
|
7
|
+
const pbxprojPath = resolve(xcodeprojPath, 'project.pbxproj');
|
|
8
|
+
const project = XcodeProject.open(pbxprojPath);
|
|
9
|
+
const rootObject = project.rootObject;
|
|
10
|
+
return rootObject.props.knownRegions ?? [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export async function getLanguagesFromXCStrings(xcstringsPath: string): Promise<string[]> {
|
|
14
|
+
const xcstrings = await readXCStrings(xcstringsPath);
|
|
15
|
+
const languages = new Set<string>();
|
|
16
|
+
|
|
17
|
+
for (const key of Object.keys(xcstrings.strings)) {
|
|
18
|
+
const unit = xcstrings.strings[key];
|
|
19
|
+
if (unit.localizations) {
|
|
20
|
+
for (const lang of Object.keys(unit.localizations)) {
|
|
21
|
+
languages.add(lang);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return Array.from(languages).sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function languages(
|
|
30
|
+
xcstringsPath: string,
|
|
31
|
+
configPath?: string
|
|
32
|
+
): Promise<string[]> {
|
|
33
|
+
const config = await loadConfig(configPath);
|
|
34
|
+
if (config?.xcodeprojPaths && config.xcodeprojPaths.length > 0) {
|
|
35
|
+
const allLanguages = new Set<string>();
|
|
36
|
+
for (const xcodeprojPath of config.xcodeprojPaths) {
|
|
37
|
+
const langs = getLanguagesFromXcodeproj(xcodeprojPath);
|
|
38
|
+
langs.forEach((lang) => allLanguages.add(lang));
|
|
39
|
+
}
|
|
40
|
+
return Array.from(allLanguages).sort();
|
|
41
|
+
}
|
|
42
|
+
return await getLanguagesFromXCStrings(xcstringsPath);
|
|
43
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { readXCStrings, writeXCStrings } from './_shared';
|
|
2
|
+
|
|
3
|
+
export async function remove(path: string, key: string): Promise<void> {
|
|
4
|
+
const data = await readXCStrings(path);
|
|
5
|
+
if (data.strings && data.strings[key]) {
|
|
6
|
+
delete data.strings[key];
|
|
7
|
+
await writeXCStrings(path, data);
|
|
8
|
+
}
|
|
9
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import yargs from 'yargs';
|
|
4
|
+
import { hideBin } from 'yargs/helpers';
|
|
5
|
+
import { remove, init, languages } from './commands/index.js';
|
|
6
|
+
import { resolve } from 'node:path';
|
|
7
|
+
import { loadConfig } from './utils/config.js';
|
|
8
|
+
import logger from './utils/logger.js';
|
|
9
|
+
import { runAddCommand } from './utils/cli.js';
|
|
10
|
+
import { select } from '@inquirer/prompts';
|
|
11
|
+
import chalk from 'chalk';
|
|
12
|
+
|
|
13
|
+
const defaultPath = resolve(process.cwd(), 'Localizable.xcstrings');
|
|
14
|
+
|
|
15
|
+
yargs(hideBin(process.argv))
|
|
16
|
+
.scriptName('xcstrings')
|
|
17
|
+
.usage('$0 <cmd> [args]')
|
|
18
|
+
.option('config', {
|
|
19
|
+
type: 'string',
|
|
20
|
+
describe: 'Path to config file',
|
|
21
|
+
})
|
|
22
|
+
.option('path', {
|
|
23
|
+
type: 'string',
|
|
24
|
+
describe: 'Path to xcstrings file',
|
|
25
|
+
default: defaultPath
|
|
26
|
+
})
|
|
27
|
+
.middleware(async (argv) => {
|
|
28
|
+
if (argv.path !== defaultPath) {
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const config = await loadConfig(argv.config as string | undefined);
|
|
33
|
+
|
|
34
|
+
if (!config || !config.xcstringsPaths || config.xcstringsPaths.length === 0) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
if (config.xcstringsPaths.length === 1) {
|
|
39
|
+
const entry = config.xcstringsPaths[0];
|
|
40
|
+
argv.path = typeof entry === 'string' ? entry : entry.path;
|
|
41
|
+
} else {
|
|
42
|
+
const choices = config.xcstringsPaths.map((entry) => {
|
|
43
|
+
if (typeof entry === 'string') {
|
|
44
|
+
return { name: entry, value: entry };
|
|
45
|
+
} else {
|
|
46
|
+
return { name: `${entry.alias} (${entry.path})`, value: entry.path };
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const selectedPath = await select({
|
|
51
|
+
message: 'Select xcstrings file:',
|
|
52
|
+
choices: choices,
|
|
53
|
+
});
|
|
54
|
+
argv.path = selectedPath;
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
.command(
|
|
58
|
+
'add',
|
|
59
|
+
'Add a string',
|
|
60
|
+
(yargs) => yargs
|
|
61
|
+
.option('key', {
|
|
62
|
+
type: 'string',
|
|
63
|
+
describe: 'The key of the string',
|
|
64
|
+
demandOption: true,
|
|
65
|
+
})
|
|
66
|
+
.option('comment', {
|
|
67
|
+
type: 'string',
|
|
68
|
+
describe: 'The comment for the string',
|
|
69
|
+
})
|
|
70
|
+
.option('strings', {
|
|
71
|
+
type: 'string',
|
|
72
|
+
describe: 'The strings JSON'
|
|
73
|
+
}),
|
|
74
|
+
async (argv) => {
|
|
75
|
+
await runAddCommand(argv.path as string, argv.key as string, argv.comment as string | undefined, argv.strings as unknown);
|
|
76
|
+
logger.info(chalk.green(`✓ Added key "${argv.key}"`));
|
|
77
|
+
},
|
|
78
|
+
)
|
|
79
|
+
.command(
|
|
80
|
+
'remove',
|
|
81
|
+
'Remove a string',
|
|
82
|
+
(yargs) => yargs.option('key', {
|
|
83
|
+
type: 'string',
|
|
84
|
+
describe: 'The key to remove',
|
|
85
|
+
demandOption: true,
|
|
86
|
+
}),
|
|
87
|
+
async (argv) => {
|
|
88
|
+
await remove(argv.path as string, argv.key as string);
|
|
89
|
+
logger.info(chalk.green(`✓ Removed key "${argv.key}"`));
|
|
90
|
+
},
|
|
91
|
+
)
|
|
92
|
+
.command(
|
|
93
|
+
'init',
|
|
94
|
+
'Initialize configuration file',
|
|
95
|
+
(yargs) => yargs,
|
|
96
|
+
async () => {
|
|
97
|
+
await init();
|
|
98
|
+
},
|
|
99
|
+
)
|
|
100
|
+
.command(
|
|
101
|
+
'languages',
|
|
102
|
+
'List supported languages from xcodeproj or xcstrings',
|
|
103
|
+
(yargs) => yargs,
|
|
104
|
+
async (argv) => {
|
|
105
|
+
const result = await languages(argv.path as string, argv.config as string | undefined);
|
|
106
|
+
logger.info(result.join(' '));
|
|
107
|
+
},
|
|
108
|
+
)
|
|
109
|
+
.demandCommand(1, '')
|
|
110
|
+
.strictCommands()
|
|
111
|
+
.recommendCommands()
|
|
112
|
+
.showHelpOnFail(true)
|
|
113
|
+
.fail((msg, err, yargsInstance) => {
|
|
114
|
+
if (err) {
|
|
115
|
+
console.error(err);
|
|
116
|
+
throw err;
|
|
117
|
+
}
|
|
118
|
+
if (msg) {
|
|
119
|
+
console.error(chalk.red(msg));
|
|
120
|
+
console.log();
|
|
121
|
+
}
|
|
122
|
+
yargsInstance.showHelp();
|
|
123
|
+
process.exit(1);
|
|
124
|
+
})
|
|
125
|
+
.help()
|
|
126
|
+
.argv;
|
package/src/utils/cli.ts
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { add } from '../commands/index.js';
|
|
2
|
+
|
|
3
|
+
export async function readStdinToString(): Promise<string> {
|
|
4
|
+
return new Promise((resolve) => {
|
|
5
|
+
let data = '';
|
|
6
|
+
process.stdin.setEncoding('utf8');
|
|
7
|
+
process.stdin.on('data', (chunk) => {
|
|
8
|
+
data += chunk;
|
|
9
|
+
});
|
|
10
|
+
process.stdin.on('end', () => {
|
|
11
|
+
resolve(data);
|
|
12
|
+
});
|
|
13
|
+
if (process.stdin.readableEnded) {
|
|
14
|
+
resolve('');
|
|
15
|
+
}
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function parseStringsArg(
|
|
20
|
+
stringsArg: unknown,
|
|
21
|
+
stdinReader: () => Promise<string> = readStdinToString,
|
|
22
|
+
): Promise<Record<string, string> | undefined> {
|
|
23
|
+
if (stringsArg === undefined) {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
if (stringsArg === '') {
|
|
27
|
+
const stdin = await stdinReader();
|
|
28
|
+
if (!stdin.trim()) return undefined;
|
|
29
|
+
return JSON.parse(stdin);
|
|
30
|
+
}
|
|
31
|
+
if (typeof stringsArg === 'string') {
|
|
32
|
+
return JSON.parse(stringsArg);
|
|
33
|
+
}
|
|
34
|
+
if (Array.isArray(stringsArg)) {
|
|
35
|
+
const merged: Record<string, string> = {};
|
|
36
|
+
for (const item of stringsArg) {
|
|
37
|
+
if (typeof item === 'string') {
|
|
38
|
+
Object.assign(merged, JSON.parse(item));
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return merged;
|
|
42
|
+
}
|
|
43
|
+
if (typeof stringsArg === 'boolean' && stringsArg === true) {
|
|
44
|
+
const stdin = await stdinReader();
|
|
45
|
+
if (!stdin.trim()) return undefined;
|
|
46
|
+
return JSON.parse(stdin);
|
|
47
|
+
}
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export async function runAddCommand(
|
|
52
|
+
path: string,
|
|
53
|
+
key: string,
|
|
54
|
+
comment: string | undefined,
|
|
55
|
+
stringsArg: unknown,
|
|
56
|
+
stdinReader: () => Promise<string> = readStdinToString,
|
|
57
|
+
): Promise<void> {
|
|
58
|
+
const strings = await parseStringsArg(stringsArg, stdinReader);
|
|
59
|
+
await add(path, key, comment, strings);
|
|
60
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { cosmiconfig } from 'cosmiconfig';
|
|
2
|
+
import json5 from 'json5';
|
|
3
|
+
|
|
4
|
+
const moduleName = 'xcstrings-cli';
|
|
5
|
+
|
|
6
|
+
const explorer = cosmiconfig(moduleName, {
|
|
7
|
+
searchPlaces: [
|
|
8
|
+
`${moduleName}.json`,
|
|
9
|
+
`${moduleName}.json5`,
|
|
10
|
+
],
|
|
11
|
+
loaders: {
|
|
12
|
+
'.json5': async (filepath: string, content: string) => {
|
|
13
|
+
return json5.parse(content);
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
cache: false,
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
export interface Config {
|
|
20
|
+
xcstringsPaths?: (string | { alias: string; path: string })[];
|
|
21
|
+
xcodeprojPaths?: string[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export async function loadConfig(explicitPath?: string): Promise<Config | null> {
|
|
25
|
+
if (explicitPath) {
|
|
26
|
+
const result = await explorer.load(explicitPath);
|
|
27
|
+
return result ? (result.config as Config) : null;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const result = await explorer.search();
|
|
31
|
+
return result ? (result.config as Config) : null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { setupTempFile, cleanupTempFiles } from './utils/testFileHelper';
|
|
5
|
+
import { spawn } from 'child_process';
|
|
6
|
+
|
|
7
|
+
afterEach(async () => await cleanupTempFiles());
|
|
8
|
+
|
|
9
|
+
describe('cli: heredoc stdin', () => {
|
|
10
|
+
it('should accept JSON from stdin when --strings flag passed without value (heredoc)', async () => {
|
|
11
|
+
const stdin = JSON.stringify({ en: 'Hello', ja: 'こんにちは', 'zh-Hans': '你好,世界.' });
|
|
12
|
+
|
|
13
|
+
const tempFile = await setupTempFile('no-strings.xcstrings');
|
|
14
|
+
|
|
15
|
+
const node = process.execPath;
|
|
16
|
+
const cliPath = resolve(process.cwd(), 'dist', 'index.js');
|
|
17
|
+
const args = ['--enable-source-maps', cliPath, 'add', '--key', 'greeting', '--comment', 'Hello, World', '--strings', '--path', tempFile];
|
|
18
|
+
|
|
19
|
+
const child = spawn(node, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
20
|
+
|
|
21
|
+
child.stdin.write(stdin);
|
|
22
|
+
child.stdin.end();
|
|
23
|
+
|
|
24
|
+
await new Promise<void>((resolvePromise, reject) => {
|
|
25
|
+
let stdout = '';
|
|
26
|
+
let stderr = '';
|
|
27
|
+
child.stdout.on('data', (chunk) => stdout += chunk);
|
|
28
|
+
child.stderr.on('data', (chunk) => stderr += chunk);
|
|
29
|
+
child.on('exit', (code) => {
|
|
30
|
+
if (code !== 0) {
|
|
31
|
+
reject(new Error(`Process exited with non-zero code ${code}. Stderr: ${stderr}`));
|
|
32
|
+
} else {
|
|
33
|
+
resolvePromise();
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const content = JSON.parse(await readFile(tempFile, 'utf-8'));
|
|
39
|
+
expect(content.strings).toHaveProperty('greeting');
|
|
40
|
+
expect(content.strings.greeting.localizations.en.stringUnit.value).toBe('Hello');
|
|
41
|
+
expect(content.strings.greeting.localizations.ja.stringUnit.value).toBe('こんにちは');
|
|
42
|
+
expect(content.strings.greeting.localizations['zh-Hans'].stringUnit.value).toBe('你好,世界.');
|
|
43
|
+
});
|
|
44
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, it, expect, afterEach } from 'vitest';
|
|
2
|
+
import { parseStringsArg, runAddCommand } from '../src/utils/cli';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { setupTempFile, cleanupTempFiles } from './utils/testFileHelper';
|
|
5
|
+
|
|
6
|
+
afterEach(async () => await cleanupTempFiles());
|
|
7
|
+
|
|
8
|
+
describe('cli: stdin strings', () => {
|
|
9
|
+
it('parseStringsArg: should read JSON from stdin when strings option provided without value', async () => {
|
|
10
|
+
const stdin = `{
|
|
11
|
+
"en": "Hello",
|
|
12
|
+
"ja": "こんにちは",
|
|
13
|
+
"zh-Hans": "你好,世界."
|
|
14
|
+
}`;
|
|
15
|
+
|
|
16
|
+
const result = await parseStringsArg(true, async () => Promise.resolve(stdin));
|
|
17
|
+
expect(result).toBeDefined();
|
|
18
|
+
expect(result?.en).toBe('Hello');
|
|
19
|
+
expect(result?.ja).toBe('こんにちは');
|
|
20
|
+
expect(result?.['zh-Hans']).toBe('你好,世界.');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('parseStringsArg: should read JSON from stdin when strings option provided as empty string ("")', async () => {
|
|
24
|
+
const stdin = `{"en":"Hello","ja":"こんにちは","zh-Hans":"你好,世界."}`;
|
|
25
|
+
|
|
26
|
+
const result = await parseStringsArg('', async () => Promise.resolve(stdin));
|
|
27
|
+
expect(result).toBeDefined();
|
|
28
|
+
expect(result?.en).toBe('Hello');
|
|
29
|
+
expect(result?.ja).toBe('こんにちは');
|
|
30
|
+
expect(result?.['zh-Hans']).toBe('你好,世界.');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('add: should add strings read from stdin', async () => {
|
|
34
|
+
const stdin = `{"en":"Hello","ja":"こんにちは","zh-Hans":"你好,世界."}`;
|
|
35
|
+
|
|
36
|
+
const tempFile = await setupTempFile('no-strings.xcstrings');
|
|
37
|
+
await runAddCommand(tempFile, 'greeting', 'Hello, World', true, async () => Promise.resolve(stdin));
|
|
38
|
+
|
|
39
|
+
const content = JSON.parse(await readFile(tempFile, 'utf-8'));
|
|
40
|
+
expect(content.strings).toHaveProperty('greeting');
|
|
41
|
+
expect(content.strings.greeting.localizations.en.stringUnit.value).toBe('Hello');
|
|
42
|
+
expect(content.strings.greeting.localizations.ja.stringUnit.value).toBe('こんにちは');
|
|
43
|
+
expect(content.strings.greeting.localizations['zh-Hans'].stringUnit.value).toBe('你好,世界.');
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('parseStringsArg: should parse strings when provided as an inline JSON string', async () => {
|
|
47
|
+
const str = `{ "en": "Hello", "ja": "こんにちは" }`;
|
|
48
|
+
const result = await parseStringsArg(str, async () => Promise.resolve(''));
|
|
49
|
+
expect(result).toBeDefined();
|
|
50
|
+
expect(result?.en).toBe('Hello');
|
|
51
|
+
expect(result?.ja).toBe('こんにちは');
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
it('parseStringsArg: should merge arrays passed for --strings multiple times', async () => {
|
|
55
|
+
const items = ['{"en":"Hello"}', '{"ja":"こんにちは"}'];
|
|
56
|
+
const result = await parseStringsArg(items as unknown as string[], async () => Promise.resolve(''));
|
|
57
|
+
expect(result).toBeDefined();
|
|
58
|
+
expect(result?.en).toBe('Hello');
|
|
59
|
+
expect(result?.ja).toBe('こんにちは');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { resolve } from 'node:path';
|
|
3
|
+
import { spawn } from 'child_process';
|
|
4
|
+
|
|
5
|
+
describe('cli: unknown command', () => {
|
|
6
|
+
it('should exit non-zero and print help when unknown subcommand is passed', async () => {
|
|
7
|
+
const node = process.execPath;
|
|
8
|
+
const cliPath = resolve(process.cwd(), 'dist', 'index.js');
|
|
9
|
+
const args = ['--enable-source-maps', cliPath, 'hello'];
|
|
10
|
+
|
|
11
|
+
const child = spawn(node, args, { stdio: ['pipe', 'pipe', 'pipe'] });
|
|
12
|
+
|
|
13
|
+
await new Promise<void>((resolvePromise) => {
|
|
14
|
+
let stderr = '';
|
|
15
|
+
child.stderr.on('data', (chunk) => stderr += chunk);
|
|
16
|
+
child.on('exit', (code) => {
|
|
17
|
+
expect(code).not.toBe(0);
|
|
18
|
+
expect(stderr).toMatch(/Unknown/i);
|
|
19
|
+
expect(stderr).toMatch(/xcstrings/);
|
|
20
|
+
resolvePromise();
|
|
21
|
+
});
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
});
|