xcstrings-cli 1.0.0 → 1.1.1

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/README.md CHANGED
@@ -20,10 +20,6 @@ Check it out here: [xcstrings-cli Helper](https://chatgpt.com/g/g-69365945f8bc81
20
20
  1. Install xcstrings-cli using npm:
21
21
  ```bash
22
22
  npm install -g xcstrings-cli
23
- # or
24
- yarn global add xcstrings-cli
25
- # or
26
- pnpm global add xcstrings-cli
27
23
  ```
28
24
 
29
25
  2. Initialize xcstrings-cli
package/package.json CHANGED
@@ -1,11 +1,16 @@
1
1
  {
2
2
  "name": "xcstrings-cli",
3
- "version": "1.0.0",
3
+ "version": "1.1.1",
4
4
  "description": "A command line tool for handling xcstrings files.",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "xcstrings": "./dist/index.js"
8
8
  },
9
+ "files": [
10
+ "dist",
11
+ "README.md",
12
+ "LICENSE"
13
+ ],
9
14
  "keywords": [
10
15
  "xcstrings",
11
16
  "cli",
@@ -25,8 +30,7 @@
25
30
  "prettier": "^3.7.4",
26
31
  "typescript": "^5.9.3",
27
32
  "vite": "^7.2.6",
28
- "vitest": "^4.0.15",
29
- "yargs": "^18.0.0"
33
+ "vitest": "^4.0.15"
30
34
  },
31
35
  "dependencies": {
32
36
  "@bacons/xcode": "1.0.0-alpha.27",
@@ -34,7 +38,8 @@
34
38
  "chalk": "^5.6.2",
35
39
  "cosmiconfig": "^9.0.0",
36
40
  "json5": "^2.2.3",
37
- "pino": "^8.16.0"
41
+ "pino": "^8.16.0",
42
+ "yargs": "^18.0.0"
38
43
  },
39
44
  "scripts": {
40
45
  "dev": "vite",
@@ -1,8 +0,0 @@
1
- # Changesets
2
-
3
- Hello and welcome! This folder has been automatically generated by `@changesets/cli`, a build tool that works
4
- with multi-package repos, or single-package repos to help you version and publish your code. You can
5
- find the full documentation for it [in our repository](https://github.com/changesets/changesets)
6
-
7
- We have a quick list of common questions to get you started engaging with this project in
8
- [our documentation](https://github.com/changesets/changesets/blob/main/docs/common-questions.md)
@@ -1,11 +0,0 @@
1
- {
2
- "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
3
- "changelog": "@changesets/cli/changelog",
4
- "commit": false,
5
- "fixed": [],
6
- "linked": [],
7
- "access": "public",
8
- "baseBranch": "main",
9
- "updateInternalDependencies": "patch",
10
- "ignore": []
11
- }
@@ -1,56 +0,0 @@
1
- name: Release
2
-
3
- on:
4
- workflow_dispatch:
5
- push:
6
- branches:
7
- - main
8
-
9
- concurrency: ${{ github.workflow }}-${{ github.ref }}
10
-
11
- permissions:
12
- contents: write
13
- pull-requests: write
14
- id-token: write
15
-
16
- jobs:
17
- release:
18
- name: Release
19
- runs-on: ubuntu-latest
20
- steps:
21
- - name: Checkout repository
22
- uses: actions/checkout@v4
23
- with:
24
- fetch-depth: 0
25
-
26
- - name: Setup pnpm
27
- uses: pnpm/action-setup@v4
28
- with:
29
- version: 10
30
-
31
- - name: Setup Node.js
32
- uses: actions/setup-node@v4
33
- with:
34
- node-version: 20
35
- cache: "pnpm"
36
- registry-url: "https://registry.npmjs.org"
37
-
38
- - name: Upgrade npm (for OIDC)
39
- run: npm install -g npm@latest
40
-
41
- - name: Install dependencies
42
- run: pnpm install --frozen-lockfile
43
-
44
- - name: Build packages
45
- run: pnpm build
46
-
47
- - name: Create Release Pull Request or Publish
48
- id: changesets
49
- uses: changesets/action@v1
50
- with:
51
- version: pnpm changeset version
52
- publish: pnpm release
53
- title: "chore: version packages"
54
- commit: "chore: version packages"
55
- env:
56
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -1,3 +0,0 @@
1
- {
2
- "extends": "markdownlint/style/relaxed"
3
- }
package/.prettierrc DELETED
@@ -1,7 +0,0 @@
1
- {
2
- "semi": true,
3
- "singleQuote": true,
4
- "trailingComma": "all",
5
- "printWidth": 80,
6
- "tabWidth": 4,
7
- }
@@ -1,63 +0,0 @@
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
- }
@@ -1,38 +0,0 @@
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
- }
@@ -1,5 +0,0 @@
1
- export { add } from './add';
2
- export { remove } from './remove';
3
- export { init } from './init';
4
- export { languages } from './languages';
5
- export * from './_shared';
@@ -1,159 +0,0 @@
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
- }
@@ -1,43 +0,0 @@
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
- }
@@ -1,9 +0,0 @@
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 DELETED
@@ -1,126 +0,0 @@
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 DELETED
@@ -1,60 +0,0 @@
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
- }
@@ -1,32 +0,0 @@
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
- }
@@ -1,7 +0,0 @@
1
- import pino from 'pino';
2
-
3
- const logger = pino({
4
- level: process.env.LOG_LEVEL || 'info',
5
- });
6
-
7
- export default logger;
@@ -1,44 +0,0 @@
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
- });
@@ -1,61 +0,0 @@
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
- });
@@ -1,24 +0,0 @@
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
- });
@@ -1,38 +0,0 @@
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
- });
@@ -1,54 +0,0 @@
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
- });
@@ -1,32 +0,0 @@
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
- }
@@ -1,5 +0,0 @@
1
- {
2
- "sourceLanguage" : "en",
3
- "strings" : {
4
- }
5
- }
@@ -1,86 +0,0 @@
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
- }
@@ -1,72 +0,0 @@
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
- });
@@ -1,21 +0,0 @@
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
- });
@@ -1,4 +0,0 @@
1
- import { resolve } from "path";
2
-
3
- export const FIXTURES_DIR = resolve(__dirname, '..', 'fixtures');
4
- export const TEMP_DIR = resolve(__dirname, '..', 'temp');
@@ -1,30 +0,0 @@
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 DELETED
@@ -1,19 +0,0 @@
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 DELETED
@@ -1,32 +0,0 @@
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
- });